/* ============================================================ page-ops.jsx — PTO, Teams, Rooms ============================================================ */ /* i18n helper — returns Arabic when language=Arabic, else the English string */ const T = (s) => (window.appT || ((x) => x))(s); /* Re-bind parseDetail from primitives.jsx */ const parseDetail = window.parseDetail || ((body, status) => { const d = body?.detail; if (typeof d === 'string') return d; if (Array.isArray(d)) return d.map(x => x?.msg || JSON.stringify(x)).join('; '); if (d && typeof d === 'object') return d.message || d.error || JSON.stringify(d); const m = body?.message; if (typeof m === 'string') return m; if (Array.isArray(m)) return m.map(x => (typeof x === 'string' ? x : (x?.message || JSON.stringify(x)))).join('; '); if (m && typeof m === 'object') return m.message || m.error || JSON.stringify(m); return `HTTP ${status}`; }); /* ── Request PTO Modal ── */ function RequestPtoModal({ onClose, onCreated }) { const D = window.STATUS_DATA; const [startDate, setStartDate] = useState(() => new Date().toISOString().slice(0,10)); const [endDate, setEndDate] = useState(() => new Date().toISOString().slice(0,10)); const [ptoType, setPtoType] = useState('pto'); const [userId, setUserId] = useState(D.ME?.id || ''); const [reason, setReason] = useState(''); const [busy, setBusy] = useState(false); const [err, setErr] = useState(''); const toast = useToast(); useEffect(() => { const onKey = (e) => { if (e.key === 'Escape') onClose(); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [onClose]); const submit = async () => { setBusy(true); setErr(''); try { const r = await fetch('/api/pto', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ user_id: userId, start_date: startDate, end_date: endDate, pto_type: ptoType, reason }), }); if (!r.ok) { const body = await r.json().catch(() => null); throw new Error(parseDetail(body, r.status)); } toast.push({ msg: T('PTO request created'), kind: 'ok' }); onCreated && onCreated(); onClose(); } catch(e) { setErr(e.message); } finally { setBusy(false); } }; return (
e.stopPropagation()} style={{ position:'fixed', top:'50%', left:'50%', transform:'translate(-50%,-50%)', background:'var(--bg-elev-1)', border:'1px solid var(--line-hi)', borderRadius:'var(--r-lg)', padding: 24, width: 420, maxWidth:'95vw', boxShadow:'0 24px 64px -16px #00000080', zIndex: 301, }}>
{T('Request time off')}
{err &&
{err}
}
setStartDate(e.target.value)} />
setEndDate(e.target.value)} />
setReason(e.target.value)} placeholder={T('Brief description...')} />
); } /* ── PTO Calendar component — real data from /api/pto/calendar ── */ function PtoCalendarPanel({ month, calEntries, loading }) { const entryMap = useMemo(() => { const m = {}; (calEntries || []).forEach(e => { if (!e.start_date || !e.end_date) return; const start = new Date(e.start_date + 'T00:00:00'); const end = new Date(e.end_date + 'T00:00:00'); for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) { const key = d.toISOString().slice(0,10); if (!m[key]) m[key] = []; m[key].push(e); } }); return m; }, [calEntries]); const userColor = (uid) => { let h = 0; const s = String(uid); for (let i = 0; i < s.length; i++) { h = (h * 31 + s.charCodeAt(i)) & 0xffff; } return `hsl(${(h % 360)}, 60%, 50%)`; }; const [year, mon] = month.split('-').map(Number); const daysInMonth = new Date(year, mon, 0).getDate(); const firstDow = new Date(year, mon - 1, 1).getDay(); const cells = []; for (let i = 0; i < firstDow; i++) cells.push(null); for (let d = 1; d <= daysInMonth; d++) cells.push(d); const DOW = ['Su','Mo','Tu','We','Th','Fr','Sa']; if (loading) return
{T('Loading calendar...')}
; return (
{DOW.map(d => (
{T(d)}
))} {cells.map((d, i) => { if (!d) return
; const dateStr = `${year}-${String(mon).padStart(2,'0')}-${String(d).padStart(2,'0')}`; const entries = entryMap[dateStr] || []; const isWeekend = ((firstDow + d - 1) % 7) === 0 || ((firstDow + d - 1) % 7) === 6; return (
{d}
{entries.slice(0,2).map((e, j) => (
{(e.user_name || '').split(' ')[0]}
))} {entries.length > 2 && (
+{entries.length - 2}
)}
); })}
); } /* ── PTO Balances panel ── */ function PtoBalancePie({ used, quota, size = 56 }) { const u = Math.max(0, Math.min(quota, used || 0)); const total = Math.max(1, quota || 0); const r = size / 2 - 4; const c = 2 * Math.PI * r; const cx = size / 2, cy = size / 2; const usedLen = (u / total) * c; const remColor = (quota - u) <= 2 ? 'var(--err)' : (quota - u) <= 4 ? 'var(--warn)' : 'var(--ok)'; return ( {quota - u} ); } function PtoBalancesPanel({ balances, loading }) { if (loading) return
{T('Loading balances...')}
; if (!balances || balances.length === 0) { return } title={T('No balance data')} subtitle={T('PTO balances will appear once quota is configured.')} />; } return (
{balances.map(b => { const remaining = b.remaining ?? (b.quota - b.used); return (
{b.display_name}
{b.used} {T('used')} · {remaining} {T('left')} · {T('quota')} {b.quota}
{b.matrix_id}
); })}
); } function PtoPage({ ptoState, setPtoState }) { const [tab, setTab] = useState('pending'); const [ptoModalOpen, setPtoModalOpen] = useState(false); const [calMonth, setCalMonth] = useState(() => new Date().toISOString().slice(0,7)); const [calEntries, setCalEntries] = useState([]); const [calLoading, setCalLoading] = useState(false); const [balances, setBalances] = useState([]); const [balLoading, setBalLoading] = useState(false); const toast = useToast(); const filtered = ptoState.filter(p => tab === 'all' ? true : p.status === tab); const fetchCalendar = async (month) => { setCalLoading(true); try { const [yr, mo] = month.split('-').map(Number); const from = `${month}-01`; const lastDay = new Date(yr, mo, 0).getDate(); const to = `${month}-${String(lastDay).padStart(2,'0')}`; const r = await fetch(`/api/pto/calendar?from=${from}&to=${to}`, { credentials: 'same-origin' }); if (!r.ok) throw new Error(`${r.status}`); const data = await r.json(); setCalEntries(Array.isArray(data) ? data : []); } catch(e) { toast.push({ msg: T('Calendar fetch failed: ') + e.message, kind: 'err' }); setCalEntries([]); } finally { setCalLoading(false); } }; const fetchBalances = async () => { setBalLoading(true); try { const r = await fetch('/api/pto/balances', { credentials: 'same-origin' }); if (!r.ok) throw new Error(`${r.status}`); const data = await r.json(); setBalances(Array.isArray(data) ? data : []); } catch(e) { toast.push({ msg: T('Balances fetch failed: ') + e.message, kind: 'err' }); setBalances([]); } finally { setBalLoading(false); } }; useEffect(() => { fetchCalendar(calMonth); }, [calMonth]); useEffect(() => { fetchBalances(); }, []); const handleRefresh = async () => { // Global re-bootstrap so the Requests list, rooms, teams, balances, etc. // all refetch — not just the calendar. try { window.dispatchEvent(new CustomEvent('status-data-refresh-requested')); if (typeof window.STATUS_REFRESH === 'function') await window.STATUS_REFRESH(); } catch (e) {} await Promise.all([fetchCalendar(calMonth), fetchBalances()]); toast.push({ msg: T('Refresh complete'), kind: 'ok' }); }; const decide = async (id, decision) => { let body = null; let ep; if (decision === 'approved') { ep = `/api/pto/requests/${id}/approve`; } else { // Backend's PTODeclineBody requires a JSON body even if reason is empty. // Prompt for an optional reason (visible to the user via DM). const reason = (typeof prompt === 'function') ? (prompt(T('Optional reason for declining (visible to the user). Leave blank to skip:'), '') || '') : ''; ep = `/api/pto/requests/${id}/decline`; body = { reason: reason.trim() }; } try { const r = await fetch(ep, { method: 'POST', credentials: 'include', headers: body ? { 'Content-Type': 'application/json' } : {}, body: body ? JSON.stringify(body) : undefined, }); if (!r.ok) { let msg; try { const j = await r.json(); if (typeof j.detail === 'string') msg = j.detail; else if (Array.isArray(j.detail)) msg = j.detail.map(e => e.msg || JSON.stringify(e)).join('; '); else msg = `HTTP ${r.status}`; } catch (_) { msg = `HTTP ${r.status}`; } throw new Error(msg); } setPtoState(s => s.map(p => p.id === id ? { ...p, status: decision } : p)); toast.push({ kind: decision === 'approved' ? 'ok' : 'err', icon: decision === 'approved' ? '✓' : '✕', msg: `${T('Request')} ${T(decision)}` }); } catch (e) { toast.push({ kind:'err', icon:'!', msg: `${T('Failed:')} ${e.message || e}` }); } }; const handleExport = async () => { // Stub kept for any legacy callers; the real export now branches by // format via handleExportFmt below. return handleExportFmt('csv'); }; const handleExportFmt = async (fmt) => { const filename = `pto-requests.${fmt}`; let url; if (fmt === 'csv') { url = '/api/reports.csv?type=pto'; } else { // PDF / XLSX go through the unified export endpoint which already // supports a request_type=pto switch (added in this commit). url = `/api/reports/export?type=pto&format=${fmt}`; } try { const r = await fetch(url, { credentials: 'same-origin' }); if (!r.ok) throw new Error(`${r.status} ${r.statusText}`); const blob = await r.blob(); const result = await window.saveDownload(blob, filename); if (!result || !result.ok) { throw new Error('Download failed via ' + (result && result.via || 'unknown')); } if (result.via !== 'share-cancelled') { toast.push({ msg: `${T('Downloaded')} ${filename}`, kind: 'ok' }); } } catch(e) { toast.push({ msg: `${T('Export failed:')} ${e.message || e}`, kind: 'err' }); } }; const handlePtoCreated = async () => { try { const fresh = await fetch('/api/pto/requests', { credentials: 'same-origin' }).then(r => r.ok ? r.json() : ptoState); if (Array.isArray(fresh)) setPtoState(fresh.map(p => ({ id: p.id, display_name: p.display_name || p.matrix_id, matrix_id: p.matrix_id, pto_type: p.pto_type || 'Vacation', start_date: p.start_date, end_date: p.end_date, days: p.days || 1, status: p.status || 'pending', approved: p.status === 'approved', }))); } catch(_) {} await fetchCalendar(calMonth); }; const pendingNoDates = calEntries.filter(e => e.status === 'pending' && !e.start_date); return (
p.status==='pending').length} ${T('pending')} · ${ptoState.filter(p=>p.status==='approved').length} ${T('approved')}`} right={
{/* Match Employees export — native { const f = e.target.value; if (f) handleExportFmt(f); e.target.value=''; }} style={{ padding: '6px 10px', fontSize: 14, cursor: 'pointer', appearance: 'none', WebkitAppearance: 'none', paddingRight: 26, backgroundImage: 'linear-gradient(45deg, transparent 50%, currentColor 50%), linear-gradient(135deg, currentColor 50%, transparent 50%)', backgroundPosition: 'calc(100% - 12px) 50%, calc(100% - 7px) 50%', backgroundSize: '5px 5px, 5px 5px', backgroundRepeat: 'no-repeat', }} title={T('Choose an export format')} >
} />
p.status==='pending').length})` }, { value:'approved', label:T('Approved') }, { value:'denied', label:T('Denied') }, { value:'all', label:T('All') }, ]} />
{filtered.length === 0 ? ( } title={tab === 'pending' ? T("No pending requests") : T("No requests yet")} subtitle={ tab === 'pending' ? {T('All clear!')} : T("PTO requests will show up here for review.") } /> ) : ( {filtered.map(p => ( ))}
{T('Person')}{T('Type')}{T('Dates')}{T('Days')}{T('Status')} {T('Actions')}
s[0]).join('') }} sub={p.matrix_id} /> {p.pto_type} {p.start_date && p.end_date ? `${p.start_date} → ${p.end_date}` : {T('Dates TBD')}} {p.days || '—'} {p.status === 'pending' ? (
) : ( {T(p.status)} )}
)}
setCalMonth(e.target.value)} className="inp" style={{ fontSize: 12, padding:'3px 6px', width: 130 }} /> } /> {pendingNoDates.length > 0 && (
{T('Awaiting dates')}
{pendingNoDates.map((e, i) => (
{e.user_name}
{T('Pending — dates TBD')}
))}
)}
} />
{ptoModalOpen && ( setPtoModalOpen(false)} onCreated={handlePtoCreated} /> )}
); } /* ── PTO quota editor (admin only) — sits in the Balances panel header ── */ function PtoQuotaEditor({ onChanged }) { const [quota, setQuota] = useState(null); const [editing, setEditing] = useState(false); const [draft, setDraft] = useState('5'); const [busy, setBusy] = useState(false); const toast = useToast(); const D = window.STATUS_DATA; const isAdmin = (window.isAdminOrAbove ? window.isAdminOrAbove(D?.ME) : (D?.ME?.org_role || '').toLowerCase() === 'admin'); useEffect(() => { fetch('/api/pto/quota', { credentials: 'include' }) .then(r => r.ok ? r.json() : null) .then(j => { if (j?.quota != null) { setQuota(j.quota); setDraft(String(j.quota)); } }) .catch(() => {}); }, []); const save = async () => { const n = parseInt(draft, 10); if (isNaN(n) || n < 0 || n > 365) { toast.push({ kind:'err', msg: T('Quota must be 0–365 days') }); return; } setBusy(true); try { const r = await fetch('/api/pto/quota', { method: 'PUT', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ quota: n }), }); if (!r.ok) { const body = await r.json().catch(() => null); throw new Error(parseDetail(body, r.status)); } setQuota(n); setEditing(false); toast.push({ kind:'ok', msg: `${T('Annual quota updated to')} ${n} ${n===1?T('day'):T('days')}` }); onChanged && onChanged(); } catch(e) { toast.push({ kind:'err', msg: T('Failed: ') + e.message }); } finally { setBusy(false); } }; if (quota == null) return ; if (!isAdmin) { return {T('cap:')} {quota}d/yr; } if (!editing) { return ( ); } return (
setDraft(e.target.value)} style={{width: 70, fontSize: 11, padding:'3px 6px', height: 26}} />
); } /* ============================================================ TEAMS PAGE — collapsible cards + full wizard ============================================================ */ /* ── Timezone list ── */ function getTimezones() { try { const tz = Intl.supportedValuesOf('timeZone'); return tz.length ? tz : null; } catch(_) { return null; } } const COMMON_TIMEZONES = [ 'UTC','America/New_York','America/Chicago','America/Denver','America/Los_Angeles', 'America/Toronto','America/Vancouver','America/Sao_Paulo','America/Bogota', 'Europe/London','Europe/Paris','Europe/Berlin','Europe/Amsterdam','Europe/Madrid', 'Europe/Rome','Europe/Warsaw','Europe/Stockholm','Europe/Zurich','Europe/Athens', 'Europe/Moscow','Europe/Istanbul', 'Asia/Dubai','Asia/Karachi','Asia/Kolkata','Asia/Dhaka','Asia/Bangkok', 'Asia/Singapore','Asia/Shanghai','Asia/Tokyo','Asia/Seoul','Asia/Hong_Kong', 'Australia/Sydney','Australia/Melbourne','Australia/Perth', 'Pacific/Auckland','Pacific/Honolulu', 'Africa/Cairo','Africa/Johannesburg','Africa/Lagos', ]; function TimezoneSelect({ value, onChange, style }) { const tzList = useMemo(() => getTimezones() || COMMON_TIMEZONES, []); return ( ); } const ALL_DAYS = [ { key:'mon', label:'Mon' }, { key:'tue', label:'Tue' }, { key:'wed', label:'Wed' }, { key:'thu', label:'Thu' }, { key:'fri', label:'Fri' }, { key:'sat', label:'Sat' }, { key:'sun', label:'Sun' }, ]; function OffDayMultiSelect({ value, onChange }) { // value is comma-separated string e.g. "mon,tue,wed,thu,fri" const selected = (value || '').split(',').map(s => s.trim()).filter(Boolean); const toggle = (key) => { const next = selected.includes(key) ? selected.filter(k => k !== key) : [...selected, key]; onChange(next.join(',')); }; return (
{ALL_DAYS.map(d => ( ))}
); } /* ── Step indicator ── */ function StepDots({ total, current }) { return (
{Array.from({ length: total }, (_, i) => (
))}
); } /* ── New Team Wizard Modal ── */ function NewTeamWizard({ onClose, onCreated, rooms, bots, employees }) { const STEPS = 6; const [step, setStep] = useState(0); const [dirty, setDirty] = useState(false); const [busy, setBusy] = useState(false); const [err, setErr] = useState(''); const toast = useToast(); // Draft state const [form, setForm] = useState(() => { const saved = sessionStorage.getItem('team_wiz_draft'); if (saved) try { return JSON.parse(saved); } catch(_) {} return { team_name: '', timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC', work_hours_target: 8, off_days: 'sat,sun', signoff_keyword: 'signing off', signin_deadline: '09:30', flexible_signin: false, member_user_ids: [], assigned_rooms: [], assigned_bot: '', auto_invite: false, }; }); const set = (key, val) => { setForm(f => { const n = { ...f, [key]: val }; sessionStorage.setItem('team_wiz_draft', JSON.stringify(n)); return n; }); setDirty(true); }; useEffect(() => { const onKey = (e) => { if (e.key === 'Escape') { if (dirty) { if (confirm(T('Discard team draft?'))) { sessionStorage.removeItem('team_wiz_draft'); onClose(); } } else onClose(); } }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [dirty, onClose]); const handleClose = () => { if (dirty) { if (!confirm(T('Discard team draft?'))) return; } sessionStorage.removeItem('team_wiz_draft'); onClose(); }; const canNext = () => { if (step === 0) return form.team_name.trim().length > 0 && form.timezone; return true; }; const submit = async () => { setBusy(true); setErr(''); try { const payload = { team_name: form.team_name.trim(), timezone: form.timezone, work_hours_target: parseFloat(form.work_hours_target) || 8, working_days: form.off_days, signoff_keyword: form.signoff_keyword, flexible_signin: !!form.flexible_signin, // Only meaningful when not flexible. signin_deadline: form.flexible_signin ? null : form.signin_deadline, assigned_rooms: form.assigned_rooms, bot_id: form.assigned_bot || null, }; const r = await fetch('/api/teams', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify(payload), }); if (!r.ok) { const body = await r.json().catch(() => null); throw new Error(parseDetail(body, r.status)); } // Assign selected members to the new team (sets each user's team field // via the same per-user profile endpoint the employee drawer uses). const memberIds = form.member_user_ids || []; let assigned = 0, failed = 0; for (const uid of memberIds) { try { const ar = await fetch(`/api/employees/${uid}/profile`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ team: form.team_name.trim() }), }); if (ar.ok) assigned++; else failed++; } catch(_) { failed++; } } sessionStorage.removeItem('team_wiz_draft'); const memberMsg = memberIds.length ? ` · ${assigned} ${assigned !== 1 ? T('members') : T('member')} ${T('added')}${failed ? ` (${failed} ${T('failed')})` : ''}` : ''; toast.push({ msg: `${T('Team')} "${form.team_name}" ${T('created')}${memberMsg}`, kind: failed ? 'warn' : 'ok' }); onCreated && onCreated(); onClose(); } catch(e) { setErr(e.message); } finally { setBusy(false); } }; const STEP_TITLES = [ T('Name & timezone'), T('Work hours & schedule'), T('Members'), T('Room assignment'), T('Bot assignment'), T('Confirm'), ]; return (
e.stopPropagation()} style={{ position:'fixed', top:'50%', left:'50%', transform:'translate(-50%,-50%)', background:'var(--bg-elev-1)', border:'1px solid var(--line-hi)', borderRadius:'var(--r-lg)', padding: 28, width: 480, maxWidth:'95vw', boxShadow:'0 24px 64px -16px #00000080', zIndex: 301, maxHeight:'90vh', overflowY:'auto', }}>
{T('New team')}
{STEP_TITLES[step]}
{err &&
{err}
} {/* Step 0: Name + timezone */} {step === 0 && (
set('team_name', e.target.value)} placeholder={T('e.g. Engineering')} autoFocus />
set('timezone', v)} />
)} {/* Step 1: Work hours / off-days / signoff-keyword / signin-deadline */} {step === 1 && (
set('work_hours_target', e.target.value)} />
set('off_days', v)} />
{T('Selected:')} {form.off_days || T('none')}
set('signoff_keyword', e.target.value)} placeholder={T('signing off')} />
{T("No fixed deadline. The bot learns each member's usual sign-in & sign-off time (in their own timezone, after ~4 days) and flags only when someone is later than their own average in — or earlier out. Signing in early is fine.")}
)} {/* Step 2: Members */} {step === 2 && (
{T('Selected users will have their team set to')} {form.team_name || T('this team')}.
{(employees || []).length === 0 &&
{T('No employees available.')}
} {(employees || []).map(u => { const checked = form.member_user_ids.includes(u.id); return ( ); })}
{form.member_user_ids.length} {T('selected')} {form.member_user_ids.length > 0 && ' · ' + T('members already on another team will be moved here')}
)} {/* Step 3: Room assignment */} {step === 3 && (
{rooms.length === 0 &&
{T('No rooms available. Create rooms first.')}
} {rooms.map(r => { const checked = form.assigned_rooms.includes(r.id); return ( ); })}
)} {/* Step 4: Bot assignment */} {step === 4 && (
)} {/* Step 5: Confirm */} {step === 5 && (
{T('Team name')}{form.team_name} {T('Timezone')}{form.timezone} {T('Work hours/day')}{form.work_hours_target} h {T('Working days')}{form.off_days} {T('Sign-off keyword')}{form.signoff_keyword} {T('Sign-in')}{form.flexible_signin ? T('Fully flexible (not tracked)') : T('Adaptive (learns each person’s usual time)')} {T('Members')}{form.member_user_ids.length > 0 ? `${form.member_user_ids.length} ${T('user(s)')}` : T('None')} {T('Rooms')}{form.assigned_rooms.length > 0 ? `${form.assigned_rooms.length} ${T('room(s)')}` : T('None')} {T('Bot')}{bots.find(b => String(b.id) === String(form.assigned_bot))?.name || T('None')}
)}
{step > 0 && ( )} {step < STEPS - 1 ? ( ) : ( )}
); } /* ── Team Schedule Drawer ── */ const DAY_LABELS = ['Monday','Tuesday','Wednesday','Thursday','Friday','Saturday','Sunday']; function TeamScheduleDrawer({ teamName, onClose }) { const [rows, setRows] = useState(null); const [saving, setSaving] = useState(false); const [err, setErr] = useState(''); const toast = useToast(); useEffect(() => { fetch(`/api/teams/${encodeURIComponent(teamName)}/schedule`, { credentials: 'same-origin' }) .then(r => r.ok ? r.json() : Promise.reject(r.status)) .then(data => setRows(data)) .catch(() => setErr(T('Failed to load schedule'))); }, [teamName]); const setRow = (idx, key, val) => setRows(prev => prev.map((r, i) => i === idx ? { ...r, [key]: val } : r)); const save = async () => { setSaving(true); setErr(''); try { const r = await fetch(`/api/teams/${encodeURIComponent(teamName)}/schedule`, { method: 'PUT', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(rows), }); if (!r.ok) throw new Error(await r.text()); toast.push({ msg: T('Schedule saved'), kind: 'ok' }); onClose(); } catch(e) { setErr(e.message); } finally { setSaving(false); } }; return (
{ if (e.target === e.currentTarget) onClose(); }}>
{T('Schedule')} — {teamName || T('(default)')}
{err &&
{err}
} {!rows ? (
{T('Loading…')}
) : ( <>
{T('Day')}
{T('Start')}
{T('End')}
{T('Working')}
{rows.map((row, idx) => (
{T(DAY_LABELS[row.day_of_week])}
setRow(idx, 'start_time', e.target.value)} disabled={!row.is_working_day} style={{ opacity: row.is_working_day ? 1 : 0.4 }} /> setRow(idx, 'end_time', e.target.value)} disabled={!row.is_working_day} style={{ opacity: row.is_working_day ? 1 : 0.4 }} />
setRow(idx, 'is_working_day', e.target.checked)} />
))}
)}
); } /* ── Team Bot Behavior Section ── */ // Reuses primitives from page-bot-behavior.jsx via window exports. // Falls back gracefully if the page hasn't loaded yet. const _BotBehaviorPage = window.BotBehaviorPage; function TeamBotBehaviorSection({ teamName }) { const _useToast2 = window.useToast || (() => ({ toast: () => {} })); const { toast } = _useToast2(); const [open, setOpen] = React.useState(false); const [settings, setSettings] = React.useState([]); const [loading, setLoading] = React.useState(false); const [localEdits, setLocalEdits] = React.useState({}); const [saving, setSaving] = React.useState(false); const [showDiff, setShowDiff] = React.useState(false); const [savedCount, setSavedCount] = React.useState(0); const [diffOpen, setDiffOpen] = React.useState(false); // Per-category accordion open-state. MUST live here (one hook), NOT inside // the categories .map() — a hook in a loop violates rules-of-hooks and // crashed with React #310 once settings loaded (0 → N categories). const [openCats, setOpenCats] = React.useState({}); const load = React.useCallback(() => { if (!teamName) return; setLoading(true); fetch(`/api/bot-behavior/settings/team/${encodeURIComponent(teamName)}`, { credentials: 'same-origin' }) .then(r => r.ok ? r.json() : Promise.reject(r.status)) .then(d => setSettings(d.settings || [])) .catch(() => {}) .finally(() => setLoading(false)); }, [teamName]); React.useEffect(() => { if (open && settings.length === 0) load(); }, [open, load, settings.length]); const handleChange = (key, value) => { setLocalEdits(prev => { if (value === undefined) { const n = { ...prev }; delete n[key]; return n; } return { ...prev, [key]: value }; }); }; const handleSave = async () => { const keys = Object.keys(localEdits); if (!keys.length) return; setSaving(true); let count = 0; for (const key of keys) { try { const r = await fetch(`/api/bot-behavior/settings/${encodeURIComponent(key)}`, { method: 'PUT', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: localEdits[key], scope: 'team', team_name: teamName }), }); if (r.ok) count++; else toast({ kind: 'warn', message: `${T('Failed:')} ${key}` }); } catch (_) {} } setSaving(false); setLocalEdits({}); setSavedCount(count); setShowDiff(true); load(); if (count > 0) toast({ kind: 'ok', message: `${T('Saved')} ${count} ${count !== 1 ? T('overrides') : T('override')} ${T('for')} ${teamName}` }); }; const handleResetAll = async () => { if (!window.confirm(`${T('Remove ALL bot behavior overrides for team')} "${teamName}"? ${T('This cannot be undone.')}`)) return; const overrides = settings.filter(s => s.source === 'team'); let count = 0; for (const s of overrides) { try { const r = await fetch(`/api/bot-behavior/settings/${encodeURIComponent(s.key)}?team_name=${encodeURIComponent(teamName)}`, { method: 'DELETE', credentials: 'same-origin', }); if (r.ok) count++; } catch (_) {} } load(); toast({ kind: 'ok', message: `${T('Removed')} ${count} ${count !== 1 ? T('team overrides') : T('team override')}` }); }; const dirtyCount = Object.keys(localEdits).length; const overrideCount = settings.filter(s => s.source === 'team').length; // Group settings by category const byCategory = React.useMemo(() => { const map = {}; (settings || []).forEach(s => { if (!map[s.category]) map[s.category] = []; map[s.category].push(s); }); return map; }, [settings]); // Categories that have team overrides const CATEGORIES_ORDER = ['Sign-in', 'Strikes', 'PTO', 'Hours', 'Reports', 'Voice', 'AI Prompts', 'Templates']; return (
{open && (
{loading ? (
{T('Loading...')}
) : ( <> {showDiff && (
{savedCount} {savedCount !== 1 ? T('overrides') : T('override')} {T('saved for')} {teamName}
)} {/* Compare to global diff view */}
{overrideCount > 0 && ( )}
{diffOpen && overrideCount === 0 && (
{T('No overrides set — all settings inherit from global.')}
)} {diffOpen && overrideCount > 0 && (
{T('Setting')} {T('Global value')} {T('Team override')}
{settings.filter(s => s.source === 'team').map(s => (
{s.key} {Array.isArray(s.global_value) ? `[${s.global_value.length} ${T('items')}]` : String(s.global_value ?? '—')} {Array.isArray(s.team_override_value) ? `[${s.team_override_value.length} ${T('items')}]` : String(s.team_override_value ?? '—')}
))}
)} {/* Accordion per category — only show categories with settings */} {CATEGORIES_ORDER.filter(cat => byCategory[cat]).map(cat => { const catSettings = byCategory[cat]; const catDirty = catSettings.filter(s => localEdits[s.key] !== undefined).length; const catOpen = !!openCats[cat]; const setCatOpen = (fn) => setOpenCats(m => ({ ...m, [cat]: typeof fn === 'function' ? fn(!!m[cat]) : fn })); return (
{catOpen && (
{catSettings.map(s => { const effectiveVal = localEdits[s.key] !== undefined ? localEdits[s.key] : (s.team_override_value !== null && s.team_override_value !== undefined ? s.team_override_value : s.global_value); const isOverridden = s.source === 'team' || localEdits[s.key] !== undefined; return (
{s.label} {isOverridden && ( {T('OVERRIDE')} )} {T('Global:')} {Array.isArray(s.global_value) ? `[${s.global_value.length}]` : String(s.global_value ?? '—')}
{/* Reuse SettingInput from page-bot-behavior.jsx if available */} {s.type === 'bool' ? ( ) : (s.type === 'int' || s.type === 'float') ? ( handleChange(s.key, s.type === 'float' ? parseFloat(e.target.value) || 0 : parseInt(e.target.value, 10) || 0)} style={{ width: 80, fontSize: 12 }} /> ) : (s.type === 'string_list') ? (