refactor: simplify team bio — replace complex achievements with simple list, remove experience
- Replace VictoryItem (type/place/category/competition/city/date) with RichListItem (text + optional link/image) - Remove VictoryItemListField, DateRangeField, CityField and related helpers - Remove experience field from admin form and user profile (can be in bio text) - Simplify TeamProfile: remove victory tabs, show achievements as RichCards - Fix auto-save: snapshot comparison prevents false saves on focus/blur - Add save on tab leave (visibilitychange) and page close (sendBeacon) - Add save after image uploads (main photo, achievements, education) - Auto-migrate old VictoryItem data to RichListItem format in DB parser
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { useRef, useEffect, useState, useMemo } from "react";
|
||||
import { Plus, X, Upload, Loader2, Link, ImageIcon, Calendar, AlertCircle, MapPin } from "lucide-react";
|
||||
import { Plus, X, Upload, Loader2, Link, ImageIcon, AlertCircle } from "lucide-react";
|
||||
import { adminFetch } from "@/lib/csrf";
|
||||
import type { RichListItem, VictoryItem } from "@/types/content";
|
||||
import type { RichListItem } from "@/types/content";
|
||||
|
||||
interface InputFieldProps {
|
||||
label: string;
|
||||
@@ -414,9 +414,10 @@ interface VictoryListFieldProps {
|
||||
onChange: (items: RichListItem[]) => void;
|
||||
placeholder?: string;
|
||||
onLinkValidate?: (key: string, error: string | null) => void;
|
||||
onUploadComplete?: () => void;
|
||||
}
|
||||
|
||||
export function VictoryListField({ label, items, onChange, placeholder, onLinkValidate }: VictoryListFieldProps) {
|
||||
export function VictoryListField({ label, items, onChange, placeholder, onLinkValidate, onUploadComplete }: VictoryListFieldProps) {
|
||||
const [draft, setDraft] = useState("");
|
||||
const [uploadingIndex, setUploadingIndex] = useState<number | null>(null);
|
||||
|
||||
@@ -455,6 +456,7 @@ export function VictoryListField({ label, items, onChange, placeholder, onLinkVa
|
||||
const result = await res.json();
|
||||
if (result.path) {
|
||||
onChange(items.map((item, i) => (i === index ? { ...item, image: result.path } : item)));
|
||||
onUploadComplete?.();
|
||||
}
|
||||
} catch { /* upload failed */ } finally {
|
||||
setUploadingIndex(null);
|
||||
@@ -530,151 +532,6 @@ export function VictoryListField({ label, items, onChange, placeholder, onLinkVa
|
||||
);
|
||||
}
|
||||
|
||||
// --- Date Range Picker ---
|
||||
// Parses Russian date formats: "22.02.2025", "22-23.02.2025", "22.02-01.03.2025"
|
||||
function parseDateRange(value: string): { start: string; end: string } {
|
||||
if (!value) return { start: "", end: "" };
|
||||
|
||||
// "22-23.02.2025" → same month range
|
||||
const sameMonth = value.match(/^(\d{1,2})-(\d{1,2})\.(\d{2})\.(\d{4})$/);
|
||||
if (sameMonth) {
|
||||
const [, d1, d2, m, y] = sameMonth;
|
||||
return {
|
||||
start: `${y}-${m}-${d1.padStart(2, "0")}`,
|
||||
end: `${y}-${m}-${d2.padStart(2, "0")}`,
|
||||
};
|
||||
}
|
||||
|
||||
// "22.02-01.03.2025" → cross-month range
|
||||
const crossMonth = value.match(/^(\d{1,2})\.(\d{2})-(\d{1,2})\.(\d{2})\.(\d{4})$/);
|
||||
if (crossMonth) {
|
||||
const [, d1, m1, d2, m2, y] = crossMonth;
|
||||
return {
|
||||
start: `${y}-${m1}-${d1.padStart(2, "0")}`,
|
||||
end: `${y}-${m2}-${d2.padStart(2, "0")}`,
|
||||
};
|
||||
}
|
||||
|
||||
// "22.02.2025" → single date
|
||||
const single = value.match(/^(\d{1,2})\.(\d{2})\.(\d{4})$/);
|
||||
if (single) {
|
||||
const [, d, m, y] = single;
|
||||
const iso = `${y}-${m}-${d.padStart(2, "0")}`;
|
||||
return { start: iso, end: "" };
|
||||
}
|
||||
|
||||
return { start: "", end: "" };
|
||||
}
|
||||
|
||||
function formatDateRange(start: string, end: string): string {
|
||||
if (!start) return "";
|
||||
const [sy, sm, sd] = start.split("-");
|
||||
if (!end) return `${sd}.${sm}.${sy}`;
|
||||
const [ey, em, ed] = end.split("-");
|
||||
if (sm === em && sy === ey) return `${sd}-${ed}.${sm}.${sy}`;
|
||||
return `${sd}.${sm}-${ed}.${em}.${ey}`;
|
||||
}
|
||||
|
||||
interface DateRangeFieldProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export function DateRangeField({ value, onChange }: DateRangeFieldProps) {
|
||||
const { start, end } = parseDateRange(value);
|
||||
|
||||
function handleChange(s: string, e: string) {
|
||||
onChange(formatDateRange(s, e));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar size={11} className="text-neutral-500 shrink-0" />
|
||||
<input
|
||||
type="date"
|
||||
value={start}
|
||||
onChange={(e) => handleChange(e.target.value, end)}
|
||||
className="w-[130px] rounded-md border border-white/10 bg-neutral-800 px-1.5 py-1.5 text-xs text-white outline-none focus:border-gold transition-colors [color-scheme:dark]"
|
||||
/>
|
||||
<span className="text-neutral-500 text-xs">—</span>
|
||||
<input
|
||||
type="date"
|
||||
value={end}
|
||||
min={start}
|
||||
onChange={(e) => handleChange(start, e.target.value)}
|
||||
className="w-[130px] rounded-md border border-white/10 bg-neutral-800 px-1.5 py-1.5 text-xs text-white outline-none focus:border-gold transition-colors [color-scheme:dark]"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- City Autocomplete Field ---
|
||||
interface CityFieldProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
error?: string;
|
||||
onSearch?: (query: string) => void;
|
||||
suggestions?: string[];
|
||||
onSelectSuggestion?: (value: string) => void;
|
||||
}
|
||||
|
||||
export function CityField({ value, onChange, error, onSearch, suggestions, onSelectSuggestion }: CityFieldProps) {
|
||||
const [focused, setFocused] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!focused) return;
|
||||
function handle(e: MouseEvent) {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setFocused(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handle);
|
||||
return () => document.removeEventListener("mousedown", handle);
|
||||
}, [focused]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative flex-1">
|
||||
<div className="relative">
|
||||
<MapPin size={11} className="absolute left-2 top-1/2 -translate-y-1/2 text-neutral-500 pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
onChange(e.target.value);
|
||||
onSearch?.(e.target.value);
|
||||
}}
|
||||
onFocus={() => setFocused(true)}
|
||||
placeholder="Город, страна"
|
||||
className={`w-full rounded-md border bg-neutral-800 pl-6 pr-3 py-1.5 text-sm text-white placeholder-neutral-600 outline-none transition-colors ${
|
||||
error ? "border-red-500/50" : "border-white/10 focus:border-gold"
|
||||
}`}
|
||||
/>
|
||||
{error && <AlertCircle size={12} className="absolute right-2 top-1/2 -translate-y-1/2 text-red-400" />}
|
||||
</div>
|
||||
{error && <p className="mt-0.5 text-[10px] text-red-400">{error}</p>}
|
||||
{focused && suggestions && suggestions.length > 0 && (
|
||||
<div className="absolute z-50 mt-1 w-full rounded-lg border border-white/10 bg-neutral-800 shadow-xl overflow-hidden">
|
||||
{suggestions.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => {
|
||||
onSelectSuggestion?.(s);
|
||||
setFocused(false);
|
||||
}}
|
||||
className="w-full px-3 py-1.5 text-left text-sm text-white hover:bg-white/5 transition-colors"
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Link Field with Validation ---
|
||||
interface ValidatedLinkFieldProps {
|
||||
value: string;
|
||||
@@ -729,110 +586,6 @@ export function ValidatedLinkField({ value, onChange, onValidate, validationKey,
|
||||
);
|
||||
}
|
||||
|
||||
interface VictoryItemListFieldProps {
|
||||
label: string;
|
||||
items: VictoryItem[];
|
||||
onChange: (items: VictoryItem[]) => void;
|
||||
cityErrors?: Record<number, string>;
|
||||
citySuggestions?: { index: number; items: string[] } | null;
|
||||
onCitySearch?: (index: number, query: string) => void;
|
||||
onCitySelect?: (index: number, value: string) => void;
|
||||
onLinkValidate?: (key: string, error: string | null) => void;
|
||||
}
|
||||
|
||||
export function VictoryItemListField({ label, items, onChange, cityErrors, citySuggestions, onCitySearch, onCitySelect, onLinkValidate }: VictoryItemListFieldProps) {
|
||||
function add() {
|
||||
onChange([...items, { type: "place", place: "", category: "", competition: "" }]);
|
||||
}
|
||||
|
||||
function remove(index: number) {
|
||||
onChange(items.filter((_, i) => i !== index));
|
||||
}
|
||||
|
||||
function update(index: number, field: keyof VictoryItem, value: string) {
|
||||
onChange(items.map((item, i) => (i === index ? { ...item, [field]: value || undefined } : item)));
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
|
||||
<div className="space-y-3">
|
||||
{items.map((item, i) => (
|
||||
<div key={i} className="rounded-lg border border-white/10 bg-neutral-800/50 p-2.5 space-y-1.5">
|
||||
<div className="flex gap-1.5">
|
||||
<select
|
||||
value={item.type || "place"}
|
||||
onChange={(e) => update(i, "type", e.target.value)}
|
||||
className="w-32 shrink-0 rounded-md border border-white/10 bg-neutral-800 px-2 py-1.5 text-sm text-white outline-none focus:border-gold transition-colors"
|
||||
>
|
||||
<option value="place">Место</option>
|
||||
<option value="nomination">Номинация</option>
|
||||
<option value="judge">Судейство</option>
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
value={item.place || ""}
|
||||
onChange={(e) => update(i, "place", e.target.value)}
|
||||
placeholder="1 место, финалист..."
|
||||
className={`w-28 shrink-0 ${smallInput}`}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={item.category || ""}
|
||||
onChange={(e) => update(i, "category", e.target.value)}
|
||||
placeholder="Категория"
|
||||
className={`flex-1 ${smallInput}`}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={item.competition || ""}
|
||||
onChange={(e) => update(i, "competition", e.target.value)}
|
||||
placeholder="Чемпионат"
|
||||
className={`flex-1 ${smallInput}`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(i)}
|
||||
className="shrink-0 rounded-md p-1.5 text-neutral-500 hover:text-red-400 transition-colors"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<CityField
|
||||
value={item.location || ""}
|
||||
onChange={(v) => update(i, "location", v)}
|
||||
error={cityErrors?.[i]}
|
||||
onSearch={(q) => onCitySearch?.(i, q)}
|
||||
suggestions={citySuggestions?.index === i ? citySuggestions.items : undefined}
|
||||
onSelectSuggestion={(v) => onCitySelect?.(i, v)}
|
||||
/>
|
||||
<DateRangeField
|
||||
value={item.date || ""}
|
||||
onChange={(v) => update(i, "date", v)}
|
||||
/>
|
||||
</div>
|
||||
<ValidatedLinkField
|
||||
value={item.link || ""}
|
||||
onChange={(v) => update(i, "link", v)}
|
||||
validationKey={`victory-${i}`}
|
||||
onValidate={onLinkValidate}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={add}
|
||||
className="flex items-center gap-2 rounded-lg border border-dashed border-white/10 bg-neutral-800/50 px-4 py-2 text-sm text-neutral-500 hover:text-gold hover:border-gold/30 transition-colors"
|
||||
>
|
||||
<Plus size={14} />
|
||||
Добавить достижение
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Autocomplete Multi-Select ---
|
||||
export function AutocompleteMulti({
|
||||
label,
|
||||
|
||||
@@ -4,10 +4,10 @@ import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import { Save, Loader2, Check, ArrowLeft, Upload, AlertCircle } from "lucide-react";
|
||||
import { InputField, TextareaField, ListField, VictoryListField, VictoryItemListField, AutocompleteMulti } from "../../_components/FormField";
|
||||
import { InputField, TextareaField, VictoryListField, AutocompleteMulti } from "../../_components/FormField";
|
||||
import { useToast } from "../../_components/Toast";
|
||||
import { adminFetch } from "@/lib/csrf";
|
||||
import type { RichListItem, VictoryItem } from "@/types/content";
|
||||
import type { RichListItem } from "@/types/content";
|
||||
|
||||
function extractUsername(value: string): string {
|
||||
if (!value) return "";
|
||||
@@ -23,8 +23,7 @@ interface MemberForm {
|
||||
instagram: string;
|
||||
shortDescription: string;
|
||||
description: string;
|
||||
experience: string[];
|
||||
victories: VictoryItem[];
|
||||
victories: RichListItem[];
|
||||
education: RichListItem[];
|
||||
}
|
||||
|
||||
@@ -46,7 +45,6 @@ function TeamMemberEditor() {
|
||||
instagram: "",
|
||||
shortDescription: "",
|
||||
description: "",
|
||||
experience: [],
|
||||
victories: [],
|
||||
education: [],
|
||||
});
|
||||
@@ -78,43 +76,6 @@ function TeamMemberEditor() {
|
||||
// Link validation for bio
|
||||
const [linkErrors, setLinkErrors] = useState<Record<string, string>>({});
|
||||
|
||||
function validateUrl(url: string): boolean {
|
||||
if (!url) return true;
|
||||
try { new URL(url); return true; } catch { return false; }
|
||||
}
|
||||
|
||||
// City validation for victories
|
||||
const [cityErrors, setCityErrors] = useState<Record<number, string>>({});
|
||||
const [citySuggestions, setCitySuggestions] = useState<{ index: number; items: string[] } | null>(null);
|
||||
const cityTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const searchCity = useCallback((index: number, query: string) => {
|
||||
if (cityTimerRef.current) clearTimeout(cityTimerRef.current);
|
||||
if (!query || query.length < 2) { setCitySuggestions(null); return; }
|
||||
cityTimerRef.current = setTimeout(async () => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(query)}&format=json&addressdetails=1&limit=5&accept-language=ru`,
|
||||
{ headers: { "User-Agent": "BlackheartAdmin/1.0" } }
|
||||
);
|
||||
const results = await res.json();
|
||||
const cities = results
|
||||
.map((r: Record<string, unknown>) => {
|
||||
const addr = r.address as Record<string, string> | undefined;
|
||||
const city = addr?.city || addr?.town || addr?.village || addr?.state || (r.name as string);
|
||||
const country = addr?.country || "";
|
||||
return country ? `${city}, ${country}` : city;
|
||||
})
|
||||
.filter((v: string, i: number, a: string[]) => a.indexOf(v) === i)
|
||||
.slice(0, 6);
|
||||
setCitySuggestions(cities.length > 0 ? { index, items: cities } : null);
|
||||
setCityErrors((prev) => { const n = { ...prev }; delete n[index]; return n; });
|
||||
} catch {
|
||||
setCitySuggestions(null);
|
||||
}
|
||||
}, 500);
|
||||
}, []);
|
||||
|
||||
// Fetch class styles for role autocomplete
|
||||
useEffect(() => {
|
||||
adminFetch("/api/admin/sections/classes")
|
||||
@@ -131,50 +92,75 @@ function TeamMemberEditor() {
|
||||
.then((r) => r.json())
|
||||
.then((member) => {
|
||||
const username = extractUsername(member.instagram || "");
|
||||
setData({
|
||||
const loaded = {
|
||||
name: member.name,
|
||||
role: member.role,
|
||||
image: member.image,
|
||||
instagram: username,
|
||||
shortDescription: member.shortDescription || "",
|
||||
description: member.description || "",
|
||||
experience: member.experience || [],
|
||||
victories: member.victories || [],
|
||||
education: member.education || [],
|
||||
});
|
||||
};
|
||||
setData(loaded);
|
||||
lastSavedRef.current = JSON.stringify(loaded);
|
||||
if (username) setIgStatus("valid"); // existing data is trusted
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [id, isNew]);
|
||||
|
||||
const hasErrors = igStatus === "invalid" || Object.keys(linkErrors).length > 0 || Object.keys(cityErrors).length > 0;
|
||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const initialLoadRef = useRef(true);
|
||||
const hasErrors = igStatus === "invalid" || Object.keys(linkErrors).length > 0;
|
||||
const lastSavedRef = useRef("");
|
||||
const dataRef = useRef(data);
|
||||
dataRef.current = data;
|
||||
|
||||
// Auto-save with 800ms debounce (existing members only)
|
||||
// Shared save logic — compares snapshot, skips if unchanged
|
||||
const saveIfDirty = useCallback(async () => {
|
||||
if (isNew || loading) return;
|
||||
const d = dataRef.current;
|
||||
const snapshot = JSON.stringify(d);
|
||||
if (snapshot === lastSavedRef.current) return;
|
||||
if (!d.name || !d.role) return;
|
||||
lastSavedRef.current = snapshot;
|
||||
const payload = { ...d, instagram: d.instagram ? `https://instagram.com/${d.instagram}` : "" };
|
||||
const res = await adminFetch(`/api/admin/team/${id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (res.ok) showSuccess("Сохранено");
|
||||
else showError("Ошибка сохранения");
|
||||
}, [isNew, loading, id, showSuccess, showError]);
|
||||
|
||||
// Save when tab loses focus or user navigates away
|
||||
useEffect(() => {
|
||||
if (isNew || loading || initialLoadRef.current) {
|
||||
initialLoadRef.current = false;
|
||||
return;
|
||||
if (isNew || loading) return;
|
||||
const onVisibilityChange = () => { if (document.hidden) saveIfDirty(); };
|
||||
const onBeforeUnload = () => {
|
||||
const d = dataRef.current;
|
||||
const snapshot = JSON.stringify(d);
|
||||
if (snapshot === lastSavedRef.current || !d.name || !d.role) return;
|
||||
const payload = { ...d, instagram: d.instagram ? `https://instagram.com/${d.instagram}` : "" };
|
||||
navigator.sendBeacon(`/api/admin/team/${id}`, JSON.stringify(payload));
|
||||
};
|
||||
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||
window.addEventListener("beforeunload", onBeforeUnload);
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||
window.removeEventListener("beforeunload", onBeforeUnload);
|
||||
};
|
||||
}, [isNew, loading, id, saveIfDirty]);
|
||||
|
||||
// Save on blur (when user leaves any field)
|
||||
useEffect(() => {
|
||||
if (isNew || loading) return;
|
||||
function handleBlur() {
|
||||
setTimeout(() => saveIfDirty(), 300);
|
||||
}
|
||||
if (hasErrors || !data.name || !data.role) return;
|
||||
document.addEventListener("focusout", handleBlur);
|
||||
return () => document.removeEventListener("focusout", handleBlur);
|
||||
}, [isNew, loading, saveIfDirty]);
|
||||
|
||||
clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = setTimeout(async () => {
|
||||
setSaving(true);
|
||||
const payload = { ...data, instagram: data.instagram ? `https://instagram.com/${data.instagram}` : "" };
|
||||
const res = await adminFetch(`/api/admin/team/${id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (res.ok) showSuccess("Сохранено");
|
||||
else showError("Ошибка сохранения");
|
||||
setSaving(false);
|
||||
}, 800);
|
||||
|
||||
return () => clearTimeout(saveTimerRef.current);
|
||||
}, [data, isNew, loading, hasErrors, id]);
|
||||
|
||||
// Manual save for new members
|
||||
async function handleSaveNew() {
|
||||
@@ -207,6 +193,7 @@ function TeamMemberEditor() {
|
||||
const result = await res.json();
|
||||
if (result.path) {
|
||||
setData((prev) => ({ ...prev, image: result.path }));
|
||||
setTimeout(saveIfDirty, 100);
|
||||
}
|
||||
} catch {
|
||||
// Upload failed silently
|
||||
@@ -365,31 +352,18 @@ function TeamMemberEditor() {
|
||||
<div className="border-t border-white/5 pt-4 mt-4">
|
||||
<p className="text-sm font-medium text-neutral-300 mb-4">Биография</p>
|
||||
<div className="space-y-4">
|
||||
<ListField
|
||||
label="Опыт"
|
||||
items={data.experience}
|
||||
onChange={(items) => setData({ ...data, experience: items })}
|
||||
placeholder="Например: 10 лет в танцах"
|
||||
/>
|
||||
<VictoryItemListField
|
||||
<VictoryListField
|
||||
label="Достижения"
|
||||
items={data.victories}
|
||||
onChange={(items) => setData({ ...data, victories: items })}
|
||||
cityErrors={cityErrors}
|
||||
citySuggestions={citySuggestions}
|
||||
onCitySearch={searchCity}
|
||||
onCitySelect={(i, v) => {
|
||||
const updated = data.victories.map((item, idx) => idx === i ? { ...item, location: v } : item);
|
||||
setData({ ...data, victories: updated });
|
||||
setCitySuggestions(null);
|
||||
setCityErrors((prev) => { const n = { ...prev }; delete n[i]; return n; });
|
||||
}}
|
||||
placeholder="Например: 1 место, Чемпионат Беларуси 2024"
|
||||
onLinkValidate={(key, error) => {
|
||||
setLinkErrors((prev) => {
|
||||
if (error) return { ...prev, [key]: error };
|
||||
const n = { ...prev }; delete n[key]; return n;
|
||||
});
|
||||
}}
|
||||
onUploadComplete={() => setTimeout(saveIfDirty, 100)}
|
||||
/>
|
||||
<VictoryListField
|
||||
label="Образование"
|
||||
@@ -402,6 +376,7 @@ function TeamMemberEditor() {
|
||||
const n = { ...prev }; delete n[key]; return n;
|
||||
});
|
||||
}}
|
||||
onUploadComplete={() => setTimeout(saveIfDirty, 100)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user