add icons

This commit is contained in:
TronoSfera 2026-03-02 20:26:57 +03:00
parent a9704d77b0
commit e2dbf530bd
19 changed files with 291 additions and 107 deletions

View file

@ -53,6 +53,17 @@
.logo a {
color: inherit;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.brand-mark {
width: 24px;
height: 24px;
display: inline-block;
flex-shrink: 0;
border-radius: 50%;
}
.menu {
@ -275,6 +286,13 @@
flex-wrap: wrap;
}
.section-head-actions {
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
}
.config-head-actions {
display: flex;
gap: 0.5rem;
@ -282,16 +300,16 @@
align-items: center;
}
.btn-icon-only {
width: 40px;
min-width: 40px;
height: 40px;
min-height: 40px;
padding: 0;
display: inline-grid;
place-items: center;
font-size: 1.06rem;
line-height: 1;
.ui-glyph {
width: 17px;
height: 17px;
display: block;
stroke: currentColor;
fill: none;
stroke-width: 1.9;
stroke-linecap: round;
stroke-linejoin: round;
vector-effect: non-scaling-stroke;
}
.muted {
@ -1205,6 +1223,43 @@
margin-top: -1px;
}
.table-footer-actions {
display: inline-flex;
align-items: center;
gap: 0.45rem;
flex-wrap: wrap;
margin-left: auto;
}
.table-control-btn {
width: 40px;
min-width: 40px;
height: 40px;
min-height: 40px;
padding: 0;
display: inline-grid;
place-items: center;
line-height: 1;
}
.table-control-loadall {
width: auto;
min-width: 58px;
padding: 0 0.62rem;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.33rem;
font-size: 0.9rem;
font-weight: 700;
letter-spacing: 0.01em;
}
.table-control-loadall .ui-glyph {
width: 15px;
height: 15px;
}
.filter-toolbar + .table-scroll-region {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
@ -1273,31 +1328,6 @@
margin-left: auto;
}
.config-control-btn {
width: 40px;
min-width: 40px;
height: 40px;
min-height: 40px;
padding: 0;
display: inline-grid;
place-items: center;
font-size: 1.05rem;
line-height: 1;
}
.config-control-btn-loadall {
width: auto;
min-width: 58px;
padding: 0 0.62rem;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.33rem;
font-size: 0.9rem;
font-weight: 700;
letter-spacing: 0.01em;
}
.block {
border: 1px solid var(--line);
border-radius: 12px;

View file

@ -4,6 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Административная панель • Правовой трекер</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg?v=20260302-01">
<link rel="stylesheet" href="/admin.css?v=20260225-12">
</head>
<body>

View file

@ -60,6 +60,7 @@ import {
translateApiError,
userInitials,
} from "./admin/shared/utils.js";
import { AddIcon, DownloadIcon, FilterIcon, NextIcon, PrevIcon, RefreshIcon } from "./admin/shared/icons.jsx";
import QRCode from "qrcode";
(function () {
@ -120,7 +121,7 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
);
}
function TablePager({ tableState, onPrev, onNext, onLoadAll }) {
function TablePager({ tableState, onPrev, onNext, onLoadAll, onRefresh, onCreate, onOpenFilter }) {
return (
<div className="pager table-footer-bar">
<div>
@ -128,25 +129,45 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
? "Всего: " + tableState.total + " • показаны все записи"
: "Всего: " + tableState.total + " • смещение: " + tableState.offset}
</div>
<div style={{ display: "flex", gap: "0.5rem" }}>
<div className="table-footer-actions">
<button
className="btn secondary"
className="btn secondary table-control-btn table-control-loadall"
type="button"
onClick={onLoadAll}
disabled={tableState.total === 0 || tableState.showAll || tableState.rows.length >= tableState.total}
title={"Загрузить все " + tableState.total}
aria-label={"Загрузить все " + tableState.total}
>
{"Загрузить все " + tableState.total}
<DownloadIcon />
<span>{tableState.total}</span>
</button>
<button className="btn secondary" type="button" onClick={onPrev} disabled={tableState.showAll || tableState.offset <= 0}>
Назад
{onRefresh ? (
<button className="btn secondary table-control-btn" type="button" onClick={onRefresh} title="Обновить" aria-label="Обновить">
<RefreshIcon />
</button>
) : null}
{onCreate ? (
<button className="btn secondary table-control-btn" type="button" onClick={onCreate} title="Добавить" aria-label="Добавить">
<AddIcon />
</button>
) : null}
{onOpenFilter ? (
<button className="btn secondary table-control-btn" type="button" onClick={onOpenFilter} title="Фильтр" aria-label="Фильтр">
<FilterIcon />
</button>
) : null}
<button className="btn secondary table-control-btn" type="button" onClick={onPrev} disabled={tableState.showAll || tableState.offset <= 0} title="Назад" aria-label="Назад">
<PrevIcon />
</button>
<button
className="btn secondary"
className="btn secondary table-control-btn"
type="button"
onClick={onNext}
disabled={tableState.showAll || tableState.offset + PAGE_SIZE >= tableState.total}
title="Вперед"
aria-label="Вперед"
>
Вперед
<NextIcon />
</button>
</div>
</div>
@ -192,8 +213,8 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
</div>
{!hideAction ? (
<div className="filter-action">
<button className="btn secondary" type="button" onClick={onOpen}>
Фильтр
<button className="btn secondary table-control-btn" type="button" onClick={onOpen} title="Фильтр" aria-label="Фильтр">
<FilterIcon />
</button>
</div>
) : null}
@ -3448,7 +3469,10 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
<div className="layout">
<aside className="sidebar">
<div className="logo">
<a href="/">Правовой трекер</a>
<a href="/">
<img className="brand-mark" src="/brand-mark.svg" alt="" width="24" height="24" />
<span>Правовой трекер</span>
</a>
</div>
<nav className="menu">
{menuItems.map((item) => (

View file

@ -1,4 +1,5 @@
import { KNOWN_CONFIG_TABLE_KEYS, OPERATOR_LABELS, PAGE_SIZE, TABLE_SERVER_CONFIG } from "../../shared/constants.js";
import { AddIcon, DownloadIcon, FilterIcon, NextIcon, PrevIcon, RefreshIcon } from "../../shared/icons.jsx";
import { boolLabel, fmtDate, listPreview, normalizeReferenceMeta, roleLabel, statusKindLabel, statusLabel } from "../../shared/utils.js";
function fmtBalance(value) {
@ -67,7 +68,6 @@ export function ConfigSection(props) {
const StatusLine = StatusLineComponent;
const IconButton = IconButtonComponent;
const UserAvatar = UserAvatarComponent;
const canOpenFilter = Boolean(configActiveKey);
const canRefresh = Boolean(configActiveKey);
const canCreateRecord = Boolean(canCreateInConfig && configActiveKey);
const canLoadAllRows = Boolean(
@ -102,16 +102,6 @@ export function ConfigSection(props) {
Баланс
</button>
) : null}
<button
className="btn secondary btn-icon-only"
type="button"
onClick={() => openFilterModal(configActiveKey)}
disabled={!canOpenFilter}
title="Фильтр"
aria-label="Фильтр"
>
</button>
</div>
</div>
<div className="config-layout">
@ -606,55 +596,65 @@ export function ConfigSection(props) {
</div>
<div className="config-controls-actions">
<button
className="btn secondary config-control-btn config-control-btn-loadall"
className="btn secondary table-control-btn table-control-loadall"
type="button"
onClick={() => loadAllRows(configActiveKey)}
disabled={!canLoadAllRows}
title={"Загрузить все " + activeConfigTableState.total}
aria-label={"Загрузить все " + activeConfigTableState.total}
>
<span aria-hidden="true"></span>
<DownloadIcon />
<span>{activeConfigTableState.total}</span>
</button>
<button
className="btn secondary btn-icon-only config-control-btn"
className="btn secondary table-control-btn"
type="button"
onClick={() => loadCurrentConfigTable(true)}
disabled={!canRefresh}
title="Обновить"
aria-label="Обновить"
>
<RefreshIcon />
</button>
<button
className="btn secondary btn-icon-only config-control-btn"
className="btn secondary table-control-btn"
type="button"
onClick={() => openCreateRecordModal(configActiveKey)}
disabled={!canCreateRecord}
title="Добавить"
aria-label="Добавить"
>
+
<AddIcon />
</button>
<button
className="btn secondary btn-icon-only config-control-btn"
className="btn secondary table-control-btn"
type="button"
onClick={() => openFilterModal(configActiveKey)}
disabled={!configActiveKey}
title="Фильтр"
aria-label="Фильтр"
>
<FilterIcon />
</button>
<button
className="btn secondary table-control-btn"
type="button"
onClick={() => loadPrevPage(configActiveKey)}
disabled={!canLoadPrev}
title="Назад"
aria-label="Назад"
>
<PrevIcon />
</button>
<button
className="btn secondary btn-icon-only config-control-btn"
className="btn secondary table-control-btn"
type="button"
onClick={() => loadNextPage(configActiveKey)}
disabled={!canLoadNext}
title="Вперед"
aria-label="Вперед"
>
<NextIcon />
</button>
</div>
</div>

View file

@ -40,20 +40,13 @@ export function InvoicesSection({
<h2>Счета</h2>
<p className="muted">Выставленные счета клиентам, статусы оплаты и выгрузка PDF.</p>
</div>
<div style={{ display: "flex", gap: "0.5rem" }}>
<button className="btn secondary" type="button" onClick={onRefresh}>
Обновить
</button>
<button className="btn" type="button" onClick={onCreate}>
Новый счет
</button>
</div>
</div>
<FilterToolbar
filters={tableState.filters}
onOpen={onOpenFilter}
onRemove={onRemoveFilter}
onEdit={onEditFilter}
hideAction
getChipLabel={(clause) => {
const fieldDef = getFieldDef("invoices", clause.field);
return (fieldDef ? fieldDef.label : clause.field) + " " + OPERATOR_LABELS[clause.op] + " " + getFilterValuePreview("invoices", clause);
@ -112,7 +105,15 @@ export function InvoicesSection({
</tr>
)}
/>
<TablePager tableState={tableState} onPrev={onPrev} onNext={onNext} onLoadAll={onLoadAll} />
<TablePager
tableState={tableState}
onPrev={onPrev}
onNext={onNext}
onLoadAll={onLoadAll}
onRefresh={onRefresh}
onCreate={onCreate}
onOpenFilter={onOpenFilter}
/>
<StatusLine status={status} />
</>
);

View file

@ -1,4 +1,5 @@
import { KANBAN_GROUPS } from "../../shared/constants.js";
import { FilterIcon, RefreshIcon } from "../../shared/icons.jsx";
import { fallbackStatusGroup, fmtKanbanDate, resolveDeadlineTone, statusLabel } from "../../shared/utils.js";
export function KanbanBoard({
@ -70,12 +71,15 @@ export function KanbanBoard({
<h2>Канбан заявок</h2>
<p className="muted">Группировка по группам статусов и серверная фильтрация карточек.</p>
</div>
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
<div className="section-head-actions">
<button className={"btn secondary" + (sortActive ? " active-success" : "")} type="button" onClick={onOpenSort}>
Сортировка
</button>
<button className="btn secondary" type="button" onClick={onRefresh} disabled={loading}>
Обновить
<button className="btn secondary table-control-btn" type="button" onClick={onRefresh} disabled={loading} title="Обновить" aria-label="Обновить">
<RefreshIcon />
</button>
<button className="btn secondary table-control-btn" type="button" onClick={onOpenFilter} title="Фильтр" aria-label="Фильтр">
<FilterIcon />
</button>
</div>
</div>
@ -85,6 +89,7 @@ export function KanbanBoard({
onOpen={onOpenFilter}
onRemove={onRemoveFilter}
onEdit={onEditFilter}
hideAction
getChipLabel={getFilterChipLabel}
/>
) : null}

View file

@ -37,20 +37,13 @@ export function QuotesSection({
<h2>Цитаты</h2>
<p className="muted">Управление публичной лентой цитат с серверными фильтрами.</p>
</div>
<div style={{ display: "flex", gap: "0.5rem" }}>
<button className="btn secondary" type="button" onClick={onRefresh}>
Обновить
</button>
<button className="btn" type="button" onClick={onCreate}>
Новая цитата
</button>
</div>
</div>
<FilterToolbar
filters={tableState.filters}
onOpen={onOpenFilter}
onRemove={onRemoveFilter}
onEdit={onEditFilter}
hideAction
getChipLabel={(clause) => {
const fieldDef = getFieldDef("quotes", clause.field);
return (fieldDef ? fieldDef.label : clause.field) + " " + OPERATOR_LABELS[clause.op] + " " + getFilterValuePreview("quotes", clause);
@ -87,7 +80,15 @@ export function QuotesSection({
</tr>
)}
/>
<TablePager tableState={tableState} onPrev={onPrev} onNext={onNext} onLoadAll={onLoadAll} />
<TablePager
tableState={tableState}
onPrev={onPrev}
onNext={onNext}
onLoadAll={onLoadAll}
onRefresh={onRefresh}
onCreate={onCreate}
onOpenFilter={onOpenFilter}
/>
<StatusLine status={status} />
</>
);

View file

@ -122,20 +122,13 @@ export function RequestsSection({
<h2>Заявки</h2>
<p className="muted">Серверная фильтрация и просмотр клиентских заявок.</p>
</div>
<div style={{ display: "flex", gap: "0.5rem" }}>
<button className="btn secondary" type="button" onClick={onRefresh}>
Обновить
</button>
<button className="btn" type="button" onClick={onCreate}>
Новая заявка
</button>
</div>
</div>
<FilterToolbar
filters={tableState.filters}
onOpen={onOpenFilter}
onRemove={onRemoveFilter}
onEdit={onEditFilter}
hideAction
getChipLabel={(clause) => {
const fieldDef = getFieldDef("requests", clause.field);
return (fieldDef ? fieldDef.label : clause.field) + " " + OPERATOR_LABELS[clause.op] + " " + getFilterValuePreview("requests", clause);
@ -195,7 +188,15 @@ export function RequestsSection({
</tr>
)}
/>
<TablePager tableState={tableState} onPrev={onPrev} onNext={onNext} onLoadAll={onLoadAll} />
<TablePager
tableState={tableState}
onPrev={onPrev}
onNext={onNext}
onLoadAll={onLoadAll}
onRefresh={onRefresh}
onCreate={onCreate}
onOpenFilter={onOpenFilter}
/>
<StatusLine status={status || (typeof getStatus === "function" ? getStatus("requests") : null)} />
</>
);

View file

@ -63,17 +63,13 @@ export function ServiceRequestsSection({
<h2>Запросы</h2>
<p className="muted">Запросы клиента к куратору и обращения на смену юриста.</p>
</div>
<div style={{ display: "flex", gap: "0.5rem" }}>
<button className="btn secondary" type="button" onClick={onRefresh}>
Обновить
</button>
</div>
</div>
<FilterToolbar
filters={tableState.filters}
onOpen={onOpenFilter}
onRemove={onRemoveFilter}
onEdit={onEditFilter}
hideAction
getChipLabel={(clause) => {
const fieldDef = getFieldDef("serviceRequests", clause.field);
return (
@ -129,7 +125,14 @@ export function ServiceRequestsSection({
</tr>
)}
/>
<TablePager tableState={tableState} onPrev={onPrev} onNext={onNext} onLoadAll={onLoadAll} />
<TablePager
tableState={tableState}
onPrev={onPrev}
onNext={onNext}
onLoadAll={onLoadAll}
onRefresh={onRefresh}
onOpenFilter={onOpenFilter}
/>
<StatusLine status={status || (typeof getStatus === "function" ? getStatus("serviceRequests") : null)} />
</>
);

View file

@ -1,4 +1,5 @@
import { boolLabel, fmtDate } from "../../shared/utils.js";
import { RefreshIcon } from "../../shared/icons.jsx";
export function AvailableTablesSection({
tables,
@ -21,8 +22,8 @@ export function AvailableTablesSection({
<h2>Доступность таблиц</h2>
<p className="muted">Скрытая служебная вкладка. Доступ только для администратора по прямой ссылке.</p>
</div>
<button className="btn secondary" type="button" onClick={onRefresh}>
Обновить
<button className="btn secondary table-control-btn" type="button" onClick={onRefresh} title="Обновить" aria-label="Обновить">
<RefreshIcon />
</button>
</div>
<DataTable

View file

@ -0,0 +1,51 @@
export function RefreshIcon() {
return (
<svg className="ui-glyph" viewBox="0 0 24 24" aria-hidden="true">
<path d="M21 12a9 9 0 1 1-2.64-6.36" />
<polyline points="21 3 21 9 15 9" />
</svg>
);
}
export function FilterIcon() {
return (
<svg className="ui-glyph" viewBox="0 0 24 24" aria-hidden="true">
<path d="M3 5h18l-7 8v5l-4 2v-7z" />
</svg>
);
}
export function AddIcon() {
return (
<svg className="ui-glyph" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 5v14" />
<path d="M5 12h14" />
</svg>
);
}
export function PrevIcon() {
return (
<svg className="ui-glyph" viewBox="0 0 24 24" aria-hidden="true">
<path d="M15 18l-6-6 6-6" />
</svg>
);
}
export function NextIcon() {
return (
<svg className="ui-glyph" viewBox="0 0 24 24" aria-hidden="true">
<path d="M9 18l6-6-6-6" />
</svg>
);
}
export function DownloadIcon() {
return (
<svg className="ui-glyph" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 4v11" />
<path d="M8 11l4 4 4-4" />
<path d="M5 20h14" />
</svg>
);
}

16
app/web/brand-mark.svg Normal file
View file

@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64" fill="none">
<defs>
<linearGradient id="law-gold" x1="14" y1="10" x2="50" y2="54" gradientUnits="userSpaceOnUse">
<stop stop-color="#E4C28D" />
<stop offset="1" stop-color="#C8924A" />
</linearGradient>
</defs>
<circle cx="32" cy="32" r="29" fill="#101A25" stroke="url(#law-gold)" stroke-width="2.5" />
<path d="M22 20h20" stroke="url(#law-gold)" stroke-width="2.8" stroke-linecap="round" />
<path d="M32 20v21" stroke="url(#law-gold)" stroke-width="2.8" stroke-linecap="round" />
<path d="M26 20l-5 10" stroke="url(#law-gold)" stroke-width="2.4" stroke-linecap="round" />
<path d="M38 20l5 10" stroke="url(#law-gold)" stroke-width="2.4" stroke-linecap="round" />
<path d="M17.5 33.5h7a4 4 0 0 1-7 0Z" fill="url(#law-gold)" />
<path d="M39.5 33.5h7a4 4 0 0 1-7 0Z" fill="url(#law-gold)" />
<path d="M25 45h14" stroke="url(#law-gold)" stroke-width="2.8" stroke-linecap="round" />
</svg>

After

Width:  |  Height:  |  Size: 1,009 B

View file

@ -12,6 +12,21 @@
margin: 0.35rem 0 0;
}
.client-title-row {
display: inline-flex;
align-items: center;
gap: 0.52rem;
}
.client-title-row .brand-mark {
width: 24px;
height: 24px;
}
.client-title-row h1 {
margin: 0;
}
.client-section {
display: block;
}

View file

@ -4,6 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Страница клиента • Правовой трекер</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg?v=20260302-01">
<link rel="stylesheet" href="/admin.css?v=20260227-01">
<link rel="stylesheet" href="/client.css">
</head>

View file

@ -770,7 +770,10 @@ import { detectAttachmentPreviewKind, fmtShortDateTime } from "./admin/shared/ut
<main className="main client-main">
<div className="topbar client-topbar">
<div>
<div className="client-title-row">
<img className="brand-mark" src="/brand-mark.svg" alt="" width="24" height="24" />
<h1>Кабинет клиента</h1>
</div>
<p className="muted">Работа с заявками: статусы, чат, файлы и обращения.</p>
</div>
<button

16
app/web/favicon.svg Normal file
View file

@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64" fill="none">
<defs>
<linearGradient id="law-gold-favicon" x1="14" y1="10" x2="50" y2="54" gradientUnits="userSpaceOnUse">
<stop stop-color="#E4C28D" />
<stop offset="1" stop-color="#C8924A" />
</linearGradient>
</defs>
<circle cx="32" cy="32" r="29" fill="#101A25" stroke="url(#law-gold-favicon)" stroke-width="2.5" />
<path d="M22 20h20" stroke="url(#law-gold-favicon)" stroke-width="2.8" stroke-linecap="round" />
<path d="M32 20v21" stroke="url(#law-gold-favicon)" stroke-width="2.8" stroke-linecap="round" />
<path d="M26 20l-5 10" stroke="url(#law-gold-favicon)" stroke-width="2.4" stroke-linecap="round" />
<path d="M38 20l5 10" stroke="url(#law-gold-favicon)" stroke-width="2.4" stroke-linecap="round" />
<path d="M17.5 33.5h7a4 4 0 0 1-7 0Z" fill="url(#law-gold-favicon)" />
<path d="M39.5 33.5h7a4 4 0 0 1-7 0Z" fill="url(#law-gold-favicon)" />
<path d="M25 45h14" stroke="url(#law-gold-favicon)" stroke-width="2.8" stroke-linecap="round" />
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -65,6 +65,9 @@
}
.brand {
display: inline-flex;
align-items: center;
gap: 0.48rem;
font-size: 0.84rem;
letter-spacing: 0.12em;
text-transform: uppercase;
@ -73,6 +76,13 @@
color: #eef4ff;
}
.brand .brand-mark {
width: 28px;
height: 28px;
flex-shrink: 0;
border-radius: 50%;
}
.nav {
display: flex;
align-items: center;

View file

@ -5,12 +5,16 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Аудиторы корпоративной безопасности</title>
<meta name="description" content="Юридический консалтинг и судебное сопровождение для сложных бизнес-ситуаций.">
<link rel="icon" type="image/svg+xml" href="/favicon.svg?v=20260302-01">
<link rel="stylesheet" href="/landing.css">
</head>
<body>
<header class="topbar">
<div class="wrap topbar-inner">
<div class="brand">Аудиторы корпоративной безопасности</div>
<div class="brand">
<img class="brand-mark" src="/brand-mark.svg" alt="" width="28" height="28">
<span>Аудиторы корпоративной безопасности</span>
</div>
<nav class="nav">
<a href="#practices">Компетенции</a>
<a href="#approach">Подход</a>

View file

@ -4,6 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Политика обработки персональных данных</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg?v=20260302-01">
<style>
body {
margin: 0;