dashboard widgets
This commit is contained in:
@@ -0,0 +1,236 @@
|
||||
import {
|
||||
AlarmClock,
|
||||
Activity,
|
||||
Clock,
|
||||
Gauge,
|
||||
LifeBuoy,
|
||||
Play,
|
||||
Workflow,
|
||||
} from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { DataState } from '@/components/dashboard/DataState';
|
||||
import { RunsChart } from '@/components/dashboard/RunsChart';
|
||||
import { StatTile } from '@/components/dashboard/StatTile';
|
||||
import { IncidentStatusBadge, TicketStatusBadge } from '@/components/dashboard/StatusBadge';
|
||||
import { TicketWorkload } from '@/components/dashboard/TicketWorkload';
|
||||
import { formatNumber, formatPercent, formatRelative } from '@/lib/format';
|
||||
import type { QueryState } from '@/lib/useApiQuery';
|
||||
import type {
|
||||
DashboardSummary,
|
||||
Incident,
|
||||
ListResponse,
|
||||
TicketListResponse,
|
||||
WidgetDefinition,
|
||||
WidgetMetric,
|
||||
Workload,
|
||||
} from '@/types/dashboard';
|
||||
|
||||
/**
|
||||
* Vykresleni jednoho widgetu. Widget si data nenacita sam - dostane je
|
||||
* z prehledu. Jinak by deset dlazdic znamenalo deset stejnych dotazu.
|
||||
*/
|
||||
|
||||
export interface WidgetData {
|
||||
summary: QueryState<DashboardSummary>;
|
||||
tickets: QueryState<TicketListResponse>;
|
||||
incidents: QueryState<ListResponse<Incident>>;
|
||||
workload: QueryState<Workload>;
|
||||
}
|
||||
|
||||
const metricMeta: Record<
|
||||
WidgetMetric,
|
||||
{ icon: LucideIcon; label: string; format: (summary: DashboardSummary) => string }
|
||||
> = {
|
||||
openTickets: {
|
||||
icon: LifeBuoy,
|
||||
label: 'Otevřené tickety',
|
||||
format: (s) => formatNumber(s.openTickets),
|
||||
},
|
||||
activeIncidents: {
|
||||
icon: AlarmClock,
|
||||
label: 'Běžící incidenty',
|
||||
format: (s) => formatNumber(s.activeIncidents),
|
||||
},
|
||||
activeAutomations: {
|
||||
icon: Workflow,
|
||||
label: 'Aktivní automatizace',
|
||||
format: (s) => formatNumber(s.activeAutomations),
|
||||
},
|
||||
runsToday: { icon: Play, label: 'Běhy dnes', format: (s) => formatNumber(s.runsToday) },
|
||||
savedHoursMonth: {
|
||||
icon: Clock,
|
||||
label: 'Ušetřeno tento měsíc',
|
||||
format: (s) => `${formatNumber(s.savedHoursMonth)} h`,
|
||||
},
|
||||
uptime: { icon: Gauge, label: 'Dostupnost', format: (s) => formatPercent(s.uptime, 2) },
|
||||
};
|
||||
|
||||
export function WidgetCard({
|
||||
definition,
|
||||
data,
|
||||
}: {
|
||||
definition: WidgetDefinition;
|
||||
data: WidgetData;
|
||||
}) {
|
||||
if (definition.kind === 'stat') {
|
||||
const metric = definition.metric ? metricMeta[definition.metric] : undefined;
|
||||
if (!metric) {
|
||||
console.warn(`[widget] ${definition.id} je typu stat, ale nema metriku`);
|
||||
return <Broken name={definition.name} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<DataState loading={data.summary.loading} error={data.summary.error} onRetry={data.summary.reload}>
|
||||
{data.summary.data && (
|
||||
<StatTile
|
||||
icon={metric.icon}
|
||||
label={metric.label}
|
||||
value={metric.format(data.summary.data)}
|
||||
tone={toneFor(definition.metric, data.summary.data)}
|
||||
/>
|
||||
)}
|
||||
</DataState>
|
||||
);
|
||||
}
|
||||
|
||||
if (definition.kind === 'chart') {
|
||||
return (
|
||||
<Panel>
|
||||
<DataState
|
||||
loading={data.summary.loading}
|
||||
error={data.summary.error}
|
||||
onRetry={data.summary.reload}
|
||||
>
|
||||
{data.summary.data && <RunsChart series={data.summary.data.series} />}
|
||||
</DataState>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
if (definition.kind === 'ticketList') {
|
||||
return (
|
||||
<Panel title="Poslední tickety" to="/dashboard/tickety">
|
||||
<DataState
|
||||
loading={data.tickets.loading}
|
||||
error={data.tickets.error}
|
||||
empty={data.tickets.data?.items.length === 0}
|
||||
onRetry={data.tickets.reload}
|
||||
emptyLabel="Žádné tickety, dobrá zpráva."
|
||||
>
|
||||
<ul className="divide-y divide-ink-600/50">
|
||||
{data.tickets.data?.items.slice(0, 5).map((ticket) => (
|
||||
<li key={ticket.id} className="flex items-start gap-3 py-3.5 first:pt-0 last:pb-0">
|
||||
<span className="mt-0.5 font-mono text-xs text-white/35">{ticket.id}</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<Link
|
||||
to={`/dashboard/tickety/${ticket.id}`}
|
||||
className="block truncate text-sm text-white/80 transition-colors hover:text-brand-200"
|
||||
>
|
||||
{ticket.subject}
|
||||
</Link>
|
||||
<p className="mt-0.5 truncate text-xs text-white/40">
|
||||
{ticket.customer.company} · {ticket.assignee?.name ?? 've frontě'} ·{' '}
|
||||
{formatRelative(ticket.updatedAt)}
|
||||
</p>
|
||||
</div>
|
||||
<TicketStatusBadge status={ticket.status} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</DataState>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
if (definition.kind === 'incidentList') {
|
||||
return (
|
||||
<Panel title="Incidenty" to="/dashboard/incidenty">
|
||||
<DataState
|
||||
loading={data.incidents.loading}
|
||||
error={data.incidents.error}
|
||||
empty={data.incidents.data?.items.length === 0}
|
||||
onRetry={data.incidents.reload}
|
||||
emptyLabel="Žádné incidenty."
|
||||
>
|
||||
<ul className="divide-y divide-ink-600/50">
|
||||
{data.incidents.data?.items.slice(0, 5).map((incident) => (
|
||||
<li key={incident.id} className="flex items-start gap-3 py-3.5 first:pt-0 last:pb-0">
|
||||
<span className="mt-0.5 grid size-7 shrink-0 place-items-center rounded-lg bg-white/5 text-white/40">
|
||||
<Activity className="size-3.5" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm text-white/80">{incident.title}</p>
|
||||
<p className="mt-0.5 truncate text-xs text-white/40">
|
||||
{incident.service} · začátek {formatRelative(incident.startedAt)}
|
||||
</p>
|
||||
</div>
|
||||
<IncidentStatusBadge status={incident.status} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</DataState>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DataState
|
||||
loading={data.workload.loading}
|
||||
error={data.workload.error}
|
||||
onRetry={data.workload.reload}
|
||||
>
|
||||
{data.workload.data && (
|
||||
// Na prehledu je to jen pohled, filtrovat se chodi na stranku ticketu.
|
||||
<TicketWorkload workload={data.workload.data} active={null} onSelect={() => {}} />
|
||||
)}
|
||||
</DataState>
|
||||
);
|
||||
}
|
||||
|
||||
/** Barva dlazdice nese vyznam jen tam, kde ma. Zbytek zustava neutralni. */
|
||||
function toneFor(
|
||||
metric: WidgetMetric | undefined,
|
||||
summary: DashboardSummary,
|
||||
): 'ok' | 'warn' | 'danger' | undefined {
|
||||
if (metric === 'openTickets') return summary.openTickets > 3 ? 'warn' : 'ok';
|
||||
if (metric === 'activeIncidents') return summary.activeIncidents > 0 ? 'danger' : 'ok';
|
||||
if (metric === 'savedHoursMonth' || metric === 'uptime') return 'ok';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function Panel({
|
||||
title,
|
||||
to,
|
||||
children,
|
||||
}: {
|
||||
title?: string;
|
||||
to?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="glass h-full rounded-card p-5 sm:p-6">
|
||||
{title && (
|
||||
<div className="mb-5 flex items-center justify-between gap-3">
|
||||
<h2 className="font-semibold text-white">{title}</h2>
|
||||
{to && (
|
||||
<Link to={to} className="text-sm font-medium text-brand-300 hover:text-brand-200">
|
||||
Všechny
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** Widget, ktery se neda vykreslit. Nesmi zmizet potichu. */
|
||||
function Broken({ name }: { name: string }) {
|
||||
return (
|
||||
<div className="rounded-card border border-danger-400/40 bg-danger-500/8 p-5 text-sm text-danger-400">
|
||||
Widget „{name}" se nepodařilo zobrazit.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Plus } from 'lucide-react';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import type { WidgetDefinition } from '@/types/dashboard';
|
||||
|
||||
/** Nabidka widgetu, ktere jde na dashboard pridat. */
|
||||
export function WidgetPicker({
|
||||
open,
|
||||
widgets,
|
||||
onClose,
|
||||
onPick,
|
||||
}: {
|
||||
open: boolean;
|
||||
widgets: WidgetDefinition[];
|
||||
onClose: () => void;
|
||||
onPick: (widget: WidgetDefinition) => void;
|
||||
}) {
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="Přidat widget"
|
||||
description="Widget se přidá na konec dashboardu, pak ho můžete posunout."
|
||||
>
|
||||
<div className="max-h-[60vh] overflow-y-auto p-5">
|
||||
<ul className="grid gap-2.5 sm:grid-cols-2">
|
||||
{widgets.map((widget) => (
|
||||
<li key={widget.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onPick(widget);
|
||||
onClose();
|
||||
}}
|
||||
className="group flex h-full w-full items-start gap-3 rounded-xl border border-ink-600/60 bg-ink-850/40 p-4 text-left transition-colors hover:border-brand-400/60 hover:bg-brand-500/8"
|
||||
>
|
||||
<span className="mt-0.5 grid size-7 shrink-0 place-items-center rounded-lg bg-white/5 text-white/40 transition-colors group-hover:bg-brand-500/15 group-hover:text-brand-300">
|
||||
<Plus className="size-4" />
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block font-medium text-white">{widget.name}</span>
|
||||
<span className="mt-0.5 block text-sm text-white/50">{widget.description}</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{widgets.length === 0 && (
|
||||
<p className="py-8 text-center text-sm text-white/45">
|
||||
Všechny dostupné widgety už na dashboardu máte.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { useEventStream } from '@/components/dashboard/EventStreamProvider';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import type { DashboardEventType } from '@/types/events';
|
||||
|
||||
interface QueryState<T> {
|
||||
export interface QueryState<T> {
|
||||
data: T | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
|
||||
@@ -1,28 +1,70 @@
|
||||
import { Activity, AlarmClock, Clock, LifeBuoy, RefreshCw, Workflow } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
AlertCircle,
|
||||
Check,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
LayoutGrid,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useAuth } from '@/auth/AuthContext';
|
||||
import { DataState } from '@/components/dashboard/DataState';
|
||||
import { RunsChart } from '@/components/dashboard/RunsChart';
|
||||
import { StatTile } from '@/components/dashboard/StatTile';
|
||||
import { IncidentStatusBadge, TicketStatusBadge } from '@/components/dashboard/StatusBadge';
|
||||
import { WidgetCard, type WidgetData } from '@/components/dashboard/widgets/WidgetCard';
|
||||
import { WidgetPicker } from '@/components/dashboard/widgets/WidgetPicker';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { formatNumber, formatPercent, formatRelative } from '@/lib/format';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { useApiQuery } from '@/lib/useApiQuery';
|
||||
import { usePageMeta } from '@/lib/usePageMeta';
|
||||
import type {
|
||||
Access,
|
||||
DashboardSummary,
|
||||
Incident,
|
||||
LayoutItem,
|
||||
LayoutResponse,
|
||||
ListResponse,
|
||||
TicketListResponse,
|
||||
WidgetDefinition,
|
||||
WidgetSize,
|
||||
Workload,
|
||||
} from '@/types/dashboard';
|
||||
|
||||
/** Sirka widgetu v mrizce o sesti sloupcich. */
|
||||
const span: Record<WidgetSize, string> = {
|
||||
third: 'col-span-6 @2xl:col-span-2',
|
||||
half: 'col-span-6 @2xl:col-span-3',
|
||||
full: 'col-span-6',
|
||||
};
|
||||
|
||||
const sizeLabels: Record<WidgetSize, string> = {
|
||||
third: 'třetina',
|
||||
half: 'polovina',
|
||||
full: 'celá šířka',
|
||||
};
|
||||
|
||||
export default function Overview() {
|
||||
usePageMeta({ title: 'Přehled — portál Automia' });
|
||||
|
||||
const { user } = useAuth();
|
||||
const access = useApiQuery<Access>('/api/dashboard/access');
|
||||
|
||||
// Prehled se prekresluje na kazdou zmenu, proto sleduje vsechny udalosti.
|
||||
const summary = useApiQuery<DashboardSummary>('/api/dashboard/summary', {
|
||||
/** Firma, za kterou se prehled kresli. Rozlozeni se uklada pro ni. */
|
||||
const [tenantId, setTenantId] = useState<string | null>(null);
|
||||
const activeTenant = tenantId ?? access.data?.defaultTenantId ?? null;
|
||||
const tenantQuery = activeTenant ? `?tenantId=${activeTenant}` : '';
|
||||
|
||||
const layout = useApiQuery<LayoutResponse>(`/api/dashboard/layout${tenantQuery}`);
|
||||
const catalog = useApiQuery<ListResponse<WidgetDefinition>>('/api/dashboard/widgets');
|
||||
|
||||
// Data si nacita prehled, ne jednotlive widgety. Deset dlazdic nesmi
|
||||
// znamenat deset stejnych dotazu.
|
||||
const scopeQuery = activeTenant ? `?scope=tenant&tenantId=${activeTenant}` : '';
|
||||
const summary = useApiQuery<DashboardSummary>(`/api/dashboard/summary${scopeQuery}`, {
|
||||
refetchOn: [
|
||||
'ticket.created',
|
||||
'ticket.resolved',
|
||||
@@ -35,157 +77,340 @@ export default function Overview() {
|
||||
'webhook.received',
|
||||
],
|
||||
});
|
||||
const tickets = useApiQuery<TicketListResponse>('/api/dashboard/tickets', {
|
||||
const tickets = useApiQuery<TicketListResponse>(`/api/dashboard/tickets${scopeQuery}`, {
|
||||
refetchOn: ['ticket.created', 'ticket.updated', 'ticket.assigned', 'ticket.resolved'],
|
||||
});
|
||||
const incidents = useApiQuery<ListResponse<Incident>>('/api/dashboard/incidents', {
|
||||
refetchOn: ['incident.started', 'incident.updated', 'incident.resolved'],
|
||||
});
|
||||
const workload = useApiQuery<Workload>(`/api/dashboard/tickets/workload${scopeQuery}`, {
|
||||
refetchOn: ['ticket.created', 'ticket.updated', 'ticket.assigned', 'ticket.resolved'],
|
||||
});
|
||||
|
||||
const data: WidgetData = { summary, tickets, incidents, workload };
|
||||
|
||||
// ----------------------------------------------------------- rezim uprav
|
||||
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState<LayoutItem[]>([]);
|
||||
const [picker, setPicker] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
// Ulozene rozlozeni prevezmeme do konceptu, dokud se needituje.
|
||||
useEffect(() => {
|
||||
if (layout.data && !editing) setDraft(layout.data.items);
|
||||
}, [layout.data, editing]);
|
||||
|
||||
const definitions = useMemo(
|
||||
() => new Map((catalog.data?.items ?? []).map((widget) => [widget.id, widget])),
|
||||
[catalog.data],
|
||||
);
|
||||
|
||||
const items = editing ? draft : (layout.data?.items ?? []);
|
||||
|
||||
function move(index: number, offset: number) {
|
||||
const target = index + offset;
|
||||
if (target < 0 || target >= draft.length) return;
|
||||
const next = [...draft];
|
||||
const [moved] = next.splice(index, 1);
|
||||
next.splice(target, 0, moved);
|
||||
setDraft(next);
|
||||
}
|
||||
|
||||
function resize(id: string, size: WidgetSize) {
|
||||
setDraft(draft.map((item) => (item.id === id ? { ...item, size } : item)));
|
||||
}
|
||||
|
||||
function remove(id: string) {
|
||||
setDraft(draft.filter((item) => item.id !== id));
|
||||
}
|
||||
|
||||
function add(widget: WidgetDefinition) {
|
||||
setDraft([
|
||||
...draft,
|
||||
// Instance musi mit vlastni ID, aby sel tentyz widget pridat vickrat.
|
||||
{ id: `w_${Date.now().toString(36)}_${draft.length}`, widgetId: widget.id, size: widget.defaultSize },
|
||||
]);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
try {
|
||||
await apiFetch<LayoutResponse>(`/api/dashboard/layout${tenantQuery}`, {
|
||||
method: 'PUT',
|
||||
body: { items: draft },
|
||||
});
|
||||
setEditing(false);
|
||||
layout.reload();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Rozložení se nepodařilo uložit.';
|
||||
console.error('[dashboard] ulozeni rozlozeni selhalo:', err);
|
||||
setSaveError(message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function resetToDefault() {
|
||||
if (!window.confirm('Vrátit dashboard do výchozího stavu? Vaše rozložení se smaže.')) return;
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
try {
|
||||
const fresh = await apiFetch<LayoutResponse>(`/api/dashboard/layout${tenantQuery}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
setDraft(fresh.items);
|
||||
setEditing(false);
|
||||
layout.reload();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Vrácení na výchozí se nepodařilo.';
|
||||
console.error('[dashboard] reset rozlozeni selhal:', err);
|
||||
setSaveError(message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const unused = (catalog.data?.items ?? []).filter(
|
||||
(widget) => !draft.some((item) => item.widgetId === widget.id),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-7">
|
||||
<div className="@container space-y-6">
|
||||
<header className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-bold text-white">
|
||||
Dobrý den, {user?.name?.split(' ')[0] ?? 'vítejte'}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-white/50">
|
||||
Tady je stav vašich automatizací a požadavků.
|
||||
{editing
|
||||
? 'Uspořádejte si dashboard. Změny se uloží až tlačítkem Uložit.'
|
||||
: 'Tady je stav vašich automatizací a požadavků.'}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
summary.reload();
|
||||
tickets.reload();
|
||||
incidents.reload();
|
||||
}}
|
||||
>
|
||||
<RefreshCw className="size-4" />
|
||||
Obnovit
|
||||
</Button>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* Prepinac firmy davá smysl jen tomu, kdo jich ma vic. */}
|
||||
{!editing && (access.data?.tenants.length ?? 0) > 1 && (
|
||||
<select
|
||||
value={activeTenant ?? ''}
|
||||
onChange={(event) => setTenantId(event.target.value)}
|
||||
aria-label="Firma"
|
||||
className="rounded-xl border border-ink-600/70 bg-ink-850/70 px-3 py-2 text-sm text-white focus:border-brand-400/70 focus:outline-none"
|
||||
>
|
||||
{access.data?.tenants.map((tenant) => (
|
||||
<option key={tenant.id} value={tenant.id} className="bg-ink-850">
|
||||
{tenant.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
{editing ? (
|
||||
<>
|
||||
<Button variant="secondary" size="sm" onClick={() => setPicker(true)}>
|
||||
<Plus className="size-4" />
|
||||
Přidat widget
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => void resetToDefault()}
|
||||
disabled={saving}
|
||||
>
|
||||
<RotateCcw className="size-4" />
|
||||
Výchozí
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setDraft(layout.data?.items ?? []);
|
||||
setEditing(false);
|
||||
setSaveError(null);
|
||||
}}
|
||||
disabled={saving}
|
||||
>
|
||||
<X className="size-4" />
|
||||
Zrušit
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => void save()} disabled={saving}>
|
||||
<Check className="size-4" />
|
||||
{saving ? 'Ukládám…' : 'Uložit'}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
summary.reload();
|
||||
tickets.reload();
|
||||
incidents.reload();
|
||||
workload.reload();
|
||||
}}
|
||||
>
|
||||
<RefreshCw className="size-4" />
|
||||
Obnovit
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={() => setEditing(true)}>
|
||||
<LayoutGrid className="size-4" />
|
||||
Upravit dashboard
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<DataState loading={summary.loading} error={summary.error} onRetry={summary.reload}>
|
||||
{summary.data && (
|
||||
<>
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<StatTile
|
||||
icon={Workflow}
|
||||
label="Aktivní automatizace"
|
||||
value={formatNumber(summary.data.activeAutomations)}
|
||||
hint={`${formatNumber(summary.data.runsToday)} spuštění dnes`}
|
||||
/>
|
||||
<StatTile
|
||||
icon={LifeBuoy}
|
||||
label="Otevřené tickety"
|
||||
value={formatNumber(summary.data.openTickets)}
|
||||
hint="včetně čekajících na vás"
|
||||
tone={summary.data.openTickets > 3 ? 'warn' : 'ok'}
|
||||
/>
|
||||
<StatTile
|
||||
icon={AlarmClock}
|
||||
label="Běžící incidenty"
|
||||
value={formatNumber(summary.data.activeIncidents)}
|
||||
hint={summary.data.activeIncidents === 0 ? 'vše v pořádku' : 'pracujeme na tom'}
|
||||
tone={summary.data.activeIncidents > 0 ? 'danger' : 'ok'}
|
||||
/>
|
||||
<StatTile
|
||||
icon={Clock}
|
||||
label="Ušetřeno tento měsíc"
|
||||
value={`${formatNumber(summary.data.savedHoursMonth)} h`}
|
||||
hint={`dostupnost ${formatPercent(summary.data.uptime, 2)}`}
|
||||
tone="ok"
|
||||
/>
|
||||
</div>
|
||||
{saveError && (
|
||||
<p className="flex items-center gap-2 rounded-xl border border-danger-400/30 bg-danger-500/10 px-4 py-3 text-sm text-danger-400">
|
||||
<AlertCircle className="size-4 shrink-0" />
|
||||
{saveError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="glass rounded-card p-5 sm:p-6">
|
||||
<RunsChart series={summary.data.series} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<DataState
|
||||
loading={layout.loading || catalog.loading}
|
||||
error={layout.error ?? catalog.error}
|
||||
onRetry={() => {
|
||||
layout.reload();
|
||||
catalog.reload();
|
||||
}}
|
||||
empty={items.length === 0}
|
||||
emptyLabel={
|
||||
editing
|
||||
? 'Dashboard je prázdný. Přidejte widget tlačítkem nahoře.'
|
||||
: 'Dashboard je prázdný. Klikněte na Upravit dashboard.'
|
||||
}
|
||||
>
|
||||
<div className="grid grid-cols-6 gap-4">
|
||||
{items.map((item, index) => {
|
||||
const definition = definitions.get(item.widgetId);
|
||||
|
||||
if (!definition) {
|
||||
// Widget zmizel z katalogu. Nesmi to tise vypadnout z rozlozeni.
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="col-span-6 rounded-card border border-danger-400/40 bg-danger-500/8 p-4 text-sm text-danger-400"
|
||||
>
|
||||
Widget „{item.widgetId}" už v katalogu není.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={item.id} className={cn(span[item.size], 'min-w-0')}>
|
||||
{editing && (
|
||||
<EditBar
|
||||
definition={definition}
|
||||
size={item.size}
|
||||
canMoveLeft={index > 0}
|
||||
canMoveRight={index < items.length - 1}
|
||||
onMove={(offset) => move(index, offset)}
|
||||
onResize={(size) => resize(item.id, size)}
|
||||
onRemove={() => remove(item.id)}
|
||||
/>
|
||||
)}
|
||||
<div className={cn(editing && 'pointer-events-none opacity-70')}>
|
||||
<WidgetCard definition={definition} data={data} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</DataState>
|
||||
|
||||
<div className="grid gap-5 xl:grid-cols-2">
|
||||
<section className="glass rounded-card p-5 sm:p-6">
|
||||
<div className="mb-5 flex items-center justify-between gap-3">
|
||||
<h2 className="font-semibold text-white">Poslední tickety</h2>
|
||||
<Link
|
||||
to="/dashboard/tickety"
|
||||
className="text-sm font-medium text-brand-300 hover:text-brand-200"
|
||||
>
|
||||
Všechny →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<DataState
|
||||
loading={tickets.loading}
|
||||
error={tickets.error}
|
||||
empty={tickets.data?.items.length === 0}
|
||||
onRetry={tickets.reload}
|
||||
emptyLabel="Žádné tickety — dobrá zpráva."
|
||||
>
|
||||
<ul className="divide-y divide-ink-600/50">
|
||||
{tickets.data?.items.slice(0, 4).map((ticket) => (
|
||||
<li key={ticket.id} className="flex items-start gap-3 py-3.5 first:pt-0 last:pb-0">
|
||||
<span className="mt-0.5 font-mono text-xs text-white/35">{ticket.id}</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<Link
|
||||
to={`/dashboard/tickety/${ticket.id}`}
|
||||
className="block truncate text-sm text-white/80 transition-colors hover:text-brand-200"
|
||||
>
|
||||
{ticket.subject}
|
||||
</Link>
|
||||
<p className="mt-0.5 text-xs text-white/40">
|
||||
{ticket.customer.company} · {ticket.assignee?.name ?? 've frontě'} ·{' '}
|
||||
{formatRelative(ticket.updatedAt)}
|
||||
</p>
|
||||
</div>
|
||||
<TicketStatusBadge status={ticket.status} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</DataState>
|
||||
</section>
|
||||
|
||||
<section className="glass rounded-card p-5 sm:p-6">
|
||||
<div className="mb-5 flex items-center justify-between gap-3">
|
||||
<h2 className="font-semibold text-white">Incidenty</h2>
|
||||
<Link
|
||||
to="/dashboard/incidenty"
|
||||
className="text-sm font-medium text-brand-300 hover:text-brand-200"
|
||||
>
|
||||
Všechny →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<DataState
|
||||
loading={incidents.loading}
|
||||
error={incidents.error}
|
||||
empty={incidents.data?.items.length === 0}
|
||||
onRetry={incidents.reload}
|
||||
emptyLabel="Žádné incidenty."
|
||||
>
|
||||
<ul className="divide-y divide-ink-600/50">
|
||||
{incidents.data?.items.slice(0, 4).map((incident) => (
|
||||
<li key={incident.id} className="flex items-start gap-3 py-3.5 first:pt-0 last:pb-0">
|
||||
<span className="mt-0.5 grid size-7 shrink-0 place-items-center rounded-lg bg-white/5 text-white/40">
|
||||
<Activity className="size-3.5" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm text-white/80">{incident.title}</p>
|
||||
<p className="mt-0.5 text-xs text-white/40">
|
||||
{incident.service} · začátek {formatRelative(incident.startedAt)}
|
||||
</p>
|
||||
</div>
|
||||
<IncidentStatusBadge status={incident.status} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</DataState>
|
||||
</section>
|
||||
</div>
|
||||
<WidgetPicker
|
||||
open={picker}
|
||||
widgets={unused}
|
||||
onClose={() => setPicker(false)}
|
||||
onPick={add}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Ovladani jednoho widgetu v rezimu uprav. */
|
||||
function EditBar({
|
||||
definition,
|
||||
size,
|
||||
canMoveLeft,
|
||||
canMoveRight,
|
||||
onMove,
|
||||
onResize,
|
||||
onRemove,
|
||||
}: {
|
||||
definition: WidgetDefinition;
|
||||
size: WidgetSize;
|
||||
canMoveLeft: boolean;
|
||||
canMoveRight: boolean;
|
||||
onMove: (offset: number) => void;
|
||||
onResize: (size: WidgetSize) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-2 flex flex-wrap items-center gap-1.5 rounded-xl border border-brand-400/35 bg-brand-500/10 px-2.5 py-2">
|
||||
<span className="mr-auto min-w-0 truncate text-xs font-medium text-brand-100">
|
||||
{definition.name}
|
||||
</span>
|
||||
|
||||
{/* Sirku nabizime jen tu, kterou widget unese. */}
|
||||
<select
|
||||
value={size}
|
||||
onChange={(event) => onResize(event.target.value as WidgetSize)}
|
||||
aria-label={`Šířka widgetu ${definition.name}`}
|
||||
className="rounded-lg border border-ink-600/70 bg-ink-900/70 px-2 py-1 text-xs text-white focus:border-brand-400/70 focus:outline-none"
|
||||
>
|
||||
{definition.sizes.map((option) => (
|
||||
<option key={option} value={option} className="bg-ink-850">
|
||||
{sizeLabels[option]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<IconButton label="Posunout dřív" disabled={!canMoveLeft} onClick={() => onMove(-1)}>
|
||||
<ChevronLeft className="size-3.5" />
|
||||
</IconButton>
|
||||
<IconButton label="Posunout později" disabled={!canMoveRight} onClick={() => onMove(1)}>
|
||||
<ChevronRight className="size-3.5" />
|
||||
</IconButton>
|
||||
<IconButton label="Odebrat widget" danger onClick={onRemove}>
|
||||
<Trash2 className="size-3.5" />
|
||||
</IconButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IconButton({
|
||||
label,
|
||||
disabled,
|
||||
danger,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
danger?: boolean;
|
||||
onClick: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
title={label}
|
||||
className={cn(
|
||||
'grid size-7 place-items-center rounded-lg text-white/50 transition-colors disabled:pointer-events-none disabled:opacity-25',
|
||||
danger ? 'hover:bg-danger-500/15 hover:text-danger-400' : 'hover:bg-white/8 hover:text-white',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
<span className="sr-only">{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -314,3 +314,42 @@ export interface DashboardSummary {
|
||||
export interface ListResponse<T> {
|
||||
items: T[];
|
||||
}
|
||||
|
||||
// ------------------------------------------------------ rozlozeni dashboardu
|
||||
|
||||
export type WidgetSize = 'third' | 'half' | 'full';
|
||||
|
||||
export type WidgetKind = 'stat' | 'chart' | 'ticketList' | 'incidentList' | 'workload';
|
||||
|
||||
export type WidgetMetric =
|
||||
| 'openTickets'
|
||||
| 'activeIncidents'
|
||||
| 'activeAutomations'
|
||||
| 'runsToday'
|
||||
| 'savedHoursMonth'
|
||||
| 'uptime';
|
||||
|
||||
export interface WidgetDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
kind: WidgetKind;
|
||||
/** Ktere sirky ma smysl nabizet. */
|
||||
sizes: WidgetSize[];
|
||||
defaultSize: WidgetSize;
|
||||
metric?: WidgetMetric;
|
||||
}
|
||||
|
||||
export interface LayoutItem {
|
||||
/** Instance widgetu. Tentyz widget muze byt na dashboardu vickrat. */
|
||||
id: string;
|
||||
widgetId: string;
|
||||
size: WidgetSize;
|
||||
}
|
||||
|
||||
export interface LayoutResponse {
|
||||
tenantId: string;
|
||||
items: LayoutItem[];
|
||||
/** false = uzivatel kouka na vychozi rozlozeni, nic si neulozil. */
|
||||
custom: boolean;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user