/* Mandera Portal — Client app shell + sections (live on MData). */ function StatusPill({ status }) { const { Badge } = window.ManderaDesignSystem_8327ce; const map = { Confirmed: ['positive', 'soft'], Scheduled: ['positive', 'soft'], Pending: ['caution', 'outline'], Declined: ['critical', 'outline'], Posted: ['neutral', 'soft'], Draft: ['neutral', 'outline'], 'In progress': ['accent', 'soft'], Planning: ['caution', 'outline'], done: ['positive', 'soft'], 'in-progress': ['accent', 'soft'], open: ['caution', 'outline'], }; const [tone, variant] = map[status] || ['neutral', 'outline']; const label = status === 'in-progress' ? 'In progress' : status.charAt(0).toUpperCase() + status.slice(1); return {label}; } function SectionWrap({ children, pad = 32 }) { return
{children}
; } function Empty({ icon, text }) { return (
{text}
); } const fmtDate = (iso) => { try { return new Date(iso + 'T00:00').toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }); } catch (e) { return iso; } }; const _todayISO = () => new Date().toISOString().slice(0, 10); function dayLabel(iso) { const t = new Date(_todayISO() + 'T00:00'); const d = new Date(iso + 'T00:00'); const diff = Math.round((d - t) / 86400000); if (diff === 0) return 'Today'; if (diff === 1) return 'Tomorrow'; if (diff > 1 && diff < 7) return new Date(iso + 'T00:00').toLocaleDateString('en-US', { weekday: 'long' }); return new Date(iso + 'T00:00').toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }); } /* Build an upcoming agenda (today onward) from calendar posts + shoots. Pass clientId to scope to one client, or null for every client (owner view). */ function buildAgenda(clientId) { const today = _todayISO(); const cal = (clientId ? window.MData.forClient('calendar', clientId) : window.MData.get('calendar')) .filter((c) => c.date && c.date >= today) .map((c) => ({ id: c.id, kind: 'post', date: c.date, time: c.time || '', title: c.title, meta: c.platform, status: c.status, clientId: c.clientId, media: c.media })); const shoots = (clientId ? window.MData.forClient('shoots', clientId) : window.MData.get('shoots')) .filter((s) => s.date && s.date >= today) .map((s) => ({ id: s.id, kind: 'shoot', date: s.date, time: s.time || '', title: s.title, meta: s.location, status: s.status, clientId: s.clientId })); const all = cal.concat(shoots).sort((a, b) => (a.date + (a.time || '')).localeCompare(b.date + (b.time || ''))); const byDate = {}; all.forEach((it) => { (byDate[it.date] = byDate[it.date] || []).push(it); }); return Object.keys(byDate).sort().map((d) => ({ date: d, items: byDate[d] })); } function AgendaRow({ it, showClient }) { const client = showClient && it.clientId ? window.MData.client(it.clientId) : null; const isImg = it.media && it.media.type && it.media.type.startsWith('image'); return (
{isImg ? : }
{it.title}
{client ? client.brand + ' · ' : ''}{it.kind === 'shoot' ? 'Shoot' : it.meta}{it.time ? ' · ' + it.time : ''}
); } function Agenda({ clientId, showClient, emptyText }) { const days = buildAgenda(clientId); if (!days.length) return ; return (
{days.map((g) => (
{dayLabel(g.date)} {fmtDate(g.date)} {g.items.length} item{g.items.length > 1 ? 's' : ''}
{g.items.map((it) => )}
))}
); } /* Items scheduled for TODAY (shoots + posts), across one client or all. */ function todayItems(clientId) { const today = _todayISO(); const cal = (clientId ? window.MData.forClient('calendar', clientId) : window.MData.get('calendar')) .filter((c) => c.date === today) .map((c) => ({ id: c.id, kind: 'post', date: c.date, time: c.time || '', title: c.title, meta: c.platform, status: c.status, clientId: c.clientId, media: c.media })); const shoots = (clientId ? window.MData.forClient('shoots', clientId) : window.MData.get('shoots')) .filter((s) => s.date === today) .map((s) => ({ id: s.id, kind: 'shoot', date: s.date, time: s.time || '', title: s.title, meta: s.location, status: s.status, clientId: s.clientId })); return cal.concat(shoots).sort((a, b) => (a.time || '').localeCompare(b.time || '')); } /* A "Today" card — the current date + everything happening on it. Used on both the client dashboard and the owner overview so they match. */ function TodayPanel({ clientId, showClient }) { const now = new Date(); const weekday = now.toLocaleDateString('en-US', { weekday: 'long' }); const rest = now.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }); const items = todayItems(clientId || null); return (
Today {weekday}, {rest} {items.length} scheduled
{items.length ?
{items.map((it) => )}
:
Nothing scheduled for today.
}
); } /* ---------------- Dashboard ---------------- */ function ClientDashboard({ clientId, client, acc, go }) { const { Button, Card } = window.ManderaDesignSystem_8327ce; const shoots = window.MData.forClient('shoots', clientId).slice().sort((a, b) => a.date.localeCompare(b.date)); const calendar = window.MData.forClient('calendar', clientId).slice().sort((a, b) => a.date.localeCompare(b.date)); const projects = window.MData.forClient('projects', clientId); const timeline = window.MData.forClient('timeline', clientId); const nextShoot = shoots[0]; const nextPost = calendar[0]; return (
Welcome back · {client.brand}

Good to see you, {(acc.name || '').split(' ')[0]}.

Your account lead is {client.manager}. Here’s where things stand across your projects.

{[ { ic: 'camera', lbl: 'Next shoot', val: nextShoot ? fmtDate(nextShoot.date) : '—', sub: nextShoot ? nextShoot.title : 'Nothing booked', color: 'var(--azure-400)' }, { ic: 'send', lbl: 'Next post', val: nextPost ? fmtDate(nextPost.date) : '—', sub: nextPost ? nextPost.title : 'Nothing scheduled', color: 'var(--positive)' }, { ic: 'folder-kanban', lbl: 'Active projects', val: projects.length + '', sub: projects.map((p) => p.name).join(' · ') || '—', color: 'var(--accent-text)' }, ].map((s) => (
{s.lbl}
{s.val}
{s.sub}
))}

Coming up

Upcoming shoots

{shoots.slice(0, 3).map((s) => (
{s.title}
{fmtDate(s.date)} · {s.time}
))} {!shoots.length && }

Project timeline

{timeline.slice(0, 5).map((t, i) => (
{i < Math.min(timeline.length, 5) - 1 && }
{t.task}
{fmtDate(t.date)}
))} {!timeline.length && }
); } /* ---------------- Shoots ---------------- */ function ClientShoots({ clientId, client, acc, focus, bump }) { const { Button } = window.ManderaDesignSystem_8327ce; const shoots = window.MData.forClient('shoots', clientId).slice().sort((a, b) => a.date.localeCompare(b.date)); const [openForm, setOpenForm] = React.useState(null); const [note, setNote] = React.useState(''); const brand = client ? client.brand : 'A client'; const teamMail = (subject, intro, rows) => { try { fetch('../api/portal-mail.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'notify_team', subject, intro, rows }) }).catch(() => {}); } catch (e) {} }; const respond = (s, ok) => { window.MData.update('shoots', s.id, { status: ok ? 'Confirmed' : 'Declined', respondedBy: acc.name, respondedAt: Date.now() }); window.MData.notify({ audience: 'team', type: 'shoot', text: brand + (ok ? ' approved' : ' declined') + ' the shoot “' + s.title + '” · ' + fmtDate(s.date), link: 'clients', refId: clientId }); teamMail((ok ? 'Shoot approved' : 'Shoot declined') + ' — ' + brand, brand + (ok ? ' approved' : ' declined') + ' a scheduled shoot.', [['Shoot', s.title], ['Date', fmtDate(s.date)], ['Time', s.time || '—'], ['Location', s.location || '—'], ['Response', ok ? 'Approved' : 'Declined']]); bump(); }; const requestChange = (s) => { const t = note.trim(); if (!t) return; window.MData.update('shoots', s.id, { changeRequest: { note: t, by: acc.name, ts: Date.now() } }); window.MData.notify({ audience: 'team', type: 'shootchange', text: brand + ' requested a change to the shoot “' + s.title + '”: “' + t + '”', link: 'clients', refId: clientId }); teamMail('Shoot change request — ' + brand, brand + ' asked to change a scheduled shoot.', [['Shoot', s.title], ['Currently', fmtDate(s.date) + (s.time ? ' · ' + s.time : '')], ['Location', s.location || '—'], ['Request', t]]); setOpenForm(null); setNote(''); bump(); }; const chipBtn = (color, bg, border) => ({ appearance: 'none', display: 'inline-flex', alignItems: 'center', gap: 7, cursor: 'pointer', fontFamily: 'var(--font-sans)', fontSize: 12.5, fontWeight: 600, color, background: bg, border: '1px solid ' + border, borderRadius: 'var(--radius-pill)', padding: '7px 14px' }); return ( {shoots.map((s) => { const hot = focus && focus === s.id; return (

{s.title}

{[['calendar', fmtDate(s.date)], ['clock', s.time], ['map-pin', s.location]].map(([ic, v]) => ( {v} ))}
{s.notes &&

{s.notes}

} {s.changeRequest && (
Change requested: “{s.changeRequest.note}” — your Mandera team has been emailed and will confirm the new details.
)} {s.status !== 'Declined' && (
{s.status === 'Pending' && ( )} {!s.changeRequest && ( )}
)} {openForm === s.id && (
{ e.preventDefault(); requestChange(s); }} style={{ display: 'flex', gap: 8, marginTop: 12 }}> setNote(e.target.value)} placeholder="What would you like to change? e.g. Can we move this to Thursday afternoon?" style={{ flex: 1, minWidth: 0, height: 40, padding: '0 12px', background: 'var(--surface-sunken)', border: '1px solid var(--border-default)', borderRadius: 'var(--radius-md)', color: 'var(--text-strong)', fontFamily: 'var(--font-sans)', fontSize: 13.5, outline: 'none' }} />
)}
); })} {!shoots.length && }
); } /* ---------------- Content calendar ---------------- */ /* Approve / request changes on a post. “Request changes” asks for a note, which lands in the item's note thread so it shows under the content on both sides. Works for Draft AND Scheduled items. */ function ApprovalRow({ it, clientId, acc, bump }) { const [open, setOpen] = React.useState(false); const [note, setNote] = React.useState(''); const cl = window.MData.client(clientId); const brand = cl ? cl.brand : 'A client'; const approve = () => { window.MData.update('calendar', it.id, { approval: 'approved' }); window.MData.notify({ audience: 'team', type: 'approval', text: brand + ' approved “' + it.title + '”', link: 'clients', refId: clientId }); bump && bump(); }; const sendChanges = (e) => { e.preventDefault(); const t = note.trim(); if (!t) return; const notes = (Array.isArray(it.notes) ? it.notes : []).concat([{ id: 'n' + Date.now().toString(36), who: acc ? acc.name : 'Client', role: 'client', text: t, ts: Date.now() }]); window.MData.update('calendar', it.id, { approval: 'changes', approvalNote: t, notes }); window.MData.notify({ audience: 'team', type: 'approval', text: brand + ' requested changes on “' + it.title + '”: “' + t + '”', link: 'clients', refId: clientId }); setOpen(false); setNote(''); bump && bump(); }; const chip = (icon, color, bg, border, label) => ( {label} ); if (it.approval === 'approved') return
{chip('check', 'var(--positive)', 'rgba(47,170,110,0.1)', 'rgba(47,170,110,0.4)', 'Approved by you')}
; return (
{it.approval === 'changes' && chip('pencil', '#e0a33e', 'rgba(224,163,62,0.1)', 'rgba(224,163,62,0.4)', 'Changes requested')} {it.status !== 'Posted' && ( )} {it.status !== 'Posted' && it.approval !== 'changes' && ( )}
{open && (
setNote(e.target.value)} placeholder="What should change?" style={{ flex: 1, minWidth: 0, height: 38, padding: '0 12px', background: 'var(--surface-sunken)', border: '1px solid var(--border-default)', borderRadius: 'var(--radius-md)', color: 'var(--text-strong)', fontFamily: 'var(--font-sans)', fontSize: 13.5, outline: 'none' }} />
)}
); } function ClientCalendar({ clientId, acc, bump }) { const items = window.MData.forClient('calendar', clientId).slice().sort((a, b) => (a.date + a.time).localeCompare(b.date + b.time)); const platIcon = { Instagram: 'instagram', TikTok: 'music', Email: 'mail', Facebook: 'facebook', YouTube: 'youtube' }; const isImg = (m) => m && m.type && m.type.startsWith('image'); const isVid = (m) => m && m.type && m.type.startsWith('video'); const byDate = {}; items.forEach((it) => { (byDate[it.date] = byDate[it.date] || []).push(it); }); return ( {Object.keys(byDate).sort().map((d) => (
{fmtDate(d)}
{byDate[d].map((it) => (
{it.time}
{it.media ? ( isImg(it.media) ? :
) : ( )}
{it.title}
{it.platform}{it.media ? ' · ' + it.media.name : ''}
{it.media && }
))}
))} {!items.length && }
); } /* ---------------- Timeline ---------------- */ function ClientTimeline({ clientId, acc, bump }) { const tl = window.MData.forClient('timeline', clientId); const groups = [['current', 'In progress'], ['upcoming', 'Upcoming'], ['completed', 'Completed']]; return ( {groups.map(([key, label]) => { const items = tl.filter((t) => t.status === key); if (!items.length) return null; return (

{label}

{items.map((t) => (
{t.task} {fmtDate(t.date)}
))}
); })} {!tl.length && }
); } /* ---------------- Files ---------------- */ function ClientFiles({ clientId, client, acc, bump }) { const { Button } = window.ManderaDesignSystem_8327ce; const files = window.MData.forClient('files', clientId); const fileRef = React.useRef(null); const [busy, setBusy] = React.useState(false); const kindIcon = { pdf: 'file-text', doc: 'file-text', zip: 'file-archive', img: 'image', video: 'file-video' }; const onUpload = async (e) => { const fl = e.target.files && e.target.files[0]; if (!fl) return; e.target.value = ''; const ext = (fl.name.split('.').pop() || '').toLowerCase(); const kind = ['pdf'].includes(ext) ? 'pdf' : ['doc', 'docx', 'txt'].includes(ext) ? 'doc' : ['zip', 'rar'].includes(ext) ? 'zip' : ['png', 'jpg', 'jpeg', 'gif', 'webp'].includes(ext) ? 'img' : ['mp4', 'mov', 'webm'].includes(ext) ? 'video' : 'doc'; setBusy(true); const up = await window.mUploadFile(fl); const dataUrl = up ? null : await window.mReadSmall(fl, 2.5); setBusy(false); if (!up && !dataUrl) { alert('Could not upload “' + fl.name + '” — the server is unreachable right now and the file is too large to keep locally. Please try again.'); return; } window.MData.add('files', { clientId, name: fl.name, kind, size: (fl.size / 1048576).toFixed(1) + ' MB', by: acc.name, date: new Date().toISOString().slice(0, 10), url: up ? up.url : null, dataUrl }); window.MData.notify({ audience: 'team', type: 'file', text: (client ? client.brand : 'A client') + ' uploaded a file: ' + fl.name, link: 'clients', refId: clientId }); bump(); }; return (

Shared assets, briefs and deliverables.

{files.map((fl, i) => (
{fl.name}
{fl.size} · {fl.by} · {fmtDate(fl.date)}
{(fl.url || fl.dataUrl) ? : }
))} {!files.length && }
); } /* ---------------- Revisions ---------------- */ function ClientRevisions({ clientId, bump }) { const { Button, Input } = window.ManderaDesignSystem_8327ce; const revs = window.MData.forClient('revisions', clientId); const [title, setTitle] = React.useState(''); const [detail, setDetail] = React.useState(''); const submit = (e) => { e.preventDefault(); if (!title.trim()) return; window.MData.add('revisions', { clientId, title: title.trim(), detail: detail.trim(), status: 'open', date: new Date().toISOString().slice(0, 10) }); const cl = window.MData.client(clientId); window.MData.notify({ audience: 'team', type: 'revision', text: (cl ? cl.brand : 'A client') + ' requested a change: ' + title.trim(), link: 'clients', refId: clientId }); setTitle(''); setDetail(''); bump(); }; return (

Request a change

setTitle(e.target.value)} />