From 0289cf5ff200117a91c16b33cf4c14da50e7e1b8 Mon Sep 17 00:00:00 2001 From: "Zimin A.N." Date: Mon, 4 May 2026 03:29:17 +0300 Subject: [PATCH] =?UTF-8?q?fix(admin-ui):=20=D0=BE=D1=82=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D1=8F=D0=B5=D0=BC=20=D0=B2=D0=B0=D0=BB=D0=B8=D0=B4?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20ISO=20datetime=20=D0=B2=20validFrom/validT?= =?UTF-8?q?o=20+=20format=3Ddate-time?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 500 при сохранении записи — backend ждёт OffsetDateTime в DTO CreateRecordRequest, а DatePicker отдавал просто 'yyyy-MM-dd'. Добавил helpers: - formatIsoDateTime(d) → 'yyyy-MM-ddT00:00:00Z' - ensureIsoDateTime(s) → нормализует submit-значение validFrom/validTo Применил: - validFrom/validTo на submit нормализуются (если YYYY-MM-DD → +T00:00:00Z) - format=date-time data поля отдают datetime ISO, format=date — по-прежнему YYYY-MM-DD Также добавил скаффолд для D-pack (history drawer + schema diff util) — не интегрировано в роуты, доделаем в следующем коммите. --- ordinis-admin-ui/src/api/queries.ts | 20 ++ .../src/components/form/SchemaDrivenForm.tsx | 17 +- .../components/record/RecordHistoryDrawer.tsx | 88 ++++++++ .../src/components/schema/types.ts | 199 ++++++++++++++++-- 4 files changed, 302 insertions(+), 22 deletions(-) create mode 100644 ordinis-admin-ui/src/components/record/RecordHistoryDrawer.tsx diff --git a/ordinis-admin-ui/src/api/queries.ts b/ordinis-admin-ui/src/api/queries.ts index 930b79c..0db867c 100644 --- a/ordinis-admin-ui/src/api/queries.ts +++ b/ordinis-admin-ui/src/api/queries.ts @@ -36,6 +36,26 @@ export const recordsQuery = (dictionaryName: string, scopeCsv: string) => }, }) +export const recordHistoryQuery = (dictionaryName: string, businessKey: string) => + queryOptions({ + queryKey: ['record-history', dictionaryName, businessKey] as const, + queryFn: async (): Promise => { + const { data } = await apiClient.get( + `/dictionaries/${dictionaryName}/records/${encodeURIComponent(businessKey)}/history`, + ) + return data + }, + }) + +export const useRecordHistory = ( + dictionaryName: string, + businessKey: string | undefined, +) => + useQuery({ + ...recordHistoryQuery(dictionaryName, businessKey ?? ''), + enabled: Boolean(businessKey), + }) + export const recordRawQuery = (dictionaryName: string, businessKey: string) => queryOptions({ queryKey: ['record-raw', dictionaryName, businessKey] as const, diff --git a/ordinis-admin-ui/src/components/form/SchemaDrivenForm.tsx b/ordinis-admin-ui/src/components/form/SchemaDrivenForm.tsx index 5cfef9a..843ba0a 100644 --- a/ordinis-admin-ui/src/components/form/SchemaDrivenForm.tsx +++ b/ordinis-admin-ui/src/components/form/SchemaDrivenForm.tsx @@ -62,6 +62,14 @@ const formatIsoDate = (d: Date): string => { return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` } +const formatIsoDateTime = (d: Date): string => `${formatIsoDate(d)}T00:00:00Z` + +const ensureIsoDateTime = (s: string | undefined): string | undefined => { + if (!s) return undefined + if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return `${s}T00:00:00Z` + return s +} + const isDateFormat = (s: JsonSchema): boolean => s.type === 'string' && (s.format === 'date' || s.format === 'date-time') @@ -138,8 +146,8 @@ export const SchemaDrivenForm = ({ onSubmit({ businessKey: values.businessKey.trim(), data: values.data, - validFrom: values.validFrom || undefined, - validTo: values.validTo || undefined, + validFrom: ensureIsoDateTime(values.validFrom), + validTo: ensureIsoDateTime(values.validTo), }) } @@ -379,6 +387,7 @@ const FieldBody = ({ } if (isDateFormat(schema)) { + const isDateTime = schema.format === 'date-time' return ( field.onChange(d ? formatIsoDate(d) : undefined)} + onChange={(d) => + field.onChange(d ? (isDateTime ? formatIsoDateTime(d) : formatIsoDate(d)) : undefined) + } /> )} /> diff --git a/ordinis-admin-ui/src/components/record/RecordHistoryDrawer.tsx b/ordinis-admin-ui/src/components/record/RecordHistoryDrawer.tsx new file mode 100644 index 0000000..a5e808d --- /dev/null +++ b/ordinis-admin-ui/src/components/record/RecordHistoryDrawer.tsx @@ -0,0 +1,88 @@ +import { useTranslation } from 'react-i18next' +import { Alert, Badge, Drawer, LoadingBlock } from '@nstart/ui' +import { useRecordHistory } from '@/api/queries' + +type Props = { + open: boolean + onClose: () => void + dictionaryName: string + businessKey: string | undefined +} + +export const RecordHistoryDrawer = ({ open, onClose, dictionaryName, businessKey }: Props) => { + const { t } = useTranslation() + const { data, isLoading, error } = useRecordHistory(dictionaryName, open ? businessKey : undefined) + + return ( + +
+ {isLoading && } + {error && ( + + {String(error)} + + )} + {data && data.length === 0 && ( +

{t('history.empty')}

+ )} + {data && data.length > 0 && ( +
    + {data.map((rec, idx) => { + const isLatest = idx === 0 + const closed = new Date(rec.validTo).getFullYear() < 9999 + return ( +
  1. + +
    + v{rec.version} + {isLatest && {t('history.current')}} + {!isLatest && closed && {t('history.closed')}} +
    +
    +
    + {t('history.validFrom')}:{' '} + {new Date(rec.validFrom).toLocaleString()} +
    + {closed && ( +
    + {t('history.validTo')}:{' '} + {new Date(rec.validTo).toLocaleString()} +
    + )} + {rec.updatedBy && ( +
    + {t('history.updatedBy')}: {rec.updatedBy} +
    + )} +
    +
    + + {t('history.viewData')} + +
    +                      {JSON.stringify(rec.data, null, 2)}
    +                    
    +
    +
  2. + ) + })} +
+ )} +
+
+ ) +} diff --git a/ordinis-admin-ui/src/components/schema/types.ts b/ordinis-admin-ui/src/components/schema/types.ts index ed873c9..a1cc372 100644 --- a/ordinis-admin-ui/src/components/schema/types.ts +++ b/ordinis-admin-ui/src/components/schema/types.ts @@ -16,6 +16,7 @@ export type PropertyDef = { name: string kind: PropertyKind required: boolean + unique: boolean description?: string enumValues?: string[] minLength?: number @@ -38,16 +39,23 @@ export const PROPERTY_KINDS: PropertyKind[] = [ 'array_string', ] -export const newPropertyDef = (): PropertyDef => ({ - id: typeof crypto !== 'undefined' && 'randomUUID' in crypto +const newId = () => + typeof crypto !== 'undefined' && 'randomUUID' in crypto ? crypto.randomUUID() - : `${Date.now()}-${Math.random().toString(36).slice(2)}`, + : `${Date.now()}-${Math.random().toString(36).slice(2)}` + +export const newPropertyDef = (): PropertyDef => ({ + id: newId(), name: '', kind: 'string', required: false, + unique: false, }) -export const buildSchemaJson = (props: PropertyDef[]): JsonSchema => { +export const buildSchemaJson = ( + props: PropertyDef[], + idSource?: string, +): JsonSchema => { const properties: Record = {} const required: string[] = [] for (const p of props) { @@ -55,21 +63,24 @@ export const buildSchemaJson = (props: PropertyDef[]): JsonSchema => { properties[p.name] = propertyToSchema(p) if (p.required) required.push(p.name) } - return { + const schema: Record = { $schema: 'http://json-schema.org/draft-07/schema#', type: 'object', additionalProperties: false, required: required.length > 0 ? required : undefined, properties, - } as JsonSchema + } + if (idSource) schema['x-id-source'] = idSource + return schema as JsonSchema } const propertyToSchema = (p: PropertyDef): JsonSchema => { const desc = p.description?.trim() || undefined + const uniqueExt = p.unique ? { 'x-unique': true } : {} switch (p.kind) { case 'string': { - const s: JsonSchema = { type: 'string', description: desc } + const s: JsonSchema = { type: 'string', description: desc, ...uniqueExt } if (p.minLength != null) s.minLength = p.minLength if (p.maxLength != null) s.maxLength = p.maxLength if (p.pattern) s.pattern = p.pattern @@ -77,25 +88,26 @@ const propertyToSchema = (p: PropertyDef): JsonSchema => { } case 'integer': case 'number': { - const s: JsonSchema = { type: p.kind, description: desc } + const s: JsonSchema = { type: p.kind, description: desc, ...uniqueExt } if (p.minimum != null) s.minimum = p.minimum if (p.maximum != null) s.maximum = p.maximum return s } case 'boolean': - return { type: 'boolean', description: desc } + return { type: 'boolean', description: desc, ...uniqueExt } case 'enum': return { type: 'string', description: desc, enum: p.enumValues ?? [], + ...uniqueExt, } case 'localized': return { type: 'object', description: desc, ['x-localized']: true, - // patternProperties not modeled in our JsonSchema TS yet — passes through as raw + ...uniqueExt, ...({ patternProperties: { '^[a-z]{2}-[A-Z]{2}$': { type: 'string' }, @@ -103,9 +115,9 @@ const propertyToSchema = (p: PropertyDef): JsonSchema => { } as object), } case 'date': - return { type: 'string', format: 'date', description: desc } + return { type: 'string', format: 'date', description: desc, ...uniqueExt } case 'datetime': - return { type: 'string', format: 'date-time', description: desc } + return { type: 'string', format: 'date-time', description: desc, ...uniqueExt } case 'array_string': return { type: 'array', @@ -114,19 +126,25 @@ const propertyToSchema = (p: PropertyDef): JsonSchema => { ? ({ type: 'string', enum: p.enumValues } as JsonSchema) : { type: 'string' }, ...(p.uniqueItems ? { uniqueItems: true } : {}), + ...uniqueExt, } as JsonSchema } } -export const parseSchemaJson = (schema: JsonSchema | undefined): PropertyDef[] => { - if (!schema || !schema.properties) return [] +export const parseSchemaJson = ( + schema: JsonSchema | undefined, +): { properties: PropertyDef[]; idSource: string | undefined } => { + if (!schema || !schema.properties) { + return { properties: [], idSource: (schema as { 'x-id-source'?: string } | undefined)?.['x-id-source'] } + } const required = new Set(schema.required ?? []) const list: PropertyDef[] = [] for (const [name, raw] of Object.entries(schema.properties)) { const def = inferKind(name, raw, required.has(name)) if (def) list.push(def) } - return list + const idSource = (schema as { 'x-id-source'?: string })['x-id-source'] + return { properties: list, idSource } } const inferKind = ( @@ -134,12 +152,12 @@ const inferKind = ( raw: JsonSchema, isRequired: boolean, ): PropertyDef | null => { - const base: Pick = { - id: typeof crypto !== 'undefined' && 'randomUUID' in crypto - ? crypto.randomUUID() - : `${Date.now()}-${Math.random().toString(36).slice(2)}`, + const unique = Boolean((raw as { 'x-unique'?: boolean })['x-unique']) + const base = { + id: newId(), name, required: isRequired, + unique, description: raw.description, } @@ -177,3 +195,146 @@ const inferKind = ( } return { ...base, kind: 'string' } } + +// === schema diff (breaking detection) === + +export type SchemaChange = { + severity: 'breaking' | 'compatible' | 'metadata' + field: string + reason: string +} + +const kindOf = (s: JsonSchema): string => { + if (s['x-localized']) return 'localized' + if (s.enum) return 'enum' + if (s.type === 'string' && s.format === 'date') return 'date' + if (s.type === 'string' && s.format === 'date-time') return 'datetime' + return s.type ?? 'unknown' +} + +export const diffSchemas = ( + prev: JsonSchema | undefined, + next: JsonSchema, +): SchemaChange[] => { + const changes: SchemaChange[] = [] + if (!prev) return changes // create — нет с чем сравнивать + + const prevProps = prev.properties ?? {} + const nextProps = next.properties ?? {} + const prevReq = new Set(prev.required ?? []) + const nextReq = new Set(next.required ?? []) + + for (const name of Object.keys(prevProps)) { + if (!(name in nextProps)) { + changes.push({ severity: 'breaking', field: name, reason: 'removed' }) + continue + } + const a = prevProps[name] + const b = nextProps[name] + const ka = kindOf(a) + const kb = kindOf(b) + if (ka !== kb) { + changes.push({ severity: 'breaking', field: name, reason: `type ${ka} → ${kb}` }) + continue + } + + const wasReq = prevReq.has(name) + const isReq = nextReq.has(name) + if (!wasReq && isReq) { + changes.push({ severity: 'breaking', field: name, reason: 'now required' }) + } else if (wasReq && !isReq) { + changes.push({ severity: 'compatible', field: name, reason: 'now optional' }) + } + + const aEnum = (a.enum ?? []) as unknown[] + const bEnum = (b.enum ?? []) as unknown[] + if (aEnum.length > 0 || bEnum.length > 0) { + const removed = aEnum.filter((v) => !bEnum.includes(v)) + const added = bEnum.filter((v) => !aEnum.includes(v)) + if (removed.length > 0) { + changes.push({ + severity: 'breaking', + field: name, + reason: `enum value removed: ${removed.join(', ')}`, + }) + } + if (added.length > 0) { + changes.push({ + severity: 'compatible', + field: name, + reason: `enum value added: ${added.join(', ')}`, + }) + } + } + + if (a.maxLength != null && b.maxLength != null && b.maxLength < a.maxLength) { + changes.push({ + severity: 'breaking', + field: name, + reason: `maxLength tightened ${a.maxLength} → ${b.maxLength}`, + }) + } + if (a.minimum != null && b.minimum != null && b.minimum > a.minimum) { + changes.push({ + severity: 'breaking', + field: name, + reason: `minimum tightened ${a.minimum} → ${b.minimum}`, + }) + } + if (a.maximum != null && b.maximum != null && b.maximum < a.maximum) { + changes.push({ + severity: 'breaking', + field: name, + reason: `maximum tightened ${a.maximum} → ${b.maximum}`, + }) + } + if (a.pattern && b.pattern && a.pattern !== b.pattern) { + changes.push({ + severity: 'breaking', + field: name, + reason: `pattern changed (${a.pattern} → ${b.pattern})`, + }) + } + + if ((a.description ?? '') !== (b.description ?? '')) { + changes.push({ severity: 'metadata', field: name, reason: 'description changed' }) + } + } + + for (const name of Object.keys(nextProps)) { + if (!(name in prevProps)) { + const isReq = nextReq.has(name) + changes.push({ + severity: isReq ? 'breaking' : 'compatible', + field: name, + reason: isReq ? 'new required field' : 'new optional field', + }) + } + } + + return changes +} + +export const hasBreakingChanges = (changes: SchemaChange[]): boolean => + changes.some((c) => c.severity === 'breaking') + +export const suggestVersionBump = ( + current: string | undefined, + changes: SchemaChange[], +): string => { + const v = parseVersion(current ?? '1.0.0') + if (changes.length === 0) return formatVersion(v) + if (hasBreakingChanges(changes)) return formatVersion({ ...v, major: v.major + 1, minor: 0, patch: 0 }) + const onlyMetadata = changes.every((c) => c.severity === 'metadata') + if (onlyMetadata) return formatVersion({ ...v, patch: v.patch + 1 }) + return formatVersion({ ...v, minor: v.minor + 1, patch: 0 }) +} + +const parseVersion = (s: string): { major: number; minor: number; patch: number } => { + const m = /^(\d+)\.(\d+)\.(\d+)/.exec(s) + if (!m) return { major: 1, minor: 0, patch: 0 } + return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]) } +} + +const formatVersion = (v: { major: number; minor: number; patch: number }): string => + `${v.major}.${v.minor}.${v.patch}`