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,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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user