diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..2351d92 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "gnexus-ui-kit"] + path = gnexus-ui-kit + url = https://git.gnexus.space/git/root/gnexus-ui-kit.git diff --git a/README.md b/README.md index 2d779d6..3e9063e 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,31 @@ mardis_calc =============== +Калькуляция переменных для фаершоу. + +Калькулятор должен представлять из себя веб страничку. + +Для начала нужен список артистов: пусть это будет json с масивом артистов, можно встроить прямо в страничку. + +Есть формула: S=Pz+Tz+(Lz\*Nl)+Rom+Rop+Rot+Rok+(Na\*Z) +Переменные формулы: +- S - общая сумма за шоу +- Pz - Стоимость пиротехники +- Tz - Стоимость титана +- Lz - Стоимость одного литра керосина +- Nl - Количество литров керосина +- Rom - Роялти коллектива как организации +- Rop - Роялти постановщика +- Rot - Роялти техника +- Rok - Роялти коммисионные, за привлечение привлечение клиента +- Na - Количество артистов +- Z - Зарплата артиста + +Роялти в процентах +Rom - 10% +Rop - 5% +Rot - 5% +Rok - 10% + +Необходима система, которая позволит ввести все известные и получить неизвестные. Сумма почти всегда известна, но было бы хорошо его тоже иметь возможность посчитать из прочих переменных. + +Для UI необходимо использовать https://git.gnexus.space/root/gnexus-ui-kit \ No newline at end of file diff --git a/css/styles.css b/css/styles.css new file mode 100644 index 0000000..3025f15 --- /dev/null +++ b/css/styles.css @@ -0,0 +1,48 @@ +.page-header { + padding: var(--space-6) 0; + border-bottom: 2px solid var(--color-accent); + margin-bottom: var(--space-6); +} + +.page-header-title { + font-size: 1.75rem; + font-weight: 600; + margin: 0; +} + +.page-header-subtitle { + color: var(--color-text-muted); + margin: var(--space-1) 0 0; +} + +.text-center { + text-align: center; +} + +.dl-row-highlight { + font-weight: 600; + border-top: 1px solid var(--color-border); + padding-top: var(--space-2); + margin-top: var(--space-2); +} + +#artists-table td, +#artists-table th { + vertical-align: middle; +} + +.checkbox-inline { + display: flex; + align-items: center; + gap: var(--space-2); + flex-wrap: wrap; +} + +.checkbox-inline .checkbox { + margin: 0; +} + +.payout-cell { + font-weight: 600; + white-space: nowrap; +} diff --git a/data/artists.json b/data/artists.json new file mode 100644 index 0000000..a93b140 --- /dev/null +++ b/data/artists.json @@ -0,0 +1,12 @@ +[ + { "name": "Алексей" }, + { "name": "Мария" }, + { "name": "Дмитрий" }, + { "name": "Анна" }, + { "name": "Иван" }, + { "name": "Елена" }, + { "name": "Сергей" }, + { "name": "Ольга" }, + { "name": "Павел" }, + { "name": "Наталья" } +] diff --git a/docs/plan.md b/docs/plan.md new file mode 100644 index 0000000..604978a --- /dev/null +++ b/docs/plan.md @@ -0,0 +1,98 @@ +# Plan: Fire Show Calculator (mardis_calc) + +## Context + +New project: a single-page web calculator for fire show budgeting. The page must calculate one unknown variable from all known ones using the formula `S = Pz + Tz + (Lz * Nl) + Rom + Rop + Rot + Rok + (Na * Z)`. Royalties are fixed percentages calculated from the net amount after material costs (`Base = S - Pz - Tz - Lz*Nl`). The UI must be built with `gnexus-ui-kit` (added as a git submodule). The page is a plain static HTML+JS app (no build system). Artists are stored in a local JSON file (names only). The calculator should show how much each assigned artist receives (salary + any assigned royalties). State must be persisted to `localStorage`, plus export/import as JSON. + +## Key clarifications + +- UI kit: add as git submodule (`https://git.gnexus.space/root/gnexus-ui-kit`). +- Royalties base: `Rom/Rop/Rot/Rok` are percentages of `Base = S - Pz - Tz - Lz*Nl`. +- One unknown at a time: when all fields but one are filled, auto-calculate the missing variable. +- Artists can have multiple roles: e.g. one person can be both an artist (gets `Z`) and a director (gets `Rop`), etc. +- Rounding: to 2 decimal places (cents). +- Persistence: `localStorage` + JSON export/import. +- Artists JSON: array of objects with at least `{ "name": "..." }`. + +## Architecture + +Single static page. No bundler. Use vanilla JS. + +### Files + +1. **`.gitmodules`** (via `git submodule add`) — link `gnexus-ui-kit`. +2. **`index.html`** — main page, loads UI kit assets from submodule path, app CSS/JS. +3. **`css/styles.css`** — minimal custom styles on top of UI kit. +4. **`js/app.js`** — all calculator logic. +5. **`data/artists.json`** — embedded artist list. + +### UI layout + +- **Header**: project title. +- **Inputs section**: numeric inputs for `S`, `Pz`, `Tz`, `Lz`, `Nl`, `Z`. `Na` and royalty fields (`Rom`, `Rop`, `Rot`, `Rok`) are **read-only / computed**. + - `Na` auto-updates from the artist roster table (count of rows with "Artist" role checked). + - Royalties auto-calculate from `Base`. + - When 5 of the 6 editable fields are filled, the 6th is computed on blur/change. +- **Artist roster section**: + - Add-artist dropdown (populated from `data/artists.json`). + - Table of added artists with role checkboxes: Artist, Director (`Rop`), Technician (`Rot`), Commissioner (`Rok`), Organizer (`Rom`). + - Each row shows the individual payout (sum of applicable shares). +- **Actions**: Export JSON, Import JSON, Reset. +- **Summary**: total show cost `S`, total material costs, total artist salaries, total royalties. + +### Calculation logic + +**Definitions:** +- `Materials = Pz + Tz + Lz * Nl` +- `Base = S - Materials` +- `Rom = 0.10 * Base` +- `Rop = 0.05 * Base` +- `Rot = 0.05 * Base` +- `Rok = 0.10 * Base` +- `TotalRoyalties = Rom + Rop + Rot + Rok = 0.30 * Base` +- `ArtistSalaries = Na * Z` + +**Identity:** +`S = Materials + TotalRoyalties + ArtistSalaries` +=> `S = Materials + 0.30 * (S - Materials) + Na * Z` +=> `0.70 * S = 0.70 * Materials + Na * Z` +=> `S = Materials + (Na * Z) / 0.70` + +**Solving for each variable (when all others are known):** +- `S = Materials + (Na * Z) / 0.70` +- `Z = (0.70 * (S - Materials)) / Na` (if `Na > 0`) +- `Na = (0.70 * (S - Materials)) / Z` (displayed as decimal guidance) +- `Pz = S - (Tz + Lz*Nl) - (Na*Z)/0.70` +- `Tz = S - (Pz + Lz*Nl) - (Na*Z)/0.70` +- `Lz = (S - Pz - Tz - (Na*Z)/0.70) / Nl` (if `Nl > 0`) +- `Nl = (S - Pz - Tz - (Na*Z)/0.70) / Lz` (if `Lz > 0`) + +**Validation / guardrails:** +- Negative results are flagged as errors (real-world values must be non-negative). +- Division-by-zero protected. +- If more than one field is empty, show a message like "Заполните все поля кроме одного" instead of computing. + +**Roster payout per person:** +- If role "Artist" checked: add `Z`. +- If role "Organizer" (`Rom`) checked and only one such person: add `Rom`. If multiple organizers, split equally (or assign full to each? To be safe, split equally; UI can note this). +- Same splitting logic for `Rop`, `Rot`, `Rok` if multiple persons share the same role. + +### Persistence + +- On every change, serialize the state (all input values + artist roster table) to `localStorage` key `mardis_calc_state`. +- On page load, restore from `localStorage` if present. +- **Export**: trigger browser download of a `.json` file containing the serialized state. +- **Import**: read a `.json` file via ``, validate structure, replace current state. + +## Verification + +1. Open `index.html` in a browser. +2. Fill in `Pz=100`, `Tz=50`, `Lz=30`, `Nl=2`, `Na=2`, leave `S` empty. `S` should auto-calculate to `100 + 50 + 60 + (2*Z)/0.70` depending on `Z`. +3. Add artists from the dropdown, assign roles, verify individual payouts sum up to `ArtistSalaries + TotalRoyalties`. +4. Refresh the page; values should restore from `localStorage`. +5. Click Export, save JSON, clear fields, click Import with that file, verify restoration. + +## Risks / open questions + +- `gnexus-ui-kit` submodule path and exact CSS/JS files to include are unknown until the submodule is fetched; may need minor path adjustments in `index.html` after adding it. +- Splitting royalties among multiple persons with the same role: plan assumes equal split. User can adjust later if needed. diff --git a/gnexus-ui-kit b/gnexus-ui-kit new file mode 160000 index 0000000..dab0f37 --- /dev/null +++ b/gnexus-ui-kit @@ -0,0 +1 @@ +Subproject commit dab0f37319daee098a8a9c18b06648c5636d08c2 diff --git a/index.html b/index.html new file mode 100644 index 0000000..3c73c2b --- /dev/null +++ b/index.html @@ -0,0 +1,188 @@ + + + + + + Калькуляция фаершоу — Mardis Calc + + + + + + + +
+
+

Параметры шоу

+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ Роялти (автоматический расчёт) +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+

Артисты и роли

+
+ + +
+ +
+ + + + + + + + + + + + + + + + + + + +
Участники шоу
АртистАртист (Z)Организатор (Rom)Постановщик (Rop)Техник (Rot)Комиссионер (Rok)Выплата
Нет добавленных артистов
+
+
+ +
+

Итоговый расчёт

+
+
+
Материальные расходы
+
0.00
+
+
+
Чистая сумма (Base)
+
0.00
+
+
+
Всего роялти
+
0.00
+
+
+
Зарплаты артистов
+
0.00
+
+
+
Общая сумма (S)
+
0.00
+
+
+
+ +
+

Действия

+
+ + + + +
+
+
+ + + + + diff --git a/js/app.js b/js/app.js new file mode 100644 index 0000000..ef81e95 --- /dev/null +++ b/js/app.js @@ -0,0 +1,445 @@ +(function() { + 'use strict'; + + const STORAGE_KEY = 'mardis_calc_state'; + + const inputs = { + s: document.getElementById('input-s'), + pz: document.getElementById('input-pz'), + tz: document.getElementById('input-tz'), + lz: document.getElementById('input-lz'), + nl: document.getElementById('input-nl'), + z: document.getElementById('input-z'), + na: document.getElementById('input-na'), + rom: document.getElementById('input-rom'), + rop: document.getElementById('input-rop'), + rot: document.getElementById('input-rot'), + rok: document.getElementById('input-rok'), + }; + + const messageEl = document.getElementById('calc-message'); + const artistSelect = document.getElementById('artist-select'); + const btnAddArtist = document.getElementById('btn-add-artist'); + const artistsTbody = document.getElementById('artists-tbody'); + const emptyRow = document.getElementById('empty-row'); + + const summary = { + materials: document.getElementById('summary-materials'), + base: document.getElementById('summary-base'), + royalties: document.getElementById('summary-royalties'), + salaries: document.getElementById('summary-salaries'), + s: document.getElementById('summary-s'), + }; + + let artists = []; // [{ name, roles: { artist, organizer, director, technician, commissioner } }] + let allArtistsList = []; + + async function loadArtists() { + try { + const res = await fetch('data/artists.json'); + allArtistsList = await res.json(); + artistSelect.innerHTML = '' + + allArtistsList.map(a => ``).join(''); + } catch (e) { + console.error('Failed to load artists.json', e); + } + } + + function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + function getValue(el) { + const v = parseFloat(el.value); + return isNaN(v) ? null : v; + } + + function setValue(el, value) { + if (value === null || value === undefined) { + el.value = ''; + } else { + el.value = round(value).toFixed(2); + } + } + + function round(n) { + return Math.round(n * 100) / 100; + } + + function countRoles() { + const counts = { artist: 0, organizer: 0, director: 0, technician: 0, commissioner: 0 }; + for (const a of artists) { + if (a.roles.artist) counts.artist++; + if (a.roles.organizer) counts.organizer++; + if (a.roles.director) counts.director++; + if (a.roles.technician) counts.technician++; + if (a.roles.commissioner) counts.commissioner++; + } + return counts; + } + + function calculate() { + messageEl.textContent = ''; + messageEl.className = 'input-info mt-4'; + + const vals = { + s: getValue(inputs.s), + pz: getValue(inputs.pz), + tz: getValue(inputs.tz), + lz: getValue(inputs.lz), + nl: getValue(inputs.nl), + z: getValue(inputs.z), + }; + + const editableKeys = ['s', 'pz', 'tz', 'lz', 'nl', 'z']; + const filled = editableKeys.filter(k => vals[k] !== null); + const missing = editableKeys.filter(k => vals[k] === null); + + // Auto-calculate one missing variable + if (missing.length === 1 && filled.length === 5) { + const missKey = missing[0]; + const materials = (vals.pz || 0) + (vals.tz || 0) + (vals.lz || 0) * (vals.nl || 0); + const counts = countRoles(); + const na = counts.artist; + + if (missKey === 's') { + if (na === 0 && vals.z !== null && vals.z > 0) { + showMessage('Добавьте артистов, чтобы рассчитать сумму', 'warning'); + updateComputed(0, 0, 0, 0, 0, 0); + return; + } + vals.s = materials + (na * (vals.z || 0)) / 0.70; + } else if (missKey === 'z') { + if (na === 0) { + showMessage('Добавьте артистов, чтобы рассчитать зарплату', 'warning'); + updateComputed(vals.s || 0, materials, 0, 0, 0, 0); + return; + } + vals.z = (0.70 * ((vals.s || 0) - materials)) / na; + } else if (missKey === 'pz') { + vals.pz = (vals.s || 0) - (vals.tz || 0) - (vals.lz || 0) * (vals.nl || 0) - (na * (vals.z || 0)) / 0.70; + } else if (missKey === 'tz') { + vals.tz = (vals.s || 0) - (vals.pz || 0) - (vals.lz || 0) * (vals.nl || 0) - (na * (vals.z || 0)) / 0.70; + } else if (missKey === 'lz') { + if ((vals.nl || 0) === 0) { + showMessage('Нельзя рассчитать Lz при Nl = 0', 'error'); + updateComputed(vals.s || 0, materials, 0, 0, 0, 0); + return; + } + vals.lz = ((vals.s || 0) - (vals.pz || 0) - (vals.tz || 0) - (na * (vals.z || 0)) / 0.70) / (vals.nl || 0); + } else if (missKey === 'nl') { + if ((vals.lz || 0) === 0) { + showMessage('Нельзя рассчитать Nl при Lz = 0', 'error'); + updateComputed(vals.s || 0, materials, 0, 0, 0, 0); + return; + } + vals.nl = ((vals.s || 0) - (vals.pz || 0) - (vals.tz || 0) - (na * (vals.z || 0)) / 0.70) / (vals.lz || 0); + } + + // Validate non-negative + if (vals[missKey] < 0) { + showMessage(`Расчёт дал отрицательное значение для ${missKey.toUpperCase()}. Проверьте введённые данные.`, 'error'); + updateComputed(vals.s || 0, materials, 0, 0, 0, 0); + return; + } + + setValue(inputs[missKey], vals[missKey]); + } else if (missing.length > 1) { + showMessage('Заполните все поля кроме одного, чтобы выполнить расчёт', 'info'); + } + + // If all filled, just recalc + const allFilled = editableKeys.every(k => getValue(inputs[k]) !== null); + if (!allFilled && missing.length !== 1) { + updateComputed(0, 0, 0, 0, 0, 0); + return; + } + + const s = getValue(inputs.s) || 0; + const pz = getValue(inputs.pz) || 0; + const tz = getValue(inputs.tz) || 0; + const lz = getValue(inputs.lz) || 0; + const nl = getValue(inputs.nl) || 0; + const z = getValue(inputs.z) || 0; + + const materials = pz + tz + lz * nl; + const base = s - materials; + + const rom = base * 0.10; + const rop = base * 0.05; + const rot = base * 0.05; + const rok = base * 0.10; + const totalRoyalties = rom + rop + rot + rok; + const counts = countRoles(); + const na = counts.artist; + const salaries = na * z; + + setValue(inputs.na, na); + setValue(inputs.rom, rom); + setValue(inputs.rop, rop); + setValue(inputs.rot, rot); + setValue(inputs.rok, rok); + + updateComputed(s, materials, base, totalRoyalties, salaries, counts); + updatePayouts(counts, z, rom, rop, rot, rok); + saveState(); + } + + function showMessage(text, type) { + messageEl.textContent = text; + messageEl.className = 'input-info mt-4'; + if (type === 'error') messageEl.classList.add('error'); + if (type === 'warning') messageEl.classList.add('warning'); + if (type === 'success') messageEl.classList.add('success'); + } + + function updateComputed(s, materials, base, royalties, salaries, counts) { + summary.materials.textContent = round(materials).toFixed(2); + summary.base.textContent = round(base).toFixed(2); + summary.royalties.textContent = round(royalties).toFixed(2); + summary.salaries.textContent = round(salaries).toFixed(2); + summary.s.textContent = round(s).toFixed(2); + + if (counts) { + const na = counts.artist; + if (na === 0 && (getValue(inputs.z) || 0) > 0) { + showMessage('Добавьте артистов в таблицу, чтобы Na соответствовало расчёту', 'warning'); + } + } + } + + function updatePayouts(counts, z, rom, rop, rot, rok) { + const rows = artistsTbody.querySelectorAll('tr[data-index]'); + rows.forEach(row => { + const idx = parseInt(row.dataset.index, 10); + const artist = artists[idx]; + let payout = 0; + if (artist.roles.artist) payout += z; + if (artist.roles.organizer && counts.organizer > 0) payout += rom / counts.organizer; + if (artist.roles.director && counts.director > 0) payout += rop / counts.director; + if (artist.roles.technician && counts.technician > 0) payout += rot / counts.technician; + if (artist.roles.commissioner && counts.commissioner > 0) payout += rok / counts.commissioner; + const cell = row.querySelector('.payout-cell'); + if (cell) cell.textContent = round(payout).toFixed(2); + }); + } + + function renderArtists() { + artistsTbody.innerHTML = ''; + if (artists.length === 0) { + artistsTbody.appendChild(emptyRow); + return; + } + + const counts = countRoles(); + const z = getValue(inputs.z) || 0; + const s = getValue(inputs.s) || 0; + const pz = getValue(inputs.pz) || 0; + const tz = getValue(inputs.tz) || 0; + const lz = getValue(inputs.lz) || 0; + const nl = getValue(inputs.nl) || 0; + const materials = pz + tz + lz * nl; + const base = s - materials; + const rom = base * 0.10; + const rop = base * 0.05; + const rot = base * 0.05; + const rok = base * 0.10; + + artists.forEach((artist, idx) => { + let payout = 0; + if (artist.roles.artist) payout += z; + if (artist.roles.organizer && counts.organizer > 0) payout += rom / counts.organizer; + if (artist.roles.director && counts.director > 0) payout += rop / counts.director; + if (artist.roles.technician && counts.technician > 0) payout += rot / counts.technician; + if (artist.roles.commissioner && counts.commissioner > 0) payout += rok / counts.commissioner; + + const tr = document.createElement('tr'); + tr.className = 'table-row'; + tr.dataset.index = idx; + tr.innerHTML = ` + ${escapeHtml(artist.name)} + + + + + + ${round(payout).toFixed(2)} + + `; + artistsTbody.appendChild(tr); + }); + } + + function addArtist() { + const name = artistSelect.value; + if (!name) return; + if (artists.some(a => a.name === name)) { + showMessage('Этот артист уже добавлен', 'warning'); + return; + } + artists.push({ name, roles: { artist: true, organizer: false, director: false, technician: false, commissioner: false } }); + artistSelect.value = ''; + renderArtists(); + calculate(); + } + + function removeArtist(index) { + artists.splice(index, 1); + renderArtists(); + calculate(); + } + + function toggleRole(index, role) { + artists[index].roles[role] = !artists[index].roles[role]; + renderArtists(); + calculate(); + } + + function saveState() { + const state = { + inputs: { + s: inputs.s.value, + pz: inputs.pz.value, + tz: inputs.tz.value, + lz: inputs.lz.value, + nl: inputs.nl.value, + z: inputs.z.value, + }, + artists, + }; + localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); + } + + function loadState() { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return; + try { + const state = JSON.parse(raw); + if (state.inputs) { + inputs.s.value = state.inputs.s || ''; + inputs.pz.value = state.inputs.pz || ''; + inputs.tz.value = state.inputs.tz || ''; + inputs.lz.value = state.inputs.lz || ''; + inputs.nl.value = state.inputs.nl || ''; + inputs.z.value = state.inputs.z || ''; + } + if (Array.isArray(state.artists)) { + artists = state.artists; + renderArtists(); + } + calculate(); + } catch (e) { + console.error('Failed to load state', e); + } + } + + function exportState() { + const state = { + inputs: { + s: inputs.s.value, + pz: inputs.pz.value, + tz: inputs.tz.value, + lz: inputs.lz.value, + nl: inputs.nl.value, + z: inputs.z.value, + }, + artists, + }; + const blob = new Blob([JSON.stringify(state, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'mardis_calc.json'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } + + function importState(file) { + const reader = new FileReader(); + reader.onload = () => { + try { + const state = JSON.parse(reader.result); + if (!state.inputs || !Array.isArray(state.artists)) { + throw new Error('Invalid file structure'); + } + inputs.s.value = state.inputs.s || ''; + inputs.pz.value = state.inputs.pz || ''; + inputs.tz.value = state.inputs.tz || ''; + inputs.lz.value = state.inputs.lz || ''; + inputs.nl.value = state.inputs.nl || ''; + inputs.z.value = state.inputs.z || ''; + artists = state.artists; + renderArtists(); + calculate(); + showMessage('Импорт завершён успешно', 'success'); + } catch (e) { + showMessage('Ошибка импорта: неверный формат файла', 'error'); + } + }; + reader.readAsText(file); + } + + function resetAll() { + inputs.s.value = ''; + inputs.pz.value = ''; + inputs.tz.value = ''; + inputs.lz.value = ''; + inputs.nl.value = ''; + inputs.z.value = ''; + inputs.na.value = ''; + inputs.rom.value = ''; + inputs.rop.value = ''; + inputs.rot.value = ''; + inputs.rok.value = ''; + artists = []; + renderArtists(); + calculate(); + localStorage.removeItem(STORAGE_KEY); + showMessage('Форма сброшена', 'info'); + } + + // Event listeners + Object.values(inputs).forEach(el => { + if (!el.readOnly) { + el.addEventListener('input', calculate); + el.addEventListener('change', calculate); + } + }); + + btnAddArtist.addEventListener('click', addArtist); + + artistsTbody.addEventListener('click', e => { + const btn = e.target.closest('button[data-action="remove"]'); + if (btn) { + const tr = btn.closest('tr[data-index]'); + if (tr) removeArtist(parseInt(tr.dataset.index, 10)); + return; + } + const checkbox = e.target.closest('input[type="checkbox"][data-role]'); + if (checkbox) { + const tr = checkbox.closest('tr[data-index]'); + if (tr) toggleRole(parseInt(tr.dataset.index, 10), checkbox.dataset.role); + } + }); + + document.getElementById('btn-export').addEventListener('click', exportState); + document.getElementById('btn-reset').addEventListener('click', resetAll); + + const importTrigger = document.getElementById('btn-import-trigger'); + const importFile = document.getElementById('btn-import-file'); + importTrigger.addEventListener('click', () => importFile.click()); + importFile.addEventListener('change', e => { + if (e.target.files.length) importState(e.target.files[0]); + e.target.value = ''; + }); + + // Init + loadArtists().then(() => { + loadState(); + calculate(); + }); +})();