Refine admin dashboard layout spacing and widget settings UX
This commit is contained in:
parent
717a6c799e
commit
2a3a336114
4 changed files with 862 additions and 227 deletions
185
src/lib/admin/dashboard.ts
Normal file
185
src/lib/admin/dashboard.ts
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
export type DashboardWidgetSize = 'S' | 'M' | 'L';
|
||||
|
||||
export type WidgetDataState = 'loading' | 'success' | 'empty' | 'error' | 'unavailable';
|
||||
|
||||
export type WidgetDataResult<T = unknown> = {
|
||||
state: WidgetDataState;
|
||||
payload?: T;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export type KpiPayload = {
|
||||
value: string;
|
||||
delta: string;
|
||||
note: string;
|
||||
tone: 'up' | 'down';
|
||||
icon: 'users' | 'building' | 'trend' | 'card';
|
||||
};
|
||||
|
||||
export type LineChartPayload = {
|
||||
labels: string[];
|
||||
series: number[];
|
||||
maxY: number;
|
||||
};
|
||||
|
||||
export type BarChartPayload = {
|
||||
labels: string[];
|
||||
series: number[];
|
||||
maxY: number;
|
||||
};
|
||||
|
||||
export type AdminWidgetPayload =
|
||||
| { kind: 'kpi'; data: KpiPayload }
|
||||
| { kind: 'line_chart'; data: LineChartPayload }
|
||||
| { kind: 'bar_chart'; data: BarChartPayload };
|
||||
|
||||
export type DashboardWidgetDefinition = {
|
||||
widgetKey: string;
|
||||
title: string;
|
||||
moduleKey: string;
|
||||
defaultSize: DashboardWidgetSize;
|
||||
defaultVisible: boolean;
|
||||
order: number;
|
||||
dataAdapterKey: string;
|
||||
readiness: 'ready' | 'planned';
|
||||
description?: string;
|
||||
};
|
||||
|
||||
const kpiData: Record<string, KpiPayload> = {
|
||||
totalUsers: {
|
||||
value: '12,458',
|
||||
delta: '+12.5%',
|
||||
note: '+1,245 this month',
|
||||
tone: 'up',
|
||||
icon: 'users',
|
||||
},
|
||||
activeCompanies: {
|
||||
value: '1,234',
|
||||
delta: '+8.2%',
|
||||
note: '+94 this month',
|
||||
tone: 'up',
|
||||
icon: 'building',
|
||||
},
|
||||
openLeads: {
|
||||
value: '847',
|
||||
delta: '-3.1%',
|
||||
note: '27 fewer than last month',
|
||||
tone: 'down',
|
||||
icon: 'trend',
|
||||
},
|
||||
creditsPurchased: {
|
||||
value: '₹45,890',
|
||||
delta: '+18.7%',
|
||||
note: '₹7,234 more this month',
|
||||
tone: 'up',
|
||||
icon: 'card',
|
||||
},
|
||||
};
|
||||
|
||||
const lineChartData: LineChartPayload = {
|
||||
labels: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'],
|
||||
series: [62, 70, 81, 75, 88, 102],
|
||||
maxY: 120,
|
||||
};
|
||||
|
||||
const barChartData: BarChartPayload = {
|
||||
labels: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'],
|
||||
series: [42000, 48000, 55000, 51000, 62000, 69000],
|
||||
maxY: 80000,
|
||||
};
|
||||
|
||||
const adapterMap: Record<string, () => Promise<WidgetDataResult<AdminWidgetPayload>>> = {
|
||||
kpi_total_users: async () => ({ state: 'success', payload: { kind: 'kpi', data: kpiData.totalUsers } }),
|
||||
kpi_active_companies: async () => ({ state: 'success', payload: { kind: 'kpi', data: kpiData.activeCompanies } }),
|
||||
kpi_open_leads: async () => ({ state: 'success', payload: { kind: 'kpi', data: kpiData.openLeads } }),
|
||||
kpi_credits_purchased: async () => ({ state: 'success', payload: { kind: 'kpi', data: kpiData.creditsPurchased } }),
|
||||
chart_leads_trend: async () => ({ state: 'success', payload: { kind: 'line_chart', data: lineChartData } }),
|
||||
chart_revenue_overview: async () => ({ state: 'success', payload: { kind: 'bar_chart', data: barChartData } }),
|
||||
};
|
||||
|
||||
export const ADMIN_DASHBOARD_WIDGETS: DashboardWidgetDefinition[] = [
|
||||
{
|
||||
widgetKey: 'kpi_total_users',
|
||||
title: 'Total Users',
|
||||
moduleKey: 'USER_MANAGEMENT',
|
||||
defaultSize: 'S',
|
||||
defaultVisible: true,
|
||||
order: 1,
|
||||
dataAdapterKey: 'kpi_total_users',
|
||||
readiness: 'ready',
|
||||
},
|
||||
{
|
||||
widgetKey: 'kpi_active_companies',
|
||||
title: 'Active Companies',
|
||||
moduleKey: 'COMPANY_MANAGEMENT',
|
||||
defaultSize: 'S',
|
||||
defaultVisible: true,
|
||||
order: 2,
|
||||
dataAdapterKey: 'kpi_active_companies',
|
||||
readiness: 'ready',
|
||||
},
|
||||
{
|
||||
widgetKey: 'kpi_open_leads',
|
||||
title: 'Open Leads',
|
||||
moduleKey: 'REQUIREMENTS_MANAGEMENT',
|
||||
defaultSize: 'S',
|
||||
defaultVisible: true,
|
||||
order: 3,
|
||||
dataAdapterKey: 'kpi_open_leads',
|
||||
readiness: 'ready',
|
||||
},
|
||||
{
|
||||
widgetKey: 'kpi_credits_purchased',
|
||||
title: 'Credits Purchased',
|
||||
moduleKey: 'CREDIT_MANAGEMENT',
|
||||
defaultSize: 'S',
|
||||
defaultVisible: true,
|
||||
order: 4,
|
||||
dataAdapterKey: 'kpi_credits_purchased',
|
||||
readiness: 'ready',
|
||||
},
|
||||
{
|
||||
widgetKey: 'chart_leads_trend',
|
||||
title: 'Leads Trend',
|
||||
moduleKey: 'REPORTS',
|
||||
defaultSize: 'M',
|
||||
defaultVisible: true,
|
||||
order: 5,
|
||||
dataAdapterKey: 'chart_leads_trend',
|
||||
readiness: 'planned',
|
||||
description: 'Monthly leads performance overview',
|
||||
},
|
||||
{
|
||||
widgetKey: 'chart_revenue_overview',
|
||||
title: 'Revenue Overview',
|
||||
moduleKey: 'REVENUE_LEDGER',
|
||||
defaultSize: 'M',
|
||||
defaultVisible: true,
|
||||
order: 6,
|
||||
dataAdapterKey: 'chart_revenue_overview',
|
||||
readiness: 'planned',
|
||||
description: 'Monthly revenue vs expenses comparison',
|
||||
},
|
||||
];
|
||||
|
||||
export function loadWidgetData(definition: DashboardWidgetDefinition): Promise<WidgetDataResult<AdminWidgetPayload>> {
|
||||
if (definition.readiness !== 'ready') {
|
||||
return Promise.resolve({
|
||||
state: 'unavailable',
|
||||
message: 'This widget will switch to module-backed data when that module is completed.',
|
||||
});
|
||||
}
|
||||
|
||||
const loader = adapterMap[definition.dataAdapterKey];
|
||||
if (!loader) {
|
||||
return Promise.resolve({
|
||||
state: 'error',
|
||||
message: `Missing data adapter: ${definition.dataAdapterKey}`,
|
||||
});
|
||||
}
|
||||
|
||||
return loader().catch(() => ({
|
||||
state: 'error',
|
||||
message: 'Failed to load widget data.',
|
||||
}));
|
||||
}
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
import type { RuntimeDashboardLayout } from './types';
|
||||
|
||||
// All API calls go through the server route gateway proxy
|
||||
const API_GATEWAY = '/api/gateway';
|
||||
|
||||
|
|
@ -22,6 +24,15 @@ type RoleRecord = {
|
|||
audience: string;
|
||||
};
|
||||
|
||||
type DashboardConfigResponse = {
|
||||
id?: string;
|
||||
role_id?: string;
|
||||
config_json?: Record<string, unknown>;
|
||||
is_active?: boolean;
|
||||
updated_at?: string;
|
||||
created_at?: string;
|
||||
};
|
||||
|
||||
export async function saveRuntimeConfig(type: RuntimeRecordType, key: string, payload: unknown, status: RuntimeRecordStatus) {
|
||||
const normalizedKey = key.trim().toUpperCase();
|
||||
if (!normalizedKey) return;
|
||||
|
|
@ -29,7 +40,7 @@ export async function saveRuntimeConfig(type: RuntimeRecordType, key: string, pa
|
|||
try {
|
||||
// 1. Ensure the Role exists first. We lookup by key.
|
||||
// If it doesn't exist, we create it generically.
|
||||
let roleRes = await fetch(`${API_GATEWAY}/api/admin/roles/${normalizedKey}`);
|
||||
const roleRes = await fetch(`${API_GATEWAY}/api/admin/roles/${normalizedKey}`);
|
||||
let role: RoleRecord;
|
||||
|
||||
if (!roleRes.ok && roleRes.status === 404) {
|
||||
|
|
@ -43,7 +54,7 @@ export async function saveRuntimeConfig(type: RuntimeRecordType, key: string, pa
|
|||
audience: 'EXTERNAL',
|
||||
})
|
||||
});
|
||||
if (!createRes.ok) throw new Error("Failed to create role");
|
||||
if (!createRes.ok) throw new Error('Failed to create role');
|
||||
role = await createRes.json();
|
||||
} else {
|
||||
role = await roleRes.json();
|
||||
|
|
@ -56,7 +67,7 @@ export async function saveRuntimeConfig(type: RuntimeRecordType, key: string, pa
|
|||
onboarding: '/api/admin/onboarding-config',
|
||||
};
|
||||
|
||||
let bodyPayload: any = {
|
||||
const bodyPayload: Record<string, unknown> = {
|
||||
role_id: role.id,
|
||||
};
|
||||
|
||||
|
|
@ -66,7 +77,7 @@ export async function saveRuntimeConfig(type: RuntimeRecordType, key: string, pa
|
|||
} else if (type === 'onboarding') {
|
||||
bodyPayload.schema_json = payload;
|
||||
} else if (type === 'role') {
|
||||
bodyPayload.config_json = payload;
|
||||
bodyPayload.config_json = payload;
|
||||
}
|
||||
|
||||
const saveRes = await fetch(`${API_GATEWAY}${endpointMap[type]}`, {
|
||||
|
|
@ -76,7 +87,7 @@ export async function saveRuntimeConfig(type: RuntimeRecordType, key: string, pa
|
|||
});
|
||||
|
||||
if (!saveRes.ok) {
|
||||
throw new Error(`Failed to save ${type} config`);
|
||||
throw new Error(`Failed to save ${type} config`);
|
||||
}
|
||||
|
||||
console.log(`Saved ${type} mapping for role ${normalizedKey} successfully to Rust backend`);
|
||||
|
|
@ -87,53 +98,167 @@ export async function saveRuntimeConfig(type: RuntimeRecordType, key: string, pa
|
|||
}
|
||||
|
||||
export async function listRuntimeConfigs<T>(type: RuntimeRecordType): Promise<Array<RuntimeListItem<T>>> {
|
||||
const endpointMap: Record<RuntimeRecordType, string> = {
|
||||
role: '/api/admin/roles',
|
||||
dashboard: '/api/admin/dashboard-config',
|
||||
onboarding: '/api/admin/onboarding-config',
|
||||
};
|
||||
const endpointMap: Record<RuntimeRecordType, string> = {
|
||||
role: '/api/admin/roles',
|
||||
dashboard: '/api/admin/dashboard-config',
|
||||
onboarding: '/api/admin/onboarding-config',
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_GATEWAY}${endpointMap[type]}`);
|
||||
if (!res.ok) throw new Error(`Failed to list ${type} configs`);
|
||||
const data = await res.json();
|
||||
try {
|
||||
const res = await fetch(`${API_GATEWAY}${endpointMap[type]}`);
|
||||
if (!res.ok) throw new Error(`Failed to list ${type} configs`);
|
||||
const data = await res.json();
|
||||
|
||||
// Map Rust backend models to UI expected format
|
||||
return data.map((item: any) => ({
|
||||
key: item.role_key || item.key,
|
||||
status: item.is_active ? 'published' : 'draft',
|
||||
updatedAt: item.updated_at || item.created_at,
|
||||
payload: item.config_json || item.schema_json || item,
|
||||
// include raw id for routing convenience
|
||||
id: item.id,
|
||||
role_id: item.role_id
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error(`Error listing runtime config [${type}]:`, err);
|
||||
return [];
|
||||
}
|
||||
// Map Rust backend models to UI expected format
|
||||
return data.map((item: any) => ({
|
||||
key: item.role_key || item.key,
|
||||
status: item.is_active ? 'published' : 'draft',
|
||||
updatedAt: item.updated_at || item.created_at,
|
||||
payload: item.config_json || item.schema_json || item,
|
||||
// include raw id for routing convenience
|
||||
id: item.id,
|
||||
role_id: item.role_id
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error(`Error listing runtime config [${type}]:`, err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRuntimeConfig<T>(type: RuntimeRecordType, roleId: string): Promise<RuntimeStoredRecord<T> | null> {
|
||||
const endpointMap: Record<RuntimeRecordType, string> = {
|
||||
role: `/api/admin/roles/${roleId}`, // Assume roleId is key if testing
|
||||
dashboard: `/api/admin/dashboard-config/${roleId}?audience=EXTERNAL`,
|
||||
onboarding: `/api/admin/onboarding-config/${roleId}`,
|
||||
const endpointMap: Record<RuntimeRecordType, string> = {
|
||||
role: `/api/admin/roles/${roleId}`, // Assume roleId is key if testing
|
||||
dashboard: `/api/admin/dashboard-config/${roleId}?audience=EXTERNAL`,
|
||||
onboarding: `/api/admin/onboarding-config/${roleId}`,
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_GATEWAY}${endpointMap[type]}`);
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
|
||||
return {
|
||||
status: data.is_active ? 'published' : 'draft',
|
||||
updatedAt: data.updated_at || data.created_at,
|
||||
payload: data.config_json || data.schema_json || data,
|
||||
};
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_GATEWAY}${endpointMap[type]}`);
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
async function resolveInternalRoleId(preferredKeys: string[] = ['ADMIN', 'SUPER_ADMIN']): Promise<string | null> {
|
||||
try {
|
||||
const res = await fetch(`${API_GATEWAY}/api/admin/roles?audience=INTERNAL`);
|
||||
if (!res.ok) return null;
|
||||
|
||||
return {
|
||||
status: data.is_active ? 'published' : 'draft',
|
||||
updatedAt: data.updated_at || data.created_at,
|
||||
payload: data.config_json || data.schema_json || data,
|
||||
};
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
const data = await res.json();
|
||||
const rows = Array.isArray(data) ? data : (data.roles || []);
|
||||
if (!Array.isArray(rows) || rows.length === 0) return null;
|
||||
|
||||
const normalized = rows.map((row: any) => ({
|
||||
id: String(row?.id || ''),
|
||||
key: String(row?.key || row?.role_key || '').toUpperCase(),
|
||||
audience: String(row?.audience || '').toUpperCase(),
|
||||
isActive: row?.is_active !== false,
|
||||
})).filter((row: any) => row.id);
|
||||
|
||||
for (const pref of preferredKeys) {
|
||||
const hit = normalized.find((row: any) => row.key === pref && row.isActive);
|
||||
if (hit) return hit.id;
|
||||
}
|
||||
|
||||
const firstInternalActive = normalized.find((row: any) => row.audience === 'INTERNAL' && row.isActive);
|
||||
if (firstInternalActive) return firstInternalActive.id;
|
||||
|
||||
return normalized[0]?.id || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeDashboardLayout(input: any): RuntimeDashboardLayout | null {
|
||||
if (!input || typeof input !== 'object') return null;
|
||||
|
||||
const rawOrder = Array.isArray(input.order)
|
||||
? input.order.map((value: unknown) => String(value)).filter(Boolean)
|
||||
: [];
|
||||
|
||||
const rawVisibility = (input.visibility && typeof input.visibility === 'object') ? input.visibility : {};
|
||||
const rawSize = (input.size && typeof input.size === 'object') ? input.size : {};
|
||||
|
||||
const visibility: Record<string, boolean> = {};
|
||||
for (const [key, value] of Object.entries(rawVisibility)) {
|
||||
visibility[String(key)] = Boolean(value);
|
||||
}
|
||||
|
||||
const size: Record<string, 'S' | 'M' | 'L'> = {};
|
||||
for (const [key, value] of Object.entries(rawSize)) {
|
||||
const normalized = String(value || '').toUpperCase();
|
||||
if (normalized === 'S' || normalized === 'M' || normalized === 'L') {
|
||||
size[String(key)] = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
order: rawOrder,
|
||||
visibility,
|
||||
size,
|
||||
};
|
||||
}
|
||||
|
||||
async function readDashboardConfig(roleId: string, audience: 'INTERNAL' | 'EXTERNAL'): Promise<DashboardConfigResponse | null> {
|
||||
try {
|
||||
const res = await fetch(`${API_GATEWAY}/api/admin/dashboard-config/${roleId}?audience=${audience}`);
|
||||
if (!res.ok) return null;
|
||||
return await res.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadAdminDashboardLayout(): Promise<RuntimeDashboardLayout | null> {
|
||||
const roleId = await resolveInternalRoleId();
|
||||
if (!roleId) return null;
|
||||
|
||||
const data = await readDashboardConfig(roleId, 'INTERNAL');
|
||||
const config = data?.config_json;
|
||||
return normalizeDashboardLayout((config as any)?.dashboard_layout);
|
||||
}
|
||||
|
||||
export async function saveAdminDashboardLayout(layout: RuntimeDashboardLayout): Promise<boolean> {
|
||||
const roleId = await resolveInternalRoleId();
|
||||
if (!roleId) return false;
|
||||
|
||||
const existing = await readDashboardConfig(roleId, 'INTERNAL');
|
||||
const existingJson = (existing?.config_json && typeof existing.config_json === 'object')
|
||||
? existing.config_json
|
||||
: {};
|
||||
|
||||
const config_json = {
|
||||
...existingJson,
|
||||
dashboard_layout: {
|
||||
order: Array.from(new Set(layout.order.map((item) => String(item)).filter(Boolean))),
|
||||
visibility: layout.visibility,
|
||||
size: layout.size,
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_GATEWAY}/api/admin/dashboard-config`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
role_id: roleId,
|
||||
audience: 'INTERNAL',
|
||||
config_json,
|
||||
}),
|
||||
});
|
||||
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteRuntimeConfig(type: RuntimeRecordType, key: string): Promise<boolean> {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,11 @@
|
|||
export type DashboardWidgetSize = 'S' | 'M' | 'L';
|
||||
|
||||
export type RuntimeDashboardLayout = {
|
||||
order: string[];
|
||||
visibility: Record<string, boolean>;
|
||||
size: Record<string, DashboardWidgetSize>;
|
||||
};
|
||||
|
||||
export type RuntimeRoleConfig = {
|
||||
roleKey: string;
|
||||
displayName: string;
|
||||
|
|
@ -11,6 +19,7 @@ export type RuntimeDashboardConfig = {
|
|||
roleKey: string;
|
||||
sidebar: Array<{ key: string; label: string; route: string }>;
|
||||
widgets: Array<{ key: string; title: string; enabled: boolean }>;
|
||||
dashboard_layout?: RuntimeDashboardLayout;
|
||||
};
|
||||
|
||||
export type RuntimeOnboardingFieldOption = {
|
||||
|
|
|
|||
|
|
@ -1,208 +1,524 @@
|
|||
import { For } from 'solid-js';
|
||||
import { For, Show, createEffect, createMemo, createSignal, onCleanup, onMount } from 'solid-js';
|
||||
import {
|
||||
BarChart3,
|
||||
Building2,
|
||||
CircleDashed,
|
||||
Coins,
|
||||
EllipsisVertical,
|
||||
Eye,
|
||||
EyeOff,
|
||||
GripVertical,
|
||||
LineChart,
|
||||
RotateCcw,
|
||||
Search,
|
||||
Settings2,
|
||||
TrendingUp,
|
||||
Users,
|
||||
} from 'lucide-solid';
|
||||
import AdminShell from '~/components/AdminShell';
|
||||
import { GripVertical } from 'lucide-solid';
|
||||
import {
|
||||
ADMIN_DASHBOARD_WIDGETS,
|
||||
type DashboardWidgetDefinition,
|
||||
type DashboardWidgetSize,
|
||||
} from '~/lib/admin/dashboard';
|
||||
import type { RuntimeDashboardLayout } from '~/lib/runtime/types';
|
||||
import { loadAdminDashboardLayout, saveAdminDashboardLayout } from '~/lib/runtime/storage';
|
||||
|
||||
const kpis = [
|
||||
{ title: 'Total Users', value: '12,458', delta: '+12.5%', note: '+1,245 this month', tone: 'up' as const, icon: 'users' as const },
|
||||
{ title: 'Active Companies', value: '1,234', delta: '+8.2%', note: '+94 this month', tone: 'up' as const, icon: 'building' as const },
|
||||
{ title: 'Open Leads', value: '847', delta: '-3.1%', note: '27 fewer than last month', tone: 'down' as const, icon: 'trend' as const },
|
||||
{ title: 'Credits Purchased', value: '₹45,890', delta: '+18.7%', note: '₹7,234 more this month', tone: 'up' as const, icon: 'card' as const },
|
||||
];
|
||||
type WidgetStateKind = 'live' | 'empty' | 'pending';
|
||||
type WidgetType = 'summary' | 'analytics';
|
||||
type SortMode = 'layout' | 'name' | 'status';
|
||||
type FilterMode = 'all' | WidgetType;
|
||||
type GridLayoutMode = '3x4' | '3x3';
|
||||
|
||||
const trendSeries = [62, 70, 81, 75, 88, 102];
|
||||
const revSeries = [42000, 48000, 55000, 51000, 62000, 69000];
|
||||
const maxAmount = 80000;
|
||||
type WidgetMeta = {
|
||||
state: WidgetStateKind;
|
||||
type: WidgetType;
|
||||
statusLabel: string;
|
||||
subtitle: string;
|
||||
emptyMessage?: string;
|
||||
pendingMessage?: string;
|
||||
};
|
||||
|
||||
function KpiIcon(props: { kind: 'users' | 'building' | 'trend' | 'card' }) {
|
||||
if (props.kind === 'users') {
|
||||
return (
|
||||
<svg class="h-6 w-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" aria-hidden="true">
|
||||
<path d="M8 13a4 4 0 1 0 0-8 4 4 0 0 0 0 8Z" />
|
||||
<path d="M16 11a3 3 0 1 0 0-6" />
|
||||
<path d="M3.5 20a5 5 0 0 1 9 0" />
|
||||
<path d="M14.5 19.5a4 4 0 0 1 6 0" />
|
||||
</svg>
|
||||
);
|
||||
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),
|
||||
visibility: Object.fromEntries(ADMIN_DASHBOARD_WIDGETS.map((definition) => [definition.widgetKey, definition.defaultVisible])),
|
||||
size: Object.fromEntries(ADMIN_DASHBOARD_WIDGETS.map((definition) => [definition.widgetKey, definition.defaultSize])),
|
||||
};
|
||||
|
||||
const WIDGET_META: Record<string, WidgetMeta> = {
|
||||
kpi_total_users: {
|
||||
state: 'empty',
|
||||
type: 'summary',
|
||||
statusLabel: 'No Data',
|
||||
subtitle: 'Powered by USER_MANAGEMENT',
|
||||
emptyMessage: 'No user data available yet',
|
||||
},
|
||||
kpi_active_companies: {
|
||||
state: 'empty',
|
||||
type: 'summary',
|
||||
statusLabel: 'No Data',
|
||||
subtitle: 'Powered by COMPANY_MANAGEMENT',
|
||||
emptyMessage: 'No company data available yet',
|
||||
},
|
||||
kpi_open_leads: {
|
||||
state: 'empty',
|
||||
type: 'summary',
|
||||
statusLabel: 'No Data',
|
||||
subtitle: 'Powered by REQUIREMENTS_MANAGEMENT',
|
||||
emptyMessage: 'No lead data available yet',
|
||||
},
|
||||
kpi_credits_purchased: {
|
||||
state: 'empty',
|
||||
type: 'summary',
|
||||
statusLabel: 'No Data',
|
||||
subtitle: 'Powered by CREDIT_MANAGEMENT',
|
||||
emptyMessage: 'No credit activity available yet',
|
||||
},
|
||||
chart_leads_trend: {
|
||||
state: 'pending',
|
||||
type: 'analytics',
|
||||
statusLabel: 'Module Pending',
|
||||
subtitle: 'Monthly leads performance overview • Powered by REPORTS',
|
||||
pendingMessage: 'This widget will connect to live reporting data once the Reports module is completed',
|
||||
},
|
||||
chart_revenue_overview: {
|
||||
state: 'pending',
|
||||
type: 'analytics',
|
||||
statusLabel: 'Module Pending',
|
||||
subtitle: 'Monthly revenue vs expenses comparison • Powered by REVENUE_LEDGER',
|
||||
pendingMessage: 'This widget will connect to ledger-based analytics once the Revenue Ledger module is completed',
|
||||
},
|
||||
};
|
||||
|
||||
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) {
|
||||
const normalizedKey = String(key || '');
|
||||
if (!knownKeys.has(normalizedKey) || seen.has(normalizedKey)) continue;
|
||||
seen.add(normalizedKey);
|
||||
normalizedOrder.push(normalizedKey);
|
||||
}
|
||||
if (props.kind === 'building') {
|
||||
return (
|
||||
<svg class="h-6 w-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" aria-hidden="true">
|
||||
<rect x="4" y="3" width="16" height="18" rx="2.5" />
|
||||
<path d="M8 7h2M12 7h2M8 11h2M12 11h2M8 15h2M12 15h2M11 21v-3h2v3" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
for (const key of fallbackOrder) {
|
||||
if (!seen.has(key)) normalizedOrder.push(key);
|
||||
}
|
||||
if (props.kind === 'trend') {
|
||||
return (
|
||||
<svg class="h-6 w-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" aria-hidden="true">
|
||||
<path d="m3 16 6-6 4 4 8-8" />
|
||||
<path d="M16 6h5v5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const visibility: Record<string, boolean> = {};
|
||||
const size: Record<string, DashboardWidgetSize> = {};
|
||||
|
||||
for (const definition of ADMIN_DASHBOARD_WIDGETS) {
|
||||
const key = definition.widgetKey;
|
||||
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;
|
||||
}
|
||||
|
||||
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) {
|
||||
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} />;
|
||||
return <LineChart size={22} class={cls} />;
|
||||
}
|
||||
|
||||
function badgeClass(state: WidgetStateKind): string {
|
||||
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]';
|
||||
}
|
||||
|
||||
function widgetSpan(mode: GridLayoutMode): string {
|
||||
return mode === '3x3' ? 'xl:col-span-4' : 'xl:col-span-3';
|
||||
}
|
||||
|
||||
function EmptyPreview() {
|
||||
return (
|
||||
<svg class="h-6 w-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" aria-hidden="true">
|
||||
<rect x="3" y="5" width="18" height="14" rx="2.5" />
|
||||
<path d="M3 10h18" />
|
||||
</svg>
|
||||
<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 DragHandle() {
|
||||
function PendingPreview() {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class="cursor-grab touch-none text-[#D1D5DB] hover:text-[#9CA3AF] transition-colors"
|
||||
aria-label="Drag to reorder"
|
||||
>
|
||||
<GripVertical size={18} />
|
||||
</button>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
function LivePreview() {
|
||||
return (
|
||||
<div class="flex h-full flex-col items-center justify-center">
|
||||
<p class="text-[26px] font-bold leading-none text-[#111827]">12,458</p>
|
||||
<p class="mt-2 inline-flex items-center gap-1 text-xs font-semibold text-[#FA5014]">↗ +12.5%</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminHomePage() {
|
||||
const [layout, setLayout] = createSignal<RuntimeDashboardLayout>(sanitizeLayout(DEFAULT_LAYOUT));
|
||||
const [settingsOpen, setSettingsOpen] = createSignal(false);
|
||||
const [isHydrating, setIsHydrating] = createSignal(true);
|
||||
const [isAutoSaving, setIsAutoSaving] = createSignal(false);
|
||||
const [autoSaveNotice, setAutoSaveNotice] = createSignal('');
|
||||
const [lastSavedSnapshot, setLastSavedSnapshot] = createSignal('');
|
||||
const [draggingKey, setDraggingKey] = createSignal<string | null>(null);
|
||||
const [openMenuId, setOpenMenuId] = createSignal<string | null>(null);
|
||||
|
||||
const [search, setSearch] = createSignal('');
|
||||
const [filterMode, setFilterMode] = createSignal<FilterMode>('all');
|
||||
const [sortMode, setSortMode] = createSignal<SortMode>('layout');
|
||||
const [gridLayout, setGridLayout] = createSignal<GridLayoutMode>('3x4');
|
||||
|
||||
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];
|
||||
return definition.title.toLowerCase().includes(query)
|
||||
|| definition.moduleKey.toLowerCase().includes(query)
|
||||
|| (meta?.subtitle || '').toLowerCase().includes(query);
|
||||
})
|
||||
.filter((definition) => {
|
||||
const mode = filterMode();
|
||||
if (mode === 'all') return true;
|
||||
return (WIDGET_META[definition.widgetKey]?.type || 'summary') === mode;
|
||||
});
|
||||
|
||||
const mode = sortMode();
|
||||
if (mode === 'layout') return rows;
|
||||
|
||||
const next = rows.slice();
|
||||
next.sort((a, b) => {
|
||||
if (mode === 'name') return a.title.localeCompare(b.title);
|
||||
const rank = (key: string) => {
|
||||
const state = WIDGET_META[key]?.state || 'empty';
|
||||
if (state === 'live') return 1;
|
||||
if (state === 'empty') return 2;
|
||||
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);
|
||||
setAutoSaveNotice('');
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
const ok = await saveAdminDashboardLayout(nextLayout);
|
||||
setIsAutoSaving(false);
|
||||
if (ok) {
|
||||
setLastSavedSnapshot(nextSnapshot);
|
||||
setAutoSaveNotice('Layout saved automatically.');
|
||||
} else {
|
||||
setAutoSaveNotice('Auto-save failed. Please try again.');
|
||||
}
|
||||
}, 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);
|
||||
setAutoSaveNotice('Layout reset to default.');
|
||||
};
|
||||
|
||||
const handleDragStart = (event: DragEvent, widgetKey: string) => {
|
||||
event.dataTransfer?.setData('text/plain', widgetKey);
|
||||
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
|
||||
setDraggingKey(widgetKey);
|
||||
};
|
||||
|
||||
const handleDrop = (event: DragEvent, targetKey: string) => {
|
||||
event.preventDefault();
|
||||
const dragged = event.dataTransfer?.getData('text/plain') || draggingKey();
|
||||
if (!dragged) return;
|
||||
|
||||
setLayout((current) => ({
|
||||
...current,
|
||||
order: reorderKeys(current.order, dragged, targetKey),
|
||||
}));
|
||||
setDraggingKey(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminShell>
|
||||
<div class="w-full space-y-8 pb-8">
|
||||
<div class="w-full bg-[#F9FAFB] pb-12">
|
||||
<div class="overflow-hidden rounded-2xl border border-[#E5E7EB] bg-white px-8 pt-10 pb-12 shadow-sm md:px-10 md:pt-11 md:pb-13">
|
||||
<div class="flex flex-col gap-5 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div class="max-w-3xl" style="padding-left: 40px; padding-right: 20px;">
|
||||
<h1 class="text-[28px] font-bold leading-tight text-[#111827]">Dashboard Overview</h1>
|
||||
<p class="mt-1 text-[14px] text-[#6B7280]">Manage widget layout, visibility, sizing, and dashboard presentation</p>
|
||||
</div>
|
||||
|
||||
{/* Page header */}
|
||||
<div>
|
||||
<div>
|
||||
<h1 class="text-[28px] font-bold leading-tight tracking-[-0.01em] text-[#0B1246]">Dashboard Overview</h1>
|
||||
<p class="mt-1.5 text-[14px] text-[#7E849F]">Welcome back! Here's what's happening with your platform today.</p>
|
||||
<div class="flex w-full lg:w-auto lg:justify-end" style="margin-right: 20px;">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-9 w-full items-center justify-center gap-1.5 whitespace-nowrap rounded-xl bg-[#0D0D2A] px-4 text-[13px] font-semibold leading-none text-white shadow-sm lg:w-auto lg:min-w-[190px]"
|
||||
onClick={() => {
|
||||
const next = !settingsOpen();
|
||||
setSettingsOpen(next);
|
||||
if (!next) setOpenMenuId(null);
|
||||
}}
|
||||
>
|
||||
<Settings2 size={14} class="text-[#FA5014]" />
|
||||
{settingsOpen() ? 'Close Settings' : 'Customize Widgets'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI cards */}
|
||||
<div class="grid grid-cols-4 gap-5">
|
||||
<For each={kpis}>
|
||||
{(item) => (
|
||||
<div class="rounded-[18px] border border-[#D6DAE4] bg-white px-5 pb-4.5 pt-4 shadow-[0_2px_10px_rgba(15,23,42,0.04)]">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="inline-flex h-10 w-10 items-center justify-center rounded-xl bg-[#FFF5EF] text-[#FA5014]">
|
||||
<KpiIcon kind={item.icon} />
|
||||
</div>
|
||||
<span
|
||||
class={`inline-flex shrink-0 items-center gap-0.5 rounded-full px-2.5 py-1 text-[11px] font-semibold leading-none ${
|
||||
item.tone === 'up'
|
||||
? 'bg-[#FFF1EB] text-[#FA5014]'
|
||||
: 'bg-[#FEF2F2] text-[#DC2626]'
|
||||
}`}
|
||||
<div class={settingsOpen() ? 'h-10' : 'h-0'} />
|
||||
|
||||
<Show when={settingsOpen()}>
|
||||
<div class="rounded-2xl border border-[#E5E7EB] bg-white p-8 shadow-sm md:p-9">
|
||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||
<div class="pl-2 pt-1">
|
||||
<h2 class="text-[15px] font-semibold text-[#111827]">Widget Settings</h2>
|
||||
<p class="mt-2 text-[13px] text-[#6B7280]">Choose visible widgets and select a grid layout.</p>
|
||||
</div>
|
||||
<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>
|
||||
<div class="mt-6 grid gap-5 md:grid-cols-2 xl:grid-cols-4">
|
||||
<label class="relative">
|
||||
<Search size={14} class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-[#FA5014]" />
|
||||
<input
|
||||
value={search()}
|
||||
onInput={(event) => setSearch(event.currentTarget.value)}
|
||||
placeholder="Search widgets"
|
||||
class="h-11 w-full rounded-lg border border-[#E5E7EB] bg-white pl-9 pr-3 text-sm text-[#111827] outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<select
|
||||
class="h-11 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">Filter: All Widgets</option>
|
||||
<option value="summary">Filter: Summary Widgets</option>
|
||||
<option value="analytics">Filter: Analytical Widgets</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
class="h-11 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">Sort: Layout Order</option>
|
||||
<option value="name">Sort: Name</option>
|
||||
<option value="status">Sort: Status</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
class="h-11 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 x 4</option>
|
||||
<option value="3x3">Grid: 3 x 3</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mt-6 grid gap-5 md:grid-cols-2">
|
||||
<For each={ADMIN_DASHBOARD_WIDGETS}>
|
||||
{(definition) => {
|
||||
const visible = () => layout().visibility[definition.widgetKey] !== false;
|
||||
|
||||
return (
|
||||
<div class="rounded-2xl border border-[#E5E7EB] bg-[#F9FAFB] px-4 py-5">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-[#111827]">{definition.title}</p>
|
||||
<p class="mt-0.5 text-[11px] font-semibold uppercase tracking-wide text-[#9CA3AF]">{definition.moduleKey}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class={`inline-flex h-8 items-center gap-1 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={13} class="text-[#FA5014]" /> : <EyeOff size={13} class="text-[#FA5014]" />} {visible() ? 'Visible' : 'Hidden'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
<Show when={isAutoSaving() || autoSaveNotice()}>
|
||||
<p class="mt-4 text-xs text-[#6B7280]">{isAutoSaving() ? 'Saving layout...' : autoSaveNotice()}</p>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class={settingsOpen() ? 'h-8' : 'h-12'} />
|
||||
|
||||
<div class="grid grid-cols-1 gap-7 xl:grid-cols-12">
|
||||
<For each={orderedWidgets()}>
|
||||
{(definition) => {
|
||||
const meta = WIDGET_META[definition.widgetKey];
|
||||
const state = meta?.state || 'empty';
|
||||
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' : ''}`}
|
||||
>
|
||||
{item.tone === 'up' ? '↗' : '↘'} {item.delta}
|
||||
</span>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<div class="mt-3.5 space-y-1.5">
|
||||
<p class="text-[12px] font-medium uppercase tracking-[0.04em] text-[#8D93AA]">{item.title}</p>
|
||||
<p class="text-[28px] font-bold leading-none tracking-[-0.01em] tabular-nums text-[#0B1246]">{item.value}</p>
|
||||
<div class="border-t border-[#EEF1F6] pt-1.5">
|
||||
<p class="text-[11px] leading-snug text-[#97A0B8]">{item.note}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
{/* Chart widgets */}
|
||||
<div class="grid grid-cols-2 gap-6">
|
||||
|
||||
{/* Leads Trend */}
|
||||
<div class="rounded-[18px] border border-[#D6DAE4] bg-white shadow-[0_2px_10px_rgba(15,23,42,0.04)]">
|
||||
<div class="flex items-center justify-between border-b border-[#E8EBF2] px-6 py-4.5">
|
||||
<div>
|
||||
<h2 class="text-[17px] font-bold text-[#0B1246]">Leads Trend</h2>
|
||||
<p class="mt-0.5 text-[11px] text-[#8D93AA]">Monthly leads performance overview</p>
|
||||
</div>
|
||||
<DragHandle />
|
||||
</div>
|
||||
<div class="px-6 pb-5 pt-4">
|
||||
<div class="grid grid-cols-[44px_1fr] gap-3">
|
||||
<div class="flex h-56 flex-col justify-between text-right text-[10px] font-semibold text-[#7A81A1]">
|
||||
<span>120</span>
|
||||
<span>90</span>
|
||||
<span>60</span>
|
||||
<span>30</span>
|
||||
<span>0</span>
|
||||
</div>
|
||||
<div>
|
||||
<div class="relative h-56">
|
||||
<div class="absolute inset-0">
|
||||
<For each={[0, 1, 2, 3]}>{() => <div class="h-1/4 border-b border-[#F3F4F6]" />}</For>
|
||||
<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 />
|
||||
</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)}`}>
|
||||
{meta?.statusLabel || 'No Data'}
|
||||
</span>
|
||||
</div>
|
||||
<svg viewBox="0 0 100 40" class="relative h-full w-full overflow-visible" preserveAspectRatio="none" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient id="trendFill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#FF5E13" stop-opacity="0.2" />
|
||||
<stop offset="100%" stop-color="#FF5E13" stop-opacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<polygon
|
||||
fill="url(#trendFill)"
|
||||
points={`0,40 ${trendSeries.map((v, i) => `${i * 20},${40 - v / 3}`).join(' ')} 100,40`}
|
||||
/>
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke="#FF5E13"
|
||||
stroke-width="1.5"
|
||||
stroke-linejoin="round"
|
||||
stroke-linecap="round"
|
||||
points={trendSeries.map((v, i) => `${i * 20},${40 - v / 3}`).join(' ')}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="mt-2.5 grid grid-cols-6 text-center text-[10px] font-semibold text-[#6D7390]">
|
||||
<For each={['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']}>{(m) => <span>{m}</span>}</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Revenue Overview */}
|
||||
<div class="rounded-[18px] border border-[#D6DAE4] bg-white shadow-[0_2px_10px_rgba(15,23,42,0.04)]">
|
||||
<div class="flex items-center justify-between border-b border-[#E8EBF2] px-6 py-4.5">
|
||||
<div>
|
||||
<h2 class="text-[17px] font-bold text-[#0B1246]">Revenue Overview</h2>
|
||||
<p class="mt-0.5 text-[11px] text-[#8D93AA]">Monthly revenue vs expenses comparison</p>
|
||||
</div>
|
||||
<DragHandle />
|
||||
</div>
|
||||
<div class="px-6 pb-5 pt-4">
|
||||
<div class="grid grid-cols-[56px_1fr] gap-3">
|
||||
<div class="flex h-56 flex-col justify-between text-right text-[10px] font-semibold text-[#7A81A1]">
|
||||
<span>80k</span>
|
||||
<span>60k</span>
|
||||
<span>40k</span>
|
||||
<span>20k</span>
|
||||
<span>0</span>
|
||||
</div>
|
||||
<div>
|
||||
<div class="relative h-56">
|
||||
<div class="absolute inset-0">
|
||||
<For each={[0, 1, 2, 3]}>{() => <div class="h-1/4 border-b border-[#F3F4F6]" />}</For>
|
||||
</div>
|
||||
<div class="relative flex h-full items-end gap-2 px-1">
|
||||
<For each={revSeries}>
|
||||
{(value) => (
|
||||
<div class="flex h-full flex-1 items-end">
|
||||
<div
|
||||
class="w-full rounded-t-lg bg-[#06083f] transition-all"
|
||||
style={{ height: `${(value / maxAmount) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2.5 grid grid-cols-6 text-center text-[10px] font-semibold text-[#6D7390]">
|
||||
<For each={['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']}>{(m) => <span>{m}</span>}</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</AdminShell>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue