/* eslint-disable */ /* timing-main.jsx — standalone оболочка конструктора (без CRM-сайдбара). */ (function () { const R = window.React; const { useState, useRef, useEffect } = R; const D = window.TimingData; const P = window.TimingBuilderParts; const PR = window.TimingPrint; const TG = window.TimingGrid; const A = window.TimingAuth; const PJ = window.TimingProjects; const STORE_KEY = 'ezc.timing.doc.v3'; function histBtn(enabled) { return { width: 34, height: 34, borderRadius: 'var(--radius-input)', border: '1px solid var(--color-line-subtle)', background: 'var(--color-surface)', color: enabled ? 'var(--color-ink-700)' : 'var(--color-ink-300)', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: enabled ? 'pointer' : 'default', }; } function loadInitial() { try { const raw = localStorage.getItem(STORE_KEY); if (raw) { const d = JSON.parse(raw); if (!d.eventGrid) d.eventGrid = D.blankGrid(d.eventStart || 960); return d; } } catch (e) {} return D.loadTemplate('opening'); } function makeRow(block) { return { id: D.uid(), kind: 'row', type: block.y, title: block.t, dur: block.d, who: '', zone: block.z || '', note: '', pin: null }; } function blankDoc() { return { key: 'blank', meta: { event: 'Новое мероприятие', date: '', venue: '', guests: '', code: '', pm: '' }, prepStart: 480, eventStart: 960, prep: [], event: [], eventGrid: D.blankGrid(960) }; } /* ============ Меню шаблонов ============ */ function TemplateMenu({ onPick, onClose }) { return (
Загрузить шаблон
{D.TEMPLATE_ORDER.map(k => { const t = D.TEMPLATES[k]; return ( ); })}
); } /* ============ Главный компонент ============ */ function TimingConstructor({ user, onLogout }) { const [doc, setDocRaw] = useState(loadInitial); const [phase, setPhase] = useState('prep'); const [grabbed, setGrabbed] = useState(null); const [overIdx, setOverIdx] = useState(null); const [showTpl, setShowTpl] = useState(false); const [showPrint, setShowPrint] = useState(false); const [printOrient, setPrintOrient] = useState('portrait'); const [printFormat, setPrintFormat] = useState('print'); const [pendingTpl, setPendingTpl] = useState(null); const [showProjects, setShowProjects] = useState(false); const [currentProject, setCurrentProject] = useState(null); const [saveState, setSaveState] = useState('idle'); const [showClear, setShowClear] = useState(false); const [libCollapsed, setLibCollapsed] = useState(() => { try { return localStorage.getItem('ezc.timing.libCollapsed') === '1'; } catch (e) { return false; } }); const [, setHistTick] = useState(0); const dragInfo = useRef(null); const focusId = useRef(null); useEffect(() => { try { localStorage.setItem('ezc.timing.libCollapsed', libCollapsed ? '1' : '0'); } catch (e) {} }, [libCollapsed]); /* ---- История (undo / redo) ---- */ const hist = useRef({ past: [], future: [] }); const prevDocRef = useRef(doc); const skipHist = useRef(false); const coalesceNext = useRef(false); const lastTs = useRef(0); useEffect(() => { if (prevDocRef.current === doc) return; if (skipHist.current) { skipHist.current = false; prevDocRef.current = doc; return; } const h = hist.current; const now = Date.now(); const coalesce = coalesceNext.current && (now - lastTs.current) < 700 && h.past.length > 0; if (!coalesce) { h.past.push(prevDocRef.current); if (h.past.length > 80) h.past.shift(); } h.future = []; lastTs.current = now; coalesceNext.current = false; prevDocRef.current = doc; setHistTick(t => t + 1); }, [doc]); function setDoc(updater, coalesce) { if (coalesce) coalesceNext.current = true; setDocRaw(updater); } function undo() { const h = hist.current; if (!h.past.length) return; skipHist.current = true; h.future.push(prevDocRef.current); setDocRaw(h.past.pop()); setHistTick(t => t + 1); } function redo() { const h = hist.current; if (!h.future.length) return; skipHist.current = true; h.past.push(prevDocRef.current); setDocRaw(h.future.pop()); setHistTick(t => t + 1); } const canUndo = hist.current.past.length > 0; const canRedo = hist.current.future.length > 0; useEffect(() => { const onKey = e => { const mod = e.metaKey || e.ctrlKey; if (!mod) return; const k = e.key.toLowerCase(); if (k === 'z') { e.preventDefault(); if (e.shiftKey) redo(); else undo(); } else if (k === 'y') { e.preventDefault(); redo(); } }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, []); useEffect(() => { try { localStorage.setItem(STORE_KEY, JSON.stringify(doc)); } catch (e) {} }, [doc]); /* ---- Проекты / сброс ---- */ function loadDocFresh(nd) { skipHist.current = true; hist.current = { past: [], future: [] }; setHistTick(t => t + 1); setDocRaw(nd); } function openProject(full) { const d = full.data || {}; if (!d.eventGrid) d.eventGrid = D.blankGrid(d.eventStart || 960); loadDocFresh(d); setCurrentProject({ id: full.id, title: full.title }); setPhase('prep'); setShowProjects(false); } function newProject() { loadDocFresh(blankDoc()); setCurrentProject(null); setPhase('prep'); setShowProjects(false); } async function saveProject() { if (saveState === 'saving') return; setSaveState('saving'); const title = (doc.meta.event || '').trim() || 'Без названия'; let p = null; try { if (currentProject && currentProject.id) p = await A.api.update(currentProject.id, { title, data: doc }); else p = await A.api.create(title, doc); } catch (e) { p = null; } if (p && p.id) setCurrentProject({ id: p.id, title: p.title || title }); setSaveState('saved'); setTimeout(() => setSaveState('idle'), 1600); } function clearDoc() { loadDocFresh(blankDoc()); setCurrentProject(null); setPhase('prep'); setShowClear(false); } const items = doc[phase]; const phaseStart = phase === 'prep' ? doc.prepStart : doc.eventStart; const computed = D.computeTimes(items, phaseStart); const span = D.phaseSpan(computed, phaseStart); const sectionMeta = {}, dayMeta = {}; { let curS = null, curD = null; computed.forEach((it, i) => { if (it.kind === 'day') { curD = i; dayMeta[i] = { count: 0, dur: 0 }; curS = null; } else if (it.kind === 'section') { curS = i; sectionMeta[i] = { count: 0, dur: 0 }; } else { if (curS != null) { sectionMeta[curS].count++; sectionMeta[curS].dur += it.dur || 0; } if (curD != null) { dayMeta[curD].count++; dayMeta[curD].dur += it.dur || 0; } } }); } const hasDays = phase === 'prep' && items.some(it => it.kind === 'day'); const days = phase === 'prep' ? D.dayBlocks(items, phaseStart) : []; const prepTotalDur = days.reduce((s, d) => s + d.dur, 0); function setItems(updater, coalesce) { setDoc(prev => { const next = Object.assign({}, prev); next[phase] = typeof updater === 'function' ? updater(prev[phase]) : updater; return next; }, coalesce); } const patch = (id, partial) => setItems(arr => arr.map(it => it.id === id ? Object.assign({}, it, partial) : it), true); const del = id => setItems(arr => arr.filter(it => it.id !== id)); const dup = id => setItems(arr => { const i = arr.findIndex(it => it.id === id); if (i < 0) return arr; const copy = Object.assign({}, arr[i], { id: D.uid(), pin: null }); const next = arr.slice(); next.splice(i + 1, 0, copy); return next; }); const addRow = () => { const nid = D.uid(); focusId.current = nid; setItems(arr => arr.concat([{ id: nid, kind: 'row', type: 'program', title: '', dur: 15, who: '', zone: '', note: '', pin: null }])); }; const addRowAfter = id => { const nid = D.uid(); focusId.current = nid; setItems(arr => { const i = arr.findIndex(x => x.id === id); const row = { id: nid, kind: 'row', type: 'program', title: '', dur: 15, who: '', zone: '', note: '', pin: null }; const next = arr.slice(); next.splice(i < 0 ? arr.length : i + 1, 0, row); return next; }); }; const addSection = () => setItems(arr => arr.concat([{ id: D.uid(), kind: 'section', title: 'Новый раздел' }])); const addDay = () => setItems(arr => arr.concat([{ id: D.uid(), kind: 'day', date: '', start: 540, collapsed: false }])); function toggleCollapse(id) { skipHist.current = true; setDocRaw(prev => { const next = Object.assign({}, prev); next[phase] = prev[phase].map(it => it.id === id ? Object.assign({}, it, { collapsed: !it.collapsed }) : it); return next; }); } function docHasContent() { return doc.prep.length > 0 || doc.event.length > 0 || (doc.eventGrid && doc.eventGrid.cards.length > 0); } const addFromLib = block => { if (phase === 'prep') { setItems(arr => arr.concat([makeRow(block)])); return; } setDoc(prev => { const g = prev.eventGrid; if (!g.tracks.length || !g.slots.length) return prev; const lastSlot = g.slots.slice().sort((a, b) => a.time - b.time).slice(-1)[0]; return Object.assign({}, prev, { eventGrid: Object.assign({}, g, { cards: g.cards.concat([{ id: D.uid(), trackId: g.tracks[0].id, slotId: lastSlot.id, title: block.t, dur: block.d, who: '', note: '' }]) }) }); }); }; const setPhaseStart = v => setDoc(prev => Object.assign({}, prev, phase === 'prep' ? { prepStart: v } : { eventStart: v }), true); const setMeta = (k, v) => setDoc(prev => Object.assign({}, prev, { meta: Object.assign({}, prev.meta, { [k]: v }) }), true); /* ---- Grid (мероприятие) ---- */ const isEvent = phase === 'event'; const eSpan = D.gridSpan(doc.eventGrid); function setGrid(updater, coalesce) { setDoc(prev => Object.assign({}, prev, { eventGrid: typeof updater === 'function' ? updater(prev.eventGrid) : updater }), coalesce); } const gridOps = { addCard: (trackId, slotId, partial) => setGrid(g => ({ ...g, cards: g.cards.concat([{ id: D.uid(), trackId, slotId, title: (partial && partial.title) || '', dur: (partial && partial.dur) || null, who: '', note: '' }]) })), addCardFromLib: (trackId, slotId, block) => setGrid(g => ({ ...g, cards: g.cards.concat([{ id: D.uid(), trackId, slotId, title: block.t, dur: block.d, who: '', note: '' }]) })), patchCard: (id, p) => setGrid(g => ({ ...g, cards: g.cards.map(c => c.id === id ? { ...c, ...p } : c) }), true), delCard: id => setGrid(g => ({ ...g, cards: g.cards.filter(c => c.id !== id) })), moveCard: (id, trackId, slotId) => setGrid(g => ({ ...g, cards: g.cards.map(c => c.id === id ? { ...c, trackId, slotId } : c) })), patchTrack: (id, p) => setGrid(g => ({ ...g, tracks: g.tracks.map(t => t.id === id ? { ...t, ...p } : t) }), true), delTrack: id => setGrid(g => ({ ...g, tracks: g.tracks.filter(t => t.id !== id), cards: g.cards.filter(c => c.trackId !== id) })), addTrack: () => setGrid(g => ({ ...g, tracks: g.tracks.concat([{ id: D.uid(), name: 'Новая колонка', color: D.nextTrackKey(g.tracks.map(t => t.color)) }]) })), patchSlot: (id, p) => setGrid(g => ({ ...g, slots: g.slots.map(s => s.id === id ? { ...s, ...p } : s) }), true), delSlot: id => setGrid(g => ({ ...g, slots: g.slots.filter(s => s.id !== id), cards: g.cards.filter(c => c.slotId !== id) })), addSlot: () => setGrid(g => { const max = g.slots.length ? Math.max.apply(null, g.slots.map(s => s.time)) : doc.eventStart; return { ...g, slots: g.slots.concat([{ id: D.uid(), time: max + 15 }]) }; }), }; function applyTemplate(k) { if (k === '__blank') setDoc(blankDoc()); else setDoc(D.loadTemplate(k)); setCurrentProject(null); setPhase('prep'); setPendingTpl(null); } function pickTemplate(k) { setShowTpl(false); if (docHasContent()) setPendingTpl(k); else applyTemplate(k); } /* ---- DnD ---- */ function onListDragOver(e) { e.preventDefault(); if (e.target.getAttribute && e.target.hasAttribute('data-list-pad')) setOverIdx(items.length); } function rowDragOver(e, i) { e.preventDefault(); const rect = e.currentTarget.getBoundingClientRect(); const after = e.clientY > rect.top + rect.height / 2; setOverIdx(after ? i + 1 : i); } function onDrop(e) { e.preventDefault(); const di = dragInfo.current; const idx = overIdx == null ? items.length : overIdx; if (di) { setItems(arr => { const next = arr.slice(); if (di.type === 'move') { const from = next.findIndex(x => x.id === di.id); if (from < 0) return arr; const kind = next[from].kind; if (kind === 'day' || kind === 'section') { let end = from + 1; while (end < next.length) { const k = next[end].kind; if (k === 'day') break; if (kind === 'section' && k === 'section') break; end++; } const block = next.splice(from, end - from); let target = idx; if (from < idx) target -= block.length; target = Math.max(0, Math.min(target, next.length)); next.splice.apply(next, [target, 0].concat(block)); } else { const [moved] = next.splice(from, 1); let target = idx; if (from < idx) target--; next.splice(target, 0, moved); } } else if (di.type === 'lib') { next.splice(idx, 0, makeRow(di.block)); } return next; }); } setOverIdx(null); setGrabbed(null); dragInfo.current = null; } function grab(id) { setGrabbed(id); dragInfo.current = id ? { type: 'move', id } : null; } function dragOverFor(i) { if (overIdx == null) return null; if (overIdx === i) return 'before'; if (i === items.length - 1 && overIdx === items.length) return 'after'; return null; } return (
{/* Top bar */}
EZ
setMeta('event', e.target.value)} placeholder="Название мероприятия" />
{[doc.meta.date, doc.meta.venue].filter(Boolean).join(' · ') || 'Конструктор тайминга'}
{[['prep', 'Подготовка'], ['event', 'Мероприятие']].map(([k, label]) => ( ))}
{showTpl && setShowTpl(false)} />}
{/* Body */}
{libCollapsed ? ( ) : ( )}
{phase === 'prep' ? 'Тайминг подготовки и монтажа' : 'Тайминг мероприятия · сетка'}
{isEvent ? D.fmt(eSpan.start) + '–' + D.fmt(eSpan.end) + ' · ' + D.dur(eSpan.total) + ' · ' + doc.eventGrid.tracks.length + ' колонок · ' + doc.eventGrid.cards.length + ' карточек' : hasDays ? days.length + ' дн. · ' + D.dur(prepTotalDur) + ' работы · ' + computed.filter(c => c.kind === 'row').length + ' блоков' : D.fmt(span.start) + '–' + D.fmt(span.end) + ' · ' + D.dur(span.total) + ' · ' + computed.filter(c => c.kind === 'row').length + ' блоков'}
{!isEvent && (
)} {isEvent && (
)}
{isEvent ? (
) : (
{ if (e.target === e.currentTarget) setOverIdx(null); }}> {(() => { let dayCollapsed = false, secCollapsed = false, dayNum = 0; return computed.map((it, i) => { if (it.kind === 'day') { dayNum++; dayCollapsed = !!it.collapsed; secCollapsed = false; return (
rowDragOver(e, i)}> patch(it.id, p)} onDelete={() => del(it.id)} onGrab={grab} grabbed={grabbed === it.id} dragOver={dragOverFor(i)} collapsed={!!it.collapsed} onToggle={() => toggleCollapse(it.id)} subtotal={dayMeta[i]} />
); } if (it.kind === 'section') { if (dayCollapsed) return null; secCollapsed = !!it.collapsed; return (
rowDragOver(e, i)}> patch(it.id, p)} onDelete={() => del(it.id)} onGrab={grab} grabbed={grabbed === it.id} dragOver={dragOverFor(i)} collapsed={!!it.collapsed} onToggle={() => toggleCollapse(it.id)} subtotal={sectionMeta[i]} />
); } if (dayCollapsed || secCollapsed) return null; return (
rowDragOver(e, i)}> {it._gap > 0 && } patch(it.id, p)} onDelete={() => del(it.id)} onDup={() => dup(it.id)} onGrab={grab} grabbed={grabbed === it.id} dragOver={dragOverFor(i)} onAddAfter={addRowAfter} autoFocusId={focusId.current} />
); }); })()}
{items.length === 0 ?
Перетащите блоки из библиотеки слева
или нажмите «Строка»
: }
)}
{/* Print preview (portal → body, so print isolation works) */} {showPrint && (() => { const PAGE = { print: { portrait: [210, 297], landscape: [297, 210] }, screen: { portrait: [202.5, 360], landscape: [360, 202.5] } }; const dims = PAGE[printFormat][printOrient]; const pmargin = printFormat === 'screen' ? 10 : 14; const pageStyle = { width: dims[0] + 'mm', minHeight: dims[1] + 'mm', padding: pmargin + 'mm' }; const pageCss = '@page{size:' + dims[0] + 'mm ' + dims[1] + 'mm;margin:' + pmargin + 'mm;}'; const seg = active => ({ padding: '6px 12px', border: 0, borderRadius: 6, fontSize: 12.5, fontWeight: active ? 600 : 500, whiteSpace: 'nowrap', background: active ? 'var(--color-surface)' : 'transparent', color: active ? 'var(--color-ink-900)' : 'var(--color-ink-500)', boxShadow: active ? '0 1px 2px rgba(20,18,14,.06)' : 'none' }); const segWrap = { display: 'inline-flex', gap: 3, background: 'var(--color-muted-50)', borderRadius: 9, padding: 3 }; return ReactDOM.createPortal(
Экспорт в PDF
2 листа: подготовка и мероприятие. В диалоге печати — «Сохранить как PDF».
, document.body ); })()} {pendingTpl && ReactDOM.createPortal(
Заменить текущий тайминг?
Загрузка шаблона перезапишет все текущие блоки, сетку и шапку документа. Действие можно отменить через ⌘Z.
, document.body )} {showProjects && setShowProjects(false)} onOpen={openProject} onNew={newProject} currentId={currentProject ? currentProject.id : null} />} {showClear && ReactDOM.createPortal(
{ if (e.target === e.currentTarget) setShowClear(false); }}>
Очистить весь тайминг и начать с нуля?
Текущий документ будет сброшен к пустому. Действие необратимо. Сохранённые проекты не затрагиваются.
, document.body )}
); } /* ============ Гейт авторизации ============ */ function AuthGate() { const [status, setStatus] = useState('checking'); const [user, setUser] = useState(null); useEffect(() => { let on = true; (async () => { const r = await A.checkAuth(); if (!on) return; if (r.authed) { setUser(r.user); setStatus('authed'); } else setStatus('login'); })(); return () => { on = false; }; }, []); if (status === 'checking') { return (
); } if (status === 'login') return { setUser(u); setStatus('authed'); }} />; return { A.api.logout(); setStatus('login'); }} />; } window.TimingApp = AuthGate; })();