alabuga/frontend/src/components/admin/AdminMissionManager.tsx
2025-09-30 20:50:52 -06:00

570 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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<number | 'new'>('new');
const [form, setForm] = useState<FormState>(initialFormState);
const [status, setStatus] = useState<string | null>(null);
const [error, setError] = useState<string | null>(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<MissionDetail>(`/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 = <K extends keyof FormState>(field: K, value: FormState[K]) => {
setForm((prev) => ({ ...prev, [field]: value }));
};
const handlePrerequisitesChange = (event: FormEvent<HTMLSelectElement>) => {
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<HTMLFormElement>) => {
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 (
<div className="card">
<h3>Миссии</h3>
<p style={{ color: 'var(--text-muted)' }}>
Создавайте и обновляйте миссии: настраивайте награды, зависимости, ветки и компетенции. Все изменения мгновенно
отражаются в списках пилотов.
</p>
<form onSubmit={handleSubmit} className="admin-form">
<label>
Выбранная миссия
<select value={selectedId === 'new' ? 'new' : String(selectedId)} onChange={(event) => handleSelect(event.target.value)}>
<option value="new">Новая миссия</option>
{missions.map((mission) => (
<option key={mission.id} value={mission.id}>
{mission.title}
</option>
))}
</select>
</label>
<label>
Название
<input value={form.title} onChange={(event) => updateField('title', event.target.value)} required />
</label>
<label>
Описание
<textarea value={form.description} onChange={(event) => updateField('description', event.target.value)} required rows={4} />
</label>
<div className="grid" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: '1rem' }}>
<label>
Награда (XP)
<input type="number" min={0} value={form.xp_reward} onChange={(event) => updateField('xp_reward', Number(event.target.value))} required />
</label>
<label>
Награда (мана)
<input type="number" min={0} value={form.mana_reward} onChange={(event) => updateField('mana_reward', Number(event.target.value))} required />
</label>
<label>
Сложность
<select value={form.difficulty} onChange={(event) => updateField('difficulty', event.target.value as Difficulty)}>
{DIFFICULTIES.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label>
Формат
<select value={form.format} onChange={(event) => updateField('format', event.target.value as 'online' | 'offline')}>
<option value="online">Онлайн</option>
<option value="offline">Офлайн встреча</option>
</select>
</label>
<label>
Доступен с ранга
<select value={form.minimum_rank_id === '' ? '' : String(form.minimum_rank_id)} onChange={(event) => updateField('minimum_rank_id', event.target.value === '' ? '' : Number(event.target.value))}>
<option value="">Любой ранг</option>
{ranks.map((rank) => (
<option key={rank.id} value={rank.id}>
{rank.title}
</option>
))}
</select>
</label>
<label>
Артефакт
<select value={form.artifact_id === '' ? '' : String(form.artifact_id)} onChange={(event) => updateField('artifact_id', event.target.value === '' ? '' : Number(event.target.value))}>
<option value="">Без артефакта</option>
{artifacts.map((artifact) => (
<option key={artifact.id} value={artifact.id}>
{artifact.name}
</option>
))}
</select>
</label>
<label className="checkbox">
<input type="checkbox" checked={form.is_active} onChange={(event) => updateField('is_active', event.target.checked)} /> Миссия активна
</label>
</div>
{form.format === 'offline' && (
<fieldset style={{ border: '1px solid rgba(162,155,254,0.3)', borderRadius: '16px', padding: '1rem' }}>
<legend style={{ padding: '0 0.5rem' }}>Офлайн-детали</legend>
<div className="grid" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: '1rem' }}>
<label>
Локация / площадка
<input value={form.event_location} onChange={(event) => updateField('event_location', event.target.value)} placeholder="Например: Кампус Алабуга" />
</label>
<label>
Адрес
<input value={form.event_address} onChange={(event) => updateField('event_address', event.target.value)} placeholder="Город, улица, дом" />
</label>
<label>
Начало
<input type="datetime-local" value={form.event_starts_at} onChange={(event) => updateField('event_starts_at', event.target.value)} />
</label>
<label>
Завершение
<input type="datetime-local" value={form.event_ends_at} onChange={(event) => updateField('event_ends_at', event.target.value)} />
</label>
<label>
Дедлайн регистрации
<input type="datetime-local" value={form.registration_deadline} onChange={(event) => updateField('registration_deadline', event.target.value)} />
</label>
<label>
Вместимость
<input type="number" min={0} value={form.capacity === '' ? '' : form.capacity} onChange={(event) => updateField('capacity', event.target.value === '' ? '' : Number(event.target.value))} placeholder="Например: 40" />
</label>
<label>
Ссылка на мероприятие
<input type="url" value={form.registration_url} onChange={(event) => updateField('registration_url', event.target.value)} placeholder="https://..." />
</label>
<label>
Дополнительные заметки
<textarea value={form.registration_notes} onChange={(event) => updateField('registration_notes', event.target.value)} rows={3} placeholder="Что взять с собой, как пройти и т.д." />
</label>
<label>
Контактное лицо
<input value={form.contact_person} onChange={(event) => updateField('contact_person', event.target.value)} placeholder="Имя HR" />
</label>
<label>
Телефон/чат
<input value={form.contact_phone} onChange={(event) => updateField('contact_phone', event.target.value)} placeholder="+7..." />
</label>
</div>
</fieldset>
)}
<label>
Ветка
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center' }}>
<select value={form.branch_id === '' ? '' : String(form.branch_id)} onChange={(event) => updateField('branch_id', event.target.value === '' ? '' : Number(event.target.value))}>
<option value="">Без ветки</option>
{branches.map((branch) => (
<option key={branch.id} value={branch.id}>
{branch.title}
</option>
))}
</select>
<label style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
Порядок
<input type="number" min={1} value={form.branch_order} onChange={(event) => updateField('branch_order', Number(event.target.value) || 1)} style={{ width: '80px' }} />
</label>
</div>
</label>
<label>
Предварительные миссии
<select multiple value={form.prerequisite_ids.map(String)} onChange={handlePrerequisitesChange} size={Math.min(6, missions.length)}>
{missions
.filter((mission) => selectedId === 'new' || mission.id !== selectedId)
.map((mission) => (
<option key={mission.id} value={mission.id}>
{mission.title}
</option>
))}
</select>
</label>
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span>Прокачка компетенций</span>
<button type="button" onClick={addReward} className="secondary">
Добавить компетенцию
</button>
</div>
{form.competency_rewards.length === 0 && (
<p style={{ color: 'var(--text-muted)', marginTop: '0.5rem' }}>Для миссии пока не назначены компетенции.</p>
)}
{form.competency_rewards.map((reward, index) => (
<div key={index} style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', marginTop: '0.5rem' }}>
<select
value={reward.competency_id === '' ? '' : String(reward.competency_id)}
onChange={(event) =>
updateReward(index, {
competency_id: event.target.value === '' ? '' : Number(event.target.value),
level_delta: reward.level_delta
})
}
>
<option value="">Выберите компетенцию</option>
{competencies.map((competency) => (
<option key={competency.id} value={competency.id}>
{competency.name}
</option>
))}
</select>
<input
type="number"
min={1}
value={reward.level_delta}
onChange={(event) =>
updateReward(index, {
competency_id: reward.competency_id,
level_delta: Number(event.target.value)
})
}
style={{ width: '80px' }}
/>
<button type="button" className="secondary" onClick={() => removeReward(index)}>
Удалить
</button>
</div>
))}
</div>
<button type="submit" className="primary" disabled={loading}>
{selectedId === 'new' ? 'Создать миссию' : 'Сохранить изменения'}
</button>
{status && <p style={{ color: 'var(--success)', marginTop: '0.5rem' }}>{status}</p>}
{error && <p style={{ color: 'var(--error)', marginTop: '0.5rem' }}>{error}</p>}
</form>
</div>
);
}