feat: multi-organization support + bank accounts

- Renamed singular /api/organization → CRUD /api/organizations
- New BankAccount table with CRUD under /api/organizations/:orgId/bank-accounts
- TochkaCredential gets optional bankAccountId for future per-account bank API config
- Active organization stored in cookie dm_org, /api/active-organization GET/POST
- activeOrgPlugin resolves req._orgId from cookie (or first non-archived as fallback)
- Migration 1_multiorg: BankAccount table + data backfill from legacy Organization.bank* fields
- Web: new /companies list + /companies/:id with tabs (Реквизиты, Банки и счета, Интеграции stub)
- Web: OrgSwitcher dropdown in header (active org + management link)
- Removed nav "Реквизиты", "Банк" — replaced by "Компании"
- Per-field error highlighting wired up on new forms

Existing organization data backfills cleanly: legacy bank* fields stay readable, but new
BankAccount becomes the source of truth going forward.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
admin
2026-05-01 11:08:26 +03:00
parent b28c0463b3
commit 524789facc
15 changed files with 909 additions and 200 deletions
+7 -5
View File
@@ -3,11 +3,13 @@ import { Link, Route, Routes } from 'react-router-dom';
import { redirectToLogin, useAuth } from './auth.js';
import { ClientsPage } from './pages/Clients.js';
import { ServicesPage } from './pages/Services.js';
import { OrganizationPage } from './pages/Organization.js';
import { CompaniesPage } from './pages/Companies.js';
import { CompanyEditPage } from './pages/CompanyEdit.js';
import { DocumentsPage } from './pages/Documents.js';
import { DocumentEditPage } from './pages/DocumentEdit.js';
import { TemplatesPage } from './pages/Templates.js';
import { TemplateEditPage } from './pages/TemplateEdit.js';
import { OrgSwitcher } from './components/OrgSwitcher.js';
function Layout({ email }: { email: string }) {
return (
@@ -18,9 +20,9 @@ function Layout({ email }: { email: string }) {
<Link to="/clients">Клиенты</Link>
<Link to="/services">Услуги</Link>
<Link to="/templates">Шаблоны</Link>
<Link to="/bank">Банк</Link>
<Link to="/organization">Реквизиты</Link>
<Link to="/companies">Компании</Link>
</nav>
<OrgSwitcher />
<span className="user">{email}</span>
</header>
);
@@ -71,8 +73,8 @@ export function App() {
<Route path="/services" element={<ServicesPage />} />
<Route path="/templates" element={<TemplatesPage />} />
<Route path="/templates/:id" element={<TemplateEditPage />} />
<Route path="/bank" element={<Placeholder title="Банк" />} />
<Route path="/organization" element={<OrganizationPage />} />
<Route path="/companies" element={<CompaniesPage />} />
<Route path="/companies/:id" element={<CompanyEditPage />} />
<Route path="*" element={<Placeholder title="Не найдено" />} />
</Routes>
</>
+19
View File
@@ -103,6 +103,7 @@ export const api = {
export type Organization = {
id: string;
name: string;
shortName: string | null;
inn: string;
kpp: string | null;
ogrn: string | null;
@@ -112,6 +113,24 @@ export type Organization = {
bankAccount: string | null;
signatoryName: string | null;
signatoryPosition: string | null;
archivedAt: string | null;
createdAt: string;
updatedAt: string;
};
export type BankAccount = {
id: string;
organizationId: string;
name: string;
bankName: string | null;
bankBik: string | null;
accountNumber: string | null;
corrAccount: string | null;
currency: string;
isPrimary: boolean;
archivedAt: string | null;
createdAt: string;
updatedAt: string;
};
export type Client = {
+72
View File
@@ -0,0 +1,72 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { api, type Organization } from '../api.js';
export function OrgSwitcher() {
const [active, setActive] = useState<Organization | null>(null);
const [items, setItems] = useState<Organization[]>([]);
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
async function load() {
try {
const [a, list] = await Promise.all([
api.get<{ id: string; organization: Organization | null }>('/api/active-organization'),
api.get<{ items: Organization[] }>('/api/organizations'),
]);
setActive(a.organization);
setItems(list.items);
} catch {
/* проглатываем — баннер не критичен */
}
}
useEffect(() => { void load(); }, []);
async function switchTo(id: string) {
setLoading(true);
try {
const r = await api.post<{ organization: Organization }>('/api/active-organization', { id });
setActive(r.organization);
setOpen(false);
// Перезагружаем страницу — данные на странице зависят от активной организации.
window.location.reload();
} finally {
setLoading(false);
}
}
if (!active && items.length === 0) {
return (
<span className="org-switcher org-switcher--empty">
<Link to="/companies">Создать компанию</Link>
</span>
);
}
return (
<div className="org-switcher">
<button className="org-switcher__btn" onClick={() => setOpen((o) => !o)} disabled={loading}>
{active?.shortName || active?.name || '— выбрать —'}
<span className="org-switcher__chev"></span>
</button>
{open ? (
<div className="org-switcher__menu" onMouseLeave={() => setOpen(false)}>
{items.map((o) => (
<button
key={o.id}
className={`org-switcher__item ${o.id === active?.id ? 'org-switcher__item--active' : ''}`}
onClick={() => switchTo(o.id)}
disabled={loading}
>
{o.shortName || o.name}
</button>
))}
<div className="org-switcher__divider" />
<Link to="/companies" className="org-switcher__item" onClick={() => setOpen(false)}>
Управление компаниями
</Link>
</div>
) : null}
</div>
);
}
+137
View File
@@ -0,0 +1,137 @@
import { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { api, ApiError, type Organization } from '../api.js';
import { Button, EmptyState, Field, Modal } from '../components/ui.js';
export function CompaniesPage() {
const [items, setItems] = useState<Organization[] | null>(null);
const [creating, setCreating] = useState<{ name: string; inn: string } | null>(null);
const [error, setError] = useState<string | null>(null);
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const navigate = useNavigate();
async function load() {
try {
const r = await api.get<{ items: Organization[] }>('/api/organizations');
setItems(r.items);
} catch (e) {
setError(String(e));
}
}
useEffect(() => { void load(); }, []);
async function create() {
if (!creating) return;
setError(null);
setFieldErrors({});
try {
const created = await api.post<Organization>('/api/organizations', {
name: creating.name,
inn: creating.inn,
shortName: null,
kpp: null, ogrn: null, legalAddress: null,
signatoryName: null, signatoryPosition: null,
});
setCreating(null);
navigate(`/companies/${created.id}`);
} catch (e) {
if (e instanceof ApiError) {
const fe = e.fieldErrors();
setFieldErrors(fe);
setError(Object.keys(fe).length ? 'Проверьте подсвеченные поля.' : e.prettyMessage());
} else {
setError(String(e));
}
}
}
async function archive(id: string) {
if (!confirm('Архивировать компанию? Документы и клиенты сохранятся.')) return;
try {
await api.del(`/api/organizations/${id}`);
await load();
} catch (e) {
setError(String(e));
}
}
return (
<main className="content">
<header className="page-head">
<h2>Компании</h2>
<Button variant="primary" onClick={() => setCreating({ name: '', inn: '' })}>+ Добавить</Button>
</header>
{error ? <div className="error-text">{error}</div> : null}
{items === null ? (
<p className="hint">Загрузка</p>
) : items.length === 0 ? (
<EmptyState>
Ещё не добавлено ни одной компании. Создайте первую её данные будут подставляться в договоры и счета как сторона-исполнитель.
</EmptyState>
) : (
<table className="table">
<thead>
<tr>
<th>Название</th>
<th>ИНН</th>
<th>КПП</th>
<th aria-label="actions" />
</tr>
</thead>
<tbody>
{items.map((o) => (
<tr key={o.id}>
<td>
<Link to={`/companies/${o.id}`}>{o.name}</Link>
{o.shortName ? <span className="hint"> · {o.shortName}</span> : null}
</td>
<td>{o.inn}</td>
<td>{o.kpp ?? '—'}</td>
<td className="row-actions">
<Button variant="ghost" onClick={() => navigate(`/companies/${o.id}`)}>Открыть</Button>
<Button variant="danger" onClick={() => archive(o.id)}>В архив</Button>
</td>
</tr>
))}
</tbody>
</table>
)}
<Modal
open={creating !== null}
title="Новая компания"
onClose={() => setCreating(null)}
footer={
<>
<Button variant="ghost" onClick={() => setCreating(null)}>Отмена</Button>
<Button variant="primary" onClick={create}>Создать</Button>
</>
}
>
{creating ? (
<div className="form-grid">
<Field
label="Название"
value={creating.name}
onChange={(e) => setCreating({ ...creating, name: e.target.value })}
placeholder="ООО «Моя компания»"
error={fieldErrors.name}
/>
<Field
label="ИНН"
value={creating.inn}
onChange={(e) => setCreating({ ...creating, inn: e.target.value })}
placeholder="10 или 12 цифр"
error={fieldErrors.inn}
/>
<p className="hint" style={{ gridColumn: '1 / -1' }}>
Остальные реквизиты заполните на странице компании.
</p>
</div>
) : null}
</Modal>
</main>
);
}
+270
View File
@@ -0,0 +1,270 @@
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { api, ApiError, type BankAccount, type Organization } from '../api.js';
import { Button, EmptyState, Field, Modal } from '../components/ui.js';
type Tab = 'requisites' | 'banks' | 'integrations';
export function CompanyEditPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [org, setOrg] = useState<Organization | null>(null);
const [tab, setTab] = useState<Tab>('requisites');
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!id) return;
api.get<Organization>(`/api/organizations/${id}`).then(setOrg).catch((e) => setError(String(e)));
}, [id]);
if (!id) return null;
if (error) return <main className="content"><div className="error-text">{error}</div></main>;
if (!org) return <main className="content"><p className="hint">Загрузка</p></main>;
return (
<main className="content">
<header className="page-head">
<h2>{org.name}</h2>
<Button onClick={() => navigate('/companies')}> К списку</Button>
</header>
<div className="tabs">
<button className={`tab ${tab === 'requisites' ? 'tab--active' : ''}`} onClick={() => setTab('requisites')}>Реквизиты</button>
<button className={`tab ${tab === 'banks' ? 'tab--active' : ''}`} onClick={() => setTab('banks')}>Банки и счета</button>
<button className={`tab ${tab === 'integrations' ? 'tab--active' : ''}`} onClick={() => setTab('integrations')}>Интеграции</button>
</div>
{tab === 'requisites' ? <RequisitesTab org={org} onChange={setOrg} /> : null}
{tab === 'banks' ? <BanksTab orgId={org.id} /> : null}
{tab === 'integrations' ? <IntegrationsTab /> : null}
</main>
);
}
function RequisitesTab({ org, onChange }: { org: Organization; onChange: (o: Organization) => void }) {
const [draft, setDraft] = useState<Organization>(org);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [savedAt, setSavedAt] = useState<Date | null>(null);
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const set = <K extends keyof Organization>(k: K, v: Organization[K] | string) => {
setDraft((d) => ({ ...d, [k]: v as Organization[K] }));
if (fieldErrors[k as string]) {
setFieldErrors((fe) => {
const next = { ...fe };
delete next[k as string];
return next;
});
}
};
async function save() {
setSaving(true);
setError(null);
setFieldErrors({});
try {
const saved = await api.put<Organization>(`/api/organizations/${org.id}`, {
name: draft.name,
shortName: draft.shortName || null,
inn: draft.inn,
kpp: draft.kpp || null,
ogrn: draft.ogrn || null,
legalAddress: draft.legalAddress || null,
signatoryName: draft.signatoryName || null,
signatoryPosition: draft.signatoryPosition || null,
});
setDraft(saved);
onChange(saved);
setSavedAt(new Date());
} catch (e) {
if (e instanceof ApiError) {
const fe = e.fieldErrors();
setFieldErrors(fe);
setError(Object.keys(fe).length ? 'Проверьте подсвеченные поля.' : e.prettyMessage());
} else {
setError(String(e));
}
} finally {
setSaving(false);
}
}
return (
<>
<section className="form-grid">
<Field label="Название" value={draft.name} onChange={(e) => set('name', e.target.value)} error={fieldErrors.name} />
<Field label="Короткое имя (для шапки)" value={draft.shortName ?? ''} onChange={(e) => set('shortName', e.target.value)} placeholder='напр. "Моя компания"' error={fieldErrors.shortName} />
<Field label="ИНН" value={draft.inn} onChange={(e) => set('inn', e.target.value)} error={fieldErrors.inn} />
<Field label="КПП" value={draft.kpp ?? ''} onChange={(e) => set('kpp', e.target.value)} error={fieldErrors.kpp} />
<Field label="ОГРН/ОГРНИП" value={draft.ogrn ?? ''} onChange={(e) => set('ogrn', e.target.value)} error={fieldErrors.ogrn} />
<Field label="Юр. адрес" value={draft.legalAddress ?? ''} onChange={(e) => set('legalAddress', e.target.value)} error={fieldErrors.legalAddress} />
<Field label="Подписант ФИО" value={draft.signatoryName ?? ''} onChange={(e) => set('signatoryName', e.target.value)} error={fieldErrors.signatoryName} />
<Field label="Должность подписанта" value={draft.signatoryPosition ?? ''} onChange={(e) => set('signatoryPosition', e.target.value)} error={fieldErrors.signatoryPosition} />
</section>
<div className="form-actions">
<Button variant="primary" onClick={save} disabled={saving}>
{saving ? 'Сохраняю…' : 'Сохранить'}
</Button>
{savedAt ? <span className="hint">Сохранено в {savedAt.toLocaleTimeString('ru-RU')}</span> : null}
{error ? <span className="error-text">{error}</span> : null}
</div>
</>
);
}
const emptyAccount = (): Partial<BankAccount> => ({
name: '', bankName: '', bankBik: '', accountNumber: '', corrAccount: '', currency: 'RUB', isPrimary: false,
});
function BanksTab({ orgId }: { orgId: string }) {
const [items, setItems] = useState<BankAccount[] | null>(null);
const [editing, setEditing] = useState<Partial<BankAccount> | null>(null);
const [error, setError] = useState<string | null>(null);
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
async function load() {
try {
const r = await api.get<{ items: BankAccount[] }>(`/api/organizations/${orgId}/bank-accounts`);
setItems(r.items);
} catch (e) {
setError(String(e));
}
}
useEffect(() => { void load(); }, [orgId]);
const set = <K extends keyof BankAccount>(k: K, v: BankAccount[K] | string | boolean) => {
setEditing((d) => (d ? { ...d, [k]: v as BankAccount[K] } : d));
if (fieldErrors[k as string]) {
setFieldErrors((fe) => { const n = { ...fe }; delete n[k as string]; return n; });
}
};
async function save() {
if (!editing) return;
setError(null);
setFieldErrors({});
const payload = {
name: editing.name ?? '',
bankName: editing.bankName || null,
bankBik: editing.bankBik || null,
accountNumber: editing.accountNumber || null,
corrAccount: editing.corrAccount || null,
currency: editing.currency || 'RUB',
isPrimary: !!editing.isPrimary,
};
try {
if (editing.id) {
await api.put(`/api/organizations/${orgId}/bank-accounts/${editing.id}`, payload);
} else {
await api.post(`/api/organizations/${orgId}/bank-accounts`, payload);
}
setEditing(null);
await load();
} catch (e) {
if (e instanceof ApiError) {
const fe = e.fieldErrors();
setFieldErrors(fe);
setError(Object.keys(fe).length ? 'Проверьте подсвеченные поля.' : e.prettyMessage());
} else {
setError(String(e));
}
}
}
async function archive(a: BankAccount) {
if (!confirm(`Архивировать счёт «${a.name}»?`)) return;
try {
await api.del(`/api/organizations/${orgId}/bank-accounts/${a.id}`);
await load();
} catch (e) {
setError(String(e));
}
}
return (
<>
<header className="page-head">
<h3>Банки и счета</h3>
<Button variant="primary" onClick={() => setEditing(emptyAccount())}>+ Добавить счёт</Button>
</header>
{error ? <div className="error-text">{error}</div> : null}
{items === null ? <p className="hint">Загрузка</p>
: items.length === 0 ? (
<EmptyState>
Счетов пока нет. Добавьте будут подставляться в счета (через интеграцию с банком).
</EmptyState>
) : (
<table className="table">
<thead>
<tr>
<th>Имя</th>
<th>Банк / БИК</th>
<th>Расчётный счёт</th>
<th>Валюта</th>
<th />
</tr>
</thead>
<tbody>
{items.map((a) => (
<tr key={a.id}>
<td>
{a.name}
{a.isPrimary ? <span className="hint"> · основной</span> : null}
</td>
<td>{a.bankName ?? '—'}{a.bankBik ? ` / ${a.bankBik}` : ''}</td>
<td>{a.accountNumber ?? '—'}</td>
<td>{a.currency}</td>
<td className="row-actions">
<Button variant="ghost" onClick={() => setEditing(a)}>Изменить</Button>
<Button variant="danger" onClick={() => archive(a)}>В архив</Button>
</td>
</tr>
))}
</tbody>
</table>
)}
<Modal
open={editing !== null}
title={editing?.id ? 'Изменить счёт' : 'Новый счёт'}
onClose={() => setEditing(null)}
footer={
<>
<Button variant="ghost" onClick={() => setEditing(null)}>Отмена</Button>
<Button variant="primary" onClick={save}>Сохранить</Button>
</>
}
>
{editing ? (
<div className="form-grid">
<Field label="Имя счёта" value={editing.name ?? ''} onChange={(e) => set('name', e.target.value)} placeholder="Точка — основной" error={fieldErrors.name} />
<Field label="Банк" value={editing.bankName ?? ''} onChange={(e) => set('bankName', e.target.value)} error={fieldErrors.bankName} />
<Field label="БИК" value={editing.bankBik ?? ''} onChange={(e) => set('bankBik', e.target.value)} placeholder="9 цифр" error={fieldErrors.bankBik} />
<Field label="Расчётный счёт" value={editing.accountNumber ?? ''} onChange={(e) => set('accountNumber', e.target.value)} placeholder="20 цифр" error={fieldErrors.accountNumber} />
<Field label="Корр. счёт" value={editing.corrAccount ?? ''} onChange={(e) => set('corrAccount', e.target.value)} placeholder="20 цифр" error={fieldErrors.corrAccount} />
<Field label="Валюта" value={editing.currency ?? 'RUB'} onChange={(e) => set('currency', e.target.value)} placeholder="RUB" error={fieldErrors.currency} />
<label className="checkbox" style={{ gridColumn: '1 / -1' }}>
<input type="checkbox" checked={!!editing.isPrimary} onChange={(e) => set('isPrimary', e.target.checked)} />
Основной счёт компании
</label>
</div>
) : null}
</Modal>
</>
);
}
function IntegrationsTab() {
return (
<section>
<h3>Интеграции</h3>
<p className="hint">
В разработке (этап M4): подключение API банка Точка для выставления счетов и приёма webhook-ов о платежах.
</p>
</section>
);
}
-160
View File
@@ -1,160 +0,0 @@
import { useEffect, useState } from 'react';
import { api, ApiError, type Organization } from '../api.js';
import { Button, Field } from '../components/ui.js';
export function OrganizationPage() {
const [org, setOrg] = useState<Organization | null>(null);
const [draft, setDraft] = useState<Partial<Organization>>({});
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [savedAt, setSavedAt] = useState<Date | null>(null);
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
useEffect(() => {
api
.get<Organization>('/api/organization')
.then((o) => {
setOrg(o);
setDraft(o);
})
.catch((e) => {
if (e instanceof ApiError && e.status === 404) {
// Первый запуск — БД сидится пустой записью; пользователь заполняет с нуля.
setDraft({ name: '', inn: '' });
return;
}
setError(String(e));
});
}, []);
async function save() {
setSaving(true);
setError(null);
setFieldErrors({});
try {
const saved = await api.put<Organization>('/api/organization', {
name: draft.name ?? '',
inn: draft.inn ?? '',
kpp: draft.kpp || null,
ogrn: draft.ogrn || null,
legalAddress: draft.legalAddress || null,
bankName: draft.bankName || null,
bankBik: draft.bankBik || null,
bankAccount: draft.bankAccount || null,
signatoryName: draft.signatoryName || null,
signatoryPosition: draft.signatoryPosition || null,
});
setOrg(saved);
setDraft(saved);
setSavedAt(new Date());
} catch (e) {
if (e instanceof ApiError) {
const fe = e.fieldErrors();
setFieldErrors(fe);
setError(Object.keys(fe).length ? 'Проверьте подсвеченные поля.' : e.prettyMessage());
} else {
setError(String(e));
}
} finally {
setSaving(false);
}
}
const set = <K extends keyof Organization>(k: K, v: Organization[K] | string) => {
setDraft((d) => ({ ...d, [k]: v as Organization[K] }));
if (fieldErrors[k as string]) {
setFieldErrors((fe) => {
const next = { ...fe };
delete next[k as string];
return next;
});
}
};
return (
<main className="content">
<h2>Реквизиты организации</h2>
<p className="hint">Будут подставляться в договоры и счета как сторона-исполнитель.</p>
<section className="form-grid">
<Field
label="Название"
value={draft.name ?? ''}
onChange={(e) => set('name', e.target.value)}
placeholder="ООО «Моя компания»"
error={fieldErrors.name}
/>
<Field
label="ИНН"
value={draft.inn ?? ''}
onChange={(e) => set('inn', e.target.value)}
placeholder="10 или 12 цифр"
error={fieldErrors.inn}
/>
<Field
label="КПП"
value={draft.kpp ?? ''}
onChange={(e) => set('kpp', e.target.value)}
placeholder="9 цифр"
error={fieldErrors.kpp}
/>
<Field
label="ОГРН/ОГРНИП"
value={draft.ogrn ?? ''}
onChange={(e) => set('ogrn', e.target.value)}
placeholder="13 или 15 цифр"
error={fieldErrors.ogrn}
/>
<Field
label="Юр. адрес"
value={draft.legalAddress ?? ''}
onChange={(e) => set('legalAddress', e.target.value)}
error={fieldErrors.legalAddress}
/>
<Field
label="Банк"
value={draft.bankName ?? ''}
onChange={(e) => set('bankName', e.target.value)}
placeholder="Точка ПАО Банка ФК Открытие"
error={fieldErrors.bankName}
/>
<Field
label="БИК"
value={draft.bankBik ?? ''}
onChange={(e) => set('bankBik', e.target.value)}
placeholder="9 цифр"
error={fieldErrors.bankBik}
/>
<Field
label="Расчётный счёт"
value={draft.bankAccount ?? ''}
onChange={(e) => set('bankAccount', e.target.value)}
placeholder="20 цифр"
error={fieldErrors.bankAccount}
/>
<Field
label="Подписант ФИО"
value={draft.signatoryName ?? ''}
onChange={(e) => set('signatoryName', e.target.value)}
error={fieldErrors.signatoryName}
/>
<Field
label="Должность подписанта"
value={draft.signatoryPosition ?? ''}
onChange={(e) => set('signatoryPosition', e.target.value)}
placeholder="Генеральный директор"
error={fieldErrors.signatoryPosition}
/>
</section>
<div className="form-actions">
<Button variant="primary" onClick={save} disabled={saving}>
{saving ? 'Сохраняю…' : 'Сохранить'}
</Button>
{savedAt ? <span className="hint">Сохранено в {savedAt.toLocaleTimeString('ru-RU')}</span> : null}
{error ? <span className="error-text">{error}</span> : null}
{org === null && !error ? null : null}
</div>
</main>
);
}
+48
View File
@@ -227,6 +227,54 @@ body {
.history-item { background: #1c1f24; border-color: #2a2e35; }
}
/* === org switcher === */
.org-switcher { position: relative; }
.org-switcher__btn {
background: rgba(255,255,255,0.08); color: #f6f7f9;
border: 1px solid rgba(255,255,255,0.12); border-radius: 6px;
padding: 6px 12px; cursor: pointer; font-size: 13px;
display: inline-flex; align-items: center; gap: 6px;
}
.org-switcher__btn:hover { background: rgba(255,255,255,0.14); }
.org-switcher__chev { font-size: 10px; opacity: 0.7; }
.org-switcher__menu {
position: absolute; right: 0; top: calc(100% + 6px);
min-width: 220px; background: #fff; color: #1c1f24;
border: 1px solid #d6d8dd; border-radius: 8px;
box-shadow: 0 8px 24px rgba(0,0,0,0.12);
display: flex; flex-direction: column; padding: 4px; z-index: 50;
}
.org-switcher__item {
background: transparent; border: none; text-align: left; cursor: pointer;
padding: 8px 12px; border-radius: 4px; font-size: 13px;
color: inherit; text-decoration: none;
}
.org-switcher__item:hover:not(:disabled) { background: #f1f2f5; }
.org-switcher__item--active { background: #dbeafe; color: #1e40af; }
.org-switcher__divider { height: 1px; background: #eef0f3; margin: 4px 0; }
.org-switcher--empty a { color: #fde68a; }
@media (prefers-color-scheme: dark) {
.org-switcher__menu { background: #1c1f24; color: #e7e8eb; border-color: #2a2e35; }
.org-switcher__item:hover:not(:disabled) { background: #25282e; }
.org-switcher__divider { background: #2a2e35; }
}
/* === tabs === */
.tabs {
display: flex; gap: 4px; border-bottom: 1px solid #d6d8dd;
margin: 8px 0 16px;
}
.tab {
background: transparent; border: none; padding: 8px 16px; cursor: pointer;
color: inherit; font-size: 14px; border-bottom: 2px solid transparent;
margin-bottom: -1px;
}
.tab:hover { background: rgba(127,127,127,0.08); }
.tab--active { border-bottom-color: #2563eb; color: #2563eb; font-weight: 600; }
@media (prefers-color-scheme: dark) {
.tabs { border-bottom-color: #2a2e35; }
}
/* === document status pills === */
.status {
display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 12px;
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/app.tsx","./src/api.ts","./src/auth.ts","./src/main.tsx","./src/components/blockseditor.tsx","./src/components/clientpicker.tsx","./src/components/lineseditor.tsx","./src/components/ui.tsx","./src/lib/richtext.ts","./src/pages/clients.tsx","./src/pages/documentedit.tsx","./src/pages/documents.tsx","./src/pages/organization.tsx","./src/pages/services.tsx","./src/pages/templateedit.tsx","./src/pages/templates.tsx"],"version":"5.9.3"}
{"root":["./src/app.tsx","./src/api.ts","./src/auth.ts","./src/main.tsx","./src/components/blockseditor.tsx","./src/components/clientpicker.tsx","./src/components/lineseditor.tsx","./src/components/orgswitcher.tsx","./src/components/ui.tsx","./src/lib/richtext.ts","./src/pages/clients.tsx","./src/pages/companies.tsx","./src/pages/companyedit.tsx","./src/pages/documentedit.tsx","./src/pages/documents.tsx","./src/pages/services.tsx","./src/pages/templateedit.tsx","./src/pages/templates.tsx"],"version":"5.9.3"}