init: M1 scaffolding + M2 organization/clients/services CRUD
- monorepo (npm workspaces): apps/api (Fastify+Prisma+TS), apps/web (Vite+React+TS), packages/shared (zod schemas) - SSO via auth.queo.ru: jose+JWKS plugin, requireDocPermission(viewer|user|admin) - DEV_BYPASS_AUTH for local development (hard-checked off in production) - M2: organization upsert, clients CRUD with search, services catalog with soft-delete - BigInt -> Number serializer for Prisma money columns - Embedded Postgres + npm run dev:demo for one-command local boot - Docker compose for queoserver: postgres + api + web (nginx as ingress proxying /api -> api:3030) - First migration 0_init committed (prisma migrate diff) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
# URL центра аутентификации Queo
|
||||
VITE_AUTH_LOGIN_URL=https://auth.queo.ru/auth/login
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Doc_manager — Queo</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@doc-manager/web",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@doc-manager/shared": "*",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.27.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.10"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useEffect } from 'react';
|
||||
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';
|
||||
|
||||
function Layout({ email }: { email: string }) {
|
||||
return (
|
||||
<header className="topbar">
|
||||
<h1>Doc_manager</h1>
|
||||
<nav>
|
||||
<Link to="/">Документы</Link>
|
||||
<Link to="/clients">Клиенты</Link>
|
||||
<Link to="/services">Услуги</Link>
|
||||
<Link to="/templates">Шаблоны</Link>
|
||||
<Link to="/bank">Банк</Link>
|
||||
<Link to="/organization">Реквизиты</Link>
|
||||
</nav>
|
||||
<span className="user">{email}</span>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function Placeholder({ title }: { title: string }) {
|
||||
return (
|
||||
<main className="content">
|
||||
<h2>{title}</h2>
|
||||
<p>В разработке. См. план: M2–M7.</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function Forbidden({ email }: { email: string }) {
|
||||
return (
|
||||
<main className="content">
|
||||
<h2>Нет доступа</h2>
|
||||
<p>
|
||||
Аккаунт <b>{email}</b> авторизован в Queo, но не имеет роли в Doc_manager. Попросите
|
||||
администратора выдать <code>doc_manager</code> permission в auth.queo.ru.
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const auth = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (auth.status === 'unauthenticated') redirectToLogin();
|
||||
}, [auth.status]);
|
||||
|
||||
if (auth.status === 'loading' || auth.status === 'unauthenticated') {
|
||||
return <div className="loading">Проверка доступа…</div>;
|
||||
}
|
||||
if (auth.status === 'forbidden') return <Forbidden email={auth.me.email} />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Layout email={auth.me.email} />
|
||||
<Routes>
|
||||
<Route path="/" element={<Placeholder title="Документы" />} />
|
||||
<Route path="/clients" element={<ClientsPage />} />
|
||||
<Route path="/services" element={<ServicesPage />} />
|
||||
<Route path="/templates" element={<Placeholder title="Шаблоны договоров" />} />
|
||||
<Route path="/bank" element={<Placeholder title="Банк" />} />
|
||||
<Route path="/organization" element={<OrganizationPage />} />
|
||||
<Route path="*" element={<Placeholder title="Не найдено" />} />
|
||||
</Routes>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { redirectToLogin } from './auth.js';
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(public status: number, public code: string, public details?: unknown) {
|
||||
super(`${status} ${code}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
const init: RequestInit = { method, credentials: 'include' };
|
||||
if (body !== undefined) {
|
||||
init.headers = { 'Content-Type': 'application/json' };
|
||||
init.body = JSON.stringify(body);
|
||||
}
|
||||
const res = await fetch(path, init);
|
||||
if (res.status === 401) {
|
||||
redirectToLogin();
|
||||
}
|
||||
if (res.status === 204) return undefined as T;
|
||||
const text = await res.text();
|
||||
const data = text ? JSON.parse(text) : undefined;
|
||||
if (!res.ok) {
|
||||
throw new ApiError(res.status, (data as { error?: string })?.error ?? 'http_error', data);
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(p: string) => request<T>('GET', p),
|
||||
post: <T>(p: string, body: unknown) => request<T>('POST', p, body),
|
||||
put: <T>(p: string, body: unknown) => request<T>('PUT', p, body),
|
||||
del: <T = void>(p: string) => request<T>('DELETE', p),
|
||||
};
|
||||
|
||||
export type Organization = {
|
||||
id: string;
|
||||
name: string;
|
||||
inn: string;
|
||||
kpp: string | null;
|
||||
ogrn: string | null;
|
||||
legalAddress: string | null;
|
||||
bankName: string | null;
|
||||
bankBik: string | null;
|
||||
bankAccount: string | null;
|
||||
signatoryName: string | null;
|
||||
signatoryPosition: string | null;
|
||||
};
|
||||
|
||||
export type Client = {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
kind: 'ul' | 'ip' | 'fl';
|
||||
name: string;
|
||||
inn: string | null;
|
||||
kpp: string | null;
|
||||
address: string | null;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
contactPerson: string | null;
|
||||
requisitesJson: Record<string, unknown> | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type Service = {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
name: string;
|
||||
unit: string;
|
||||
defaultPriceCents: number; // BigInt сериализуется в number (см. apps/api/src/lib/bigint.ts)
|
||||
defaultVat: 'none' | 'vat_0' | 'vat_5' | 'vat_7' | 'vat_10' | 'vat_20';
|
||||
notes: string | null;
|
||||
archivedAt: string | null;
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { PermissionRole } from '@doc-manager/shared';
|
||||
|
||||
export type Me = {
|
||||
sub: string;
|
||||
email: string;
|
||||
groups: string[];
|
||||
isSuperuser: boolean;
|
||||
docPermission: PermissionRole | null;
|
||||
};
|
||||
|
||||
const AUTH_LOGIN_URL = import.meta.env.VITE_AUTH_LOGIN_URL ?? 'https://auth.queo.ru/auth/login';
|
||||
|
||||
export function redirectToLogin(): never {
|
||||
const returnTo = encodeURIComponent(window.location.href);
|
||||
window.location.href = `${AUTH_LOGIN_URL}?return_to=${returnTo}`;
|
||||
throw new Error('redirecting');
|
||||
}
|
||||
|
||||
export type AuthState =
|
||||
| { status: 'loading' }
|
||||
| { status: 'authenticated'; me: Me }
|
||||
| { status: 'unauthenticated' }
|
||||
| { status: 'forbidden'; me: Me };
|
||||
|
||||
export function useAuth(): AuthState {
|
||||
const [state, setState] = useState<AuthState>({ status: 'loading' });
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetch('/api/me', { credentials: 'include' })
|
||||
.then(async (r) => {
|
||||
if (cancelled) return;
|
||||
if (r.status === 401) {
|
||||
setState({ status: 'unauthenticated' });
|
||||
return;
|
||||
}
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
const me = (await r.json()) as Me;
|
||||
// Для M1 любой залогиненный пользователь видит шелл; запрет отдельных страниц — позже.
|
||||
if (me.docPermission == null && !me.isSuperuser) {
|
||||
setState({ status: 'forbidden', me });
|
||||
return;
|
||||
}
|
||||
setState({ status: 'authenticated', me });
|
||||
})
|
||||
.catch((e) => {
|
||||
if (cancelled) return;
|
||||
console.error('auth fetch failed', e);
|
||||
setState({ status: 'unauthenticated' });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return state;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { type ButtonHTMLAttributes, type InputHTMLAttributes, type ReactNode, type SelectHTMLAttributes, type TextareaHTMLAttributes, useEffect } from 'react';
|
||||
|
||||
export function Button({
|
||||
variant = 'default',
|
||||
...props
|
||||
}: ButtonHTMLAttributes<HTMLButtonElement> & { variant?: 'default' | 'primary' | 'danger' | 'ghost' }) {
|
||||
return <button {...props} className={`btn btn--${variant} ${props.className ?? ''}`} />;
|
||||
}
|
||||
|
||||
export function Field(
|
||||
props: InputHTMLAttributes<HTMLInputElement> & { label: string; error?: string },
|
||||
) {
|
||||
const { label, error, ...input } = props;
|
||||
return (
|
||||
<label className="field">
|
||||
<span className="field__label">{label}</span>
|
||||
<input {...input} className={`field__input ${error ? 'field__input--err' : ''}`} />
|
||||
{error ? <span className="field__error">{error}</span> : null}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function Textarea(props: TextareaHTMLAttributes<HTMLTextAreaElement> & { label: string }) {
|
||||
const { label, ...textarea } = props;
|
||||
return (
|
||||
<label className="field">
|
||||
<span className="field__label">{label}</span>
|
||||
<textarea {...textarea} className="field__input field__input--area" />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function Select<T extends string>(
|
||||
props: Omit<SelectHTMLAttributes<HTMLSelectElement>, 'value' | 'onChange'> & {
|
||||
label: string;
|
||||
value: T;
|
||||
onChange: (v: T) => void;
|
||||
options: ReadonlyArray<{ value: T; label: string }>;
|
||||
},
|
||||
) {
|
||||
const { label, value, onChange, options, ...sel } = props;
|
||||
return (
|
||||
<label className="field">
|
||||
<span className="field__label">{label}</span>
|
||||
<select
|
||||
{...sel}
|
||||
className="field__input"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value as T)}
|
||||
>
|
||||
{options.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function Modal({
|
||||
open,
|
||||
title,
|
||||
onClose,
|
||||
children,
|
||||
footer,
|
||||
}: {
|
||||
open: boolean;
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [open, onClose]);
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div className="modal__backdrop" onClick={onClose}>
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||
<header className="modal__header">
|
||||
<h3>{title}</h3>
|
||||
<button className="modal__close" onClick={onClose} aria-label="Закрыть">
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
<div className="modal__body">{children}</div>
|
||||
{footer ? <footer className="modal__footer">{footer}</footer> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmptyState({ children }: { children: ReactNode }) {
|
||||
return <div className="empty">{children}</div>;
|
||||
}
|
||||
|
||||
export function formatRub(cents: number): string {
|
||||
return (cents / 100).toLocaleString('ru-RU', {
|
||||
style: 'currency',
|
||||
currency: 'RUB',
|
||||
minimumFractionDigits: 2,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { App } from './App.js';
|
||||
import './styles.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,189 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api, ApiError, type Client } from '../api.js';
|
||||
import { Button, EmptyState, Field, Modal, Select } from '../components/ui.js';
|
||||
|
||||
const KIND_LABEL: Record<Client['kind'], string> = {
|
||||
ul: 'Юр. лицо',
|
||||
ip: 'ИП',
|
||||
fl: 'Физ. лицо',
|
||||
};
|
||||
|
||||
const emptyDraft = (): Partial<Client> => ({
|
||||
kind: 'ul',
|
||||
name: '',
|
||||
inn: '',
|
||||
kpp: '',
|
||||
address: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
contactPerson: '',
|
||||
});
|
||||
|
||||
export function ClientsPage() {
|
||||
const [items, setItems] = useState<Client[] | null>(null);
|
||||
const [q, setQ] = useState('');
|
||||
const [editing, setEditing] = useState<Partial<Client> | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function load() {
|
||||
setError(null);
|
||||
try {
|
||||
const r = await api.get<{ items: Client[] }>(
|
||||
`/api/clients${q ? `?q=${encodeURIComponent(q)}` : ''}`,
|
||||
);
|
||||
setItems(r.items);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [q]);
|
||||
|
||||
async function save() {
|
||||
if (!editing) return;
|
||||
setError(null);
|
||||
try {
|
||||
const payload = {
|
||||
kind: editing.kind ?? 'ul',
|
||||
name: editing.name ?? '',
|
||||
inn: editing.inn || null,
|
||||
kpp: editing.kpp || null,
|
||||
address: editing.address || null,
|
||||
email: editing.email || null,
|
||||
phone: editing.phone || null,
|
||||
contactPerson: editing.contactPerson || null,
|
||||
};
|
||||
if (editing.id) {
|
||||
await api.put<Client>(`/api/clients/${editing.id}`, payload);
|
||||
} else {
|
||||
await api.post<Client>('/api/clients', payload);
|
||||
}
|
||||
setEditing(null);
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(e instanceof ApiError ? `${e.code} (${e.status})` : String(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id: string) {
|
||||
if (!confirm('Удалить клиента?')) return;
|
||||
try {
|
||||
await api.del(`/api/clients/${id}`);
|
||||
await load();
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.code === 'has_documents') {
|
||||
alert(`Нельзя удалить — есть ${(e.details as { count?: number })?.count ?? 0} документов. Архивацию добавим позже.`);
|
||||
return;
|
||||
}
|
||||
setError(String(e));
|
||||
}
|
||||
}
|
||||
|
||||
const set = <K extends keyof Client>(k: K, v: Client[K] | string) =>
|
||||
setEditing((d) => (d ? { ...d, [k]: v as Client[K] } : d));
|
||||
|
||||
return (
|
||||
<main className="content">
|
||||
<header className="page-head">
|
||||
<h2>Клиенты</h2>
|
||||
<Button variant="primary" onClick={() => setEditing(emptyDraft())}>
|
||||
+ Добавить
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<div className="toolbar">
|
||||
<input
|
||||
className="search"
|
||||
placeholder="Поиск по названию, ИНН, email…"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error ? <div className="error-text">{error}</div> : null}
|
||||
|
||||
{items === null ? (
|
||||
<p className="hint">Загрузка…</p>
|
||||
) : items.length === 0 ? (
|
||||
<EmptyState>
|
||||
{q ? 'Ничего не найдено.' : 'Пока нет клиентов. Добавьте первого, чтобы выставлять документы.'}
|
||||
</EmptyState>
|
||||
) : (
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Тип</th>
|
||||
<th>Название</th>
|
||||
<th>ИНН</th>
|
||||
<th>Email</th>
|
||||
<th>Телефон</th>
|
||||
<th aria-label="actions" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((c) => (
|
||||
<tr key={c.id}>
|
||||
<td>{KIND_LABEL[c.kind]}</td>
|
||||
<td>{c.name}</td>
|
||||
<td>{c.inn ?? '—'}</td>
|
||||
<td>{c.email ?? '—'}</td>
|
||||
<td>{c.phone ?? '—'}</td>
|
||||
<td className="row-actions">
|
||||
<Button variant="ghost" onClick={() => setEditing(c)}>
|
||||
Изменить
|
||||
</Button>
|
||||
<Button variant="danger" onClick={() => remove(c.id)}>
|
||||
Удалить
|
||||
</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>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="form-grid">
|
||||
<Select
|
||||
label="Тип"
|
||||
value={(editing?.kind ?? 'ul') as Client['kind']}
|
||||
onChange={(v) => set('kind', v)}
|
||||
options={[
|
||||
{ value: 'ul' as const, label: 'Юр. лицо' },
|
||||
{ value: 'ip' as const, label: 'ИП' },
|
||||
{ value: 'fl' as const, label: 'Физ. лицо' },
|
||||
]}
|
||||
/>
|
||||
<Field label="Название" value={editing?.name ?? ''} onChange={(e) => set('name', e.target.value)} />
|
||||
<Field label="ИНН" value={editing?.inn ?? ''} onChange={(e) => set('inn', e.target.value)} />
|
||||
<Field label="КПП" value={editing?.kpp ?? ''} onChange={(e) => set('kpp', e.target.value)} />
|
||||
<Field label="Адрес" value={editing?.address ?? ''} onChange={(e) => set('address', e.target.value)} />
|
||||
<Field label="Email" type="email" value={editing?.email ?? ''} onChange={(e) => set('email', e.target.value)} />
|
||||
<Field label="Телефон" value={editing?.phone ?? ''} onChange={(e) => set('phone', e.target.value)} />
|
||||
<Field
|
||||
label="Контактное лицо"
|
||||
value={editing?.contactPerson ?? ''}
|
||||
onChange={(e) => set('contactPerson', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
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);
|
||||
|
||||
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);
|
||||
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) {
|
||||
setError(e instanceof ApiError ? `${e.code} (${e.status})` : 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] }));
|
||||
|
||||
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="ООО «Моя компания»"
|
||||
/>
|
||||
<Field
|
||||
label="ИНН"
|
||||
value={draft.inn ?? ''}
|
||||
onChange={(e) => set('inn', e.target.value)}
|
||||
placeholder="10 или 12 цифр"
|
||||
/>
|
||||
<Field
|
||||
label="КПП"
|
||||
value={draft.kpp ?? ''}
|
||||
onChange={(e) => set('kpp', e.target.value)}
|
||||
placeholder="9 цифр"
|
||||
/>
|
||||
<Field
|
||||
label="ОГРН/ОГРНИП"
|
||||
value={draft.ogrn ?? ''}
|
||||
onChange={(e) => set('ogrn', e.target.value)}
|
||||
placeholder="13 или 15 цифр"
|
||||
/>
|
||||
<Field
|
||||
label="Юр. адрес"
|
||||
value={draft.legalAddress ?? ''}
|
||||
onChange={(e) => set('legalAddress', e.target.value)}
|
||||
/>
|
||||
<Field
|
||||
label="Банк"
|
||||
value={draft.bankName ?? ''}
|
||||
onChange={(e) => set('bankName', e.target.value)}
|
||||
placeholder="Точка ПАО Банка ФК Открытие"
|
||||
/>
|
||||
<Field
|
||||
label="БИК"
|
||||
value={draft.bankBik ?? ''}
|
||||
onChange={(e) => set('bankBik', e.target.value)}
|
||||
placeholder="9 цифр"
|
||||
/>
|
||||
<Field
|
||||
label="Расчётный счёт"
|
||||
value={draft.bankAccount ?? ''}
|
||||
onChange={(e) => set('bankAccount', e.target.value)}
|
||||
placeholder="20 цифр"
|
||||
/>
|
||||
<Field
|
||||
label="Подписант ФИО"
|
||||
value={draft.signatoryName ?? ''}
|
||||
onChange={(e) => set('signatoryName', e.target.value)}
|
||||
/>
|
||||
<Field
|
||||
label="Должность подписанта"
|
||||
value={draft.signatoryPosition ?? ''}
|
||||
onChange={(e) => set('signatoryPosition', e.target.value)}
|
||||
placeholder="Генеральный директор"
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api, ApiError, type Service } from '../api.js';
|
||||
import { Button, EmptyState, Field, Modal, Select, Textarea, formatRub } from '../components/ui.js';
|
||||
|
||||
const VAT_OPTIONS = [
|
||||
{ value: 'none' as const, label: 'Без НДС' },
|
||||
{ value: 'vat_0' as const, label: '0%' },
|
||||
{ value: 'vat_5' as const, label: '5%' },
|
||||
{ value: 'vat_7' as const, label: '7%' },
|
||||
{ value: 'vat_10' as const, label: '10%' },
|
||||
{ value: 'vat_20' as const, label: '20%' },
|
||||
];
|
||||
|
||||
const VAT_LABEL: Record<Service['defaultVat'], string> = {
|
||||
none: 'Без НДС',
|
||||
vat_0: '0%',
|
||||
vat_5: '5%',
|
||||
vat_7: '7%',
|
||||
vat_10: '10%',
|
||||
vat_20: '20%',
|
||||
};
|
||||
|
||||
type Draft = {
|
||||
id?: string;
|
||||
name: string;
|
||||
unit: string;
|
||||
priceRub: string; // строка для контрол-инпута, конвертим в копейки при отправке
|
||||
defaultVat: Service['defaultVat'];
|
||||
notes: string;
|
||||
};
|
||||
|
||||
const emptyDraft = (): Draft => ({
|
||||
name: '',
|
||||
unit: 'шт',
|
||||
priceRub: '',
|
||||
defaultVat: 'none',
|
||||
notes: '',
|
||||
});
|
||||
|
||||
const toDraft = (s: Service): Draft => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
unit: s.unit,
|
||||
priceRub: (s.defaultPriceCents / 100).toFixed(2),
|
||||
defaultVat: s.defaultVat,
|
||||
notes: s.notes ?? '',
|
||||
});
|
||||
|
||||
export function ServicesPage() {
|
||||
const [items, setItems] = useState<Service[] | null>(null);
|
||||
const [q, setQ] = useState('');
|
||||
const [includeArchived, setIncludeArchived] = useState(false);
|
||||
const [editing, setEditing] = useState<Draft | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function load() {
|
||||
setError(null);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (q) params.set('q', q);
|
||||
if (includeArchived) params.set('includeArchived', '1');
|
||||
const r = await api.get<{ items: Service[] }>(`/api/services?${params.toString()}`);
|
||||
setItems(r.items);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [q, includeArchived]);
|
||||
|
||||
async function save() {
|
||||
if (!editing) return;
|
||||
setError(null);
|
||||
const priceCents = Math.round(parseFloat(editing.priceRub.replace(',', '.') || '0') * 100);
|
||||
if (Number.isNaN(priceCents) || priceCents < 0) {
|
||||
setError('Некорректная цена');
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
name: editing.name,
|
||||
unit: editing.unit,
|
||||
defaultPriceCents: priceCents,
|
||||
defaultVat: editing.defaultVat,
|
||||
notes: editing.notes || null,
|
||||
};
|
||||
try {
|
||||
if (editing.id) {
|
||||
await api.put<Service>(`/api/services/${editing.id}`, payload);
|
||||
} else {
|
||||
await api.post<Service>('/api/services', payload);
|
||||
}
|
||||
setEditing(null);
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(e instanceof ApiError ? `${e.code} (${e.status})` : String(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function archive(s: Service) {
|
||||
try {
|
||||
await api.post(`/api/services/${s.id}/archive`, {});
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
}
|
||||
async function unarchive(s: Service) {
|
||||
try {
|
||||
await api.post(`/api/services/${s.id}/unarchive`, {});
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
}
|
||||
|
||||
const set = <K extends keyof Draft>(k: K, v: Draft[K]) => setEditing((d) => (d ? { ...d, [k]: v } : d));
|
||||
|
||||
return (
|
||||
<main className="content">
|
||||
<header className="page-head">
|
||||
<h2>Каталог услуг</h2>
|
||||
<Button variant="primary" onClick={() => setEditing(emptyDraft())}>
|
||||
+ Добавить
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<div className="toolbar">
|
||||
<input
|
||||
className="search"
|
||||
placeholder="Поиск по названию или примечаниям…"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
/>
|
||||
<label className="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeArchived}
|
||||
onChange={(e) => setIncludeArchived(e.target.checked)}
|
||||
/>
|
||||
Показать архив
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{error ? <div className="error-text">{error}</div> : null}
|
||||
|
||||
{items === null ? (
|
||||
<p className="hint">Загрузка…</p>
|
||||
) : items.length === 0 ? (
|
||||
<EmptyState>
|
||||
{q ? 'Ничего не найдено.' : 'Каталог пуст. Добавьте услугу — её можно будет вставить в счёт или договор.'}
|
||||
</EmptyState>
|
||||
) : (
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Услуга</th>
|
||||
<th>Ед.</th>
|
||||
<th>Цена</th>
|
||||
<th>НДС</th>
|
||||
<th>Статус</th>
|
||||
<th aria-label="actions" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((s) => (
|
||||
<tr key={s.id} className={s.archivedAt ? 'row--archived' : ''}>
|
||||
<td>
|
||||
<div>{s.name}</div>
|
||||
{s.notes ? <div className="hint">{s.notes}</div> : null}
|
||||
</td>
|
||||
<td>{s.unit}</td>
|
||||
<td>{formatRub(s.defaultPriceCents)}</td>
|
||||
<td>{VAT_LABEL[s.defaultVat]}</td>
|
||||
<td>{s.archivedAt ? 'архив' : 'активна'}</td>
|
||||
<td className="row-actions">
|
||||
<Button variant="ghost" onClick={() => setEditing(toDraft(s))}>
|
||||
Изменить
|
||||
</Button>
|
||||
{s.archivedAt ? (
|
||||
<Button variant="ghost" onClick={() => unarchive(s)}>
|
||||
Восстановить
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="danger" onClick={() => archive(s)}>
|
||||
В архив
|
||||
</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>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="form-grid">
|
||||
<Field
|
||||
label="Название"
|
||||
value={editing?.name ?? ''}
|
||||
onChange={(e) => set('name', e.target.value)}
|
||||
placeholder="Монтаж видеостены"
|
||||
/>
|
||||
<Field
|
||||
label="Единица"
|
||||
value={editing?.unit ?? ''}
|
||||
onChange={(e) => set('unit', e.target.value)}
|
||||
placeholder="шт / час / м²"
|
||||
/>
|
||||
<Field
|
||||
label="Цена ₽"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
step="0.01"
|
||||
value={editing?.priceRub ?? ''}
|
||||
onChange={(e) => set('priceRub', e.target.value)}
|
||||
/>
|
||||
<Select
|
||||
label="НДС по умолчанию"
|
||||
value={(editing?.defaultVat ?? 'none') as Service['defaultVat']}
|
||||
onChange={(v) => set('defaultVat', v)}
|
||||
options={VAT_OPTIONS}
|
||||
/>
|
||||
<Textarea
|
||||
label="Примечания"
|
||||
value={editing?.notes ?? ''}
|
||||
onChange={(e) => set('notes', e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
:root {
|
||||
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||
font-size: 15px;
|
||||
line-height: 1.45;
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: #f6f7f9;
|
||||
color: #1c1f24;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body { background: #14161a; color: #e7e8eb; }
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding: 12px 24px;
|
||||
background: #1c1f24;
|
||||
color: #f6f7f9;
|
||||
border-bottom: 1px solid #2a2e35;
|
||||
}
|
||||
.topbar h1 { margin: 0; font-size: 18px; font-weight: 600; }
|
||||
.topbar nav { display: flex; gap: 16px; flex: 1; }
|
||||
.topbar nav a { color: #c9cbcf; text-decoration: none; }
|
||||
.topbar nav a:hover { color: #fff; }
|
||||
.topbar .user { opacity: 0.7; font-size: 13px; }
|
||||
|
||||
.content { padding: 24px; max-width: 1200px; margin: 0 auto; }
|
||||
|
||||
.loading {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
height: 100vh; opacity: 0.6;
|
||||
}
|
||||
|
||||
/* === page primitives === */
|
||||
.page-head {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.page-head h2 { margin: 0; }
|
||||
.toolbar {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.search {
|
||||
flex: 1; max-width: 360px;
|
||||
padding: 8px 12px; border: 1px solid #d6d8dd; border-radius: 6px;
|
||||
background: #fff; color: inherit; font-size: 14px;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.search { background: #1c1f24; border-color: #2a2e35; }
|
||||
}
|
||||
.checkbox {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
font-size: 13px; cursor: pointer;
|
||||
}
|
||||
.hint { opacity: 0.65; font-size: 13px; margin: 4px 0; }
|
||||
.error-text { color: #c0392b; font-size: 13px; margin: 8px 0; }
|
||||
.empty {
|
||||
padding: 48px 24px; text-align: center; opacity: 0.6;
|
||||
border: 1px dashed #d6d8dd; border-radius: 8px;
|
||||
}
|
||||
|
||||
/* === buttons === */
|
||||
.btn {
|
||||
appearance: none; cursor: pointer;
|
||||
padding: 6px 14px; border: 1px solid transparent; border-radius: 6px;
|
||||
font-size: 14px; line-height: 1.4;
|
||||
transition: background-color 0.1s, border-color 0.1s;
|
||||
}
|
||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn--default { background: #fff; border-color: #d6d8dd; color: inherit; }
|
||||
.btn--default:hover:not(:disabled) { background: #f1f2f5; }
|
||||
.btn--primary { background: #2563eb; color: #fff; }
|
||||
.btn--primary:hover:not(:disabled) { background: #1d4ed8; }
|
||||
.btn--danger { background: transparent; border-color: #c0392b; color: #c0392b; }
|
||||
.btn--danger:hover:not(:disabled) { background: #c0392b; color: #fff; }
|
||||
.btn--ghost { background: transparent; color: inherit; }
|
||||
.btn--ghost:hover:not(:disabled) { background: rgba(127,127,127,0.1); }
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.btn--default { background: #1c1f24; border-color: #2a2e35; }
|
||||
.btn--default:hover:not(:disabled) { background: #25282e; }
|
||||
}
|
||||
|
||||
/* === tables === */
|
||||
.table {
|
||||
width: 100%; border-collapse: collapse;
|
||||
background: #fff; border-radius: 8px; overflow: hidden;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
|
||||
}
|
||||
.table th, .table td {
|
||||
text-align: left; padding: 10px 14px; vertical-align: top;
|
||||
border-bottom: 1px solid #eef0f3;
|
||||
font-size: 14px;
|
||||
}
|
||||
.table th { font-weight: 600; opacity: 0.7; font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
.table tr:last-child td { border-bottom: none; }
|
||||
.row-actions { white-space: nowrap; text-align: right; }
|
||||
.row-actions .btn { margin-left: 6px; }
|
||||
.row--archived { opacity: 0.55; }
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.table { background: #1c1f24; box-shadow: none; }
|
||||
.table th, .table td { border-bottom-color: #2a2e35; }
|
||||
}
|
||||
|
||||
/* === fields === */
|
||||
.field { display: flex; flex-direction: column; gap: 4px; }
|
||||
.field__label { font-size: 12px; opacity: 0.7; }
|
||||
.field__input {
|
||||
padding: 8px 10px; border: 1px solid #d6d8dd; border-radius: 6px;
|
||||
background: #fff; color: inherit; font-size: 14px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.field__input--area { min-height: 64px; resize: vertical; }
|
||||
.field__input--err { border-color: #c0392b; }
|
||||
.field__error { color: #c0392b; font-size: 12px; }
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.field__input { background: #1c1f24; border-color: #2a2e35; }
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px;
|
||||
}
|
||||
.form-actions { display: flex; align-items: center; gap: 12px; margin-top: 16px; }
|
||||
|
||||
/* === modal === */
|
||||
.modal__backdrop {
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,0.5);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 100; padding: 24px;
|
||||
}
|
||||
.modal {
|
||||
background: #fff; color: inherit;
|
||||
border-radius: 8px; width: min(720px, 100%);
|
||||
max-height: calc(100vh - 48px); overflow: hidden;
|
||||
display: flex; flex-direction: column;
|
||||
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
|
||||
}
|
||||
.modal__header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 14px 20px; border-bottom: 1px solid #eef0f3;
|
||||
}
|
||||
.modal__header h3 { margin: 0; font-size: 16px; }
|
||||
.modal__close {
|
||||
background: none; border: none; color: inherit; cursor: pointer;
|
||||
font-size: 22px; line-height: 1; padding: 4px 8px; border-radius: 4px;
|
||||
}
|
||||
.modal__close:hover { background: rgba(127,127,127,0.1); }
|
||||
.modal__body { padding: 20px; overflow: auto; }
|
||||
.modal__footer {
|
||||
display: flex; justify-content: flex-end; gap: 8px;
|
||||
padding: 12px 20px; border-top: 1px solid #eef0f3;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.modal { background: #1c1f24; }
|
||||
.modal__header, .modal__footer { border-color: #2a2e35; }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"jsx": "react-jsx",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"root":["./src/app.tsx","./src/api.ts","./src/auth.ts","./src/main.tsx","./src/components/ui.tsx","./src/pages/clients.tsx","./src/pages/organization.tsx","./src/pages/services.tsx"],"version":"5.9.3"}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: true, // 0.0.0.0 — доступно с других устройств в LAN
|
||||
port: 5173,
|
||||
proxy: {
|
||||
// В dev фронт ходит на API через /api и /webhooks. CORS+credentials уже настроены, но прокси убирает cross-origin.
|
||||
'/api': { target: 'http://localhost:3030', changeOrigin: true },
|
||||
'/webhooks': { target: 'http://localhost:3030', changeOrigin: true },
|
||||
'/health': { target: 'http://localhost:3030', changeOrigin: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user