2026-04-19 22:20:08 +02:00
|
|
|
|
import {
|
|
|
|
|
|
For,
|
|
|
|
|
|
Show,
|
|
|
|
|
|
createEffect,
|
|
|
|
|
|
createMemo,
|
|
|
|
|
|
createSignal,
|
|
|
|
|
|
onCleanup,
|
|
|
|
|
|
onMount,
|
|
|
|
|
|
createResource,
|
|
|
|
|
|
} from "solid-js";
|
|
|
|
|
|
import { useSearchParams } from "@solidjs/router";
|
2026-03-27 18:29:21 +01:00
|
|
|
|
import {
|
|
|
|
|
|
BarChart3,
|
|
|
|
|
|
Building2,
|
|
|
|
|
|
CircleDashed,
|
|
|
|
|
|
Coins,
|
|
|
|
|
|
EllipsisVertical,
|
|
|
|
|
|
Eye,
|
|
|
|
|
|
EyeOff,
|
|
|
|
|
|
GripVertical,
|
|
|
|
|
|
LineChart,
|
|
|
|
|
|
RotateCcw,
|
|
|
|
|
|
Search,
|
|
|
|
|
|
Settings2,
|
|
|
|
|
|
TrendingUp,
|
|
|
|
|
|
Users,
|
2026-04-19 22:20:08 +02:00
|
|
|
|
} from "lucide-solid";
|
2026-03-27 18:29:21 +01:00
|
|
|
|
import {
|
|
|
|
|
|
ADMIN_DASHBOARD_WIDGETS,
|
|
|
|
|
|
type DashboardWidgetDefinition,
|
|
|
|
|
|
type DashboardWidgetSize,
|
2026-04-19 22:20:08 +02:00
|
|
|
|
} from "~/lib/admin/dashboard";
|
|
|
|
|
|
import type { RuntimeDashboardLayout } from "~/lib/runtime/types";
|
|
|
|
|
|
import { loadAdminDashboardLayout, saveAdminDashboardLayout } from "~/lib/runtime/storage";
|
2026-03-27 18:29:21 +01:00
|
|
|
|
|
2026-04-26 23:58:42 +02:00
|
|
|
|
const API = "";
|
2026-04-02 13:09:42 +02:00
|
|
|
|
|
|
|
|
|
|
async function fetchMetrics() {
|
2026-04-19 22:20:08 +02:00
|
|
|
|
const accessToken =
|
|
|
|
|
|
typeof sessionStorage !== "undefined"
|
|
|
|
|
|
? (sessionStorage.getItem("nxtgauge_admin_access_token") || "").trim()
|
|
|
|
|
|
: "";
|
2026-04-02 13:09:42 +02:00
|
|
|
|
const res = await fetch(`${API}/api/admin/dashboard/metrics`, {
|
|
|
|
|
|
headers: {
|
2026-04-19 22:20:08 +02:00
|
|
|
|
Accept: "application/json",
|
2026-04-02 13:09:42 +02:00
|
|
|
|
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
|
|
|
|
|
|
},
|
2026-04-19 22:20:08 +02:00
|
|
|
|
credentials: "include",
|
2026-04-02 13:09:42 +02:00
|
|
|
|
});
|
2026-04-19 22:20:08 +02:00
|
|
|
|
if (!res.ok) throw new Error("Failed to load metrics");
|
2026-04-02 13:09:42 +02:00
|
|
|
|
return res.json();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-19 22:20:08 +02:00
|
|
|
|
type WidgetStateKind = "live" | "empty" | "pending";
|
|
|
|
|
|
type WidgetType = "summary" | "analytics";
|
|
|
|
|
|
type SortMode = "layout" | "name" | "status";
|
|
|
|
|
|
type FilterMode = "all" | WidgetType;
|
|
|
|
|
|
type GridLayoutMode = "3x4" | "3x3";
|
2026-03-27 18:29:21 +01:00
|
|
|
|
|
|
|
|
|
|
type WidgetMeta = {
|
|
|
|
|
|
state: WidgetStateKind;
|
|
|
|
|
|
type: WidgetType;
|
|
|
|
|
|
statusLabel: string;
|
|
|
|
|
|
subtitle: string;
|
|
|
|
|
|
emptyMessage?: string;
|
|
|
|
|
|
pendingMessage?: string;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const WIDGET_DEFINITION_MAP = Object.fromEntries(
|
|
|
|
|
|
ADMIN_DASHBOARD_WIDGETS.map((definition) => [definition.widgetKey, definition])
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const DEFAULT_LAYOUT: RuntimeDashboardLayout = {
|
|
|
|
|
|
order: ADMIN_DASHBOARD_WIDGETS.slice()
|
|
|
|
|
|
.sort((a, b) => a.order - b.order)
|
|
|
|
|
|
.map((definition) => definition.widgetKey),
|
2026-04-19 22:20:08 +02:00
|
|
|
|
visibility: Object.fromEntries(
|
|
|
|
|
|
ADMIN_DASHBOARD_WIDGETS.map((definition) => [definition.widgetKey, definition.defaultVisible])
|
|
|
|
|
|
),
|
|
|
|
|
|
size: Object.fromEntries(
|
|
|
|
|
|
ADMIN_DASHBOARD_WIDGETS.map((definition) => [definition.widgetKey, definition.defaultSize])
|
|
|
|
|
|
),
|
2026-03-27 18:29:21 +01:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const WIDGET_META: Record<string, WidgetMeta> = {
|
|
|
|
|
|
kpi_total_users: {
|
2026-04-19 22:20:08 +02:00
|
|
|
|
state: "live",
|
|
|
|
|
|
type: "summary",
|
|
|
|
|
|
statusLabel: "Live Data",
|
|
|
|
|
|
subtitle: "Powered by USER_MANAGEMENT",
|
2026-03-27 18:29:21 +01:00
|
|
|
|
},
|
|
|
|
|
|
kpi_active_companies: {
|
2026-04-19 22:20:08 +02:00
|
|
|
|
state: "live",
|
|
|
|
|
|
type: "summary",
|
|
|
|
|
|
statusLabel: "Live Data",
|
|
|
|
|
|
subtitle: "Powered by COMPANY_MANAGEMENT",
|
2026-03-27 18:29:21 +01:00
|
|
|
|
},
|
|
|
|
|
|
kpi_open_leads: {
|
2026-04-19 22:20:08 +02:00
|
|
|
|
state: "live",
|
|
|
|
|
|
type: "summary",
|
|
|
|
|
|
statusLabel: "Live Data",
|
|
|
|
|
|
subtitle: "Powered by REQUIREMENTS_MANAGEMENT",
|
2026-04-06 06:19:14 +02:00
|
|
|
|
},
|
|
|
|
|
|
kpi_pending_approvals: {
|
2026-04-19 22:20:08 +02:00
|
|
|
|
state: "live",
|
|
|
|
|
|
type: "summary",
|
|
|
|
|
|
statusLabel: "Live Data",
|
|
|
|
|
|
subtitle: "Powered by APPROVAL_MANAGEMENT",
|
2026-04-06 06:19:14 +02:00
|
|
|
|
},
|
|
|
|
|
|
kpi_total_revenue: {
|
2026-04-19 22:20:08 +02:00
|
|
|
|
state: "live",
|
|
|
|
|
|
type: "summary",
|
|
|
|
|
|
statusLabel: "Live Data",
|
|
|
|
|
|
subtitle: "Powered by REVENUE_LEDGER",
|
2026-03-27 18:29:21 +01:00
|
|
|
|
},
|
|
|
|
|
|
kpi_credits_purchased: {
|
2026-04-19 22:20:08 +02:00
|
|
|
|
state: "empty",
|
|
|
|
|
|
type: "summary",
|
|
|
|
|
|
statusLabel: "No Data",
|
|
|
|
|
|
subtitle: "Powered by CREDIT_MANAGEMENT",
|
|
|
|
|
|
emptyMessage: "No credit activity available yet",
|
2026-03-27 18:29:21 +01:00
|
|
|
|
},
|
|
|
|
|
|
chart_leads_trend: {
|
2026-04-19 22:20:08 +02:00
|
|
|
|
state: "live",
|
|
|
|
|
|
type: "analytics",
|
|
|
|
|
|
statusLabel: "Live Data",
|
|
|
|
|
|
subtitle: "Weekly leads performance overview • Powered by REPORTS",
|
2026-03-27 18:29:21 +01:00
|
|
|
|
},
|
|
|
|
|
|
chart_revenue_overview: {
|
2026-04-19 22:20:08 +02:00
|
|
|
|
state: "live",
|
|
|
|
|
|
type: "analytics",
|
|
|
|
|
|
statusLabel: "Live Data",
|
|
|
|
|
|
subtitle: "Weekly revenue overview • Powered by REVENUE_LEDGER",
|
2026-03-27 18:29:21 +01:00
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
function sanitizeLayout(layout: RuntimeDashboardLayout | null | undefined): RuntimeDashboardLayout {
|
|
|
|
|
|
const knownKeys = new Set(ADMIN_DASHBOARD_WIDGETS.map((definition) => definition.widgetKey));
|
|
|
|
|
|
const fallbackOrder = DEFAULT_LAYOUT.order;
|
|
|
|
|
|
const incomingOrder = Array.isArray(layout?.order) ? layout!.order : [];
|
|
|
|
|
|
|
|
|
|
|
|
const seen = new Set<string>();
|
|
|
|
|
|
const normalizedOrder: string[] = [];
|
|
|
|
|
|
|
|
|
|
|
|
for (const key of incomingOrder) {
|
2026-04-19 22:20:08 +02:00
|
|
|
|
const normalizedKey = String(key || "");
|
2026-03-27 18:29:21 +01:00
|
|
|
|
if (!knownKeys.has(normalizedKey) || seen.has(normalizedKey)) continue;
|
|
|
|
|
|
seen.add(normalizedKey);
|
|
|
|
|
|
normalizedOrder.push(normalizedKey);
|
2026-03-26 06:20:22 +01:00
|
|
|
|
}
|
2026-03-27 18:29:21 +01:00
|
|
|
|
|
|
|
|
|
|
for (const key of fallbackOrder) {
|
|
|
|
|
|
if (!seen.has(key)) normalizedOrder.push(key);
|
2026-03-26 06:20:22 +01:00
|
|
|
|
}
|
2026-03-27 18:29:21 +01:00
|
|
|
|
|
|
|
|
|
|
const visibility: Record<string, boolean> = {};
|
|
|
|
|
|
const size: Record<string, DashboardWidgetSize> = {};
|
|
|
|
|
|
|
|
|
|
|
|
for (const definition of ADMIN_DASHBOARD_WIDGETS) {
|
|
|
|
|
|
const key = definition.widgetKey;
|
2026-04-19 22:20:08 +02:00
|
|
|
|
visibility[key] =
|
|
|
|
|
|
typeof layout?.visibility?.[key] === "boolean"
|
|
|
|
|
|
? Boolean(layout?.visibility?.[key])
|
|
|
|
|
|
: definition.defaultVisible;
|
|
|
|
|
|
|
|
|
|
|
|
const rawSize = String(layout?.size?.[key] || "").toUpperCase();
|
|
|
|
|
|
size[key] =
|
|
|
|
|
|
rawSize === "S" || rawSize === "M" || rawSize === "L"
|
|
|
|
|
|
? (rawSize as DashboardWidgetSize)
|
|
|
|
|
|
: definition.defaultSize;
|
2026-03-26 06:20:22 +01:00
|
|
|
|
}
|
2026-03-27 18:29:21 +01:00
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
order: normalizedOrder,
|
|
|
|
|
|
visibility,
|
|
|
|
|
|
size,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function reorderKeys(order: string[], draggedKey: string, targetKey: string): string[] {
|
|
|
|
|
|
if (draggedKey === targetKey) return order;
|
|
|
|
|
|
const next = order.slice();
|
|
|
|
|
|
const fromIndex = next.indexOf(draggedKey);
|
|
|
|
|
|
const toIndex = next.indexOf(targetKey);
|
|
|
|
|
|
if (fromIndex === -1 || toIndex === -1) return order;
|
|
|
|
|
|
next.splice(fromIndex, 1);
|
|
|
|
|
|
next.splice(toIndex, 0, draggedKey);
|
|
|
|
|
|
return next;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function iconForWidget(widgetKey: string) {
|
2026-04-19 22:20:08 +02:00
|
|
|
|
const cls = "text-[#FA5014]";
|
|
|
|
|
|
if (widgetKey.includes("users")) return <Users size={22} class={cls} />;
|
|
|
|
|
|
if (widgetKey.includes("companies")) return <Building2 size={22} class={cls} />;
|
|
|
|
|
|
if (widgetKey.includes("leads")) return <TrendingUp size={22} class={cls} />;
|
|
|
|
|
|
if (widgetKey.includes("credits")) return <Coins size={22} class={cls} />;
|
|
|
|
|
|
if (widgetKey.includes("revenue")) return <BarChart3 size={22} class={cls} />;
|
|
|
|
|
|
if (widgetKey.includes("approvals")) return <CircleDashed size={22} class={cls} />;
|
2026-03-27 18:29:21 +01:00
|
|
|
|
return <LineChart size={22} class={cls} />;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function badgeClass(state: WidgetStateKind): string {
|
2026-04-19 22:20:08 +02:00
|
|
|
|
if (state === "live") return "border-[#FDBA8C] bg-[#FFF1EB] text-[#FA5014]";
|
|
|
|
|
|
if (state === "pending") return "border-[#D1D5DB] bg-[#F3F4F6] text-[#6B7280]";
|
|
|
|
|
|
return "border-[#E5E7EB] bg-white text-[#6B7280]";
|
2026-03-27 18:29:21 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function widgetSpan(mode: GridLayoutMode): string {
|
2026-04-19 22:20:08 +02:00
|
|
|
|
return mode === "3x3" ? "xl:col-span-4" : "xl:col-span-3";
|
2026-03-27 18:29:21 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function EmptyPreview() {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div class="flex h-full flex-col items-center justify-center">
|
|
|
|
|
|
<CircleDashed size={22} class="text-[#FA5014]" />
|
|
|
|
|
|
<div class="mt-3 text-2xl font-bold leading-none text-[#9CA3AF]">--</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function PendingPreview() {
|
2026-03-26 06:20:22 +01:00
|
|
|
|
return (
|
2026-03-27 18:29:21 +01:00
|
|
|
|
<div class="flex h-full flex-col items-center justify-center gap-3">
|
|
|
|
|
|
<LineChart size={22} class="text-[#FA5014]" />
|
|
|
|
|
|
<div class="w-full max-w-[130px] space-y-2">
|
|
|
|
|
|
<div class="h-2 rounded bg-[#E5E7EB]" />
|
|
|
|
|
|
<div class="h-2 w-5/6 rounded bg-[#E5E7EB]" />
|
|
|
|
|
|
<div class="h-2 w-4/6 rounded bg-[#E5E7EB]" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-03-26 06:20:22 +01:00
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-03-25 23:55:43 +01:00
|
|
|
|
|
2026-04-02 13:09:42 +02:00
|
|
|
|
function LivePreview(props: { value?: string; trend?: string; trendUp?: boolean }) {
|
feat(admin): redesign sidebar, dashboard, dept, designation & roles UI
- Sidebar: white bg, rounded pill nav items, orange left indicator for active
- Dashboard: remove Export/View All buttons, add Customise Dashboard + drag handles on widgets
- Department/Designation/Roles: new design system with orange label header, stat cards, light table header, 3-dot action menus, status badges
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 08:01:23 +01:00
|
|
|
|
return (
|
2026-03-27 18:29:21 +01:00
|
|
|
|
<div class="flex h-full flex-col items-center justify-center">
|
2026-04-19 22:20:08 +02:00
|
|
|
|
<p class="text-[26px] font-bold leading-none text-[#111827]">{props.value || "0"}</p>
|
2026-04-02 13:09:42 +02:00
|
|
|
|
<p class="mt-2 inline-flex items-center gap-1 text-xs font-semibold text-[#FA5014]">
|
2026-04-19 22:20:08 +02:00
|
|
|
|
{props.trendUp ? "↗" : "↘"} {props.trend || "0%"}
|
2026-04-02 13:09:42 +02:00
|
|
|
|
</p>
|
2026-03-27 18:29:21 +01:00
|
|
|
|
</div>
|
feat(admin): redesign sidebar, dashboard, dept, designation & roles UI
- Sidebar: white bg, rounded pill nav items, orange left indicator for active
- Dashboard: remove Export/View All buttons, add Customise Dashboard + drag handles on widgets
- Department/Designation/Roles: new design system with orange label header, stat cards, light table header, 3-dot action menus, status badges
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 08:01:23 +01:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-26 06:20:22 +01:00
|
|
|
|
export default function AdminHomePage() {
|
2026-03-27 21:25:00 +01:00
|
|
|
|
const [searchParams] = useSearchParams();
|
2026-03-27 18:29:21 +01:00
|
|
|
|
const [layout, setLayout] = createSignal<RuntimeDashboardLayout>(sanitizeLayout(DEFAULT_LAYOUT));
|
|
|
|
|
|
const [settingsOpen, setSettingsOpen] = createSignal(false);
|
|
|
|
|
|
const [isHydrating, setIsHydrating] = createSignal(true);
|
|
|
|
|
|
const [isAutoSaving, setIsAutoSaving] = createSignal(false);
|
2026-04-19 22:20:08 +02:00
|
|
|
|
const [autoSaveNotice, setAutoSaveNotice] = createSignal("");
|
|
|
|
|
|
const [lastSavedSnapshot, setLastSavedSnapshot] = createSignal("");
|
2026-03-27 18:29:21 +01:00
|
|
|
|
const [draggingKey, setDraggingKey] = createSignal<string | null>(null);
|
|
|
|
|
|
const [openMenuId, setOpenMenuId] = createSignal<string | null>(null);
|
|
|
|
|
|
|
2026-04-19 22:20:08 +02:00
|
|
|
|
const [search, setSearch] = createSignal("");
|
|
|
|
|
|
const [filterMode, setFilterMode] = createSignal<FilterMode>("all");
|
|
|
|
|
|
const [sortMode, setSortMode] = createSignal<SortMode>("layout");
|
|
|
|
|
|
const [gridLayout, setGridLayout] = createSignal<GridLayoutMode>("3x4");
|
2026-03-27 18:29:21 +01:00
|
|
|
|
|
2026-04-02 13:09:42 +02:00
|
|
|
|
const [metrics] = createResource(fetchMetrics);
|
|
|
|
|
|
|
|
|
|
|
|
const getWidgetState = (key: string) => {
|
2026-04-19 22:20:08 +02:00
|
|
|
|
if (key.startsWith("kpi_")) {
|
2026-04-02 13:09:42 +02:00
|
|
|
|
const idMap: Record<string, string> = {
|
2026-04-19 22:20:08 +02:00
|
|
|
|
kpi_total_users: "users",
|
|
|
|
|
|
kpi_active_companies: "companies",
|
|
|
|
|
|
kpi_open_leads: "leads",
|
|
|
|
|
|
kpi_pending_approvals: "approvals",
|
|
|
|
|
|
kpi_total_revenue: "revenue",
|
|
|
|
|
|
kpi_credits_purchased: "credits",
|
2026-04-02 13:09:42 +02:00
|
|
|
|
};
|
2026-04-19 22:20:08 +02:00
|
|
|
|
if (metrics.loading) return { state: "pending", statusLabel: "Loading..." };
|
2026-04-02 13:09:42 +02:00
|
|
|
|
const m = metrics()?.kpis?.find((k: any) => k.id === idMap[key]);
|
2026-04-19 22:20:08 +02:00
|
|
|
|
if (m) return { state: "live", statusLabel: "Live Data", data: m };
|
|
|
|
|
|
return { state: "empty", statusLabel: "No Data" };
|
2026-04-02 13:09:42 +02:00
|
|
|
|
}
|
2026-04-19 22:20:08 +02:00
|
|
|
|
if (metrics.loading) return { state: "pending", statusLabel: "Loading..." };
|
2026-04-06 06:19:14 +02:00
|
|
|
|
const m = metrics();
|
2026-04-19 22:20:08 +02:00
|
|
|
|
if (key === "chart_leads_trend") {
|
2026-04-06 06:19:14 +02:00
|
|
|
|
const data = m?.trend_series;
|
2026-04-19 22:20:08 +02:00
|
|
|
|
if (data && data.length > 0)
|
|
|
|
|
|
return { state: "live", statusLabel: "Live Data", data: { trend_series: data } };
|
|
|
|
|
|
return { state: "empty", statusLabel: "No Data" };
|
2026-04-06 06:19:14 +02:00
|
|
|
|
}
|
2026-04-19 22:20:08 +02:00
|
|
|
|
if (key === "chart_revenue_overview") {
|
2026-04-06 06:19:14 +02:00
|
|
|
|
const data = m?.rev_series;
|
2026-04-19 22:20:08 +02:00
|
|
|
|
if (data && data.length > 0)
|
|
|
|
|
|
return { state: "live", statusLabel: "Live Data", data: { rev_series: data } };
|
|
|
|
|
|
return { state: "empty", statusLabel: "No Data" };
|
2026-04-06 06:19:14 +02:00
|
|
|
|
}
|
2026-04-02 13:09:42 +02:00
|
|
|
|
const meta = WIDGET_META[key];
|
2026-04-19 22:20:08 +02:00
|
|
|
|
return { state: meta?.state || "empty", statusLabel: meta?.statusLabel || "No Data" };
|
2026-04-02 13:09:42 +02:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-27 18:29:21 +01:00
|
|
|
|
const orderedWidgets = createMemo(() => {
|
|
|
|
|
|
const current = layout();
|
|
|
|
|
|
const rows = current.order
|
|
|
|
|
|
.map((key) => WIDGET_DEFINITION_MAP[key])
|
|
|
|
|
|
.filter((definition): definition is DashboardWidgetDefinition => Boolean(definition))
|
|
|
|
|
|
.filter((definition) => current.visibility[definition.widgetKey] !== false)
|
|
|
|
|
|
.filter((definition) => {
|
|
|
|
|
|
const query = search().trim().toLowerCase();
|
|
|
|
|
|
if (!query) return true;
|
|
|
|
|
|
const meta = WIDGET_META[definition.widgetKey];
|
2026-04-19 22:20:08 +02:00
|
|
|
|
return (
|
|
|
|
|
|
definition.title.toLowerCase().includes(query) ||
|
|
|
|
|
|
definition.moduleKey.toLowerCase().includes(query) ||
|
|
|
|
|
|
(meta?.subtitle || "").toLowerCase().includes(query)
|
|
|
|
|
|
);
|
2026-03-27 18:29:21 +01:00
|
|
|
|
})
|
|
|
|
|
|
.filter((definition) => {
|
|
|
|
|
|
const mode = filterMode();
|
2026-04-19 22:20:08 +02:00
|
|
|
|
if (mode === "all") return true;
|
|
|
|
|
|
return (WIDGET_META[definition.widgetKey]?.type || "summary") === mode;
|
2026-03-27 18:29:21 +01:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const mode = sortMode();
|
2026-04-19 22:20:08 +02:00
|
|
|
|
if (mode === "layout") return rows;
|
2026-03-27 18:29:21 +01:00
|
|
|
|
|
|
|
|
|
|
const next = rows.slice();
|
|
|
|
|
|
next.sort((a, b) => {
|
2026-04-19 22:20:08 +02:00
|
|
|
|
if (mode === "name") return a.title.localeCompare(b.title);
|
2026-03-27 18:29:21 +01:00
|
|
|
|
const rank = (key: string) => {
|
2026-04-02 13:09:42 +02:00
|
|
|
|
const info = getWidgetState(key);
|
2026-04-19 22:20:08 +02:00
|
|
|
|
if (info.state === "live") return 1;
|
|
|
|
|
|
if (info.state === "empty") return 2;
|
2026-03-27 18:29:21 +01:00
|
|
|
|
return 3;
|
|
|
|
|
|
};
|
|
|
|
|
|
return rank(a.widgetKey) - rank(b.widgetKey);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
return next;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
onMount(async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const persistedLayout = await loadAdminDashboardLayout();
|
|
|
|
|
|
const normalized = sanitizeLayout(persistedLayout || DEFAULT_LAYOUT);
|
|
|
|
|
|
setLayout(normalized);
|
|
|
|
|
|
setLastSavedSnapshot(JSON.stringify(normalized));
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setIsHydrating(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
createEffect(() => {
|
|
|
|
|
|
if (isHydrating()) return;
|
|
|
|
|
|
const nextLayout = layout();
|
|
|
|
|
|
const nextSnapshot = JSON.stringify(nextLayout);
|
|
|
|
|
|
if (nextSnapshot === lastSavedSnapshot()) return;
|
|
|
|
|
|
|
|
|
|
|
|
setIsAutoSaving(true);
|
2026-04-19 22:20:08 +02:00
|
|
|
|
setAutoSaveNotice("");
|
2026-03-27 18:29:21 +01:00
|
|
|
|
|
|
|
|
|
|
const timer = setTimeout(async () => {
|
|
|
|
|
|
const ok = await saveAdminDashboardLayout(nextLayout);
|
|
|
|
|
|
setIsAutoSaving(false);
|
|
|
|
|
|
if (ok) {
|
|
|
|
|
|
setLastSavedSnapshot(nextSnapshot);
|
2026-04-19 22:20:08 +02:00
|
|
|
|
setAutoSaveNotice("Layout saved automatically.");
|
2026-03-27 18:29:21 +01:00
|
|
|
|
} else {
|
2026-04-19 22:20:08 +02:00
|
|
|
|
setAutoSaveNotice("Auto-save failed. Please try again.");
|
2026-03-27 18:29:21 +01:00
|
|
|
|
}
|
|
|
|
|
|
}, 450);
|
|
|
|
|
|
|
|
|
|
|
|
onCleanup(() => clearTimeout(timer));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const setWidgetVisibility = (widgetKey: string, visible: boolean) => {
|
|
|
|
|
|
setLayout((current) => ({
|
|
|
|
|
|
...current,
|
|
|
|
|
|
visibility: {
|
|
|
|
|
|
...current.visibility,
|
|
|
|
|
|
[widgetKey]: visible,
|
|
|
|
|
|
},
|
|
|
|
|
|
}));
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const resetLayout = () => {
|
|
|
|
|
|
const normalized = sanitizeLayout(DEFAULT_LAYOUT);
|
|
|
|
|
|
setLayout(normalized);
|
2026-04-19 22:20:08 +02:00
|
|
|
|
setAutoSaveNotice("Layout reset to default.");
|
2026-03-27 18:29:21 +01:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleDragStart = (event: DragEvent, widgetKey: string) => {
|
2026-04-19 22:20:08 +02:00
|
|
|
|
event.dataTransfer?.setData("text/plain", widgetKey);
|
|
|
|
|
|
if (event.dataTransfer) event.dataTransfer.effectAllowed = "move";
|
2026-03-27 18:29:21 +01:00
|
|
|
|
setDraggingKey(widgetKey);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleDrop = (event: DragEvent, targetKey: string) => {
|
|
|
|
|
|
event.preventDefault();
|
2026-04-19 22:20:08 +02:00
|
|
|
|
const dragged = event.dataTransfer?.getData("text/plain") || draggingKey();
|
2026-03-27 18:29:21 +01:00
|
|
|
|
if (!dragged) return;
|
|
|
|
|
|
|
|
|
|
|
|
setLayout((current) => ({
|
|
|
|
|
|
...current,
|
|
|
|
|
|
order: reorderKeys(current.order, dragged, targetKey),
|
|
|
|
|
|
}));
|
|
|
|
|
|
setDraggingKey(null);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-17 15:37:49 +01:00
|
|
|
|
return (
|
2026-04-19 22:20:08 +02:00
|
|
|
|
<div class="w-full">
|
|
|
|
|
|
<Show when={Boolean(searchParams.denied)}>
|
|
|
|
|
|
<div
|
|
|
|
|
|
class="rounded-2xl border border-[#E5E7EB] bg-white shadow-sm"
|
|
|
|
|
|
style="margin-bottom: 18px"
|
|
|
|
|
|
>
|
|
|
|
|
|
<p class="notice">
|
|
|
|
|
|
You don’t have access to {String(searchParams.denied || "").replace(/_/g, " ")}.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Show>
|
|
|
|
|
|
<div
|
|
|
|
|
|
class="rounded-2xl border border-[#E5E7EB] bg-white px-6 py-5 shadow-sm md:px-8"
|
|
|
|
|
|
style="margin-bottom: 28px"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h1 class="text-[22px] font-bold leading-tight text-[#111827]">Dashboard Overview</h1>
|
|
|
|
|
|
<p class="mt-0.5 text-[13px] text-[#6B7280]">
|
|
|
|
|
|
Manage widget layout, visibility, sizing, and dashboard presentation
|
|
|
|
|
|
</p>
|
2026-03-27 21:25:00 +01:00
|
|
|
|
</div>
|
feat(admin): redesign sidebar, dashboard, dept, designation & roles UI
- Sidebar: white bg, rounded pill nav items, orange left indicator for active
- Dashboard: remove Export/View All buttons, add Customise Dashboard + drag handles on widgets
- Department/Designation/Roles: new design system with orange label header, stat cards, light table header, 3-dot action menus, status badges
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 08:01:23 +01:00
|
|
|
|
|
2026-04-19 22:20:08 +02:00
|
|
|
|
<div class="flex shrink-0">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="inline-flex h-9 items-center gap-1.5 whitespace-nowrap rounded-xl bg-[#0D0D2A] px-5 text-[13px] font-semibold leading-none text-white shadow-sm"
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
const next = !settingsOpen();
|
|
|
|
|
|
setSettingsOpen(next);
|
|
|
|
|
|
if (!next) setOpenMenuId(null);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Settings2 size={14} class="text-[#FA5014]" />
|
|
|
|
|
|
{settingsOpen() ? "Close Settings" : "Customize Widgets"}
|
|
|
|
|
|
</button>
|
feat(admin): redesign sidebar, dashboard, dept, designation & roles UI
- Sidebar: white bg, rounded pill nav items, orange left indicator for active
- Dashboard: remove Export/View All buttons, add Customise Dashboard + drag handles on widgets
- Department/Designation/Roles: new design system with orange label header, stat cards, light table header, 3-dot action menus, status badges
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 08:01:23 +01:00
|
|
|
|
</div>
|
2026-03-26 00:35:41 +01:00
|
|
|
|
</div>
|
2026-04-19 22:20:08 +02:00
|
|
|
|
</div>
|
feat(admin): Phase 0 — Tailwind v4 foundation, shell rewrite, modern dashboard
- Install Tailwind CSS v4 via @tailwindcss/vite; configure vite.config.ts
- Rewrite app.css: Tailwind base, Exo 2 font, brand tokens (orange #fd6216, navy #050026), scrollbar utility; fix @import order
- Rewrite AdminShell.tsx: fixed header, fixed inset body grid (sidebar + main), session check, sub-tab system, logout, admin avatar/name/role
- Rewrite AdminSidebar.tsx: collapsible w-64/w-20, orange active rail + badge/dot, CSS filter for SVG icon tinting, min-h-0 flex fix
- Replace 84 route stub CSS classes (page-title, card, btn, table-wrap, etc.) with Tailwind equivalents via safe class-attr-only regex script
- Rewrite admin dashboard: Lucide icons in colored chip backgrounds, 4-col KPI grid, Control Plane 6-module grid, hover lift animations
- Disable SSR (ssr: false) to fix Vinxi dev manifest error; clear stale .vinxi cache
- Add lucide-solid icon library
- Add scripts/cleanup-css.mjs for class migration
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 23:00:21 +01:00
|
|
|
|
|
2026-04-19 22:20:08 +02:00
|
|
|
|
<Show when={settingsOpen()}>
|
|
|
|
|
|
<div class="rounded-2xl border border-[#E5E7EB] bg-white p-6 shadow-sm md:p-7">
|
|
|
|
|
|
{/* Settings header */}
|
|
|
|
|
|
<div class="flex flex-wrap items-start justify-between gap-4">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h2 class="text-[15px] font-semibold text-[#111827]">Widget Settings</h2>
|
|
|
|
|
|
<p class="mt-1 text-[13px] text-[#6B7280]">
|
|
|
|
|
|
Choose visible widgets and select a grid layout.
|
|
|
|
|
|
</p>
|
feat(admin): redesign sidebar, dashboard, dept, designation & roles UI
- Sidebar: white bg, rounded pill nav items, orange left indicator for active
- Dashboard: remove Export/View All buttons, add Customise Dashboard + drag handles on widgets
- Department/Designation/Roles: new design system with orange label header, stat cards, light table header, 3-dot action menus, status badges
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 08:01:23 +01:00
|
|
|
|
</div>
|
2026-04-19 22:20:08 +02:00
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="inline-flex h-9 items-center gap-1.5 rounded-lg border border-[#E5E7EB] bg-white px-3 text-xs font-semibold text-[#111827] shadow-sm"
|
|
|
|
|
|
onClick={resetLayout}
|
|
|
|
|
|
>
|
|
|
|
|
|
<RotateCcw size={13} class="text-[#FA5014]" />
|
|
|
|
|
|
Reset Layout
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
2026-03-27 20:10:08 +01:00
|
|
|
|
|
2026-04-19 22:20:08 +02:00
|
|
|
|
{/* Filter controls */}
|
|
|
|
|
|
<div class="mt-5 flex flex-wrap gap-3">
|
|
|
|
|
|
<div class="relative min-w-[200px] flex-1">
|
|
|
|
|
|
<Search
|
|
|
|
|
|
size={14}
|
|
|
|
|
|
class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-[#FA5014]"
|
|
|
|
|
|
style="z-index:1"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<input
|
|
|
|
|
|
value={search()}
|
|
|
|
|
|
onInput={(event) => setSearch(event.currentTarget.value)}
|
|
|
|
|
|
placeholder="Search widgets"
|
|
|
|
|
|
class="h-10 w-full rounded-lg border border-[#E5E7EB] bg-white pl-10 pr-3 text-sm text-[#111827] outline-none focus:border-[#FA5014]"
|
|
|
|
|
|
/>
|
2026-03-26 06:20:22 +01:00
|
|
|
|
</div>
|
2026-03-27 20:10:08 +01:00
|
|
|
|
|
2026-04-19 22:20:08 +02:00
|
|
|
|
<select
|
|
|
|
|
|
class="h-10 rounded-lg border border-[#E5E7EB] bg-white px-3 text-sm text-[#111827]"
|
|
|
|
|
|
value={filterMode()}
|
|
|
|
|
|
onChange={(event) => setFilterMode(event.currentTarget.value as FilterMode)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<option value="all">All Widgets</option>
|
|
|
|
|
|
<option value="summary">Summary Only</option>
|
|
|
|
|
|
<option value="analytics">Analytical Only</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
|
|
|
|
|
|
<select
|
|
|
|
|
|
class="h-10 rounded-lg border border-[#E5E7EB] bg-white px-3 text-sm text-[#111827]"
|
|
|
|
|
|
value={sortMode()}
|
|
|
|
|
|
onChange={(event) => setSortMode(event.currentTarget.value as SortMode)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<option value="layout">Layout Order</option>
|
|
|
|
|
|
<option value="name">Name</option>
|
|
|
|
|
|
<option value="status">Status</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
|
|
|
|
|
|
<select
|
|
|
|
|
|
class="h-10 rounded-lg border border-[#E5E7EB] bg-white px-3 text-sm text-[#111827]"
|
|
|
|
|
|
value={gridLayout()}
|
|
|
|
|
|
onChange={(event) => setGridLayout(event.currentTarget.value as GridLayoutMode)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<option value="3x4">Grid 3 × 4</option>
|
|
|
|
|
|
<option value="3x3">Grid 3 × 3</option>
|
|
|
|
|
|
</select>
|
2026-03-26 06:20:22 +01:00
|
|
|
|
</div>
|
2026-03-25 23:01:42 +01:00
|
|
|
|
|
2026-04-19 22:20:08 +02:00
|
|
|
|
{/* Widget list */}
|
|
|
|
|
|
<div class="mt-5 grid gap-3 md:grid-cols-2">
|
|
|
|
|
|
<For each={ADMIN_DASHBOARD_WIDGETS}>
|
2026-03-27 18:29:21 +01:00
|
|
|
|
{(definition) => {
|
2026-04-19 22:20:08 +02:00
|
|
|
|
const visible = () => layout().visibility[definition.widgetKey] !== false;
|
2026-03-27 18:29:21 +01:00
|
|
|
|
const meta = WIDGET_META[definition.widgetKey];
|
2026-04-02 13:09:42 +02:00
|
|
|
|
const stateInfo = getWidgetState(definition.widgetKey);
|
|
|
|
|
|
const state = stateInfo.state;
|
2026-03-27 18:29:21 +01:00
|
|
|
|
|
|
|
|
|
|
return (
|
2026-04-19 22:20:08 +02:00
|
|
|
|
<div class="flex items-center justify-between gap-4 rounded-xl border border-[#E5E7EB] bg-[#F9FAFB] px-4 py-4">
|
|
|
|
|
|
<div class="flex items-center gap-3 min-w-0">
|
|
|
|
|
|
<div class="shrink-0 text-[#FA5014]">
|
2026-03-27 18:29:21 +01:00
|
|
|
|
{iconForWidget(definition.widgetKey)}
|
|
|
|
|
|
</div>
|
2026-04-19 22:20:08 +02:00
|
|
|
|
<div class="min-w-0">
|
|
|
|
|
|
<p class="text-sm font-semibold text-[#111827]">{definition.title}</p>
|
|
|
|
|
|
<p class="mt-0.5 text-[11px] font-medium uppercase tracking-wide text-[#9CA3AF]">
|
|
|
|
|
|
{definition.moduleKey}
|
|
|
|
|
|
</p>
|
2026-03-27 18:29:21 +01:00
|
|
|
|
</div>
|
2026-04-19 22:20:08 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
<div class="flex shrink-0 items-center gap-2">
|
|
|
|
|
|
<span
|
|
|
|
|
|
class={`hidden rounded-full border px-2 py-0.5 text-[10px] font-semibold sm:inline-flex ${badgeClass(state as WidgetStateKind)}`}
|
|
|
|
|
|
>
|
2026-04-02 13:09:42 +02:00
|
|
|
|
{stateInfo.statusLabel}
|
2026-03-27 18:29:21 +01:00
|
|
|
|
</span>
|
2026-04-19 22:20:08 +02:00
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class={`inline-flex h-8 items-center gap-1.5 rounded-md border px-3 text-xs font-semibold ${
|
|
|
|
|
|
visible()
|
|
|
|
|
|
? "border-[#FDBA8C] bg-[#FFF1EB] text-[#FA5014]"
|
|
|
|
|
|
: "border-[#E5E7EB] bg-white text-[#6B7280]"
|
|
|
|
|
|
}`}
|
|
|
|
|
|
onClick={() => setWidgetVisibility(definition.widgetKey, !visible())}
|
|
|
|
|
|
>
|
|
|
|
|
|
{visible() ? <Eye size={12} /> : <EyeOff size={12} />}
|
|
|
|
|
|
{visible() ? "Visible" : "Hidden"}
|
|
|
|
|
|
</button>
|
feat(admin): redesign sidebar, dashboard, dept, designation & roles UI
- Sidebar: white bg, rounded pill nav items, orange left indicator for active
- Dashboard: remove Export/View All buttons, add Customise Dashboard + drag handles on widgets
- Department/Designation/Roles: new design system with orange label header, stat cards, light table header, 3-dot action menus, status badges
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 08:01:23 +01:00
|
|
|
|
</div>
|
2026-04-19 22:20:08 +02:00
|
|
|
|
</div>
|
2026-03-27 18:29:21 +01:00
|
|
|
|
);
|
|
|
|
|
|
}}
|
|
|
|
|
|
</For>
|
2026-04-19 22:20:08 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<Show when={isAutoSaving() || autoSaveNotice()}>
|
|
|
|
|
|
<p class="mt-4 text-xs text-[#6B7280]">
|
|
|
|
|
|
{isAutoSaving() ? "Saving layout..." : autoSaveNotice()}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</Show>
|
2026-03-26 06:20:22 +01:00
|
|
|
|
</div>
|
2026-04-19 22:20:08 +02:00
|
|
|
|
</Show>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="grid grid-cols-1 gap-5 xl:grid-cols-12" style="margin-top: 28px">
|
|
|
|
|
|
<For each={orderedWidgets()}>
|
|
|
|
|
|
{(definition) => {
|
|
|
|
|
|
const meta = WIDGET_META[definition.widgetKey];
|
|
|
|
|
|
const stateInfo = getWidgetState(definition.widgetKey);
|
|
|
|
|
|
const state = stateInfo.state;
|
|
|
|
|
|
const isOpenMenu = () => openMenuId() === definition.widgetKey;
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<section
|
|
|
|
|
|
draggable={settingsOpen()}
|
|
|
|
|
|
onDragStart={(event) => handleDragStart(event, definition.widgetKey)}
|
|
|
|
|
|
onDragOver={(event) => event.preventDefault()}
|
|
|
|
|
|
onDrop={(event) => handleDrop(event, definition.widgetKey)}
|
|
|
|
|
|
onDragEnd={() => setDraggingKey(null)}
|
|
|
|
|
|
class={`relative aspect-square min-h-[235px] rounded-2xl border border-[#E5E7EB] bg-white p-5 shadow-sm md:min-h-[260px] md:p-6 ${widgetSpan(gridLayout())} ${
|
|
|
|
|
|
state === "pending" ? "opacity-95" : ""
|
|
|
|
|
|
} ${draggingKey() === definition.widgetKey ? "opacity-60" : ""}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Show when={settingsOpen()}>
|
|
|
|
|
|
<>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="absolute left-3 top-3 inline-flex h-8 w-8 items-center justify-center rounded-md border border-[#E5E7EB] bg-white cursor-grab text-[#FA5014]"
|
|
|
|
|
|
aria-label="Drag to reorder"
|
|
|
|
|
|
>
|
|
|
|
|
|
<GripVertical size={14} class="text-[#FA5014]" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<div class="absolute right-3 top-3">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="inline-flex h-8 w-8 items-center justify-center rounded-md border border-[#E5E7EB] bg-white text-[#FA5014]"
|
|
|
|
|
|
onClick={() => setOpenMenuId(isOpenMenu() ? null : definition.widgetKey)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<EllipsisVertical size={14} class="text-[#FA5014]" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
|
|
<Show when={isOpenMenu()}>
|
|
|
|
|
|
<div class="absolute right-0 top-10 z-20 w-40 rounded-lg border border-[#E5E7EB] bg-white p-1 shadow-md">
|
|
|
|
|
|
{["Edit Widget", "Duplicate Widget", "Hide Widget", "Remove Widget"].map(
|
|
|
|
|
|
(item) => (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="block w-full rounded-md px-2 py-2 text-left text-xs font-medium text-[#111827] hover:bg-[#F9FAFB]"
|
|
|
|
|
|
onClick={() => setOpenMenuId(null)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{item}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Show>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</>
|
|
|
|
|
|
</Show>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="flex h-full flex-col items-center justify-center px-4">
|
|
|
|
|
|
<div class="mb-3 text-[#FA5014]">{iconForWidget(definition.widgetKey)}</div>
|
|
|
|
|
|
<h3 class="text-center text-[15px] font-semibold text-[#111827]">
|
|
|
|
|
|
{definition.title}
|
|
|
|
|
|
</h3>
|
|
|
|
|
|
<div class="mt-4 h-[116px] w-full max-w-[210px] rounded-xl border border-[#E5E7EB] bg-[#F9FAFB] p-4">
|
|
|
|
|
|
<Show when={state === "live"}>
|
|
|
|
|
|
<LivePreview
|
|
|
|
|
|
value={stateInfo.data?.value}
|
|
|
|
|
|
trend={stateInfo.data?.trend}
|
|
|
|
|
|
trendUp={stateInfo.data?.trendUp}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</Show>
|
|
|
|
|
|
<Show when={state === "empty"}>
|
|
|
|
|
|
<EmptyPreview />
|
|
|
|
|
|
</Show>
|
|
|
|
|
|
<Show when={state === "pending"}>
|
|
|
|
|
|
<PendingPreview />
|
|
|
|
|
|
</Show>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<span
|
|
|
|
|
|
class={`mt-4 inline-flex rounded-full border px-2 py-1 text-[11px] font-semibold ${badgeClass(state as WidgetStateKind)}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
{stateInfo.statusLabel}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
);
|
|
|
|
|
|
}}
|
|
|
|
|
|
</For>
|
feat(admin): Phase 0 — Tailwind v4 foundation, shell rewrite, modern dashboard
- Install Tailwind CSS v4 via @tailwindcss/vite; configure vite.config.ts
- Rewrite app.css: Tailwind base, Exo 2 font, brand tokens (orange #fd6216, navy #050026), scrollbar utility; fix @import order
- Rewrite AdminShell.tsx: fixed header, fixed inset body grid (sidebar + main), session check, sub-tab system, logout, admin avatar/name/role
- Rewrite AdminSidebar.tsx: collapsible w-64/w-20, orange active rail + badge/dot, CSS filter for SVG icon tinting, min-h-0 flex fix
- Replace 84 route stub CSS classes (page-title, card, btn, table-wrap, etc.) with Tailwind equivalents via safe class-attr-only regex script
- Rewrite admin dashboard: Lucide icons in colored chip backgrounds, 4-col KPI grid, Control Plane 6-module grid, hover lift animations
- Disable SSR (ssr: false) to fix Vinxi dev manifest error; clear stale .vinxi cache
- Add lucide-solid icon library
- Add scripts/cleanup-css.mjs for class migration
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 23:00:21 +01:00
|
|
|
|
</div>
|
2026-04-19 22:20:08 +02:00
|
|
|
|
</div>
|
2026-03-17 15:37:49 +01:00
|
|
|
|
);
|
2026-03-16 23:20:54 +01:00
|
|
|
|
}
|