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:
admin
2026-04-30 21:24:26 +03:00
commit 4553f63deb
52 changed files with 7110 additions and 0 deletions
+109
View File
@@ -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,
});
}