/* 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 */}
{[['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;
})();