'use client'; import { FormEvent, useMemo, useState } from 'react'; import { useRouter } from 'next/navigation'; import { apiFetch } from '../../lib/api'; const DIFFICULTIES = [ { value: 'easy', label: 'Лёгкая' }, { value: 'medium', label: 'Средняя' }, { value: 'hard', label: 'Сложная' } ] as const; type Difficulty = (typeof DIFFICULTIES)[number]['value']; type MissionBase = { id: number; title: string; description: string; xp_reward: number; mana_reward: number; difficulty: Difficulty; format: 'online' | 'offline'; event_location?: string | null; event_address?: string | null; event_starts_at?: string | null; event_ends_at?: string | null; registration_deadline?: string | null; registration_url?: string | null; registration_notes?: string | null; capacity?: number | null; contact_person?: string | null; contact_phone?: string | null; is_active: boolean; }; interface MissionDetail extends MissionBase { minimum_rank_id: number | null; artifact_id: number | null; prerequisites: number[]; competency_rewards: Array<{ competency_id: number; competency_name: string; level_delta: number }>; } type Branch = { id: number; title: string; description: string; category: string; missions: Array<{ mission_id: number; mission_title: string; order: number }>; }; type Rank = { id: number; title: string; description: string; required_xp: number; }; type Competency = { id: number; name: string; description: string; category: string; }; type Artifact = { id: number; name: string; description: string; rarity: string; }; interface Props { token: string; missions: MissionBase[]; branches: Branch[]; ranks: Rank[]; competencies: Competency[]; artifacts: Artifact[]; } type RewardInput = { competency_id: number | ''; level_delta: number }; type FormState = { title: string; description: string; xp_reward: number; mana_reward: number; difficulty: Difficulty; format: 'online' | 'offline'; event_location: string; event_address: string; event_starts_at: string; event_ends_at: string; registration_deadline: string; registration_url: string; registration_notes: string; capacity: number | ''; contact_person: string; contact_phone: string; minimum_rank_id: number | ''; artifact_id: number | ''; branch_id: number | ''; branch_order: number; prerequisite_ids: number[]; competency_rewards: RewardInput[]; is_active: boolean; }; const initialFormState: FormState = { title: '', description: '', xp_reward: 0, mana_reward: 0, difficulty: 'medium', format: 'online', event_location: '', event_address: '', event_starts_at: '', event_ends_at: '', registration_deadline: '', registration_url: '', registration_notes: '', capacity: '', contact_person: '', contact_phone: '', minimum_rank_id: '', artifact_id: '', branch_id: '', branch_order: 1, prerequisite_ids: [], competency_rewards: [], is_active: true }; export function AdminMissionManager({ token, missions, branches, ranks, competencies, artifacts }: Props) { const router = useRouter(); const [selectedId, setSelectedId] = useState('new'); const [form, setForm] = useState(initialFormState); const [status, setStatus] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); const toInputDateTime = (value?: string | null) => { if (!value) return ''; const date = new Date(value); const offset = date.getTimezoneOffset(); const local = new Date(date.getTime() - offset * 60000); return local.toISOString().slice(0, 16); }; const fromInputDateTime = (value: string) => { if (!value) return null; const date = new Date(value); return date.toISOString(); }; const sanitizeString = (value: string) => { const trimmed = value.trim(); return trimmed === '' ? null : trimmed; }; // Позволяет мгновенно подставлять базовые поля при переключении миссии, // пока загрузка детальной карточки не завершилась. const missionById = useMemo(() => new Map(missions.map((mission) => [mission.id, mission])), [missions]); const resetForm = () => { setForm(initialFormState); }; const loadMission = async (missionId: number) => { try { setLoading(true); const mission = await apiFetch(`/api/admin/missions/${missionId}`, { authToken: token }); setForm({ title: mission.title, description: mission.description, xp_reward: mission.xp_reward, mana_reward: mission.mana_reward, difficulty: mission.difficulty, format: mission.format, event_location: mission.event_location ?? '', event_address: mission.event_address ?? '', event_starts_at: toInputDateTime(mission.event_starts_at), event_ends_at: toInputDateTime(mission.event_ends_at), registration_deadline: toInputDateTime(mission.registration_deadline), registration_url: mission.registration_url ?? '', registration_notes: mission.registration_notes ?? '', capacity: mission.capacity ?? '', contact_person: mission.contact_person ?? '', contact_phone: mission.contact_phone ?? '', minimum_rank_id: mission.minimum_rank_id ?? '', artifact_id: mission.artifact_id ?? '', branch_id: (() => { const branchLink = branches .flatMap((branch) => branch.missions.map((item) => ({ branch, item }))) .find(({ item }) => item.mission_id === mission.id); return branchLink?.branch.id ?? ''; })(), branch_order: (() => { const branchLink = branches .flatMap((branch) => branch.missions.map((item) => ({ branch, item }))) .find(({ item }) => item.mission_id === mission.id); return branchLink?.item.order ?? 1; })(), prerequisite_ids: mission.prerequisites, competency_rewards: mission.competency_rewards.map((reward) => ({ competency_id: reward.competency_id, level_delta: reward.level_delta })), is_active: mission.is_active }); } catch (err) { setError(err instanceof Error ? err.message : 'Не удалось загрузить миссию'); } finally { setLoading(false); } }; const handleSelect = (value: string) => { setStatus(null); setError(null); if (value === 'new') { setSelectedId('new'); resetForm(); return; } const id = Number(value); const baseMission = missionById.get(id); if (baseMission) { setForm((prev) => ({ ...prev, title: baseMission.title, description: baseMission.description, xp_reward: baseMission.xp_reward, mana_reward: baseMission.mana_reward, difficulty: baseMission.difficulty, format: baseMission.format, event_location: baseMission.event_location ?? '', event_address: baseMission.event_address ?? '', event_starts_at: toInputDateTime(baseMission.event_starts_at), event_ends_at: toInputDateTime(baseMission.event_ends_at), registration_deadline: toInputDateTime(baseMission.registration_deadline), registration_url: baseMission.registration_url ?? '', registration_notes: baseMission.registration_notes ?? '', capacity: baseMission.capacity ?? '', contact_person: baseMission.contact_person ?? '', contact_phone: baseMission.contact_phone ?? '', is_active: baseMission.is_active })); } setSelectedId(id); void loadMission(id); }; const updateField = (field: K, value: FormState[K]) => { setForm((prev) => ({ ...prev, [field]: value })); }; const handlePrerequisitesChange = (event: FormEvent) => { const options = Array.from(event.currentTarget.selectedOptions); updateField( 'prerequisite_ids', options.map((option) => Number(option.value)) ); }; const addReward = () => { updateField('competency_rewards', [...form.competency_rewards, { competency_id: '', level_delta: 1 }]); }; const updateReward = (index: number, value: RewardInput) => { const next = [...form.competency_rewards]; next[index] = value; updateField('competency_rewards', next); }; const removeReward = (index: number) => { const next = [...form.competency_rewards]; next.splice(index, 1); updateField('competency_rewards', next); }; const handleSubmit = async (event: FormEvent) => { event.preventDefault(); setStatus(null); setError(null); setLoading(true); const payloadBase = { title: form.title, description: form.description, xp_reward: Number(form.xp_reward), mana_reward: Number(form.mana_reward), difficulty: form.difficulty, format: form.format, event_location: sanitizeString(form.event_location), event_address: sanitizeString(form.event_address), event_starts_at: fromInputDateTime(form.event_starts_at), event_ends_at: fromInputDateTime(form.event_ends_at), registration_deadline: fromInputDateTime(form.registration_deadline), registration_url: sanitizeString(form.registration_url), registration_notes: sanitizeString(form.registration_notes), capacity: form.capacity === '' ? null : Number(form.capacity), contact_person: sanitizeString(form.contact_person), contact_phone: sanitizeString(form.contact_phone), minimum_rank_id: form.minimum_rank_id === '' ? null : Number(form.minimum_rank_id), artifact_id: form.artifact_id === '' ? null : Number(form.artifact_id), prerequisite_ids: form.prerequisite_ids, competency_rewards: form.competency_rewards .filter((reward) => reward.competency_id !== '') .map((reward) => ({ competency_id: Number(reward.competency_id), level_delta: Number(reward.level_delta) })), branch_id: form.branch_id === '' ? null : Number(form.branch_id), branch_order: Number(form.branch_order) || 1, is_active: form.is_active }; try { if (selectedId === 'new') { const { is_active, ...createPayload } = payloadBase; await apiFetch('/api/admin/missions', { method: 'POST', body: JSON.stringify(createPayload), authToken: token }); setStatus('Миссия создана'); resetForm(); setSelectedId('new'); } else { await apiFetch(`/api/admin/missions/${selectedId}`, { method: 'PUT', body: JSON.stringify(payloadBase), authToken: token }); setStatus('Миссия обновлена'); } router.refresh(); } catch (err) { setError(err instanceof Error ? err.message : 'Не удалось сохранить миссию'); } finally { setLoading(false); } }; return (

Миссии

Создавайте и обновляйте миссии: настраивайте награды, зависимости, ветки и компетенции. Все изменения мгновенно отражаются в списках пилотов.