import { SERVICES, BUFFER_MIN, freeSlots, parseTime, fmtTime, dateShift, weekday, nowTallinn, esc, html, newRef, newToken, rateOk, makeSession, checkSession, csrfToken, notifyCancelled, } from './api/_lib.js'; const CSS = `:root{--paper:#EFE8D8;--paper2:#F7F2E6;--ink:#181309;--ink2:#5E5645;--dark:#131008;--bone:#F0E9DA;--bone2:#A89C82;--brass:#8F6B2C;--brass-b:#C9A35C;--ok:#3E5E34;--bad:#7A3B2E} *{margin:0;padding:0;box-sizing:border-box} body{background:var(--paper);color:var(--ink);font-family:Archivo,-apple-system,Arial,sans-serif;font-size:15px;min-height:100vh} a{color:inherit} header{background:var(--dark);color:var(--bone);position:sticky;top:0;z-index:10} .hbar{display:flex;align-items:center;gap:12px;padding:12px 18px;max-width:1080px;margin:0 auto} .mono{font-family:Georgia,serif;font-style:italic;color:var(--brass-b);border:1.5px solid var(--brass-b);width:30px;height:30px;display:grid;place-items:center;flex:none} .hbar b{letter-spacing:.1em;font-size:14px}.hbar .sp{flex:1} nav.tabs{display:flex;gap:2px;max-width:1080px;margin:0 auto;padding:0 18px 10px;flex-wrap:wrap} nav.tabs a{font-size:12px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--bone2);padding:8px 13px;text-decoration:none;border:1px solid rgba(240,233,218,.2)} nav.tabs a.on{background:var(--brass-b);color:var(--dark);border-color:var(--brass-b)} main{max-width:1080px;margin:0 auto;padding:22px 18px 80px} h1{font-family:Georgia,serif;font-weight:600;font-size:24px;margin-bottom:4px} .sub{color:var(--ink2);font-size:13px;margin-bottom:18px} .flash{background:#DFE8D9;color:var(--ok);padding:11px 16px;margin-bottom:18px;font-size:14px;border:1px solid #c6d4bd} .datenav{display:flex;gap:8px;align-items:center;margin-bottom:20px;flex-wrap:wrap} .datenav a{border:1.5px solid var(--ink);padding:8px 14px;font-size:13px;font-weight:700;text-decoration:none} .datenav a:hover{background:var(--ink);color:var(--paper)} .datenav .cur{font-family:Georgia,serif;font-style:italic;font-size:17px;padding:0 8px} .card{background:var(--paper2);border:1px solid rgba(24,19,9,.3);margin-bottom:12px;display:flex;overflow:hidden} .card .t{flex:none;width:86px;background:var(--dark);color:var(--bone);display:flex;flex-direction:column;align-items:center;justify-content:center;padding:12px 6px} .card .t b{font-size:17px}.card .t span{font-size:10px;color:var(--bone2)} .card .t i{font-style:normal;font-size:9.5px;color:var(--brass-b);margin-top:5px} .card .b{flex:1;padding:12px 16px;min-width:0} .card .b h3{font-size:15.5px;margin-bottom:2px} .card .b .cl{font-size:13.5px;color:var(--ink2)} .card .b .cl a{color:var(--brass);font-weight:600;text-decoration:none} .card .acts{flex:none;display:flex;flex-direction:column;gap:6px;padding:12px;justify-content:center} .abtn{border:1.5px solid var(--ink);background:none;font:700 10.5px Archivo;letter-spacing:.06em;text-transform:uppercase;padding:7px 10px;cursor:pointer} .abtn:hover{background:var(--ink);color:var(--paper)} .abtn.warn{border-color:var(--bad);color:var(--bad)}.abtn.warn:hover{background:var(--bad);color:#fff} .card.block{opacity:.85} .card.block .t{background:repeating-linear-gradient(45deg,#2a251c,#2a251c 6px,#1d1912 6px,#1d1912 12px)} .card.done{opacity:.6} .tag{display:inline-block;font-size:9.5px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;padding:2px 7px;margin-left:6px;vertical-align:2px} .tag.done{background:#DFE8D9;color:var(--ok)}.tag.adm{background:#E8E0CC;color:var(--brass)} .empty{border:2px dashed rgba(24,19,9,.3);padding:34px;text-align:center;color:var(--ink2);font-style:italic;font-family:Georgia,serif} .grid2{display:grid;gap:22px}@media(min-width:860px){.grid2{grid-template-columns:1fr 1fr}} form.panel{background:var(--paper2);border:2px solid var(--ink);padding:20px} form.panel h2{font-family:Georgia,serif;font-size:19px;margin-bottom:14px} form.panel label{display:block;font-size:10.5px;font-weight:700;letter-spacing:.14em;text-transform:uppercase;color:var(--ink2);margin:12px 0 4px} form.panel input,form.panel select{width:100%;padding:11px;border:1px solid rgba(24,19,9,.4);background:#fff;font:500 14px Archivo;color:var(--ink)} form.panel .row2{display:grid;grid-template-columns:1fr 1fr;gap:12px} .sbtn{margin-top:16px;width:100%;background:var(--dark);color:var(--bone);border:0;font:700 13px Archivo;letter-spacing:.08em;text-transform:uppercase;padding:14px;cursor:pointer} .sbtn:hover{background:var(--brass)} table.cl{width:100%;border-collapse:collapse;font-size:14px} table.cl th{font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--ink2);text-align:left;padding:8px;border-bottom:2px solid var(--ink)} table.cl td{padding:10px 8px;border-bottom:1px solid rgba(24,19,9,.15);vertical-align:top} table.cl td a{color:var(--brass);text-decoration:none;font-weight:600} .wk{display:grid;gap:10px}@media(min-width:900px){.wk{grid-template-columns:repeat(7,1fr)}} .wd{background:var(--paper2);border:1px solid rgba(24,19,9,.3);padding:10px;min-height:90px} .wd h4{font-size:11px;letter-spacing:.1em;text-transform:uppercase;color:var(--ink2);margin-bottom:8px} .wd h4 a{text-decoration:none} .wd .it{font-size:12px;padding:5px 7px;background:var(--dark);color:var(--bone);margin-bottom:5px} .wd .it.blk{background:repeating-linear-gradient(45deg,#2a251c,#2a251c 6px,#1d1912 6px,#1d1912 12px)} .wd.today{outline:2px solid var(--brass-b)} .login{max-width:340px;margin:12vh auto;padding:0 20px} .login h1{margin-bottom:16px} .login input{width:100%;padding:13px;border:2px solid var(--ink);background:#fff;font-size:15px} .stat{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-bottom:22px} .stat div{background:var(--paper2);border:1px solid rgba(24,19,9,.3);padding:12px;text-align:center} .stat b{font-family:Georgia,serif;font-size:22px;display:block} .stat span{font-size:10px;letter-spacing:.1em;text-transform:uppercase;color:var(--ink2)}`; const page = body => html(` Mekan CRM${body}`); const hidden = (csrf, extra = {}) => `` + Object.entries(extra).map(([k, v]) => ``).join(''); const prettyDay = ds => new Date(ds + 'T12:00:00Z').toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', timeZone: 'UTC' }); const dm = ds => ds.slice(8, 10) + '.' + ds.slice(5, 7); async function dayRows(env, date) { const { results } = await env.DB.prepare( "SELECT * FROM bookings WHERE date=?1 AND status!='cancelled' ORDER BY start_min").bind(date).all(); return results; } /* ================= VIEWS ================= */ function shell(view, inner, flash) { const tab = (v, label, extra = '') => `${label}`; return `
MMEKAN CRM Выйти
${flash ? `
${esc(flash)}
` : ''}${inner}
`; } async function viewDay(env, d, csrf) { const rows = await dayRows(env, d); const { results: cancelled } = await env.DB.prepare( "SELECT * FROM bookings WHERE date=?1 AND status='cancelled' ORDER BY start_min").bind(d).all(); const visits = rows.filter(r => r.source !== 'block'); const rev = visits.reduce((s, r) => s + r.price, 0); const today = nowTallinn().date; const card = r => { const isBlock = r.source === 'block'; const maps = 'https://www.google.com/maps/search/?api=1&query=' + encodeURIComponent(r.address + ', Estonia'); return `
${fmtTime(r.start_min)}до ${fmtTime(r.end_min)}${isBlock ? '' : `+${r.buffer_min}м буфер`}
${isBlock ? `

⛔ ${esc(r.service_name)}

Время закрыто для записи
` : `

${esc(r.service_name)} · ${r.price}€ ${r.status === 'done' ? 'выполнено' : ''} ${r.source === 'admin' ? 'вручную' : ''}

${esc(r.name)} · ${esc(r.phone)} · WhatsApp
${r.note ? `
✎ ${esc(r.note)}
` : ''}`}
${isBlock ? `
${hidden(csrf, { action: 'delete_block', id: r.id, back: 'day', backd: d })}
` : `${r.status === 'confirmed' ? `
${hidden(csrf, { action: 'done', id: r.id, back: 'day', backd: d })}
` : ''}
${hidden(csrf, { action: 'cancel', id: r.id, back: 'day', backd: d })}
`}
`; }; return `

${prettyDay(d)}

${d === today ? 'Сегодня' : d} · после каждой записи заложен буфер ${BUFFER_MIN} мин

${dm(d)} Сегодня
${visits.length}визитов
${rev}€выручка дня
${cancelled.length}отмен
${rows.length ? rows.map(card).join('') : '
Свободный день — ни одной записи
'} ${cancelled.length ? `

Отменённые:

` + cancelled.map(r => `
${fmtTime(r.start_min)}

${esc(r.service_name)}

${esc(r.name)} · ${esc(r.phone)}
${hidden(csrf, { action: 'restore', id: r.id, back: 'day', backd: d })}
`).join('') : ''}`; } async function viewWeek(env, d) { const dow = (weekday(d) + 6) % 7; // 0=пн const monday = dateShift(d, -dow); const today = nowTallinn().date; let cols = ''; for (let i = 0; i < 7; i++) { const dd = dateShift(monday, i); const rr = await dayRows(env, dd); cols += `

${new Date(dd + 'T12:00:00Z').toLocaleDateString('en-GB', { weekday: 'short', timeZone: 'UTC' })} ${Number(dd.slice(8))}.${Number(dd.slice(5, 7))}

${rr.length ? rr.map(r => `
${fmtTime(r.start_min)} ${r.source === 'block' ? '⛔' : esc((r.name || r.service_name).slice(0, 14))}
`).join('') : '
'}
`; } return `

Неделя

${dm(monday)} — ${dm(dateShift(monday, 6))} Тек. неделя
${cols}
`; } function viewAdd(d, csrf) { const opts = Object.entries(SERVICES).map(([id, s]) => ``).join(''); return `

Добавить

Ручная запись или блокировка времени. Буфер ${BUFFER_MIN} мин учитывается автоматически.

Новая запись

${hidden(csrf, { action: 'add_booking', back: 'day', backd: d })}

Заблокировать время

${hidden(csrf, { action: 'add_block', back: 'day', backd: d })}
`; } async function viewClients(env, d, csrf) { const { results: clients } = await env.DB.prepare( `SELECT phone, MAX(name) name, COUNT(*) visits, SUM(price) total, MAX(date) last FROM bookings WHERE source!='block' AND status IN ('confirmed','done') AND phone!='' GROUP BY phone ORDER BY last DESC`).all(); const { results: noteRows } = await env.DB.prepare('SELECT phone, note FROM client_notes').all(); const notes = Object.fromEntries(noteRows.map(n => [n.phone, n.note])); return `

Клиенты (${clients.length})

Группировка по номеру телефона. Заметка сохраняется по кнопке ✓.

${clients.map(c => ``).join('')}
КлиентВизитыСуммаПоследнийЗаметка
${esc(c.name || '—')}
${esc(c.phone)} · WA
${c.visits}${c.total}€${esc(c.last)}
${hidden(csrf, { action: 'client_note', back: 'clients', backd: d, phone: c.phone })}
${clients.length ? '' : '
Пока нет клиентов
'}`; } /* ================= HANDLERS ================= */ const loginPage = err => page(`

Mekan CRM

${err ? `

${esc(err)}

` : ''}
`); export async function onRequestGet({ request, env }) { if (!env.DB) return page('

Mekan CRM

База D1 не привязана — см. README.

'); const url = new URL(request.url); if (url.searchParams.get('logout')) { return new Response(null, { status: 302, headers: { Location: '/admin', 'Set-Cookie': 'adm=; Path=/; Max-Age=0' } }); } if (!(await checkSession(env, request.headers.get('Cookie')))) return loginPage(''); const view = url.searchParams.get('v') || 'day'; const dRaw = url.searchParams.get('d') || ''; const d = /^\d{4}-\d{2}-\d{2}$/.test(dRaw) ? dRaw : nowTallinn().date; const flash = url.searchParams.get('m') || ''; const csrf = await csrfToken(env); let inner; if (view === 'week') inner = await viewWeek(env, d); else if (view === 'add') inner = viewAdd(d, csrf); else if (view === 'clients') inner = await viewClients(env, d, csrf); else inner = await viewDay(env, d, csrf); return page(shell(view, inner, flash)); } export async function onRequestPost({ request, env }) { if (!env.DB) return loginPage('База D1 не привязана'); const form = await request.formData(); const f = k => String(form.get(k) ?? '').trim(); /* логин */ if (form.has('pw')) { const ip = request.headers.get('CF-Connecting-IP') || '0'; if (!(await rateOk(env, 'admlogin:' + ip, 10, 600))) return loginPage('Слишком много попыток — подождите'); if (!env.ADMIN_PASSWORD || f('pw') !== env.ADMIN_PASSWORD) return loginPage('Неверный пароль'); return new Response(null, { status: 302, headers: { Location: '/admin', 'Set-Cookie': `adm=${await makeSession(env)}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=43200`, } }); } if (!(await checkSession(env, request.headers.get('Cookie')))) return loginPage(''); if (f('csrf') !== await csrfToken(env)) return page('

Bad CSRF — обновите страницу

'); const a = f('action'); let flash = ''; const back = f('back') || 'day'; const backd = /^\d{4}-\d{2}-\d{2}$/.test(f('backd')) ? f('backd') : nowTallinn().date; if (a === 'cancel' || a === 'done' || a === 'restore') { const id = Number(f('id')); const b = await env.DB.prepare('SELECT * FROM bookings WHERE id=?1').bind(id).first(); if (b) { const st = a === 'cancel' ? 'cancelled' : a === 'done' ? 'done' : 'confirmed'; await env.DB.prepare('UPDATE bookings SET status=?1 WHERE id=?2').bind(st, id).run(); if (a === 'cancel' && b.source !== 'block') await notifyCancelled(env, b, 'админ'); flash = a === 'cancel' ? 'Запись отменена' : a === 'done' ? 'Отмечено выполненным' : 'Восстановлено'; } } if (a === 'delete_block') { await env.DB.prepare("DELETE FROM bookings WHERE id=?1 AND source='block'").bind(Number(f('id'))).run(); flash = 'Блокировка снята'; } if (a === 'add_booking') { const svc = SERVICES[f('service')]; const date = f('date'), time = f('time'); if (svc && /^\d{4}-\d{2}-\d{2}$/.test(date) && /^\d{1,2}:\d{2}$/.test(time)) { const start = parseTime(time), end = start + svc.duration; const slots = await freeSlots(env, date, svc.duration); if (slots.includes(start)) { await env.DB.prepare(`INSERT INTO bookings (ref,status,service_id,service_name,price,date,start_min,end_min,buffer_min,name,phone,email,address,note,token,source,created_at) VALUES (?1,'confirmed',?2,?3,?4,?5,?6,?7,?8,?9,?10,'',?11,?12,?13,'admin',?14)`) .bind(newRef(), f('service'), svc.name, svc.price, date, start, end, BUFFER_MIN, f('name').slice(0, 120), f('phone').replace(/[^\d+ ]/g, '').slice(0, 30), f('address').slice(0, 250), f('note').slice(0, 500), newToken(), new Date().toISOString()).run(); flash = 'Запись добавлена'; } else flash = '⚠ Этот слот занят (учтён буфер ' + BUFFER_MIN + ' мин) — выберите другое время'; } else flash = '⚠ Заполните услугу, дату и время'; } if (a === 'add_block') { const date = f('date'), from = f('from'), to = f('to'); if (/^\d{4}-\d{2}-\d{2}$/.test(date) && /^\d{1,2}:\d{2}$/.test(from) && /^\d{1,2}:\d{2}$/.test(to) && parseTime(to) > parseTime(from)) { await env.DB.prepare(`INSERT INTO bookings (ref,status,service_id,service_name,price,date,start_min,end_min,buffer_min,name,phone,email,address,note,token,source,created_at) VALUES (?1,'block','',?2,0,?3,?4,?5,0,'','','','','','','block',?6)`) .bind(newRef(), (f('note') || 'Занято').slice(0, 120), date, parseTime(from), parseTime(to), new Date().toISOString()).run(); flash = 'Время заблокировано'; } else flash = '⚠ Проверьте дату и время блокировки'; } if (a === 'client_note') { await env.DB.prepare('INSERT INTO client_notes(phone,note) VALUES(?1,?2) ON CONFLICT(phone) DO UPDATE SET note=excluded.note') .bind(f('phone').slice(0, 30), f('note').slice(0, 500)).run(); flash = 'Заметка сохранена'; } return new Response(null, { status: 302, headers: { Location: `/admin?v=${encodeURIComponent(back)}&d=${encodeURIComponent(backd)}&m=${encodeURIComponent(flash)}`, } }); }