diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d558b51 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,173 @@ + +# Vibe42 — учебная песочница для лендингов + +Workspace юзера `Issida_Ashirmatova`. Это **учебная среда**, где обычные люди (не разработчики) пробуют сделать свой первый сайт. + +--- + +## 🎯 ТВОЯ РОЛЬ + +Ты — **гид и помощник**, а не слепой исполнитель. Цель сессии — чтобы юзер вышел с: +1. **рабочим лендингом**, опубликованным по адресу `https://pages.git.vibe42.kz/Issida_Ashirmatova//`, +2. ощущением «это было легко» — без серверов, БД, токенов, конфигов. + +Юзер не разработчик. Ему важен **результат, который видно в браузере**, а не код. + +--- + +## 🗺 СЦЕНАРИЙ ПЕРВОГО ЗАХОДА (юзер только зашёл, ещё ничего нет) + +1. Поздоровайся коротко: «Привет! Тут за 10 минут собираем лендинг и публикуем его в интернете. О чём хочешь сделать?» +2. Если он не знает — предложи **4 конкретных идеи** (выбирай близкие к нему, не абстрактные): + - Промо хобби (фотография / музыка / спорт) + - Резюме / personal page с контактами + - Афиша мероприятия (концерт, день рождения, мастер-класс) + - Меню заведения / прайс услуг + - Лендинг продукта или будущего проекта (waitlist) +3. Уточни **2 короткие детали**: стиль (тёмный/светлый/яркий) и главную цель (рассказать / собрать заявку / показать работы). +4. Сразу делай `./new-project ` и собирай страницу. Не спрашивай разрешения на каждый шаг. + +--- + +## 💬 ЕСЛИ ЮЗЕР ОТВЕЧАЕТ РАСПЛЫВЧАТО + +Юзер говорит «сделай что-нибудь» / «ну хз» / «сюрприз» → **не делай ничего абстрактного**. + +Скажи: «Давай определимся, я задам 3 коротких вопроса: +1. Это для тебя лично, для проекта/бизнеса, или для события? +2. Главная цель — рассказать о чём-то / собрать заявку / показать портфолио? +3. Любимое настроение — строгое тёмное, лёгкое светлое, яркое цветное?» + +После ответов **сразу** предложи 2 конкретных варианта названия+структуры. Дай выбрать и иди делать. + +--- + +## 🚫 ЕСЛИ ЮЗЕР ХОЧЕТ СЛОЖНОЕ — ПЕРЕФОРМУЛИРУЙ В ЛЕНДИНГ + +| Запрос | Что делаем вместо | +|--------|-------------------| +| «магазин с корзиной» | лендинг с товарами + кнопка «купить» = ссылка на WhatsApp / Telegram | +| «соцсеть» | лендинг будущего проекта + waitlist-форма (Formspree / Getform) | +| «блог с админкой» | personal-page + ссылки на статьи в Telegram/Medium | +| «приложение для записи» | лендинг услуги + ссылка на Calendly / WhatsApp | +| «сайт с входом юзеров» | публичный лендинг без логина (нам логин не нужен) | +| «бот в Telegram» | лендинг с описанием бота + кнопка `t.me/...` | + +**Не говори «это невозможно».** Скажи: «У нас песочница только для статических сайтов. Давай сделаем лендинг, который покажет твою идею — а кнопки/формы свяжем с готовыми сервисами (WhatsApp, Telegram, Formspree)». Юзер счастлив, результат за 15 минут. + +--- + +## 📐 ШАБЛОНЫ СТРАНИЦ (выбирай под идею юзера) + +### A — Промо продукта/услуги +**Секции:** Hero (заголовок + подзаголовок + CTA-кнопка) → 3-4 преимущества (иконка emoji + текст) → социальное доказательство (отзыв или цифра) → CTA (кнопка/телефон/мессенджер). + +### B — Personal / резюме +**Секции:** Hero (фото-аватарка + имя + одна фраза «кто я») → О себе (1-2 абзаца) → 3-5 карточек проектов/опыта → Контакты (email, telegram, github как ссылки-кнопки). + +### C — Афиша мероприятия +**Секции:** Hero (название + дата + место крупно) → Программа (список с временем) → Локация (картинка-placeholder + адрес) → Регистрация (форма Formspree или контакт). + +### D — Меню / прайс +**Секции:** Hero (название + слоган) → Меню/прайс (категории с ценами) → Контакты (телефон, адрес, часы работы, карта-картинка). + +### E — Waitlist для будущего проекта +**Секции:** Hero (название проекта + одна фраза + email-форма) → 3 фичи «что будет» → FAQ (3 пункта) → CTA (та же email-форма). + +Все шаблоны — **одна страница, прокрутка вниз**. Никаких роутов, ничего динамического. + +--- + +## ⚡ РИТУАЛ ПОСЛЕ ПЕРВОГО ЗАПУСКА + +Как только готов первый рабочий вариант (даже грубый): + +1. **Сразу запушь:** + ```bash + git add -A + git commit -m "v1" + git push origin HEAD:pages + ``` +2. **ОБЯЗАТЕЛЬНО** дай юзеру ссылку **жирно**: + > 🎉 Готово! Твой лендинг здесь: **https://pages.git.vibe42.kz/Issida_Ashirmatova//** +3. Скажи: «Открой в новой вкладке, посмотри. Что хочешь поменять?» +4. Дальше короткие итерации: правка → push → новый URL-показ. Каждые 2-3 правки — push. + +--- + +## ⚠️ ЖЕЛЕЗНЫЕ ПРАВИЛА (НЕ нарушать никогда) + +1. **Только статика — HTML + CSS + JS в браузере.** +2. **Никакого бэкенда.** Никаких Node/Express/FastAPI/Django/PHP/Go-серверов. Никаких БД. Никакого Redis. +3. **Никакой аутентификации / OAuth / JWT.** +4. **Никакого Docker, nginx, sudo, системных настроек.** +5. **Никаких тяжёлых сборщиков** (`npm install` дерево на 500МБ). Tailwind — только через CDN. +6. **НИКОГДА `git init` в workspace root (`/workspaces/Issida_Ashirmatova`)** — это папка-контейнер юзера, не репозиторий. + +--- + +## ✅ ВСЕГДА работай через `./new-project` + +Если юзер сказал «сделай сайт NAME» / «создай проект NAME»: + +```bash +cd /workspaces/Issida_Ashirmatova +./new-project NAME # создаёт repo в Gitea + клонит локально в ./NAME/ +cd NAME +# теперь создавай index.html / style.css / script.js внутри ./NAME +``` + +`./new-project` сам создаёт repo, клонит, и копирует туда `AGENTS.md` + `design.md`. + +--- + +## 🌐 Git и публикация + +**НЕТ GitHub.** Self-hosted git: **https://git.vibe42.kz** + +- Профиль юзера: https://git.vibe42.kz/Issida_Ashirmatova +- Pages (живые лендинги): https://pages.git.vibe42.kz/Issida_Ashirmatova// +- Креды уже в `/workspaces/Issida_Ashirmatova/.git-credentials` — git push/clone работают без пароля +- **НЕ спрашивай юзера про GitHub URL / токен** — их не нужно + +### Опубликовать лендинг + +```bash +git add -A +git commit -m "site" +git push origin HEAD:pages +``` + +Ветка **`pages`** (Caddy её обслуживает; `gh-pages` тоже работает как fallback). Push → лендинг доступен мгновенно. + +--- + +## 🔧 Когда что-то идёт не так + +- **Pages 404** → запушь ветку `pages` снова: `git push origin HEAD:pages -f` +- **Не дёргай Gitea API типа `/repos/.../pages`, `/settings/pages`, `/deploy_keys`** — их нет +- **Не пытайся «настроить Pages через UI Gitea»** — Pages у нас работают только через push в ветку `pages` +- Запуталось — сделай новый чистый проект через `./new-project NAME-v2`, перенеси туда работающий index.html + +--- + +## ❌ Чего НЕ делать НИКОГДА + +- ❌ `git init` в workspace root +- ❌ `npm install` с прод-зависимостями (express/mongoose/pg/prisma/next/nuxt) +- ❌ Создавать `server.js` / `app.py` / `main.go` как backend +- ❌ Использовать `gh` CLI или GitHub API +- ❌ Вызывать Gitea Pages-API (его нет) +- ❌ Долгое отлаживание Pages — почти всегда решение «push HEAD:pages» +- ❌ Просить юзера ввести токен/URL/пароль — всё уже настроено +- ❌ Задавать юзеру 10 вопросов подряд (максимум 2-3 за раз) +- ❌ Показывать юзеру голый код больше 1 раза — ему важен результат, а не как написано +- ❌ Предлагать «давай сначала дизайн в Figma» — мы делаем сразу в HTML +- ❌ Говорить «это сложно» — переформулируй в простое +- ❌ Зависать в обсуждениях — сделай первый вариант грубо, потом итерируй + +--- + +## 🎨 design.md + +Рядом лежит `design.md` с готовой палитрой, типографикой и стартер-шаблоном `index.html`. **Начинай с него.** Не выдумывай новые цвета — модифицируй существующие. diff --git a/app.js b/app.js new file mode 100644 index 0000000..5f7acbf --- /dev/null +++ b/app.js @@ -0,0 +1,509 @@ +/* ========================================================= + Дашборд кафедры — логика + Данные хранятся в localStorage (без сервера/БД) + ========================================================= */ + +const STORE_KEY = 'clinical_dashboard_v1'; + +// ---- Базовые ( неудаляемые ) поля ---- +const BASE_FIELDS = [ + { key:'fullName', label:'ФИО', type:'text', required:true, locked:true }, + { key:'birthDate', label:'Дата рождения', type:'date', locked:true }, + { key:'education', label:'Образование', type:'textarea',locked:true }, + { key:'academicDegree', label:'Научная степень', type:'select', options:['Нет','Кандидат наук','Доктор наук'], locked:true }, + { key:'category', label:'Категория', type:'select', options:['Нет','Вторая','Первая','Высшая'], locked:true }, + { key:'specialistCertificate', label:'Сертификат специалиста', type:'text', locked:true }, + { key:'associations', label:'Членство в ассоциациях', type:'text', locked:true }, + { key:'clinicalBase', label:'Клиническая база', type:'text', locked:true }, +]; + +// ---- Состояние ---- +let state = { + teachers: [], + customFields: [], + editingId: null, +}; + +let filters = { search:'', degree:'', category:'', base:'' }; + +/* ---------- Инициализация ---------- */ +function init(){ + load(); + if(state.teachers.length===0 && state.customFields.length===0){ + seedDemo(); + } + bindEvents(); + buildFilterOptions(); + renderAll(); +} + +function load(){ + try{ + const raw = localStorage.getItem(STORE_KEY); + if(raw){ + const data = JSON.parse(raw); + state.teachers = data.teachers || []; + state.customFields = data.customFields || []; + } + }catch(e){ console.warn('load failed', e); } +} + +function save(){ + localStorage.setItem(STORE_KEY, JSON.stringify({ + teachers: state.teachers, + customFields: state.customFields, + })); +} + +function allFields(){ return BASE_FIELDS.concat(state.customFields); } + +function uid(){ return 't_' + Math.random().toString(36).slice(2,10) + Date.now().toString(36).slice(-4); } + +/* ---------- Демо-данные при первом запуске ---------- */ +function seedDemo(){ + state.teachers = [ + { id:uid(), fullName:'Ахметова Гульнара Сериковна', birthDate:'1975-03-12', + education:'КазНМУ им. С.Асфендиярова, лечебное дело, 1998; интернатура по кардиологии', + academicDegree:'Доктор наук', category:'Высшая', + specialistCertificate:'№АА1122334, 2022, Кардиология', + associations:'Ассоциация кардиологов РК, ESC', + clinicalBase:'Городской кардиологический центр' }, + { id:uid(), fullName:'Оспанов Тимур Бакытович', birthDate:'1982-11-05', + education:'КГМУ, педиатрия, 2005; резидентура по неонатологии', + academicDegree:'Кандидат наук', category:'Первая', + specialistCertificate:'№АА5566778, 2023, Неонатология', + associations:'Ассоциация неонатологов РК', + clinicalBase:'Перинатальный центр №1' }, + { id:uid(), fullName:'Жумабаева Айгерим Нурлановна', birthDate:'1989-07-21', + education:'КазНМУ, общая медицина, 2012; резидентура по терапии', + academicDegree:'Нет', category:'Вторая', + specialistCertificate:'№АА9988776, 2024, Терапия', + associations:'', + clinicalBase:'ГКП Поликлиника №12' }, + ]; + save(); +} + +/* ---------- События ---------- */ +function bindEvents(){ + // Табы + document.querySelectorAll('.tab').forEach(t=>{ + t.addEventListener('click', ()=>{ + document.querySelectorAll('.tab').forEach(x=>x.classList.remove('active')); + document.querySelectorAll('.tabpanel').forEach(x=>x.classList.remove('active')); + t.classList.add('active'); + document.getElementById('tab-'+t.dataset.tab).classList.add('active'); + if(t.dataset.tab==='reports') renderReports(); + }); + }); + + // Кнопки + document.getElementById('btnAdd').addEventListener('click', ()=>openEditor()); + document.getElementById('btnFields').addEventListener('click', openFields); + document.getElementById('btnExport').addEventListener('click', exportCSV); + document.getElementById('btnImport').addEventListener('click', ()=>document.getElementById('importFile').click()); + document.getElementById('importFile').addEventListener('change', importJSON); + document.getElementById('btnSave').addEventListener('click', saveEditor); + document.getElementById('btnDelete').addEventListener('click', deleteEditor); + document.getElementById('btnAddField').addEventListener('click', addCustomField); + document.getElementById('newFieldType').addEventListener('change', e=>{ + document.getElementById('newFieldOptions').hidden = e.target.value!=='select'; + }); + document.getElementById('btnReportCsv').addEventListener('click', exportReportCSV); + document.getElementById('reportField').addEventListener('change', renderReports); + + // Фильтры + document.getElementById('search').addEventListener('input', e=>{ filters.search=e.target.value.toLowerCase(); renderTable(); }); + document.getElementById('filterDegree').addEventListener('change', e=>{ filters.degree=e.target.value; renderTable(); }); + document.getElementById('filterCategory').addEventListener('change', e=>{ filters.category=e.target.value; renderTable(); }); + document.getElementById('filterBase').addEventListener('change', e=>{ filters.base=e.target.value; renderTable(); }); + + // Закрытие модалок по клику на фон + document.querySelectorAll('.modal-overlay').forEach(ov=>{ + ov.addEventListener('click', e=>{ if(e.target===ov) ov.hidden=true; }); + }); + document.addEventListener('keydown', e=>{ if(e.key==='Escape'){ closeEditor(); closeFields(); } }); +} + +/* ---------- Выпуски фильтров ---------- */ +function buildFilterOptions(){ + const degreeSel = document.getElementById('filterDegree'); + const catSel = document.getElementById('filterCategory'); + const baseSel = document.getElementById('filterBase'); + degreeSel.innerHTML = ''+uniqueValues('academicDegree').map(v=>``).join(''); + catSel.innerHTML = ''+uniqueValues('category').map(v=>``).join(''); + baseSel.innerHTML = ''+uniqueValues('clinicalBase').map(v=>``).join(''); +} +function uniqueValues(key){ + const set = new Set(); + state.teachers.forEach(t=>{ if(t[key]) set.add(t[key]); }); + return [...set].sort(); +} + +/* ---------- Рендер всего ---------- */ +function renderAll(){ renderStats(); renderTable(); buildFilterOptions(); } + +/* ---------- Карточки статистики ---------- */ +function renderStats(){ + const total = state.teachers.length; + const doctors = state.teachers.filter(t=>t.academicDegree==='Доктор наук').length; + const candidates = state.teachers.filter(t=>t.academicDegree==='Кандидат наук').length; + const withDegree = doctors+candidates; + const withCert = state.teachers.filter(t=>t.specialistCertificate && t.specialistCertificate.trim()).length; + const inAssoc = state.teachers.filter(t=>t.associations && t.associations.trim()).length; + + document.getElementById('statsGrid').innerHTML = ` + ${statCard('Всего преподавателей', total, 'всего на кафедре','accent')} + ${statCard('С научной степенью', withDegree, `${candidates} кандидат · ${doctors} доктор`,'green')} + ${statCard('Докторов наук', doctors, 'высшая квалификация','amber')} + ${statCard('С сертификатом', withCert, 'специалиста','accent')} + ${statCard('Члены ассоциаций', inAssoc, 'проф. членство','green')} + `; +} +function statCard(label,value,sub,cls){ + return `
${label}
${value}
${sub||''}
`; +} + +/* ---------- Таблица ---------- */ +function renderTable(){ + const head = document.getElementById('tableHead'); + const body = document.getElementById('tableBody'); + const empty = document.getElementById('emptyState'); + + // Колонки: ФИО, Степень, Категория, База, Сертификат, действия + head.innerHTML = ` + ФИОНаучная степеньКатегория + Клиническая базаСертификатДействия`; + + const rows = filtered(); + if(rows.length===0){ + document.getElementById('teacherTable').style.display = state.teachers.length===0 ? 'none':'table'; + empty.hidden = state.teachers.length!==0; + if(state.teachers.length!==0){ + empty.hidden=false; empty.querySelector('p').textContent='Ничего не найдено по фильтрам.'; + empty.querySelector('button').style.display='none'; + } else { + empty.querySelector('p').textContent='Пока нет ни одного преподавателя.'; + empty.querySelector('button').style.display=''; + } + document.getElementById('countPill').textContent=''; + body.innerHTML=''; + return; + } + document.getElementById('teacherTable').style.display='table'; + empty.hidden=true; + + body.innerHTML = rows.map(t=>` + + ${esc(t.fullName||'—')} + ${degreeBadge(t.academicDegree)} + ${t.category && t.category!=='Нет' ? `${esc(t.category)}` : ''} + ${esc(t.clinicalBase||'—')} + ${t.specialistCertificate ? `✓ есть` : ''} + +
+ + +
+ + `).join(''); + + document.getElementById('countPill').textContent = `Показано: ${rows.length} из ${state.teachers.length}`; +} + +function filtered(){ + return state.teachers.filter(t=>{ + if(filters.degree && t.academicDegree!==filters.degree) return false; + if(filters.category && t.category!==filters.category) return false; + if(filters.base && t.clinicalBase!==filters.base) return false; + if(filters.search){ + const hay = [t.fullName,t.education,t.clinicalBase,t.associations,t.specialistCertificate,t.academicDegree,t.category] + .filter(Boolean).join(' ').toLowerCase(); + if(!hay.includes(filters.search)) return false; + } + return true; + }); +} + +function degreeBadge(d){ + if(d==='Доктор наук') return 'Доктор наук'; + if(d==='Кандидат наук') return 'Кандидат наук'; + if(d && d!=='Нет') return `${esc(d)}`; + return 'нет'; +} + +window.removeTeacher = function(id){ + const t = state.teachers.find(x=>x.id===id); + if(!t) return; + if(confirm(`Удалить преподавателя «${t.fullName}»?`)){ + state.teachers = state.teachers.filter(x=>x.id!==id); + save(); renderAll(); + } +}; + +/* ---------- Редактор (модалка) ---------- */ +window.openEditor = function(id){ + state.editingId = id || null; + const t = id ? state.teachers.find(x=>x.id===id) : null; + document.getElementById('editorTitle').textContent = t ? 'Редактировать преподавателя' : 'Новый преподаватель'; + document.getElementById('btnDelete').hidden = !t; + + const form = document.getElementById('editorForm'); + form.innerHTML = '
' + allFields().map(f=>fieldInput(f, t)).join('') + '
'; + + document.getElementById('editorModal').hidden=false; +}; + +function fieldInput(f, t){ + const val = t ? (t[f.key] ?? '') : ''; + const req = f.required ? '*' : ''; + const full = (f.type==='textarea') ? ' full' : ''; + let control=''; + if(f.type==='text' || f.type==='date' || f.type==='number'){ + control = ``; + } else if(f.type==='textarea'){ + control = ``; + } else if(f.type==='select'){ + control = ``; + } else if(f.type==='checkbox'){ + control = ``; + } + return `
+ + ${control} +
`; +} + +function closeEditor(){ document.getElementById('editorModal').hidden=true; state.editingId=null; } + +function saveEditor(){ + const form = document.getElementById('editorForm'); + const data = {}; + let firstError=null; + for(const f of allFields()){ + const el = form.elements[f.key]; + if(!el){ data[f.key]=''; continue; } + if(f.type==='checkbox'){ + data[f.key] = el.checked; + } else { + data[f.key] = el.value.trim(); + } + if(f.required && !data[f.key] && data[f.key]!==false){ + if(!firstError) firstError=f.label; + } + } + if(firstError){ + alert('Заполните обязательное поле: '+firstError); + return; + } + if(state.editingId){ + const idx = state.teachers.findIndex(t=>t.id===state.editingId); + state.teachers[idx] = { ...state.teachers[idx], ...data }; + } else { + data.id = uid(); + state.teachers.push(data); + } + save(); closeEditor(); renderAll(); +} + +function deleteEditor(){ + if(!state.editingId) return; + const t = state.teachers.find(x=>x.id===state.editingId); + if(t && confirm(`Удалить преподавателя «${t.fullName}»?`)){ + state.teachers = state.teachers.filter(x=>x.id!==state.editingId); + save(); closeEditor(); renderAll(); + } +} + +/* ---------- Настройка полей ---------- */ +function openFields(){ + renderFieldList(); + document.getElementById('fieldsModal').hidden=false; +} +function closeFields(){ document.getElementById('fieldsModal').hidden=true; } + +function renderFieldList(){ + const list = document.getElementById('fieldList'); + list.innerHTML = allFields().map(f=>` +
  • +
    + ${esc(f.label)} ${f.required?'*':''} + ${fieldTypeName(f.type)}${f.locked?' · базовое':''} +
    + ${f.locked + ? '🔒' + : ``} +
  • `).join(''); +} + +function fieldTypeName(t){ + return {text:'Текст',date:'Дата',number:'Число',select:'Список',textarea:'Длинный текст',checkbox:'Да/Нет'}[t]||t; +} + +function addCustomField(){ + const label = document.getElementById('newFieldLabel').value.trim(); + const type = document.getElementById('newFieldType').value; + const optsRaw = document.getElementById('newFieldOptions').value.trim(); + if(!label){ alert('Введите название поля'); return; } + const key = 'c_'+label.toLowerCase().replace(/[^a-zа-я0-9]+/gi,'_').slice(0,24)+'_'+Math.random().toString(36).slice(2,5); + const f = { key, label, type, locked:false }; + if(type==='select'){ + f.options = optsRaw ? optsRaw.split(',').map(s=>s.trim()).filter(Boolean) : []; + if(f.options.length===0){ alert('Укажите варианты через запятую'); return; } + } + state.customFields.push(f); + save(); + document.getElementById('newFieldLabel').value=''; + document.getElementById('newFieldOptions').value=''; + document.getElementById('newFieldOptions').hidden=true; + renderFieldList(); + buildFilterOptions(); +} + +window.removeField = function(key){ + const f = state.customFields.find(x=>x.key===key); + if(!f) return; + if(!confirm(`Удалить поле «${f.label}»? Значения этого поля у всех преподавателей будут потеряны.`)) return; + state.customFields = state.customFields.filter(x=>x.key!==key); + state.teachers.forEach(t=>{ delete t[key]; }); + save(); renderFieldList(); renderAll(); +}; + +/* ---------- Отчёты ---------- */ +function renderReports(){ + const sel = document.getElementById('reportField'); + // Заполняем список полей один раз + if(sel.options.length===0){ + sel.innerHTML = allFields().filter(f=>f.type!=='textarea').map(f=>``).join(''); + } + const key = sel.value; + const f = allFields().find(x=>x.key===key); + const grid = document.getElementById('reportGrid'); + const summary = document.getElementById('reportSummary'); + + if(state.teachers.length===0){ + grid.innerHTML=''; summary.innerHTML='

    Нет данных для отчёта.

    '; return; + } + + // Группировка + const groups = {}; + state.teachers.forEach(t=>{ + let v = t[key]; + if(f && f.type==='checkbox') v = v ? 'Да':'Нет'; + if(v===undefined||v===null||v==='') v='(не указано)'; + groups[v] = (groups[v]||0)+1; + }); + + const entries = Object.entries(groups).sort((a,b)=>b[1]-a[1]); + const max = Math.max(...entries.map(e=>e[1])); + + grid.innerHTML = ` +
    +

    ${esc(f?f.label:key)} — распределение

    + ${entries.map(([label,count])=>` +
    + ${esc(label)} + + ${count} +
    `).join('')} +
    `; + + // Сводка + const total = state.teachers.length; + const withDegree = state.teachers.filter(t=>t.academicDegree && t.academicDegree!=='Нет').length; + const pct = total?Math.round(withDegree/total*100):0; + summary.innerHTML = ` +

    Сводка

    +

    Всего преподавателей: ${total}

    +

    С научной степенью: ${withDegree} (${pct}%)

    +

    Полей для параметров: ${allFields().length} (из них пользовательских: ${state.customFields.length})

    +

    Выберите поле выше, чтобы сгруппировать данные. Отчёт можно скачать в CSV.

    `; +} + +/* ---------- Экспорт / Импорт ---------- */ +function exportCSV(){ + if(state.teachers.length===0){ alert('Нет данных для экспорта'); return; } + const fields = allFields(); + const header = ['ФИО','Дата рождения','Образование','Научная степень','Категория','Сертификат специалиста','Членство в ассоциациях','Клиническая база'] + .concat(state.customFields.map(f=>f.label)); + const rows = state.teachers.map(t=>{ + return fields.map(f=>{ + let v = t[f.key] ?? ''; + if(f.type==='checkbox') v = v?'Да':'Нет'; + return csvCell(v); + }); + }); + const csv = '\uFEFF' + [header.map(csvCell).join(';')].concat(rows.map(r=>r.join(';'))).join('\r\n'); + download(csv, 'prepodavateli.csv', 'text/csv;charset=utf-8'); +} + +function exportReportCSV(){ + const key = document.getElementById('reportField').value; + const f = allFields().find(x=>x.key===key); + if(!f){ alert('Выберите поле'); return; } + const groups = {}; + state.teachers.forEach(t=>{ + let v = t[key]; + if(f.type==='checkbox') v = v?'Да':'Нет'; + if(v===undefined||v===null||v==='') v='(не указано)'; + groups[v] = (groups[v]||0)+1; + }); + const rows = Object.entries(groups).sort((a,b)=>b[1]-a[1]); + const csv = '\uFEFF' + [`${f.label};Количество`].concat(rows.map(r=>`${csvCell(r[0])};${r[1]}`)).join('\r\n'); + download(csv, `otchet_${f.label}.csv`, 'text/csv;charset=utf-8'); +} + +function csvCell(v){ + const s = String(v==null?'':v); + if(/[;"\n]/.test(s)) return '"'+s.replace(/"/g,'""')+'"'; + return s; +} + +function importJSON(e){ + const file = e.target.files[0]; + if(!file) return; + const reader = new FileReader(); + reader.onload = ev=>{ + try{ + const data = JSON.parse(ev.target.result); + if(!Array.isArray(data.teachers)) throw new Error('Нет массива teachers'); + if(!confirm(`Импортировать ${data.teachers.length} преподавателей? Текущие данные будут заменены.`)) return; + state.teachers = data.teachers; + state.customFields = data.customFields || []; + save(); renderAll(); buildFilterOptions(); + // сбросить список полей отчёта + document.getElementById('reportField').innerHTML=''; + alert('Импорт выполнен'); + }catch(err){ alert('Ошибка импорта: '+err.message); } + }; + reader.readAsText(file); + e.target.value=''; +} + +function download(content, filename, type){ + const blob = new Blob([content], {type}); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href=url; a.download=filename; a.click(); + URL.revokeObjectURL(url); +} + +// Кнопка экспорта всей БД в JSON (резервная копия) — добавим в экспорт-меню через длинное нажатие не нужно; сделаем просто +function exportBackup(){ + download(JSON.stringify({teachers:state.teachers, customFields:state.customFields}, null, 2), 'backup_dashboard.json', 'application/json'); +} + +/* ---------- Утилиты ---------- */ +function esc(s){ + return String(s==null?'':s).replace(/[&<>"']/g, c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); +} + +/* ---------- Старт ---------- */ +// Резервная копия (JSON) — по двойному клику на кнопке "Экспорт CSV" +const _btnExport = document.getElementById('btnExport'); +if(_btnExport){ _btnExport.title='Одинарный клик — CSV, двойной — резервная копия JSON'; _btnExport.addEventListener('dblclick', exportBackup); } + +init(); diff --git a/design.md b/design.md new file mode 100644 index 0000000..5c8e829 --- /dev/null +++ b/design.md @@ -0,0 +1,110 @@ + +# Design system — Vibe42 песочница + +Базовые цвета и типографика для лендингов. Можно отклоняться, но начинай с этого. + +## Палитра + +| Token | Hex | Использование | +|-------|-----|---------------| +| `--ink` | `#0F1218` | Тёмный фон / основной текст | +| `--cyan` | `#00E5FF` | Основной акцент (кнопки, лого) | +| `--cyan-50` | `#E8FCFF` | Светлая подложка для акцентов | +| `--white` | `#FFFFFF` | Основной фон | +| `--gray-500` | `#5B6573` | Вторичный текст | +| `--gray-100` | `#F2F4F7` | Сепараторы / тонкие фоны | + +## Типографика + +```css +font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, system-ui, sans-serif; +``` + +| Уровень | Размер | Вес | line-height | +|---------|--------|-----|-------------| +| h1 (hero) | 56px | 800 | 1.05 | +| h2 (section) | 36px | 700 | 1.15 | +| h3 | 22px | 700 | 1.3 | +| body | 17px | 400 | 1.6 | +| small | 14px | 400 | 1.5 | + +На мобиле — h1 уменьши до 36px, h2 до 28px. + +## Лейаут + +- max-width контента: **1140px** (контейнер с padding по бокам) +- секция: `padding: 80px 24px` (мобила: `48px 20px`) +- gap между блоками внутри секции: `24-32px` +- border-radius: `8px` (кнопки, карточки), `16px` (большие карточки) + +## Кнопки + +```css +.btn-primary { + background: var(--cyan); color: var(--ink); + padding: 14px 28px; border-radius: 8px; + font-weight: 700; text-decoration: none; + display: inline-block; +} +.btn-secondary { + background: transparent; color: var(--ink); + border: 2px solid var(--ink); + padding: 12px 26px; border-radius: 8px; +} +``` + +## Стартер `index.html` + +```html + + + + + +Мой проект + + + +
    +
    +

    Заголовок проекта

    +

    Подзаголовок — пара предложений о чём это.

    + Начать +
    +
    +
    +
    +

    Секция

    +
    Контент карточки.
    +
    Контент карточки.
    +
    +
    + + +``` + +## Чем НЕ пользоваться + +- Bootstrap, Material UI, Chakra, Ant Design — слишком тяжело и не нужно для лендинга +- Font Awesome — используй emoji (🚀 ⚡ ✨) или inline SVG +- jQuery — vanilla JS более чем достаточно + +## Чем МОЖНО (если очень надо) + +- **Tailwind через CDN**: `` — для прототипа OK +- **Lottie animations через CDN** +- **Placeholder картинки**: `https://picsum.photos/800/600`, `https://placehold.co/600x400` +- **Шрифты Google Fonts через ``** в head diff --git a/index.html b/index.html new file mode 100644 index 0000000..27e4284 --- /dev/null +++ b/index.html @@ -0,0 +1,125 @@ + + + + + +Дашборд кафедры — преподаватели + + + +
    +
    +
    +
    +
    +

    Клиническая кафедра

    +

    Дашборд преподавателей

    +
    +
    +
    + + + + +
    +
    +
    + + + +
    + + +
    +
    + +
    + + + + + +
    + +
    + + + +
    + +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    + + + + + + + + + + + + diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..622b6e4 --- /dev/null +++ b/styles.css @@ -0,0 +1,173 @@ +:root{ + --ink:#0F1218; + --cyan:#00B8D4; + --cyan-strong:#0891B2; + --cyan-50:#E8FCFF; + --white:#fff; + --gray-900:#1A2030; + --gray-700:#3C4658; + --gray-500:#5B6573; + --gray-300:#C9D0DB; + --gray-100:#F2F4F7; + --gray-50:#F8FAFC; + --green:#16A34A; + --green-50:#DCFCE7; + --amber:#D97706; + --amber-50:#FEF3C7; + --red:#DC2626; + --red-50:#FEE2E2; + --border:#E2E8F0; + --shadow:0 1px 3px rgba(15,18,24,.08), 0 1px 2px rgba(15,18,24,.04); + --shadow-lg:0 12px 32px rgba(15,18,24,.18); + --radius:10px; +} +*{box-sizing:border-box;margin:0;padding:0} +html,body{height:100%} +body{ + font:15px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,system-ui,sans-serif; + color:var(--ink);background:var(--gray-50); +} +.container{max-width:1280px;margin:0 auto;padding:0 24px} + +/* ===== Topbar ===== */ +.topbar{background:var(--ink);color:#fff;box-shadow:var(--shadow)} +.topbar-inner{max-width:1280px;margin:0 auto;padding:16px 24px;display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap} +.brand{display:flex;align-items:center;gap:14px} +.brand-mark{width:44px;height:44px;border-radius:10px;background:linear-gradient(135deg,var(--cyan),var(--cyan-strong));display:grid;place-items:center;font-size:24px;font-weight:800;color:var(--ink)} +.topbar h1{font-size:20px;font-weight:800;line-height:1.2} +.brand-sub{font-size:13px;color:#9aa3b2} +.topbar-actions{display:flex;gap:8px;flex-wrap:wrap} + +/* ===== Buttons ===== */ +.btn{ + font:inherit;font-weight:600;font-size:14px;cursor:pointer; + padding:10px 16px;border-radius:8px;border:1px solid transparent; + display:inline-flex;align-items:center;gap:6px;transition:.15s;white-space:nowrap; +} +.btn.primary{background:var(--cyan-strong);color:#fff} +.btn.primary:hover{background:#0E7490} +.btn.ghost{background:rgba(255,255,255,.08);color:#fff;border-color:rgba(255,255,255,.18)} +.btn.ghost:hover{background:rgba(255,255,255,.16)} +.btn.danger{background:var(--red);color:#fff} +.btn.danger:hover{background:#B91C1C} +.btn.dark{background:var(--ink);color:#fff} +.btn.dark:hover{background:#000} +.icon-btn{background:none;border:none;font-size:18px;cursor:pointer;color:var(--gray-500);padding:4px 8px;border-radius:6px} +.icon-btn:hover{background:var(--gray-100)} + +/* ===== Tabs ===== */ +.tabs{background:var(--white);border-bottom:1px solid var(--border);position:sticky;top:0;z-index:20} +.tab{ + background:none;border:none;cursor:pointer;font:inherit;font-weight:600;font-size:15px; + padding:16px 20px;color:var(--gray-500);border-bottom:2px solid transparent; +} +.tab:hover{color:var(--ink)} +.tab.active{color:var(--cyan-strong);border-bottom-color:var(--cyan-strong)} +.tabpanel{display:none;padding:28px 0 60px} +.tabpanel.active{display:block} + +/* ===== Inputs ===== */ +.input{ + font:inherit;font-size:14px;padding:9px 12px;border:1px solid var(--border); + border-radius:8px;background:#fff;color:var(--ink);width:100%; +} +.input:focus{outline:none;border-color:var(--cyan-strong);box-shadow:0 0 0 3px var(--cyan-50)} +select.input{cursor:pointer} +textarea.input{resize:vertical;min-height:80px;line-height:1.5} + +/* ===== Stats ===== */ +.stats-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:14px;margin-bottom:24px} +.stat-card{background:#fff;border:1px solid var(--border);border-radius:var(--radius);padding:18px 20px;box-shadow:var(--shadow)} +.stat-card .label{font-size:12px;font-weight:600;color:var(--gray-500);text-transform:uppercase;letter-spacing:.04em;margin-bottom:6px} +.stat-card .value{font-size:30px;font-weight:800;line-height:1} +.stat-card .sub{font-size:12px;color:var(--gray-500);margin-top:6px} +.stat-card.accent{border-left:4px solid var(--cyan-strong)} +.stat-card.green{border-left:4px solid var(--green)} +.stat-card.amber{border-left:4px solid var(--amber)} + +/* ===== Toolbar ===== */ +.toolbar{display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin-bottom:16px} +.toolbar .input{width:auto;min-width:180px;flex:1} +.toolbar select.input{flex:0 0 auto;min-width:150px} +.count-pill{margin-left:auto;font-size:13px;color:var(--gray-500);background:var(--gray-100);padding:6px 12px;border-radius:20px;font-weight:600} + +/* ===== Table ===== */ +.table-wrap{background:#fff;border:1px solid var(--border);border-radius:var(--radius);overflow:hidden;box-shadow:var(--shadow)} +.data-table{width:100%;border-collapse:collapse;font-size:14px} +.data-table thead{background:var(--gray-50)} +.data-table th{ + text-align:left;padding:12px 14px;font-size:12px;font-weight:700;color:var(--gray-500); + text-transform:uppercase;letter-spacing:.04em;border-bottom:1px solid var(--border);white-space:nowrap; +} +.data-table td{padding:12px 14px;border-bottom:1px solid var(--border);vertical-align:top} +.data-table tbody tr:last-child td{border-bottom:none} +.data-table tbody tr:hover{background:var(--cyan-50)} +.data-table .name-cell{font-weight:700;color:var(--ink)} +.data-table .actions{white-space:nowrap;text-align:right} +.row-actions{display:inline-flex;gap:4px} +.badge{display:inline-block;font-size:12px;font-weight:600;padding:3px 9px;border-radius:20px;background:var(--gray-100);color:var(--gray-700)} +.badge.green{background:var(--green-50);color:var(--green)} +.badge.amber{background:var(--amber-50);color:var(--amber)} +.badge.cyan{background:var(--cyan-50);color:var(--cyan-strong)} +.badge.red{background:var(--red-50);color:var(--red)} + +.empty-state{text-align:center;padding:60px 20px;color:var(--gray-500)} +.empty-state .btn{margin-top:16px} + +/* ===== Modal ===== */ +.modal-overlay{ + position:fixed;inset:0;background:rgba(15,18,24,.55);backdrop-filter:blur(2px); + z-index:100;display:grid;place-items:center;padding:20px;overflow-y:auto; +} +.modal{background:#fff;border-radius:14px;width:100%;max-width:680px;box-shadow:var(--shadow-lg);max-height:92vh;display:flex;flex-direction:column} +.modal-head{display:flex;align-items:center;justify-content:space-between;padding:20px 24px;border-bottom:1px solid var(--border)} +.modal-head h2{font-size:18px;font-weight:700} +.modal-body{padding:20px 24px;overflow-y:auto} +.modal-foot{display:flex;gap:10px;justify-content:flex-end;padding:16px 24px;border-top:1px solid var(--border);background:var(--gray-50)} +.modal-foot .danger{margin-right:auto} + +.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px} +.form-field{display:flex;flex-direction:column;gap:6px} +.form-field.full{grid-column:1/-1} +.form-field label{font-size:13px;font-weight:600;color:var(--gray-700)} +.form-field .req{color:var(--red)} +.form-field .hint{font-size:12px;color:var(--gray-500)} +.form-error{color:var(--red);font-size:13px;margin-top:8px} + +/* ===== Fields config ===== */ +.hint{font-size:13px;color:var(--gray-500);margin-bottom:14px} +.field-list{list-style:none;display:flex;flex-direction:column;gap:8px;margin-bottom:20px} +.field-item{display:flex;align-items:center;justify-content:space-between;padding:10px 14px;background:var(--gray-50);border:1px solid var(--border);border-radius:8px} +.field-item .meta{display:flex;flex-direction:column;gap:2px} +.field-item .meta .lbl{font-weight:600;font-size:14px} +.field-item .meta .typ{font-size:12px;color:var(--gray-500)} +.field-item.locked .del{opacity:.4;cursor:not-allowed} +.field-adder{border-top:1px dashed var(--border);padding-top:18px} +.field-adder h3{font-size:15px;margin-bottom:12px} +.row{display:flex;gap:10px;margin-bottom:10px} +.field-adder .btn{margin-top:6px} + +/* ===== Reports ===== */ +.report-controls{display:flex;gap:14px;align-items:center;flex-wrap:wrap;margin-bottom:22px;background:#fff;border:1px solid var(--border);border-radius:var(--radius);padding:16px 20px;box-shadow:var(--shadow)} +.report-controls label{display:flex;align-items:center;gap:10px;font-size:14px;font-weight:600;color:var(--gray-700)} +.report-controls select.input{min-width:240px} +.report-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:14px;margin-bottom:24px} +.report-card{background:#fff;border:1px solid var(--border);border-radius:var(--radius);padding:18px 20px;box-shadow:var(--shadow)} +.report-card h3{font-size:13px;text-transform:uppercase;letter-spacing:.04em;color:var(--gray-500);margin-bottom:14px} +.report-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;font-size:14px} +.report-bar .bar-track{flex:1;height:10px;background:var(--gray-100);border-radius:6px;overflow:hidden} +.report-bar .bar-fill{height:100%;background:linear-gradient(90deg,var(--cyan),var(--cyan-strong));border-radius:6px;min-width:2px} +.report-bar .bar-label{min-width:120px;color:var(--gray-700)} +.report-bar .bar-count{font-weight:700;min-width:36px;text-align:right} +.report-summary{background:#fff;border:1px solid var(--border);border-radius:var(--radius);padding:20px 24px;box-shadow:var(--shadow)} +.report-summary h3{font-size:13px;text-transform:uppercase;letter-spacing:.04em;color:var(--gray-500);margin-bottom:12px} +.report-summary p{font-size:15px;line-height:1.7} +.report-summary .big{font-size:22px;font-weight:800;color:var(--cyan-strong)} + +@media (max-width:760px){ + .topbar h1{font-size:17px} + .form-grid{grid-template-columns:1fr} + .toolbar .input{min-width:140px} + .data-table{font-size:13px} + .data-table th,.data-table td{padding:10px 8px} +}