Firmy a prava: tenance napric portalem
Portal nemel zadnou tenanci. Kterykoliv prihlaseny uzivatel videl vsechny tickety vsech firem i cely seznam resitelu, requireRole se nikde nevolal. Tenant je hranice viditelnosti, tenantId na ticketu, resiteli i automatizaci. Uzivatel muze patrit do vic firem, v kazde s jinou roli. Pristup napric firmami je zvlast jako platformAdmin. Tri pohledy na tickety: all, tenant, mine. Admin mezi nimi prepina vcetne vyberu firmy. O pravech rozhoduje jedine data/access.ts, klient si nic nedovozuje a bere je z GET /api/dashboard/access. Filtr na firmu je v ulozistich povinny argument, takze zapomenuty filtr neznamena vse, ale nezkompiluje se. Cizi firma vraci 403 nebo 404, nikdy tise zuzeny vysledek. Prirazeni jen v ramci firmy. Prehazovat praci mezi lidmi smi jen admin, agent si smi vzit ticket na sebe. Zmena prihlasovani: ucet klient@firma.cz zanikl, demo ucty jsou nove. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,12 +2,21 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState }
|
||||
import type { ReactNode } from 'react';
|
||||
import { apiFetch, getToken, setToken } from '@/lib/api';
|
||||
|
||||
export type TenantRole = 'admin' | 'agent';
|
||||
|
||||
export interface Membership {
|
||||
tenantId: string;
|
||||
role: TenantRole;
|
||||
}
|
||||
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: 'admin' | 'client';
|
||||
company: string;
|
||||
/** Vidi napric vsemi firmami a muze mezi nimi prepinat. */
|
||||
platformAdmin: boolean;
|
||||
/** Uzivatel muze patrit do vic firem, v kazde s jinou roli. */
|
||||
memberships: Membership[];
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
|
||||
@@ -13,13 +13,15 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '@/auth/AuthContext';
|
||||
import { useAuth, type AuthUser } from '@/auth/AuthContext';
|
||||
import { EventStreamProvider } from '@/components/dashboard/EventStreamProvider';
|
||||
import { EventToasts } from '@/components/dashboard/EventToasts';
|
||||
import { LiveIndicator } from '@/components/dashboard/LiveIndicator';
|
||||
import { SimulationModal } from '@/components/dashboard/SimulationModal';
|
||||
import { Logo } from '@/components/layout/Logo';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { useApiQuery } from '@/lib/useApiQuery';
|
||||
import type { Access } from '@/types/dashboard';
|
||||
|
||||
const nav = [
|
||||
{ to: '/dashboard', label: 'Přehled', icon: LayoutDashboard, end: true },
|
||||
@@ -43,8 +45,18 @@ export function DashboardLayout() {
|
||||
);
|
||||
}
|
||||
|
||||
/** Kratky popis pristupu do topbaru. Uzivatel musi poznat, co vlastne vidi. */
|
||||
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;
|
||||
if (count > 1) return `Přístup do ${count} firem`;
|
||||
const role = user?.memberships[0]?.role;
|
||||
return role === 'admin' ? 'Správce firmy' : 'Řešitel';
|
||||
}
|
||||
|
||||
function DashboardShell() {
|
||||
const { user, logout } = useAuth();
|
||||
const access = useApiQuery<Access>('/api/dashboard/access');
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
@@ -157,11 +169,9 @@ function DashboardShell() {
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold text-white">
|
||||
{user?.company ?? 'Klientský portál'}
|
||||
</p>
|
||||
<p className="truncate text-xs text-white/40">
|
||||
{user?.role === 'admin' ? 'Interní přístup' : 'Klientský přístup'}
|
||||
{access.data?.tenants[0]?.name ?? 'Klientský portál'}
|
||||
</p>
|
||||
<p className="truncate text-xs text-white/40">{accessLabel(user, access.data)}</p>
|
||||
</div>
|
||||
|
||||
<LiveIndicator className="hidden sm:inline-flex" />
|
||||
|
||||
@@ -304,15 +304,18 @@ function ActionCard({
|
||||
const inputs = resolved.operation.inputs;
|
||||
|
||||
return (
|
||||
<div className="rounded-card border border-ink-600/60 bg-ink-800/60 p-4 transition-colors hover:border-ink-600">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="grid size-10 shrink-0 place-items-center rounded-xl bg-ink-700/70 text-brand-300">
|
||||
<Icon className="size-4.5" />
|
||||
<div className="@container rounded-card border border-ink-600/60 bg-ink-800/60 p-3 transition-colors hover:border-ink-600 sm:p-4">
|
||||
<div className="flex items-start gap-2.5 sm:gap-3">
|
||||
<span className="grid size-8 shrink-0 place-items-center rounded-xl bg-ink-700/70 text-brand-300 @xs:size-10">
|
||||
<Icon className="size-4 @xs:size-4.5" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs text-white/40">{resolved.connector.name}</p>
|
||||
<p className="mt-0.5 font-semibold text-white">{resolved.operation.name}</p>
|
||||
<p className="mt-0.5 text-sm text-white/50">{resolved.operation.description}</p>
|
||||
<p className="truncate text-xs text-white/40">{resolved.connector.name}</p>
|
||||
<p className="mt-0.5 font-semibold break-words text-white">{resolved.operation.name}</p>
|
||||
{/* Popis je jen doplnek. V uzkem sloupci by se lamal po slovech. */}
|
||||
<p className="mt-0.5 hidden text-sm text-white/50 @xs:block">
|
||||
{resolved.operation.description}
|
||||
</p>
|
||||
</div>
|
||||
<StepControls
|
||||
canMoveUp={canMoveUp}
|
||||
@@ -386,10 +389,10 @@ function ConditionCard({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-card border border-accent-400/30 bg-accent-500/6 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="grid size-10 shrink-0 place-items-center rounded-xl bg-accent-500/20 text-accent-300">
|
||||
<GitBranch className="size-4.5" />
|
||||
<div className="@container rounded-card border border-accent-400/30 bg-accent-500/6 p-3 sm:p-4">
|
||||
<div className="flex items-start gap-2.5 sm:gap-3">
|
||||
<span className="grid size-8 shrink-0 place-items-center rounded-xl bg-accent-500/20 text-accent-300 @xs:size-10">
|
||||
<GitBranch className="size-4 @xs:size-4.5" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
@@ -403,7 +406,7 @@ function ConditionCard({
|
||||
podmínku odeberte.
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-0.5 font-semibold text-white">
|
||||
<p className="mt-0.5 font-semibold break-words text-white">
|
||||
{describeCondition(field, step.operator, step.value)}
|
||||
</p>
|
||||
)}
|
||||
@@ -472,12 +475,18 @@ function ConditionCard({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-4 lg:grid-cols-2">
|
||||
{/*
|
||||
Vetve vedle sebe jen tehdy, kdyz je na to v TETO karte misto.
|
||||
Rozhoduje sirka karty, ne okna - kazde zanoreni ji pulí, takze
|
||||
od urcite hloubky se vetve samy prepnou pod sebe misto toho,
|
||||
aby se karty zmackly na par pixelu.
|
||||
*/}
|
||||
<div className="mt-4 grid gap-4 @2xl:grid-cols-2">
|
||||
{(['yes', 'no'] as const).map((branch) => (
|
||||
<div
|
||||
key={branch}
|
||||
className={cn(
|
||||
'rounded-xl border p-3',
|
||||
'min-w-0 rounded-xl border p-3',
|
||||
branch === 'yes'
|
||||
? 'border-ok-400/25 bg-ok-500/5'
|
||||
: 'border-danger-400/25 bg-danger-500/5',
|
||||
@@ -523,14 +532,15 @@ function StepControls({
|
||||
onMove: (offset: number) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
// V uzke karte se ovladani skryva do sloupce, aby nebralo sirku textu.
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
<div className="flex shrink-0 flex-col items-center gap-0.5 @xs:flex-row">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onMove(-1)}
|
||||
disabled={!canMoveUp}
|
||||
title="Posunout výš"
|
||||
className="grid size-8 place-items-center rounded-lg text-white/35 transition-colors hover:bg-white/5 hover:text-white disabled:pointer-events-none disabled:opacity-25"
|
||||
className="grid size-7 place-items-center rounded-lg text-white/35 transition-colors hover:bg-white/5 hover:text-white disabled:pointer-events-none disabled:opacity-25 @xs:size-8"
|
||||
>
|
||||
<ChevronUp className="size-4" />
|
||||
<span className="sr-only">Posunout výš</span>
|
||||
@@ -540,7 +550,7 @@ function StepControls({
|
||||
onClick={() => onMove(1)}
|
||||
disabled={!canMoveDown}
|
||||
title="Posunout níž"
|
||||
className="grid size-8 place-items-center rounded-lg text-white/35 transition-colors hover:bg-white/5 hover:text-white disabled:pointer-events-none disabled:opacity-25"
|
||||
className="grid size-7 place-items-center rounded-lg text-white/35 transition-colors hover:bg-white/5 hover:text-white disabled:pointer-events-none disabled:opacity-25 @xs:size-8"
|
||||
>
|
||||
<ChevronDown className="size-4" />
|
||||
<span className="sr-only">Posunout níž</span>
|
||||
@@ -549,7 +559,7 @@ function StepControls({
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
title="Odebrat krok"
|
||||
className="grid size-8 place-items-center rounded-lg text-white/35 transition-colors hover:bg-danger-500/12 hover:text-danger-400"
|
||||
className="grid size-7 place-items-center rounded-lg text-white/35 transition-colors hover:bg-danger-500/12 hover:text-danger-400 @xs:size-8"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
<span className="sr-only">Odebrat krok</span>
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { Construction } from 'lucide-react';
|
||||
import { useAuth } from '@/auth/AuthContext';
|
||||
import { useApiQuery } from '@/lib/useApiQuery';
|
||||
import { usePageMeta } from '@/lib/usePageMeta';
|
||||
import type { Access } from '@/types/dashboard';
|
||||
|
||||
/** Zamerne jen kostra - obsah nastaveni doresime pri stavbe dashboardu. */
|
||||
export default function Settings() {
|
||||
usePageMeta({ title: 'Nastavení — portál Automia' });
|
||||
const { user } = useAuth();
|
||||
const access = useApiQuery<Access>('/api/dashboard/access');
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -19,11 +22,41 @@ export default function Settings() {
|
||||
<dl className="mt-4 grid gap-4 sm:grid-cols-2">
|
||||
<Row label="Jméno" value={user?.name ?? '—'} />
|
||||
<Row label="E-mail" value={user?.email ?? '—'} />
|
||||
<Row label="Organizace" value={user?.company ?? '—'} />
|
||||
<Row label="Role" value={user?.role === 'admin' ? 'Interní správce' : 'Klient'} />
|
||||
<Row
|
||||
label="Přístup"
|
||||
value={user?.platformAdmin ? 'Správce platformy' : 'Účet firmy'}
|
||||
/>
|
||||
<Row label="Firem" value={String(user?.memberships.length ?? 0)} />
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section className="glass rounded-card p-5 sm:p-6">
|
||||
<h2 className="font-semibold text-white">Členství ve firmách</h2>
|
||||
<p className="mt-1 text-sm text-white/45">
|
||||
Role je vždy až uvnitř firmy. Ve dvou firmách můžete mít různá práva.
|
||||
</p>
|
||||
{access.data && access.data.tenants.length > 0 ? (
|
||||
<ul className="mt-4 space-y-2">
|
||||
{access.data.tenants.map((tenant) => {
|
||||
const role = user?.memberships.find((m) => m.tenantId === tenant.id)?.role;
|
||||
return (
|
||||
<li
|
||||
key={tenant.id}
|
||||
className="flex flex-wrap items-center justify-between gap-2 rounded-xl border border-ink-600/50 bg-ink-850/50 px-4 py-3"
|
||||
>
|
||||
<span className="text-sm text-white/80">{tenant.name}</span>
|
||||
<span className="text-xs text-white/45">
|
||||
{role === 'admin' ? 'Správce firmy' : role === 'agent' ? 'Řešitel' : 'Přes platformu'}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="mt-4 text-sm text-white/40">Účet nepatří do žádné firmy.</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="rounded-card border border-dashed border-ink-600/70 p-8 text-center">
|
||||
<Construction className="mx-auto size-6 text-white/30" />
|
||||
<h2 className="mt-3 font-semibold text-white">Připravujeme</h2>
|
||||
|
||||
@@ -14,8 +14,10 @@ import { formatDateTime, formatRelative } from '@/lib/format';
|
||||
import { useApiQuery } from '@/lib/useApiQuery';
|
||||
import { usePageMeta } from '@/lib/usePageMeta';
|
||||
import type {
|
||||
Access,
|
||||
TicketChannel,
|
||||
TicketListResponse,
|
||||
TicketScope,
|
||||
TicketStatus,
|
||||
Workload,
|
||||
} from '@/types/dashboard';
|
||||
@@ -30,6 +32,12 @@ const statusFilters: Array<{ value: TicketStatus; label: string }> = [
|
||||
{ value: 'resolved', label: 'Vyřešené' },
|
||||
];
|
||||
|
||||
const scopeOptions: Array<{ value: TicketScope; label: string; hint: string }> = [
|
||||
{ value: 'all', label: 'Vše', hint: 'Napříč všemi firmami. Jen pro správce platformy.' },
|
||||
{ value: 'tenant', label: 'Firma', hint: 'Všechny tickety vybrané firmy.' },
|
||||
{ value: 'mine', label: 'Moje', hint: 'Jen tickety, které mám u sebe.' },
|
||||
];
|
||||
|
||||
const channelFilters: Array<{ value: TicketChannel; label: string }> = [
|
||||
{ value: 'whatsapp', label: 'WhatsApp' },
|
||||
{ value: 'facebook', label: 'Facebook' },
|
||||
@@ -43,7 +51,12 @@ const channelFilters: Array<{ value: TicketChannel; label: string }> = [
|
||||
export default function Tickets() {
|
||||
usePageMeta({ title: 'Tickety — portál Automia' });
|
||||
|
||||
/** null = vsichni, 'me' = prihlaseny, 'unassigned' = fronta, jinak ID resitele. */
|
||||
// Co uzivatel smi, rozhoduje server. Klient jen kresli, co prislo.
|
||||
const access = useApiQuery<Access>('/api/dashboard/access');
|
||||
|
||||
const [scope, setScope] = useState<TicketScope | null>(null);
|
||||
const [tenantId, setTenantId] = useState<string | null>(null);
|
||||
/** null = vsichni, 'unassigned' = fronta, jinak ID resitele. */
|
||||
const [assignee, setAssignee] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState<TicketStatus | null>(null);
|
||||
const [channel, setChannel] = useState<TicketChannel | null>(null);
|
||||
@@ -52,17 +65,26 @@ export default function Tickets() {
|
||||
// Filtrovani resi server, aby seznam a prehled nikdy neukazovaly jina cisla.
|
||||
const path = useMemo(() => {
|
||||
const params = new URLSearchParams();
|
||||
if (scope) params.set('scope', scope);
|
||||
if (tenantId) params.set('tenantId', tenantId);
|
||||
if (assignee) params.set('assignee', assignee);
|
||||
if (status) params.set('status', status);
|
||||
if (channel) params.set('channel', channel);
|
||||
const search = params.toString();
|
||||
return `/api/dashboard/tickets${search ? `?${search}` : ''}`;
|
||||
}, [assignee, status, channel]);
|
||||
}, [scope, tenantId, assignee, status, channel]);
|
||||
|
||||
const workloadPath = useMemo(() => {
|
||||
const params = new URLSearchParams();
|
||||
// Prehled vytizeni je vzdy za firmu. U pohledu "vse" nema tym co znamenat.
|
||||
if (scope === 'all') params.set('scope', 'all');
|
||||
else if (tenantId) params.set('tenantId', tenantId);
|
||||
const search = params.toString();
|
||||
return `/api/dashboard/tickets/workload${search ? `?${search}` : ''}`;
|
||||
}, [scope, tenantId]);
|
||||
|
||||
const tickets = useApiQuery<TicketListResponse>(path, { refetchOn: [...ticketEvents] });
|
||||
const workload = useApiQuery<Workload>('/api/dashboard/tickets/workload', {
|
||||
refetchOn: [...ticketEvents],
|
||||
});
|
||||
const workload = useApiQuery<Workload>(workloadPath, { refetchOn: [...ticketEvents] });
|
||||
|
||||
// Hledani je jen dohledani v uz nactenem seznamu, proto na klientovi.
|
||||
const items = useMemo(() => {
|
||||
@@ -78,9 +100,15 @@ export default function Tickets() {
|
||||
);
|
||||
}, [tickets.data, query]);
|
||||
|
||||
const meId = tickets.data?.meId ?? null;
|
||||
const data = tickets.data;
|
||||
const meId = data?.meId ?? null;
|
||||
const filtered = assignee !== null || status !== null || channel !== null;
|
||||
|
||||
const allowedScopes = access.data?.scopes ?? [];
|
||||
const tenants = access.data?.tenants ?? [];
|
||||
// Dokud server neodpovi, drzime se toho, co poslal u seznamu.
|
||||
const activeScope = scope ?? data?.scope ?? null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header>
|
||||
@@ -105,21 +133,41 @@ export default function Tickets() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{/* Pohled je jina osa nez filtry - urcuje, ktera data vubec dorazi. */}
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||
{scopeOptions
|
||||
.filter((option) => allowedScopes.includes(option.value))
|
||||
.map((option) => (
|
||||
<Chip
|
||||
key={option.value}
|
||||
active={activeScope === option.value}
|
||||
onClick={() => setScope(option.value)}
|
||||
title={option.hint}
|
||||
>
|
||||
{option.label}
|
||||
</Chip>
|
||||
))}
|
||||
|
||||
{/* Prepinac firmy davá smysl jen tomu, kdo jich ma vic. */}
|
||||
{tenants.length > 1 && activeScope !== 'all' && (
|
||||
<select
|
||||
value={tenantId ?? data?.tenantId ?? ''}
|
||||
onChange={(event) => setTenantId(event.target.value)}
|
||||
aria-label="Firma"
|
||||
className="rounded-full border border-ink-600/70 bg-ink-850/70 px-3 py-1.5 text-xs text-white 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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<Chip active={assignee === null} onClick={() => setAssignee(null)}>
|
||||
Všechny
|
||||
</Chip>
|
||||
<Chip
|
||||
active={assignee === 'me'}
|
||||
onClick={() => setAssignee(assignee === 'me' ? null : 'me')}
|
||||
disabled={meId === null}
|
||||
title={
|
||||
meId === null
|
||||
? 'Přihlášený účet není v seznamu řešitelů, nemá tedy vlastní tickety.'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
Moje
|
||||
Kdokoliv
|
||||
</Chip>
|
||||
<Chip
|
||||
active={assignee === 'unassigned'}
|
||||
|
||||
@@ -17,9 +17,27 @@ export type TicketChannel =
|
||||
export type IncidentSeverity = 'sev1' | 'sev2' | 'sev3';
|
||||
export type IncidentStatus = 'investigating' | 'identified' | 'monitoring' | 'resolved';
|
||||
|
||||
export interface Tenant {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** Pohled na tickety. Server rozhoduje, ktery smi kdo pouzit. */
|
||||
export type TicketScope = 'all' | 'tenant' | 'mine';
|
||||
|
||||
/** Co uzivatel smi. Klient si to nesmi dovozovat sam. */
|
||||
export interface Access {
|
||||
scopes: TicketScope[];
|
||||
tenants: Tenant[];
|
||||
defaultTenantId: string | null;
|
||||
canAssignOthers: boolean;
|
||||
personId: string | null;
|
||||
}
|
||||
|
||||
/** Resitel ticketu. Nemusi mit ucet v portalu, spojka je e-mail. */
|
||||
export interface Person {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
@@ -37,6 +55,8 @@ export interface TicketCustomer {
|
||||
|
||||
export interface Ticket {
|
||||
id: string;
|
||||
/** Firma, ktere ticket patri. */
|
||||
tenantId: string;
|
||||
subject: string;
|
||||
/** Cely text pozadavku. Prazdny = krok "Zalozit ticket" obsah nenaplnil. */
|
||||
body: string;
|
||||
@@ -80,6 +100,10 @@ export interface TicketDetail extends Ticket {
|
||||
export interface TicketListResponse {
|
||||
items: Ticket[];
|
||||
meId: string | null;
|
||||
/** Pohled, ktery server skutecne pouzil. */
|
||||
scope: TicketScope;
|
||||
/** Firma, na kterou je pohled zamereny. null u pohledu `all`. */
|
||||
tenantId: string | null;
|
||||
}
|
||||
|
||||
export interface PeopleResponse {
|
||||
|
||||
Reference in New Issue
Block a user