diff --git a/css/planner.css b/css/planner.css index c517d57..3a12bba 100644 --- a/css/planner.css +++ b/css/planner.css @@ -137,27 +137,59 @@ color: var(--t3); } -/* ── Job Slots Placeholder ───────────────────────────────────────────────────── */ -.job-slots-placeholder { - padding: 20px; - text-align: center; - color: var(--t3); - font-size: 13px; - background: var(--bg2); - border: 1px dashed var(--border); - border-radius: var(--r); +/* ── Job Slots ────────────────────────────────────────────────────────────────── */ +.job-slots-grid { + display: flex; + gap: 8px; } +.job-slot { + flex: 1; + min-width: 0; +} + +.job-slot select { + width: 100%; + font-size: 13px; + padding: 5px 6px; + border-left: 2px solid var(--border); +} + +.job-slot--tank select { border-left-color: var(--blue); } +.job-slot--healer select { border-left-color: var(--green); } +.job-slot--dps select { border-left-color: rgba(200,168,75,.5); } + /* ── Mechanic Cards ──────────────────────────────────────────────────────────── */ .mechanic-card { display: grid; - grid-template-columns: 52px 1fr; + grid-template-columns: 52px 1fr auto; gap: 14px; - padding: 12px 0; + padding: 12px 6px; border-bottom: 1px solid var(--border); align-items: start; + cursor: pointer; + transition: background 0.12s; + border-radius: var(--r); + margin: 0 -6px; } .mechanic-card:last-child { border-bottom: none; } +.mechanic-card:hover { background: var(--bg2); } + +.mechanic-delete-btn { + opacity: 0; + transition: opacity 0.12s; + margin-top: 2px; +} +.mechanic-card:hover .mechanic-delete-btn { opacity: 1; } + +.mechanic-edit-hint { + font-size: 11px; + color: var(--t3); + margin-top: 2px; + opacity: 0; + transition: opacity 0.12s; +} +.mechanic-card:hover .mechanic-edit-hint { opacity: 1; } .mechanic-time { font-family: var(--font-d); @@ -204,17 +236,47 @@ } .badge-assign { + display: inline-flex; + align-items: center; + gap: 5px; background: var(--bg2); border: 1px solid var(--borderem); border-radius: var(--r); - padding: 2px 8px; - font-size: 12px; + padding: 4px 10px; + font-size: 13px; color: var(--t2); } .badge-assign-buff { background: rgba(200,168,75,.08); border-color: rgba(200,168,75,.4); color: var(--gold); } .badge-assign-debuff { background: rgba(224,92,92,.08); border-color: rgba(224,92,92,.4); color: var(--red); } .badge-assign-shield { background: rgba(74,158,255,.08); border-color: rgba(74,158,255,.4); color: var(--blue); } +.badge-assign--missing-job { + border-style: dashed; + opacity: 0.55; +} + +.badge-icon { + width: 20px; + height: 20px; + object-fit: contain; + flex-shrink: 0; +} + +.badge-remove { + background: none; + border: none; + color: inherit; + padding: 0; + margin-left: 2px; + cursor: pointer; + font-size: 13px; + line-height: 1; + opacity: 0; + transition: opacity 0.12s; +} +.badge-assign:hover .badge-remove { opacity: 0.6; } +.badge-remove:hover { opacity: 1 !important; } + .mechanic-notes { font-size: 12px; color: var(--t3); @@ -309,3 +371,48 @@ gap: 8px; margin-top: 6px; } + +/* ── Ability Assignment Modal ────────────────────────────────────────────────── */ +.ability-modal-box { + max-width: 640px; + max-height: 80vh; + overflow-y: auto; +} + +.ability-job-group { + margin-bottom: 14px; +} + +.ability-job-label { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 6px; +} + +.ability-chips { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.ability-chip { + display: inline-flex; + align-items: center; + gap: 5px; + background: none; + border: 1px solid var(--borderem); + border-radius: var(--r); + padding: 4px 10px; + font-size: 12px; + color: var(--t2); + cursor: pointer; + transition: all 0.12s; +} +.ability-chip:hover { background: var(--bg3); color: var(--t1); } + +.ability-chip.badge-assign-buff.ability-chip--active { background: rgba(200,168,75,.18); border-color: rgba(200,168,75,.6); color: var(--gold); } +.ability-chip.badge-assign-debuff.ability-chip--active { background: rgba(224,92,92,.18); border-color: rgba(224,92,92,.6); color: var(--red); } +.ability-chip.badge-assign-shield.ability-chip--active { background: rgba(74,158,255,.18); border-color: rgba(74,158,255,.6); color: var(--blue); } + +.ability-chip--other-job { opacity: 0.45; } diff --git a/js/planner.js b/js/planner.js index 10ebc4f..d696594 100644 --- a/js/planner.js +++ b/js/planner.js @@ -199,8 +199,8 @@ function renderPlanDetail(plan) {
Jobaufstellung
-
- Jobaufstellung wird in einem späteren Schritt konfigurierbar +
+ ${renderJobSlotsHtml(plan)}
@@ -208,16 +208,20 @@ function renderPlanDetail(plan) {
Mechaniken
- ${renderMechanicList(plan)} +
+ ${renderMechanicListHtml(plan)} +
`; document.getElementById('plan-name-edit-btn')?.addEventListener('click', () => { startRename(plan.id, plan.name); }); + initJobSlots(plan.id); + initMechanicClicks(plan.id); } -function renderMechanicList(plan) { +function renderMechanicListHtml(plan) { if (plan.mechanics.length === 0) { return `
@@ -230,34 +234,136 @@ function renderMechanicList(plan) { `; } - return plan.mechanics.map(m => ` -
-
${escHtml(fmtTimestamp(m.timestamp))}
-
- ${m.phase ? `
${escHtml(m.phase)}
` : ''} -
${escHtml(m.name)}
- ${m.unmitigatedDamage - ? `
${fmtNumber(m.unmitigatedDamage)} unmitigiert
` - : '' - } -
- ${m.assignments.length === 0 - ? 'Keine Zuweisung' - : m.assignments.map(a => { - const cls = a.buffType === 'debuff' ? 'badge-assign-debuff' - : a.buffType === 'shield' ? 'badge-assign-shield' - : a.buffType === 'buff' ? 'badge-assign-buff' - : ''; - const ability = localizedAbilityName(a.ability, a.abilityName ?? a.ability); - const label = a.job ? `${escHtml(a.job)} · ${escHtml(ability)}` : escHtml(ability); - return `${label}`; - }).join('') + const activeJobSet = new Set(plan.jobComposition.filter(j => j)); + + return plan.mechanics.map(m => { + const sorted = sortedAssignments(m.assignments); + const assignHtml = sorted.length === 0 + ? 'Keine Zuweisung' + : sorted.map(a => { + const cls = a.buffType === 'debuff' ? 'badge-assign-debuff' + : a.buffType === 'shield' ? 'badge-assign-shield' + : 'badge-assign-buff'; + const isMissing = !!a.job && !activeJobSet.has(a.job); + const icon = MITIG_ICONS[a.ability] ?? ''; + const ability = localizedAbilityName(a.ability, a.abilityName ?? a.ability); + const label = a.job ? `${escHtml(a.job)} · ${escHtml(ability)}` : escHtml(ability); + const title = isMissing ? `${escHtml(a.job)} nicht in Jobaufstellung` : ''; + return ` + ${icon ? `` : ''} + ${label} + + `; + }).join(''); + + return ` +
+
${escHtml(fmtTimestamp(m.timestamp))}
+
+ ${m.phase ? `
${escHtml(m.phase)}
` : ''} +
${escHtml(m.name)}
+ ${m.unmitigatedDamage + ? `
${fmtNumber(m.unmitigatedDamage)} unmitigiert
` + : '' } +
${assignHtml}
+ ${m.notes ? `
${escHtml(m.notes)}
` : ''} +
Klicken zum Bearbeiten
- ${m.notes ? `
${escHtml(m.notes)}
` : ''} -
-
- `).join(''); + +
`; + }).join(''); +} + +// ── Job Slots ───────────────────────────────────────────────────────────────── + +function renderJobSlotsHtml(plan) { + return Array.from({ length: 8 }, (_, i) => { + const job = plan.jobComposition[i] ?? ''; + const role = JOB_ROLE[job] ?? ''; + return ` +
+ +
`; + }).join(''); +} + +function initJobSlots(planId) { + const grid = document.getElementById('job-slots-grid'); + if (!grid) return; + grid.addEventListener('change', e => { + const sel = e.target.closest('.job-slot-select'); + if (!sel) return; + const plan = getPlan(planId); + if (!plan) return; + const comp = [...plan.jobComposition]; + comp[parseInt(sel.dataset.idx, 10)] = sel.value; + updatePlan(planId, { jobComposition: comp }); + const slot = sel.closest('.job-slot'); + if (slot) { + const role = JOB_ROLE[sel.value] ?? ''; + slot.className = 'job-slot' + (role ? ` job-slot--${role}` : ''); + } + refreshMechanicList(planId); + }); +} + +// ── Mechanic list helpers ───────────────────────────────────────────────────── + +function refreshMechanicList(planId) { + const plan = getPlan(planId); + if (!plan) return; + const el = document.getElementById('mechanic-list'); + if (el) el.innerHTML = renderMechanicListHtml(plan); +} + +function removeAssignment(planId, mechanicId, abilityName) { + const plan = getPlan(planId); + if (!plan) return; + const mechanic = plan.mechanics.find(m => m.id === mechanicId); + if (!mechanic) return; + mechanic.assignments = mechanic.assignments.filter(a => a.ability !== abilityName); + updatePlan(planId, { mechanics: plan.mechanics }); + refreshMechanicList(planId); + if (abilityModalMechanicId === mechanicId) renderAbilityModalContent(); +} + +function deleteMechanic(planId, mechanicId) { + const plan = getPlan(planId); + if (!plan) return; + plan.mechanics = plan.mechanics.filter(m => m.id !== mechanicId); + updatePlan(planId, { mechanics: plan.mechanics }); + refreshMechanicList(planId); + renderPlanList(); +} + +function initMechanicClicks(planId) { + const list = document.getElementById('mechanic-list'); + if (!list) return; + list.addEventListener('click', e => { + const removeBtn = e.target.closest('.badge-remove'); + if (removeBtn) { + e.stopPropagation(); + removeAssignment(planId, removeBtn.dataset.mechanicId, removeBtn.dataset.ability); + return; + } + const deleteBtn = e.target.closest('.mechanic-delete-btn'); + if (deleteBtn) { + e.stopPropagation(); + deleteMechanic(planId, deleteBtn.dataset.mechanicId); + return; + } + const card = e.target.closest('.mechanic-card'); + if (!card) return; + showAbilityModal(planId, card.dataset.mechanicId); + }); } // ── Rename ──────────────────────────────────────────────────────────────────── @@ -373,6 +479,152 @@ const TANK_JOBS = new Set(['PLD', 'WAR', 'DRK', 'GNB']); const MELEE_JOBS = new Set(['MNK', 'DRG', 'NIN', 'SAM', 'RPR', 'VPR']); const CASTER_JOBS = new Set(['BLM', 'SMN', 'RDM', 'PCT']); +const JOB_ROLE = { + 'PLD': 'tank', 'WAR': 'tank', 'DRK': 'tank', 'GNB': 'tank', + 'WHM': 'healer', 'SCH': 'healer', 'AST': 'healer', 'SGE': 'healer', + 'MNK': 'dps', 'DRG': 'dps', 'NIN': 'dps', 'SAM': 'dps', + 'RPR': 'dps', 'VPR': 'dps', 'BRD': 'dps', 'MCH': 'dps', + 'DNC': 'dps', 'BLM': 'dps', 'SMN': 'dps', 'RDM': 'dps', 'PCT': 'dps', +}; + +const ALL_JOBS = [ + { group: 'Tank', jobs: ['PLD', 'WAR', 'DRK', 'GNB'] }, + { group: 'Healer', jobs: ['WHM', 'SCH', 'AST', 'SGE'] }, + { group: 'Melee', jobs: ['MNK', 'DRG', 'NIN', 'SAM', 'RPR', 'VPR'] }, + { group: 'Ranged', jobs: ['BRD', 'MCH', 'DNC'] }, + { group: 'Caster', jobs: ['BLM', 'SMN', 'RDM', 'PCT'] }, +]; + +const JOB_ABILITIES = { + 'PLD': [ + { name: 'Passage of Arms', buffType: 'buff' }, + { name: 'Divine Veil', buffType: 'shield' }, + { name: 'Guardian', buffType: 'shield' }, + { name: 'Reprisal', buffType: 'debuff' }, + ], + 'WAR': [ + { name: 'Shake It Off', buffType: 'shield' }, + { name: 'Bloodwhetting', buffType: 'shield' }, + { name: 'Reprisal', buffType: 'debuff' }, + ], + 'DRK': [ + { name: 'Dark Missionary', buffType: 'buff' }, + { name: 'Reprisal', buffType: 'debuff' }, + ], + 'GNB': [ + { name: 'Heart of Light', buffType: 'buff' }, + { name: 'Reprisal', buffType: 'debuff' }, + ], + 'WHM': [ + { name: 'Temperance', buffType: 'buff' }, + { name: 'Divine Benison', buffType: 'shield' }, + { name: 'Divine Caress', buffType: 'shield' }, + ], + 'SCH': [ + { name: 'Sacred Soil', buffType: 'buff' }, + { name: 'Expedient', buffType: 'buff' }, + { name: 'Fey Illumination', buffType: 'buff' }, + { name: 'Galvanize', buffType: 'shield' }, + { name: 'Seraphic Veil', buffType: 'shield' }, + { name: 'Catalyze', buffType: 'shield' }, + { name: 'Addle', buffType: 'debuff' }, + ], + 'AST': [ + { name: 'Collective Unconscious', buffType: 'buff' }, + { name: 'Neutral Sect', buffType: 'shield' }, + { name: 'Intersection', buffType: 'shield' }, + { name: 'the Spire', buffType: 'shield' }, + ], + 'SGE': [ + { name: 'Kerachole', buffType: 'buff' }, + { name: 'Holos', buffType: 'buff' }, + { name: 'Holosakos', buffType: 'shield' }, + { name: 'Panhaima', buffType: 'shield' }, + { name: 'Eukrasian Prognosis', buffType: 'shield' }, + { name: 'Eukrasian Prognosis II', buffType: 'shield' }, + { name: 'Eukrasian Diagnosis', buffType: 'shield' }, + { name: 'Differential Diagnosis', buffType: 'shield' }, + { name: 'Haima', buffType: 'shield' }, + { name: 'Addle', buffType: 'debuff' }, + ], + 'BRD': [{ name: 'Troubadour', buffType: 'buff' }], + 'MCH': [{ name: 'Tactician', buffType: 'buff' }], + 'DNC': [ + { name: 'Shield Samba', buffType: 'buff' }, + { name: 'Improvised Finish', buffType: 'shield' }, + ], + 'MNK': [{ name: 'Feint', buffType: 'debuff' }], + 'DRG': [{ name: 'Feint', buffType: 'debuff' }], + 'NIN': [{ name: 'Feint', buffType: 'debuff' }], + 'SAM': [{ name: 'Feint', buffType: 'debuff' }], + 'RPR': [{ name: 'Feint', buffType: 'debuff' }], + 'VPR': [{ name: 'Feint', buffType: 'debuff' }], + 'BLM': [{ name: 'Addle', buffType: 'debuff' }], + 'SMN': [ + { name: 'Addle', buffType: 'debuff' }, + { name: 'Radiant Aegis', buffType: 'shield' }, + ], + 'RDM': [ + { name: 'Addle', buffType: 'debuff' }, + { name: 'Magick Barrier', buffType: 'buff' }, + ], + 'PCT': [ + { name: 'Addle', buffType: 'debuff' }, + { name: 'Tempera Coat', buffType: 'shield' }, + { name: 'Tempera Grassa', buffType: 'shield' }, + ], +}; + +const MITIG_ICONS = { + 'Passage of Arms': 'assets/icons/mitigation/passage-of-arms.png', + 'Dark Missionary': 'assets/icons/mitigation/dark-missionary.png', + 'Heart of Light': 'assets/icons/mitigation/heart-of-light.png', + 'Temperance': 'assets/icons/mitigation/temperance.png', + 'Sacred Soil': 'assets/icons/mitigation/sacred-soil.png', + 'Expedient': 'assets/icons/mitigation/expedient.png', + 'Fey Illumination': 'assets/icons/mitigation/fey-illumination.png', + 'Collective Unconscious': 'assets/icons/mitigation/collective-unconscious.png', + 'Holos': 'assets/icons/mitigation/holos.png', + 'Kerachole': 'assets/icons/mitigation/kerachole.png', + 'Troubadour': 'assets/icons/mitigation/troubadour.png', + 'Tactician': 'assets/icons/mitigation/tactician.png', + 'Shield Samba': 'assets/icons/mitigation/shield-samba.png', + 'Magick Barrier': 'assets/icons/mitigation/magick-barrier.png', + 'Reprisal': 'assets/icons/mitigation/reprisal.png', + 'Feint': 'assets/icons/mitigation/feint.png', + 'Addle': 'assets/icons/mitigation/addle.png', + 'Divine Veil': 'assets/icons/mitigation/divine-veil.png', + 'Guardian': 'assets/icons/mitigation/guardian.png', + 'Shake It Off': 'assets/icons/mitigation/shake-it-off.png', + 'Bloodwhetting': 'assets/icons/mitigation/bloodwhetting.png', + 'Divine Benison': 'assets/icons/mitigation/divine-benison.png', + 'Divine Caress': 'assets/icons/mitigation/divine-caress.png', + 'Intersection': 'assets/icons/mitigation/intersection.png', + 'Neutral Sect': 'assets/icons/mitigation/neutral-sect.png', + 'the Spire': 'assets/icons/mitigation/the-spire.png', + 'Panhaima': 'assets/icons/mitigation/panhaima.png', + 'Holosakos': 'assets/icons/mitigation/holos.png', + 'Eukrasian Prognosis': 'assets/icons/mitigation/eukrasian-prognosis.png', + 'Eukrasian Prognosis II': 'assets/icons/mitigation/eukrasian-prognosis-ii.png', + 'Eukrasian Diagnosis': 'assets/icons/mitigation/eukrasian-diagnosis.png', + 'Differential Diagnosis': 'assets/icons/mitigation/eukrasian-diagnosis.png', + 'Haima': 'assets/icons/mitigation/haima.png', + 'Galvanize': 'assets/icons/mitigation/galvanize.png', + 'Seraphic Veil': 'assets/icons/mitigation/seraphic-veil.png', + 'Radiant Aegis': 'assets/icons/mitigation/radiant-aegis.png', + 'Tempera Coat': 'assets/icons/mitigation/tempera-coat.png', + 'Tempera Grassa': 'assets/icons/mitigation/tempera-grassa.png', + 'Improvised Finish': 'assets/icons/mitigation/improvised-finish.png', +}; + +const ASSIGN_ORDER = { debuff: 0, buff: 1, shield: 2 }; + +function sortedAssignments(assignments) { + return [...assignments].sort((a, b) => + (ASSIGN_ORDER[a.buffType] ?? 1) - (ASSIGN_ORDER[b.buffType] ?? 1) + ); +} + function guessJob(abilityName, players) { if (ABILITY_JOB_MAP[abilityName]) return ABILITY_JOB_MAP[abilityName]; const jobs = (players ?? []).map(p => JOB_FROM_TYPE[p.type] ?? ''); @@ -448,6 +700,19 @@ function aoeEventsToMechanics(aoeEvents, fightStart, phases, players, withMitiga // ── Merge + Create plan from import ────────────────────────────────────────── +function extractJobComp(players) { + const order = { tank: 0, healer: 1, dps: 2 }; + const sorted = [...(players ?? [])] + .filter(p => JOB_FROM_TYPE[p.type]) + .sort((a, b) => { + const roleCmp = (order[a.role] ?? 2) - (order[b.role] ?? 2); + return roleCmp !== 0 ? roleCmp : a.name.localeCompare(b.name); + }); + const comp = sorted.map(p => JOB_FROM_TYPE[p.type] ?? '').slice(0, 8); + while (comp.length < 8) comp.push(''); + return comp; +} + function doImport(data, withMitigations, whereMode, mergeId, newName) { const { aoeEvents, fightStart, phases, players, fightName, reportCode } = data; const mechanics = aoeEventsToMechanics(aoeEvents, fightStart, phases, players, withMitigations); @@ -456,7 +721,8 @@ function doImport(data, withMitigations, whereMode, mergeId, newName) { const plan = createPlan(newName || fightName || 'Importierter Plan'); return updatePlan(plan.id, { mechanics, - source: { reportCode, fightName }, + source: { reportCode, fightName }, + jobComposition: extractJobComp(players), }); } @@ -476,6 +742,117 @@ function doImport(data, withMitigations, whereMode, mergeId, newName) { return updatePlan(mergeId, { mechanics: merged }); } +// ── Ability Assignment Modal ────────────────────────────────────────────────── + +let abilityModalPlanId = null; +let abilityModalMechanicId = null; + +function showAbilityModal(planId, mechanicId) { + abilityModalPlanId = planId; + abilityModalMechanicId = mechanicId; + renderAbilityModalContent(); + document.getElementById('planner-ability-modal').style.display = 'flex'; +} + +function hideAbilityModal() { + document.getElementById('planner-ability-modal').style.display = 'none'; + abilityModalPlanId = null; + abilityModalMechanicId = null; +} + +function renderAbilityModalContent() { + const plan = getPlan(abilityModalPlanId); + if (!plan) return; + const mechanic = plan.mechanics.find(m => m.id === abilityModalMechanicId); + if (!mechanic) return; + + document.getElementById('ability-modal-title').textContent = mechanic.name; + + const activeJobs = [...new Set(plan.jobComposition.filter(j => j))]; + const content = document.getElementById('ability-modal-content'); + + if (activeJobs.length === 0) { + content.innerHTML = ` +

+ Bitte zuerst die Jobaufstellung konfigurieren. +

`; + return; + } + + content.innerHTML = activeJobs.map(job => { + const abilities = JOB_ABILITIES[job] ?? []; + if (!abilities.length) return ''; + const role = JOB_ROLE[job] ?? 'dps'; + + const chips = abilities.map(ab => { + const assigned = mechanic.assignments.find(a => a.ability === ab.name); + const isActive = !!assigned; + const byOtherJob = isActive && assigned.job !== job; + const cls = ab.buffType === 'debuff' ? 'badge-assign-debuff' + : ab.buffType === 'shield' ? 'badge-assign-shield' + : 'badge-assign-buff'; + const activeClass = isActive ? ' ability-chip--active' : ''; + const otherClass = byOtherJob ? ' ability-chip--other-job' : ''; + const title = byOtherJob ? `Bereits von ${escHtml(assigned.job)} zugewiesen` : ''; + const icon = MITIG_ICONS[ab.name] ?? ''; + const label = localizedAbilityName(ab.name, ab.name); + return ``; + }).join(''); + + return ` +
+
+ ${escHtml(job)} +
+
${chips}
+
`; + }).join(''); +} + +function toggleAbilityAssignment(abilityName, job, buffType) { + const plan = getPlan(abilityModalPlanId); + if (!plan) return; + const mechanic = plan.mechanics.find(m => m.id === abilityModalMechanicId); + if (!mechanic) return; + + const idx = mechanic.assignments.findIndex(a => a.ability === abilityName); + if (idx !== -1) { + if (mechanic.assignments[idx].job === job) { + mechanic.assignments.splice(idx, 1); + } else { + mechanic.assignments[idx].job = job; + } + } else { + mechanic.assignments.push({ ability: abilityName, abilityName: localizedAbilityName(abilityName, abilityName), job, buffType }); + } + + updatePlan(abilityModalPlanId, { mechanics: plan.mechanics }); + refreshMechanicList(abilityModalPlanId); + renderAbilityModalContent(); +} + +function initAbilityModal() { + const overlay = document.getElementById('planner-ability-modal'); + if (!overlay) return; + + document.getElementById('ability-modal-close')?.addEventListener('click', hideAbilityModal); + overlay.addEventListener('click', e => { if (e.target === overlay) hideAbilityModal(); }); + document.addEventListener('keydown', e => { + if (e.key === 'Escape' && abilityModalPlanId) hideAbilityModal(); + }); + + document.getElementById('ability-modal-content')?.addEventListener('click', e => { + const btn = e.target.closest('.ability-chip'); + if (!btn) return; + toggleAbilityAssignment(btn.dataset.ability, btn.dataset.job, btn.dataset.buffType); + }); +} + // ── Import Modal ────────────────────────────────────────────────────────────── let pendingImportData = null; @@ -591,6 +968,7 @@ window.plannerTab = { document.addEventListener('DOMContentLoaded', () => { initNewPlanForm(); initImportModal(); + initAbilityModal(); renderPlanList(); renderPlanDetail(null); }); diff --git a/templates/page.php b/templates/page.php index 1f2eadb..17e3bc9 100644 --- a/templates/page.php +++ b/templates/page.php @@ -80,6 +80,17 @@
+ + +