Prepinac firmy plati pro cely portal, ne jen pro prehled
Prepinac byl stav uvnitr stranky Prehled, takze prepnuti na LogiTrans zmenilo prehled a nic jineho - ostatni stranky volaly API bez tenantId a server sahnul po vychozi firme. V Lidech tak zustali lide Automie. - vybrana firma je jedna hodnota pro cely dashboard (web/src/lib/tenant.ts) a prezije obnoveni stranky - tenantId doplnuje apiFetch, ne jednotlive stranky. Kdyz si to mela pridavat kazda stranka sama, vetsina na to zapomnela - a prave to byla ta chyba. Nedoplnuje se, kdyz cesta uz firmu nese, u scope=all a mimo /api/dashboard. - useApiQuery se pri prepnuti zepta znovu, jinak by na strance zustala cisla predchozi firmy - prepinac je v hlavicce nad obsahem a nahradil text, ktery ukazoval natvrdo prvni firmu ze seznamu bez ohledu na vyber - ulozena firma, do ktere uzivatel uz nepatri, spadne na vychozi Zjisteno u toho a zapsane do 07-firmy-a-prava.md: prava se scitaji pres vsechny firmy, neprepocitavaji se podle vybrane. Data se tim neprolomi, server je filtruje dal, ale tlacitka ukazuji vic, nez by mela. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
a771834e57
commit
2329ccbdc1
@@ -25,6 +25,7 @@ import { EventToasts } from '@/components/dashboard/EventToasts';
|
||||
import { LiveIndicator } from '@/components/dashboard/LiveIndicator';
|
||||
import { Logo } from '@/components/layout/Logo';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { setActiveTenant, useActiveTenant } from '@/lib/tenant';
|
||||
import { useApiQuery } from '@/lib/useApiQuery';
|
||||
import type { Access } from '@/types/dashboard';
|
||||
|
||||
@@ -64,6 +65,57 @@ export function DashboardLayout() {
|
||||
}
|
||||
|
||||
/** Kratky popis pristupu do topbaru. Uzivatel musi poznat, co vlastne vidi. */
|
||||
/**
|
||||
* Prepinac firmy.
|
||||
*
|
||||
* Stoji **nad celym dashboardem**, ne uvnitr jedne stranky. Driv byl v Prehledu
|
||||
* a tykal se jen jeho: uzivatel se prepnul na jinou firmu a v Lidech dal koukal
|
||||
* na lidi te puvodni. Vybrana firma je proto jedna hodnota pro cely portal
|
||||
* a `apiFetch` ji dava do kazdeho dotazu.
|
||||
*
|
||||
* Kdo ma jednu firmu, vidi jen jeji nazev - vyber z jedne moznosti neni vyber.
|
||||
*/
|
||||
function TenantSwitch({ access }: { access: Access | null }) {
|
||||
const activeTenant = useActiveTenant();
|
||||
const tenants = access?.tenants ?? [];
|
||||
|
||||
/*
|
||||
* Dorovnani po nacteni. Ulozena volba muze byt firma, do ktere uz uzivatel
|
||||
* nepatri (odebrali ho, prihlasil se nekdo jiny na tomtez pocitaci) - pak by
|
||||
* kazdy dotaz koncil na 403. Radsi se spadne na vychozi firmu uctu.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (tenants.length === 0) return;
|
||||
if (activeTenant && tenants.some((tenant) => tenant.id === activeTenant)) return;
|
||||
setActiveTenant(access?.defaultTenantId ?? tenants[0]?.id ?? null);
|
||||
}, [tenants, activeTenant, access?.defaultTenantId]);
|
||||
|
||||
const current = tenants.find((tenant) => tenant.id === activeTenant);
|
||||
|
||||
if (tenants.length <= 1) {
|
||||
return (
|
||||
<p className="truncate text-sm font-semibold text-white">
|
||||
{current?.name ?? tenants[0]?.name ?? 'Klientský portál'}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<select
|
||||
value={activeTenant ?? ''}
|
||||
onChange={(event) => setActiveTenant(event.target.value)}
|
||||
aria-label="Firma, za kterou pracujete"
|
||||
className="-ml-2 max-w-full truncate rounded-lg border border-transparent bg-transparent px-2 py-0.5 text-sm font-semibold text-white transition-colors hover:border-ink-600/70 hover:bg-ink-850/70 focus:border-brand-400/70 focus:outline-none"
|
||||
>
|
||||
{tenants.map((tenant) => (
|
||||
<option key={tenant.id} value={tenant.id} className="bg-ink-850">
|
||||
{tenant.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
function accessLabel(user: AuthUser | null, access: Access | null): string {
|
||||
if (user?.platformAdmin) return 'Správce platformy, vidí všechny firmy';
|
||||
const count = access?.tenants.length ?? 0;
|
||||
@@ -201,9 +253,7 @@ function DashboardShell() {
|
||||
</button>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold text-white">
|
||||
{access.data?.tenants[0]?.name ?? 'Klientský portál'}
|
||||
</p>
|
||||
<TenantSwitch access={access.data} />
|
||||
<p className="truncate text-xs text-white/40">{accessLabel(user, access.data)}</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -57,13 +57,10 @@ function Field({
|
||||
|
||||
export function NewTicketDialog({
|
||||
open,
|
||||
tenantId,
|
||||
onClose,
|
||||
onCreated,
|
||||
}: {
|
||||
open: boolean;
|
||||
/** Firma, do ktere ticket patri. Posila se na server, ne se vybira ve formulari. */
|
||||
tenantId: string | null;
|
||||
onClose: () => void;
|
||||
onCreated: () => void;
|
||||
}) {
|
||||
@@ -87,15 +84,18 @@ export function NewTicketDialog({
|
||||
if (!open) return;
|
||||
setError(null);
|
||||
|
||||
const query = tenantId ? `?tenantId=${encodeURIComponent(tenantId)}` : '';
|
||||
// Typy i řešitelé jsou vlastnost firmy, proto se načítají s jejím ID.
|
||||
apiFetch<{ items: TicketTypeOption[] }>(`/api/dashboard/settings/ticket-types${query}`)
|
||||
/*
|
||||
Typy i řešitelé jsou vlastnost firmy. Její ID se sem nepředává - doplní
|
||||
ho `apiFetch` podle přepínače nad dashboardem, takže se nemůže rozejít
|
||||
s tím, co má uživatel právě vybrané.
|
||||
*/
|
||||
apiFetch<{ items: TicketTypeOption[] }>('/api/dashboard/settings/ticket-types')
|
||||
.then((response) => setTypes(response.items))
|
||||
.catch(() => setTypes([]));
|
||||
apiFetch<PeopleResponse>(`/api/dashboard/people${query}`)
|
||||
apiFetch<PeopleResponse>('/api/dashboard/people')
|
||||
.then(setPeople)
|
||||
.catch(() => setPeople(null));
|
||||
}, [open, tenantId]);
|
||||
}, [open]);
|
||||
|
||||
function reset() {
|
||||
setSubject('');
|
||||
@@ -121,8 +121,7 @@ export function NewTicketDialog({
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const query = tenantId ? `?tenantId=${encodeURIComponent(tenantId)}` : '';
|
||||
await apiFetch(`/api/dashboard/tickets${query}`, {
|
||||
await apiFetch('/api/dashboard/tickets', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
subject: subject.trim(),
|
||||
|
||||
+12
-1
@@ -3,6 +3,8 @@
|
||||
* aby se autorizace a chybove hlaseni resily na jednom miste.
|
||||
*/
|
||||
|
||||
import { withTenant } from '@/lib/tenant';
|
||||
|
||||
const TOKEN_KEY = 'automia.token';
|
||||
|
||||
/**
|
||||
@@ -79,7 +81,16 @@ export async function apiFetch<T>(path: string, options: RequestOptions = {}): P
|
||||
const { body, auth = true, headers, ...rest } = options;
|
||||
const token = auth ? getToken() : null;
|
||||
|
||||
const response = await fetch(apiUrl(path), {
|
||||
/*
|
||||
* Vybrana firma se doplnuje **tady**, ne na strankach.
|
||||
*
|
||||
* Kdyz si ji mela pridavat kazda stranka sama, vetsina na to zapomnela
|
||||
* a server sahnul po vychozi firme - uzivatel se prepnul na Logitrans
|
||||
* a v Lidech koukal na lidi Automie. Jedno misto to resi natrvalo.
|
||||
*/
|
||||
const target = withTenant(path);
|
||||
|
||||
const response = await fetch(apiUrl(target), {
|
||||
...rest,
|
||||
headers: {
|
||||
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Firma, za kterou se cely portal kresli.
|
||||
*
|
||||
* **Je to jedna hodnota pro cely dashboard, ne stav jedne stranky.** Driv byl
|
||||
* prepinac uvnitr Prehledu, takze prepnuti na Logitrans zmenilo prehled a nic
|
||||
* jineho - v Lidech zustali lide Automie. Ostatni stranky volaly API bez
|
||||
* `tenantId` a server sahnul po vychozi firme. To neni nepohodli, to je cizi
|
||||
* data pod hlavickou jine firmy.
|
||||
*
|
||||
* Proto je hodnota **mimo React**: `apiFetch` neni komponenta a musi se k ni
|
||||
* dostat taky. Doplnuje `tenantId` do kazdeho dotazu na `/api/dashboard`, takze
|
||||
* na to nejde na zadne strance zapomenout - a prave zapomnetlivost byla ta chyba.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const STORAGE_KEY = 'automia.tenant';
|
||||
|
||||
let activeTenantId: string | null = null;
|
||||
const listeners = new Set<(tenantId: string | null) => void>();
|
||||
|
||||
/** Naposledy vybrana firma. Prezije obnoveni stranky. */
|
||||
function restore(): string | null {
|
||||
try {
|
||||
return localStorage.getItem(STORAGE_KEY);
|
||||
} catch (err) {
|
||||
console.warn('[tenant] localStorage neni dostupne:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
activeTenantId = restore();
|
||||
|
||||
export function getActiveTenant(): string | null {
|
||||
return activeTenantId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepne firmu. Nic se nevaliduje tady - co uzivatel smi, rika server
|
||||
* v `access.tenants` a podle nej se nabidka kresli.
|
||||
*/
|
||||
export function setActiveTenant(tenantId: string | null): void {
|
||||
if (tenantId === activeTenantId) return;
|
||||
activeTenantId = tenantId;
|
||||
|
||||
try {
|
||||
if (tenantId) localStorage.setItem(STORAGE_KEY, tenantId);
|
||||
else localStorage.removeItem(STORAGE_KEY);
|
||||
} catch (err) {
|
||||
console.warn('[tenant] volbu firmy nelze ulozit:', err);
|
||||
}
|
||||
|
||||
for (const listener of listeners) listener(tenantId);
|
||||
}
|
||||
|
||||
/** Prihlasi se k odberu zmen. Vraci funkci na odhlaseni. */
|
||||
export function onTenantChange(listener: (tenantId: string | null) => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
/** Vybrana firma jako stav komponenty. Prekresli se pri kazdem prepnuti. */
|
||||
export function useActiveTenant(): string | null {
|
||||
const [tenantId, setTenantId] = useState(getActiveTenant);
|
||||
useEffect(() => onTenantChange(setTenantId), []);
|
||||
return tenantId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Doplni do cesty vybranou firmu.
|
||||
*
|
||||
* Nedoplnuje se, kdyz:
|
||||
* - cesta uz `tenantId` nese. Stranka, ktera si firmu urcuje sama (proklik
|
||||
* z widgetu na konkretni firmu), ma prednost.
|
||||
* - jde o pohled pres vsechny firmy (`scope=all`). Tam by to nic neznamenalo.
|
||||
* - neni vybrano. Server pak pouzije vychozi firmu uctu, jako driv.
|
||||
*/
|
||||
export function withTenant(path: string): string {
|
||||
if (!activeTenantId) return path;
|
||||
if (!path.startsWith('/api/dashboard')) return path;
|
||||
|
||||
const [base, search = ''] = path.split('?');
|
||||
const params = new URLSearchParams(search);
|
||||
if (params.has('tenantId') || params.get('scope') === 'all') return path;
|
||||
|
||||
params.set('tenantId', activeTenantId);
|
||||
return `${base}?${params.toString()}`;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useEventStream } from '@/components/dashboard/EventStreamProvider';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { getActiveTenant, onTenantChange } from '@/lib/tenant';
|
||||
import type { DashboardEventType } from '@/types/events';
|
||||
|
||||
export interface QueryState<T> {
|
||||
@@ -37,9 +38,17 @@ export function useApiQuery<T>(path: string, options: QueryOptions = {}): QueryS
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [tick, setTick] = useState(0);
|
||||
/*
|
||||
* Firma je soucasti kazdeho dotazu (doplnuje ji `apiFetch`), takze jeji
|
||||
* zmena znamena jina data. Bez tehle zavislosti by na strance zustala
|
||||
* cisla predchozi firmy, dokud by ji nekdo neobnovil.
|
||||
*/
|
||||
const [tenantId, setTenantId] = useState(getActiveTenant);
|
||||
|
||||
const reload = useCallback(() => setTick((value) => value + 1), []);
|
||||
|
||||
useEffect(() => onTenantChange(setTenantId), []);
|
||||
|
||||
const enabled = options.enabled !== false;
|
||||
// Telo se do zavislosti effectu neda dat primo - nova reference pri kazdem
|
||||
// vykresleni by znamenala dotaz v nekonecne smycce.
|
||||
@@ -76,7 +85,7 @@ export function useApiQuery<T>(path: string, options: QueryOptions = {}): QueryS
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [path, tick, bodyKey, enabled]);
|
||||
}, [path, tick, bodyKey, enabled, tenantId]);
|
||||
|
||||
// Obnoveni dat pri prichozi udalosti ze streamu.
|
||||
const { subscribe } = useEventStream();
|
||||
|
||||
@@ -23,7 +23,6 @@ import { cn } from '@/lib/cn';
|
||||
import { useApiQuery } from '@/lib/useApiQuery';
|
||||
import { usePageMeta } from '@/lib/usePageMeta';
|
||||
import type {
|
||||
Access,
|
||||
DashboardSummary,
|
||||
Incident,
|
||||
LayoutItem,
|
||||
@@ -59,19 +58,21 @@ export default function Overview() {
|
||||
usePageMeta({ title: 'Přehled - portál Automia' });
|
||||
|
||||
const { user } = useAuth();
|
||||
const access = useApiQuery<Access>('/api/dashboard/access');
|
||||
|
||||
/** 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}` : '';
|
||||
/*
|
||||
* Firma se nevybira tady. Prepinac je nad celym dashboardem a `apiFetch`
|
||||
* doplnuje `tenantId` do kazdeho dotazu - driv byl prepinac jen v teto
|
||||
* strance a na ostatnich zustavala firma puvodni. Rozlozeni se tim uklada
|
||||
* pro tu firmu, ktera je prave vybrana, aniz by to stranka resila.
|
||||
*/
|
||||
|
||||
const layout = useApiQuery<LayoutResponse>(`/api/dashboard/layout${tenantQuery}`);
|
||||
const layout = useApiQuery<LayoutResponse>('/api/dashboard/layout');
|
||||
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}` : '';
|
||||
// Jen pohled. Firmu doplni `apiFetch` podle prepinace nahore.
|
||||
const scopeQuery = '?scope=tenant';
|
||||
const summary = useApiQuery<DashboardSummary>(`/api/dashboard/summary${scopeQuery}`, {
|
||||
refetchOn: [
|
||||
'ticket.created',
|
||||
@@ -230,7 +231,7 @@ export default function Overview() {
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
try {
|
||||
await apiFetch<LayoutResponse>(`/api/dashboard/layout${tenantQuery}`, {
|
||||
await apiFetch<LayoutResponse>('/api/dashboard/layout', {
|
||||
method: 'PUT',
|
||||
body: { items: draft },
|
||||
});
|
||||
@@ -250,7 +251,7 @@ export default function Overview() {
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
try {
|
||||
const fresh = await apiFetch<LayoutResponse>(`/api/dashboard/layout${tenantQuery}`, {
|
||||
const fresh = await apiFetch<LayoutResponse>('/api/dashboard/layout', {
|
||||
method: 'DELETE',
|
||||
});
|
||||
setDraft(fresh.items);
|
||||
@@ -284,22 +285,6 @@ export default function Overview() {
|
||||
</div>
|
||||
|
||||
<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)}>
|
||||
|
||||
@@ -159,7 +159,6 @@ export default function Tickets() {
|
||||
|
||||
<NewTicketDialog
|
||||
open={creating}
|
||||
tenantId={tenantId ?? data?.tenantId ?? null}
|
||||
onClose={() => setCreating(false)}
|
||||
onCreated={() => tickets.reload()}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user