Resitel je clenstvi uctu, firma z ARES, prepinani jazyku schovane

Resitel uz neni vlastni zaznam spojeny s uctem pres e-mail: je to
clenstvi uctu ve firme a jeho ID je ID uctu. Popisek, kapacita, externi
ID a zapnuti visi na clenstvi, takze clovek ve dvou firmach je v kazde
jinak a spravce firmy ho vypne jen u sebe. Odebrani z firmy odebere jen
clenstvi. Stara data se pri startu jednou prevedou (migratePeople.ts),
vcetne odkazu v ticketech, skupinach, automatizacich, akcich a widgetech.
Sprava lidi v zalozce Lide zaklada ucty, pozvanka uz nema volbu resitele.

Zalozeni firmy z registru ARES: hledani podle IC nebo nazvu, dotazeni
IC, DIC, sidla a pravni formy, vyber soucasnych statutarnich zastupcu
a prokury, ucty spravce firmy s nahradnim e-mailem IC-poradi@placeholder.cz.
Vychozi rozlozeni dashboardu bez resitele neobsahuje list.myTickets.

Prepinani jazyku je docasne schovane (MULTILANG_ENABLED), web je cesky.
Dokumentace aktualizovana.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
JiriUhlir
2026-09-09 13:09:35 +02:00
co-authored by Claude Fable 5.1
parent 6eed909a0d
commit bc6508e1f3
41 changed files with 1415 additions and 328 deletions
@@ -131,7 +131,7 @@ export function AresTenantDialog({
ico: company.ico,
name: name.trim() || undefined,
note: note.trim() || undefined,
persons: selected.map((row) => ({ name: row.name.trim(), email: row.email.trim() })),
persons: selected.map((row) => ({ name: row.name.trim(), email: row.email.trim(), roles: row.roles })),
},
});
setCreated(result);
@@ -161,7 +161,9 @@ export function AresTenantDialog({
</p>
{created.users.length > 0 ? (
<div className="space-y-2">
<p className="text-sm text-white/60">Založené účty, všechny jako správce firmy:</p>
<p className="text-sm text-white/60">
Založené účty, všechny jako správce firmy. Najdete je v záložce Firma:
</p>
<ul className="space-y-1 text-sm">
{created.users.map((user) => (
<li key={user.id} className="flex flex-wrap items-center gap-2">
+1 -1
View File
@@ -265,7 +265,7 @@ export function GroupPanel() {
{persons.length === 0 && (
<p className="mt-4 text-sm text-white/40">
Ve firmě zatím nikdo není. Nejdřív přidejte řešitele.
Ve firmě zatím nikdo není. Nejdřív přidejte lidi v záložce Řešitelé nebo je pozvěte.
</p>
)}
</div>
+2 -19
View File
@@ -28,7 +28,6 @@ interface InviteRow {
email: string;
note: string;
roleIds: string[];
asPerson: boolean;
expiresAt: string;
usedAt: string | null;
createdByEmail: string;
@@ -47,7 +46,6 @@ export function InvitePanel() {
const [email, setEmail] = useState('');
const [note, setNote] = useState('');
const [roleIds, setRoleIds] = useState<string[]>([]);
const [asPerson, setAsPerson] = useState(true);
const [copied, setCopied] = useState<string | null>(null);
const roles = invites.data?.roles ?? [];
@@ -56,7 +54,7 @@ export function InvitePanel() {
const creation = useSubmit(async () => {
await apiFetch('/api/dashboard/invites', {
method: 'POST',
body: { email, note, roleIds, asPerson },
body: { email, note, roleIds },
});
setEmail('');
setNote('');
@@ -141,22 +139,7 @@ export function InvitePanel() {
</div>
</fieldset>
<label className="mt-4 flex items-start gap-2 text-sm text-white/70">
<input
type="checkbox"
checked={asPerson}
onChange={(event) => setAsPerson(event.target.checked)}
className="mt-0.5 size-4 accent-brand-400"
/>
<span>
Založit i jako řešitele
<span className="mt-0.5 block text-xs text-white/35">
Řešitel je ten, komu jde přehodit ticket. Účetní může mít přístup do portálu,
aniž by kdy ticket řešila.
</span>
</span>
</label>
{/* Kazdy clen firmy je zaroven resitel, zadna volba "zalozit i jako resitele" uz neni. */}
{error && <p className="mt-3 text-sm text-danger-400">{error}</p>}
<Button
+3 -3
View File
@@ -7,7 +7,7 @@ import { Logo } from '@/components/layout/Logo';
import { ButtonLink } from '@/components/ui/Button';
import { Container } from '@/components/ui/Container';
import { mainNav } from '@/data/navigation';
import { useT } from '@/i18n';
import { MULTILANG_ENABLED, useT } from '@/i18n';
import { cn } from '@/lib/cn';
export function Navbar() {
@@ -63,7 +63,7 @@ export function Navbar() {
</nav>
<div className="hidden items-center gap-3 md:flex">
<LanguageSwitch />
{MULTILANG_ENABLED && <LanguageSwitch />}
{user ? (
<ButtonLink to="/dashboard" size="sm">
<LayoutDashboard className="size-4" />
@@ -111,7 +111,7 @@ export function Navbar() {
</NavLink>
))}
<LanguageSwitch variant="full" className="mt-3 px-1" />
{MULTILANG_ENABLED && <LanguageSwitch variant="full" className="mt-3 px-1" />}
<div className="mt-3 flex flex-col gap-2">
{user ? (
+13 -1
View File
@@ -27,6 +27,13 @@ export const locales: Array<{ code: Locale; label: string; short: string }> = [
const STORAGE_KEY = 'automia.locale';
/**
* Jeden prepinac pro cely web. `false` = prepinac jazyka se nekresli
* a vzdy se pouzije cestina. Az bude preklad hotovy a znacka rozhodnuta,
* staci prepnout tady.
*/
export const MULTILANG_ENABLED = false;
const dictionaries: Record<Locale, Partial<Record<MessageKey, string>>> = { cs, en };
/**
@@ -36,6 +43,11 @@ const dictionaries: Record<Locale, Partial<Record<MessageKey, string>>> = { cs,
* az na druhem miste - kdo si jednou prepnul, mysli to vazne.
*/
function detect(): Locale {
// Prepinani jazyku je docasne schovane (rozhodnuti 2026-09-09): web je jen
// cesky, dokud se nerozhodne o znacce. Slovniky zustavaji, at se preklad
// neztrati; ulozena volba `en` se ignoruje, jinak by nekdo zustal v anglictine
// bez moznosti prepnout zpet.
if (!MULTILANG_ENABLED) return 'cs';
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored === 'cs' || stored === 'en') return stored;
@@ -57,7 +69,7 @@ export function getLocale(): Locale {
}
export function setLocale(locale: Locale): void {
if (locale === active) return;
if (!MULTILANG_ENABLED || locale === active) return;
active = locale;
// Kvuli cteckam a prohlizecum, ktere podle toho nabizeji preklad stranky.
+135 -53
View File
@@ -226,59 +226,7 @@ export default function People() {
</>
)}
{tab === 'resitele' && (
<EntityAdmin
title="Řešitelé"
description="Lidé, na které jde přehodit ticket. Nemusí mít přístup do portálu, spojka je e-mail."
endpoint="/api/dashboard/settings/people"
listEndpoint="/api/dashboard/settings/people-overview"
onChanged={people.reload}
columns={[
{ label: 'Jméno', render: (person: NamedRecord) => String(person.name ?? '') },
{ label: 'E-mail', render: (person: NamedRecord) => String(person.email ?? '') },
{ label: 'Zaměření', render: (person: NamedRecord) => String(person.role ?? '') },
{
label: 'Kapacita',
narrow: true,
render: (person: NamedRecord) => String(person.capacity ?? ''),
},
{
label: 'Stav',
narrow: true,
render: (person: NamedRecord) =>
person.enabled === false ? (
<Badge tone="warn">vypnutý</Badge>
) : (
<Badge tone="ok">aktivní</Badge>
),
},
]}
fields={[
{ name: 'name', label: 'Jméno', kind: 'text', required: true },
{ name: 'email', label: 'E-mail', kind: 'text', required: true },
{
name: 'role',
label: 'Zaměření',
kind: 'text',
hint: 'Jen popisek, nic nerozhoduje.',
},
{
name: 'capacity',
label: 'Kapacita',
kind: 'number',
initial: 8,
hint: 'Kolik nevyřízených ticketů je pro něj zdravá zátěž. Není to limit.',
},
{
name: 'externalIds',
label: 'ID z cizích aplikací',
kind: 'tags',
hint: 'Oddělená čárkou. Podle nich umí automatizace předat ticket právě jemu, například voicebotId.',
},
{ name: 'enabled', label: 'Aktivní', kind: 'checkbox', initial: true },
]}
/>
)}
{tab === 'resitele' && <PeopleAdmin onChanged={people.reload} />}
{tab === 'skupiny' && <GroupPanel />}
@@ -291,6 +239,140 @@ export default function People() {
);
}
/** Role firmy, jak je vraci `/settings/roles-available`. Odkaz v clenstvi smi byt ID i klic. */
interface RoleOption {
id: string;
key: string;
name: string;
}
/**
* Sprava resitelu.
*
* Resitel neni vlastni zaznam, je to clen firmy: ID resitele je ID uctu.
* Zalozeni tady proto zaklada ucet (nebo pridava clenstvi uz existujicimu)
* a smazani jen rusi clenstvi. Driv byl resitel zvlastni zaznam spojeny
* s uctem pres e-mail a zmena e-mailu tu vazbu rozbila.
*
* Vlastni komponenta, aby se role nacitaly az pri otevreni zalozky.
*/
function PeopleAdmin({ onChanged }: { onChanged: () => void }) {
const roles = useApiQuery<{ items: RoleOption[] }>('/api/dashboard/settings/roles-available');
const roleItems = useMemo(() => roles.data?.items ?? [], [roles.data]);
const roleOptions = useMemo(
() => roleItems.map((role) => ({ value: role.id, label: role.name })),
[roleItems],
);
// Vychozi role noveho cloveka: agent. Bez role by nemel v portalu co delat.
const agentRoleId = roleItems.find((role) => role.key === 'agent')?.id;
/** Jmena roli podle odkazu v clenstvi (ID role nebo klic systemove role). */
function roleNames(ids: unknown): string {
if (!Array.isArray(ids)) return '-';
return (ids as string[])
.map((id) => roleItems.find((role) => role.id === id || role.key === id)?.name ?? id)
.join(', ');
}
return (
<EntityAdmin
title="Řešitelé"
description="Lidé ve firmě. Každý člen má účet do portálu a může mít u sebe tickety. Smazání jen odebere členství ve firmě, účet zůstává."
endpoint="/api/dashboard/settings/people"
listEndpoint="/api/dashboard/settings/people-overview"
refetchOn={['person.created', 'person.updated', 'person.deleted']}
onChanged={onChanged}
columns={[
{ label: 'Jméno', render: (person: NamedRecord) => String(person.name ?? '') },
{ label: 'E-mail', render: (person: NamedRecord) => String(person.email ?? '') },
{ label: 'Zaměření', render: (person: NamedRecord) => String(person.role ?? '') },
{
label: 'Kapacita',
narrow: true,
render: (person: NamedRecord) => String(person.capacity ?? ''),
},
{ label: 'Role', render: (person: NamedRecord) => roleNames(person.roleIds) },
{
label: 'Stav',
narrow: true,
render: (person: NamedRecord) =>
person.enabled === false ? (
<Badge tone="warn">vypnutý</Badge>
) : (
<Badge tone="ok">aktivní</Badge>
),
},
]}
fields={[
{ name: 'name', label: 'Jméno', kind: 'text', required: true },
{ name: 'email', label: 'E-mail', kind: 'text', required: true },
{
name: 'password',
label: 'Heslo',
kind: 'text',
createOnly: true,
hint: 'Prázdné = náhodné heslo, člověk si ho nastaví přes pozvánku nebo mu ho změní správce.',
},
{
name: 'roleIds',
label: 'Role ve firmě',
kind: 'multiselect',
options: roleOptions,
initial: agentRoleId ? [agentRoleId] : [],
// Bez roli v zaznamu by prazdny vyber pri uprave role tise smazal.
visible: (person) => person === null || Array.isArray(person.roleIds),
hint: 'Co smí v portálu dělat. Nový člověk je agent.',
},
{
name: 'role',
label: 'Zaměření',
kind: 'text',
hint: 'Jen popisek, nic nerozhoduje.',
},
{
name: 'capacity',
label: 'Kapacita',
kind: 'number',
initial: 8,
hint: 'Kolik nevyřízených ticketů je pro něj zdravá zátěž. Není to limit.',
},
{
name: 'externalIds',
label: 'ID z cizích aplikací',
kind: 'tags',
hint: 'Oddělená čárkou. Podle nich umí automatizace předat ticket právě jemu, například voicebotId.',
},
{
name: 'enabled',
label: 'Aktivní v této firmě',
kind: 'checkbox',
initial: true,
hint: 'Vypnutý se nenabízí k přiřazení, staré tickety mu zůstanou. Účet v jiných firmách to neovlivní.',
},
]}
toBody={(values, entity) => {
const body: Record<string, unknown> = {
name: values.name,
email: values.email,
role: values.role,
capacity: values.capacity,
externalIds: values.externalIds,
enabled: values.enabled !== false,
};
// Role se posilaji jen kdyz je bylo videt: pri zalozeni vzdy, pri uprave
// jen kdyz je server v zaznamu vratil.
if (!entity || Array.isArray(entity.roleIds)) body.roleIds = values.roleIds;
// Prazdne heslo se neposila, server si vygeneruje nahodne.
if (!entity && typeof values.password === 'string' && values.password !== '') {
body.password = values.password;
}
return body;
}}
/>
);
}
function Stat({
label,
value,
+1 -1
View File
@@ -200,7 +200,7 @@ export default function Tickets() {
] as const).filter((item) => item.show);
/*
* Kdo neni veden jako resitel, nema zalozku Moje. Vychozi stav ji ale
* Kdo nesmi videt svoje tickety (chybi rozsah `mine`), nema zalozku Moje. Vychozi stav ji ale
* predpoklada, protoze prava dorazi az po prvnim vykresleni - bez teto
* pojistky by koukal na prazdno a zadna zalozka by nesvitila.
*/