fix: project create UX + project picker in DocumentEdit

- Projects.tsx: pre-validate empty name, show fieldErrors on Field
  (root cause of '400 validation_error' was empty name silently rejected)
- ProjectPicker component for DocumentEdit; selecting a project autofills
  default client when no client is yet set

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
admin
2026-05-01 13:48:42 +03:00
parent b2c221e643
commit 90cebb0e0f
3 changed files with 66 additions and 3 deletions
+33
View File
@@ -0,0 +1,33 @@
import { useEffect, useState } from 'react';
import { api, type ProjectSummary } from '../api.js';
export function ProjectPicker({
value,
onChange,
}: {
value: string | null;
onChange: (id: string | null, project?: ProjectSummary | null) => void;
}) {
const [projects, setProjects] = useState<ProjectSummary[]>([]);
useEffect(() => {
api.get<{ items: ProjectSummary[] }>('/api/projects?status=active&limit=200')
.then((r) => setProjects(r.items))
.catch(() => setProjects([]));
}, []);
return (
<select
className="field__input"
value={value ?? ''}
onChange={(e) => {
const id = e.target.value || null;
const p = id ? projects.find((x) => x.id === id) ?? null : null;
onChange(id, p);
}}
>
<option value=""> без проекта </option>
{projects.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
);
}
+14
View File
@@ -4,6 +4,7 @@ import { api, ApiError, type Block, type DocBody, type DocStatus, type DocType,
import { BlocksEditor } from '../components/BlocksEditor.js'; import { BlocksEditor } from '../components/BlocksEditor.js';
import { ClientPicker } from '../components/ClientPicker.js'; import { ClientPicker } from '../components/ClientPicker.js';
import { LinesEditor, type LineDraft } from '../components/LinesEditor.js'; import { LinesEditor, type LineDraft } from '../components/LinesEditor.js';
import { ProjectPicker } from '../components/ProjectPicker.js';
import { Button, Field, Select } from '../components/ui.js'; import { Button, Field, Select } from '../components/ui.js';
import { emptyRich } from '../lib/richtext.js'; import { emptyRich } from '../lib/richtext.js';
@@ -257,6 +258,19 @@ export function DocumentEditPage() {
value={issuedAt} value={issuedAt}
onChange={(e) => setIssuedAt(e.target.value)} onChange={(e) => setIssuedAt(e.target.value)}
/> />
<label className="field">
<span className="field__label">Проект</span>
<ProjectPicker
value={projectId}
onChange={(id, project) => {
setProjectId(id);
// При смене проекта подставим default-клиента, если у документа клиент ещё не задан
if (project && project.defaultClientId && !clientId) {
setClientId(project.defaultClientId);
}
}}
/>
</label>
<label className="field"> <label className="field">
<span className="field__label">Клиент</span> <span className="field__label">Клиент</span>
<ClientPicker value={clientId} onChange={setClientId} /> <ClientPicker value={clientId} onChange={setClientId} />
+19 -3
View File
@@ -15,6 +15,7 @@ export function ProjectsPage() {
const [q, setQ] = useState(''); const [q, setQ] = useState('');
const [creating, setCreating] = useState<{ name: string } | null>(null); const [creating, setCreating] = useState<{ name: string } | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const navigate = useNavigate(); const navigate = useNavigate();
async function load() { async function load() {
@@ -34,9 +35,14 @@ export function ProjectsPage() {
async function create() { async function create() {
if (!creating) return; if (!creating) return;
if (!creating.name.trim()) {
setFieldErrors({ name: 'обязательное поле' });
return;
}
setFieldErrors({});
try { try {
const p = await api.post<ProjectSummary>('/api/projects', { const p = await api.post<ProjectSummary>('/api/projects', {
name: creating.name, name: creating.name.trim(),
status: 'active', status: 'active',
defaultClientId: null, defaultClientId: null,
defaultTemplateId: null, defaultTemplateId: null,
@@ -46,7 +52,13 @@ export function ProjectsPage() {
setCreating(null); setCreating(null);
navigate(`/projects/${p.id}`); navigate(`/projects/${p.id}`);
} catch (e) { } catch (e) {
setError(e instanceof ApiError ? e.prettyMessage() : String(e)); if (e instanceof ApiError) {
const fe = e.fieldErrors();
setFieldErrors(fe);
setError(Object.keys(fe).length ? 'Проверьте подсвеченные поля.' : e.prettyMessage());
} else {
setError(String(e));
}
} }
} }
@@ -144,8 +156,12 @@ export function ProjectsPage() {
<Field <Field
label="Название" label="Название"
value={creating.name} value={creating.name}
onChange={(e) => setCreating({ name: e.target.value })} onChange={(e) => {
setCreating({ name: e.target.value });
if (fieldErrors.name) setFieldErrors((fe) => { const n = { ...fe }; delete n.name; return n; });
}}
placeholder="напр. Свадьба Ивановых, июнь 2026" placeholder="напр. Свадьба Ивановых, июнь 2026"
error={fieldErrors.name}
/> />
) : null} ) : null}
</Modal> </Modal>