dashboard widgets

This commit is contained in:
JiriUhlir
2026-08-03 13:10:41 +02:00
parent 2a3d75c85e
commit bbc2236c0d
14 changed files with 1196 additions and 141 deletions
@@ -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 na dashboardu máte.
</p>
)}
</div>
</Modal>
);
}
+1 -1
View File
@@ -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;
+365 -140
View File
@@ -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>
);
}
+39
View File
@@ -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;
}