Files
meguri/static/app.js
T
Aleksandr BogomiakovandClaude 4764f3545f Add OIDC sign-in, group-derived roles and declarative instance configuration
Workspaces, group mappings and sign-in methods now come from a configuration
file synchronized at startup; workspaces dropped from it are disabled, never
deleted. Instance administrators (a new role) see and help every workspace and
may delete disabled ones; workspace administrators and employees are derived
from the user's groups at every SSO sign-in or administrator edit, and the
employee permission set belongs to the workspace. Self-registration is gone:
users come from SSO (only when their groups map) or from the administrator.
Break-glass bootstrap accounts get an HMAC-derived hourly password that all
replicas agree on. First SSO sign-in links a matching email contact to the
user. The demo runs on the same model with a generated per-start password.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-07 13:28:43 +01:00

348 lines
45 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const copy = value => JSON.parse(JSON.stringify(value));
const countryCodes = 'AD AE AF AG AI AL AM AO AQ AR AS AT AU AW AX AZ BA BB BD BE BF BG BH BI BJ BL BM BN BO BQ BR BS BT BV BW BY BZ CA CC CD CF CG CH CI CK CL CM CN CO CR CU CV CW CX CY CZ DE DJ DK DM DO DZ EC EE EG EH ER ES ET FI FJ FK FM FO FR GA GB GD GE GF GG GH GI GL GM GN GP GQ GR GS GT GU GW GY HK HM HN HR HT HU ID IE IL IM IN IO IQ IR IS IT JE JM JO JP KE KG KH KI KM KN KP KR KW KY KZ LA LB LC LI LK LR LS LT LU LV LY MA MC MD ME MF MG MH MK ML MM MN MO MP MQ MR MS MT MU MV MW MX MY MZ NA NC NE NF NG NI NL NO NP NR NU NZ OM PA PE PF PG PH PK PL PM PN PR PS PT PW PY QA RE RO RS RU RW SA SB SC SD SE SG SH SI SJ SK SL SM SN SO SR SS ST SV SX SY SZ TC TD TF TG TH TJ TK TL TM TN TO TR TT TV TW TZ UA UG UM US UY UZ VA VC VE VG VI VN VU WF WS YE YT ZA ZM ZW'.split(' ');
const timeValue = value => value?.length === 5 ? value + ':00' : value;
const browserZone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
const displayDate = value => /^\d{4}-\d{2}-\d{2}$/.test(value || '') ? value.split('-').reverse().join('-') : '';
const parseDisplayDate = value => {
if(!/^\d{2}-\d{2}-\d{4}$/.test(value))return '';
const iso=value.split('-').reverse().join('-'),date=new Date(iso+'T12:00:00Z');
return !isNaN(date) && date.toISOString().slice(0,10)===iso ? iso : '';
};
document.addEventListener('alpine:init', () => {
Alpine.data('dateField',()=>({
value:'',text:'',init(){this.$watch('value',v=>{if(v)this.text=displayDate(v);});},
edit(input){this.text=input.value;this.value=parseDisplayDate(this.text);input.setCustomValidity(this.value?'':tr('enter_a_valid_date_as_dd_mm_yyyy_f6df03'));},
picked(){this.text=displayDate(this.value);this.$refs.textDate.setCustomValidity('');}
}));
Alpine.data('meguri', () => ({
...generationUi(),
user: null, csrf: '', ready: false, busy: false, error: '', toast: '', demo: false, demoLifecycle:null, auth:{oidc:false,password:true}, loginError:null, admin:null,
loginForm: { username: '', password: '' },
directoryState: { workspaces: [] }, selected: '', detail: null, calendar: { days: [], assignments: [] },
view: 'calendar', scheduleId: '', startDay: '', modal: '', form: {}, menu: false,
navigationRestoring:0, navigationEpoch:0, setupOpen:false, scheduleSearch:'', schedulePicker:false, coverQuery:'', requestResult:null, requestError:'', requestLoading:false, requestSequence:0,
t:tr, l:I18n.diagnostic,
get language(){return I18n.locale();},
label(value){return MEGURI_MESSAGES['enum_'+value]?tr('enum_'+value):value;},
get countries(){const names=new Intl.DisplayNames([this.language],{type:'region'});return countryCodes.map(code=>({code,name:names.of(code)})).sort((a,b)=>a.name.localeCompare(b.name,this.language));},
browserZone, zones: [...new Set(['UTC',browserZone,...Intl.supportedValuesOf('timeZone')])].sort(),
draftPeople: [], picker: null, personQuery: '', pickerCountry: 'CY', pickerName: '', pickerEmail: '', pickerEditing: null,
csvText: '', csvResult: null, csvLoading: false, csvError: '', csvSequence: 0,
removal: null, draggedRoute: '', dragRevision: null, dragWorkspace: '', routeDropTarget: '', routeDropSide: 'before',
absenceResult:null, absenceError:'', absenceLoading:false, absenceSequence:0, absenceRevision:0, showAllAbsence:false,
formatDate:displayDate,
ttlText(seconds){return seconds>=3600?tr('duration_hours',{count:seconds/3600}):tr('duration_minutes',{count:seconds/60});},
get orderedRoutes() { return [...(this.doc?.routes || [])].sort((a,b)=>a.order-b.order); },
get canRemoveSchedule() { const p=this.removal?.plan; return !!p && !p.active_replacements.length && (!!this.form.redirect_to || (!p.coverage_fallbacks.length && !p.policies_emptied.length)); },
get displayZone() { return this.user?.timezone || browserZone; },
get directory() { return this.detail?.directory || []; },
get people() {
const people = new Map(this.directory.map(u=>[u.id,{id:u.id,user_id:u.id,display_name:u.display_name,email:null,country:u.country || 'CY',username:u.username}]));
for(const p of [...(this.doc?.people || []),...this.draftPeople]) people.set(p.id,{...people.get(p.id),...p});
return [...people.values()];
},
get pickerResults() { const q=this.personQuery.trim().toLowerCase(); return this.people.filter(p=>[p.display_name,p.email,p.username].some(s=>s?.toLowerCase().includes(q))); },
get selectedSchedule() { return this.doc?.schedules.find(s=>s.id===this.scheduleId); },
get holidayGroups() { return [...new Set((this.doc?.holidays || []).map(h=>h.country))].sort((a,b)=>this.countryName(a).localeCompare(this.countryName(b))).map(country=>({country,holidays:this.doc.holidays.filter(h=>h.country===country).sort((a,b)=>a.date.localeCompare(b.date))})); },
countryName(code) { return code==='*'?tr('all_countries_legacy_04451d'):new Intl.DisplayNames([this.language],{type:'region'}).of(code || 'CY'); },
holidaysOn(day) { return (this.doc?.holidays || []).filter(h=>h.date===day.date).map(h=>h.country+' · '+h.name).join(' / '); },
workingSummary() { const w=this.doc?.working_interval; return w ? w.weekdays.map(d=>[tr('mon_24b2a0'),tr('tue_529541'),tr('wed_23408b'),tr('thu_3593cc'),tr('fri_bbd6e3'),tr('sat_6b782d'),tr('sun_48c98c')][d-1]).join(', ')+' · '+w.start.slice(0,5)+'–'+w.end.slice(0,5)+' · '+w.timezone : ''; },
conditionText(c) { return (c.negate?tr('not_975146'):'')+this.label(c.field)+' '+this.label(c.operation)+' “'+c.value+'”'; },
scheduleTime(id) { return this.doc?.schedules.find(s=>s.id===id)?.handover?.slice(0,5) || '11:00'; },
get nav(){return [{ id: 'calendar', name: tr('calendar_adab50'), icon: '▦' }, { id: 'requests', name: tr('requests_f7194e'), icon: '⇄' }, { id: 'configuration', name: tr('schedule_setup'), icon: '⊞' }, { id: 'activity', name: tr('activity_81c0d9'), icon: '≡' }, { id: 'settings', name: tr('team_settings_06b774'), icon: '⚙' }, { id: 'instance', name: tr('access_instance_nav'), icon: '⬢' }];},
get grantOptions(){return [{ id: 'view', name: tr('view_calendar_b702b1') }, { id: 'request_overrides', name: tr('request_replacements_386c5e') }, { id: 'edit_schedules', name: tr('edit_schedules_policies_a02744') }, { id: 'force_overrides', name: tr('apply_without_confirmation_f0350d') }, { id: 'manage_members', name: tr('manage_team_access_db8b53') }, { id: 'manage_integration', name: tr('manage_provider_804901') }, { id: 'view_audit', name: tr('view_audit_history_896487') }];},
get workspace() { return this.detail?.workspace; },
get doc() { return this.workspace?.document; },
get members() { return this.detail?.members || []; },
get pending() { return (this.doc?.requests || []).filter(r => r.status === 'pending'); },
get coverageItems() {
return (this.detail?.coverage?.issues||[]).flatMap(gap=>{
const pending=this.pending.flatMap(request=>request.legs.filter(leg=>leg.schedule_id===gap.schedule_id && leg.from_user===gap.user_id && Date.parse(leg.start)<Date.parse(gap.end) && Date.parse(gap.start)<Date.parse(leg.end)).map(leg=>({request,leg})));
const bounds=[...new Set([Date.parse(gap.start),Date.parse(gap.end),...pending.flatMap(({leg})=>[Math.max(Date.parse(leg.start),Date.parse(gap.start)),Math.min(Date.parse(leg.end),Date.parse(gap.end))])])].sort((a,b)=>a-b);
return bounds.slice(0,-1).map((start,i)=>{
const end=bounds[i+1],requests=pending.filter(({leg})=>Date.parse(leg.start)<=start && end<=Date.parse(leg.end)).map(({request})=>request);
const approvers=[...new Set(requests.flatMap(r=>r.required_approvers.filter(id=>!r.approvals.includes(id))))];
return {...gap,start:new Date(start).toISOString(),end:new Date(end).toISOString(),awaiting:requests.length>0,approvers,substitutes:[...new Set(pending.filter(({leg})=>Date.parse(leg.start)<=start && end<=Date.parse(leg.end)).map(({leg})=>leg.to_user))]};
});
});
},
get needsMe() { return this.pending.filter(r=>r.required_approvers.includes(this.user?.id) && !r.approvals.includes(this.user?.id)); },
get myRequests() { return this.pending.filter(r=>r.requested_by===this.user?.id); },
get employeeGrantOptions(){return this.grantOptions.filter(g=>!['manage_members','manage_integration'].includes(g.id));},
canEditUserGroups(u){return !u.sso && !u.bootstrap && u.id!==this.user?.id;},
async loadAdmin(){this.admin=await this.api('/api/admin/overview');},
useDemoAccount(account){this.loginForm.username=account.username;this.loginForm.password=this.demoLifecycle?.password||'';},
async copyDemoPassword(){try{await navigator.clipboard.writeText(this.demoLifecycle?.password||'');this.toast=tr('access_demo_password_copied');setTimeout(()=>this.toast='',4500);}catch(e){this.loginForm.password=this.demoLifecycle?.password||'';}},
get setupView() { return ['configuration','settings'].includes(this.view); },
get coverPeople() { const q=this.coverQuery.toLowerCase(); return this.people.filter(p=>p.id!==this.form.from_user && (this.members.some(m=>m.user.id===p.id) || this.form.force_mode) && [p.display_name,p.email,p.username].some(v=>v?.toLowerCase().includes(q))); },
get nextDuty() { const now=Date.now(); return this.calendar.assignments.filter(a=>a.schedule_id===this.scheduleId && a.user_id===this.user?.id && !a.unavailable && Date.parse(a.end)>now).sort((a,b)=>a.start.localeCompare(b.start))[0]; },
get matchingRequest() { return this.needsMe.find(r=>!r.absence_id && r.legs.length===1 && r.legs[0].schedule_id===this.form.schedule_id && r.legs[0].from_user===this.form.from_user && r.legs[0].to_user===this.form.to_user && Date.parse(r.legs[0].start)===Date.parse(this.form.start) && Date.parse(r.legs[0].end)===Date.parse(this.form.end)); },
get inbox() { return [...(this.doc?.requests || [])].reverse(); },
get title() { return this.nav.find(n => n.id === this.view)?.name || tr('calendar_adab50'); },
get monthTitle() { return new Date((this.startDay || this.today()) + 'T12:00:00Z').toLocaleDateString(this.language, { month: 'long', year: 'numeric', timeZone: 'UTC' }); },
get nowAssignment() { const now = new Date(); return this.calendar.assignments.find(a => !a.unavailable && a.schedule_id === this.scheduleId && new Date(a.start) <= now && now < new Date(a.end)); },
can(permission) { return (this.detail?.permissions || []).includes(permission); },
personById(id) { return this.people.find(p=>p.id===id) || this.people.find(p=>p.aliases?.includes(id)); },
person(id) { return this.personById(id)?.display_name || this.members.find(m=>m.user.id===id)?.user.display_name || tr('unknown_employee_f68692'); },
shortName(id) { return this.person(id).split(' ')[0]; },
initials(id) { return this.person(id).split(' ').map(s => s[0]).slice(0, 2).join(''); },
color(id) { return ['teal', 'purple', 'amber', 'blue', 'rose'][Math.max(0, this.people.findIndex(p => p.id === id || p.aliases?.includes(id))) % 5]; },
scheduleName(id) { return this.doc?.schedules.find(s => s.id === id)?.name || tr('archived_schedule_40f4da'); },
today() { return this.dayKey(new Date(), this.doc?.timezone || 'UTC'); },
dayKey(date, zone = 'UTC') { const p = Object.fromEntries(new Intl.DateTimeFormat('en', { timeZone: zone, year: 'numeric', month: '2-digit', day: '2-digit' }).formatToParts(date).map(p => [p.type, p.value])); return `${p.year}-${p.month}-${p.day}`; },
addDays(day, count) { const d = new Date(day + 'T12:00:00Z'); d.setUTCDate(d.getUTCDate() + count); return this.dayKey(d); },
monday() { const day = this.today(); const d = new Date(day + 'T12:00:00Z').getUTCDay(); return this.addDays(day, -((d + 6) % 7)); },
dayNumber(day) { return new Intl.DateTimeFormat('en',{timeZone:this.displayZone,day:'numeric'}).format(new Date(day.start)); },
weekday(day) { return new Intl.DateTimeFormat(this.language,{timeZone:this.displayZone,weekday:'short'}).format(new Date(day.start)).toUpperCase(); },
localClock(value) { return new Date(value).toLocaleTimeString('en-GB',{timeZone:this.displayZone,hour:'2-digit',minute:'2-digit',timeZoneName:'shortOffset'}); },
dayInterval(day) { return this.formatTime(day.start)+' → '+this.formatTime(day.end); },
assignmentInterval(a,day) { return this.formatTime(new Date(Math.max(Date.parse(a.start),Date.parse(day.start))))+' → '+this.formatTime(new Date(Math.min(Date.parse(a.end),Date.parse(day.end)))); },
onDay(day) { return this.calendar.assignments.filter(a => a.schedule_id === this.scheduleId && Date.parse(a.start) < Date.parse(day.end) && Date.parse(day.start) < Date.parse(a.end)); },
formatTime(value) { const p=Object.fromEntries(new Intl.DateTimeFormat('en-GB',{timeZone:this.displayZone,year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',second:'2-digit',timeZoneName:'shortOffset'}).formatToParts(new Date(value)).map(p=>[p.type,p.value]));return `${p.day}-${p.month}-${p.year} ${p.hour}:${p.minute}:${p.second} ${p.timeZoneName}`; },
endpoint(suffix = '') { return `/api/workspaces/${this.workspace.id}${suffix}`; },
async api(path, method = 'GET', body) {
const response = await fetch(path, { method, headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.csrf }, credentials: 'same-origin', ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
const data = await response.json();
if (!response.ok) { if (response.status === 401) this.user = null; throw new Error(data.error || tr('the_request_failed_02d674')); }
return data;
},
async run(task, message = '') { if (this.busy) return; this.busy = true; this.error = ''; try { await task(); if (message) { this.toast = message; setTimeout(() => this.toast = '', 4500); } } catch (e) { this.error = e.message; } finally { this.busy = false; } },
readNavigation(){const query=new URLSearchParams(location.search);const raw=query.get('view');const view=['schedules','policies','generation'].includes(raw)?'configuration':raw;return {view:this.nav.some(n=>n.id===view)?view:'calendar',workspace:query.get('workspace')||''};},
menuHref(view,workspace=this.selected){const url=new URL(location.href);url.searchParams.set('view',view);if(workspace)url.searchParams.set('workspace',workspace);else url.searchParams.delete('workspace');return url.pathname+url.search+url.hash;},
writeNavigation(replace=false){if(!this.ready || this.navigationRestoring)return;const href=this.menuHref(this.view);if(href!==location.pathname+location.search+location.hash)history[replace?'replaceState':'pushState']({},'',href);},
menuClick(event,view){if(event.button!==0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey)return;event.preventDefault();this.view=view;},
async changeWorkspace(){this.modal='';this.scheduleId='';this.startDay='';this.writeNavigation();await this.run(()=>this.loadWorkspace());},
async restoreNavigation(){const epoch=++this.navigationEpoch;this.navigationRestoring++;try{const route=this.readNavigation();this.modal='';this.error='';this.view=route.view==='instance' && this.user && !this.user.instance_admin?'calendar':route.view;if(this.user){const selected=this.directoryState.workspaces.some(t=>t.id===route.workspace)?route.workspace:this.directoryState.workspaces[0]?.id||'';if(selected!==this.selected){this.selected=selected;this.scheduleId='';this.startDay='';await this.loadWorkspace();}}else this.selected=route.workspace;await this.$nextTick();}catch(e){if(epoch===this.navigationEpoch)this.error=e.message;}finally{this.navigationRestoring--;if(!this.navigationRestoring)this.writeNavigation(true);}},
async init() {
this.$watch('user',user=>{Alpine.store('locale').language=user?.language || I18n.browser;});
this.$watch('form',()=>{if(this.modal==='absence')this.absenceChanged();if(this.modal==='request')this.requestChanged();if(['variable','generation-rule'].includes(this.modal))this.generationChanged();});
this.$watch('draftPeople',()=>{if(this.modal==='absence')this.absenceChanged();});
this.$watch('generationDraft',()=>{if(this.generationDraft && this.can('edit_schedules'))this.generationChanged();});
this.$watch('modal',(value,old)=>{if(['variable','generation-rule'].includes(old) && !value){this.bindingEditor=null;this.rulePeopleTarget=null;this.generationChanged();}});
this.$watch('view',v=>{if(v==='instance'){if(this.user?.instance_admin)this.run(()=>this.loadAdmin());}if(v==='configuration')this.loadGenerationDraft();if(this.setupView)this.setupOpen=true;this.writeNavigation();});
this.consumeLoginError();
const initial=this.readNavigation();this.selected=initial.workspace;this.view=initial.view;window.addEventListener('popstate',()=>this.restoreNavigation());
await this.run(async () => { const session = await this.api('/api/session'); this.user = session.user; this.csrf = session.csrf || ''; this.auth = session.auth || {oidc:false,password:true}; this.demo = session.demo;this.demoLifecycle=session.demo_lifecycle; if (this.user) { if(this.view==='instance' && !this.user.instance_admin)this.view='calendar'; await this.loadWorkspaces(); if(this.view==='instance')await this.loadAdmin(); } });
if(this.setupView)this.setupOpen=true;this.ready = true;this.writeNavigation(true);this.$nextTick(()=>{const update=()=>document.documentElement.style.setProperty('--demo-offset',(this.$refs.demoBanner?.getBoundingClientRect().height||0)+'px');new ResizeObserver(update).observe(this.$refs.demoBanner);update();});if(this.view==='configuration')this.loadGenerationDraft();
setInterval(() => { if (this.user && this.workspace && this.view!=='configuration' && !this.modal && !this.busy && !this.draggedRoute) this.run(() => this.loadWorkspace()); }, 20000);
},
consumeLoginError(){const url=new URL(location.href),code=url.searchParams.get('login_error');if(code===null)return;const ref=url.searchParams.get('ref')||'';this.loginError={code:['no_access','failed','unavailable'].includes(code)?code:'failed',ref:ref.slice(0,64)};url.searchParams.delete('login_error');url.searchParams.delete('ref');history.replaceState({},'',url.pathname+url.search+url.hash);},
async signIn() { await this.run(async () => { this.loginError=null; const s = await this.api('/api/login', 'POST', this.loginForm); this.user = s.user; this.csrf = s.csrf; this.loginForm.password = ''; await this.loadWorkspaces(); if(this.view==='instance'){if(this.user.instance_admin)await this.loadAdmin();else this.view='calendar';} }); },
async signOut() { await this.run(async () => { await this.api('/api/logout', 'POST', {}); this.generationSequence++;clearTimeout(this.generationTimer);this.generationDraft=null;this.generationResult=null;this.generationWorkspace='';this.view='calendar';this.modal='';this.form={};this.user = null; this.admin = null; this.detail = null; this.menu = false; this.selected = '';this.directoryState={workspaces:[]};this.startDay=''; }); },
async loadWorkspaces() { this.directoryState = await this.api('/api/workspaces'); if (!this.directoryState.workspaces.some(t => t.id === this.selected)) this.selected = (this.demo && ['alice','bob','carol'].includes(this.user?.username) ? this.directoryState.workspaces.find(t=>t.name===(this.user.username==='carol'?'Customer Support':'Acme Engineering'))?.id : '') || this.directoryState.workspaces[0]?.id || ''; if (this.selected) await this.loadWorkspace(); else this.detail = null; this.writeNavigation(true); },
async loadWorkspace() { const selected = this.directoryState.workspaces.find(t => t.id === this.selected); if (!selected) return; const detail = await this.api(`/api/workspaces/${selected.id}`);if(this.selected!==selected.id)return;this.detail=detail; if (!this.doc.schedules.some(s => s.id === this.scheduleId)) this.scheduleId = this.doc.schedules[0]?.id || ''; if (!this.startDay) this.startDay = this.monday(); await this.loadCalendar(); if(this.view==='configuration')this.loadGenerationDraft(); },
async loadCalendar() { const workspace=this.workspace.id;const calendar=await this.api(this.endpoint(`/calendar?from=${this.startDay}&days=28&schedule=${this.scheduleId}`));if(this.selected===workspace)this.calendar=calendar; },
async navigateDays(n) { await this.run(async () => { this.startDay = n === 0 ? this.monday() : this.addDays(this.startDay, n); await this.loadCalendar(); }); },
trapDialog(event){const list=[...this.$refs.dialog.querySelectorAll('button,input,select,textarea,summary,[tabindex="0"]')].filter(e=>!e.disabled && e.offsetParent!==null && e.tabIndex>=0);const first=list[0],last=list.at(-1);if(event.shiftKey && document.activeElement===first){event.preventDefault();last?.focus();}else if(!event.shiftKey && document.activeElement===last){event.preventDefault();first?.focus();}},
open(type, data = {}) {
this.error = ''; this.toast=''; this.menu = false; this.modal = type; this.form = copy(data); this.draftPeople=[]; this.picker=null; this.csvSequence++;
if(type==='holiday-import') { this.csvText='';this.csvResult=null;this.csvError='';this.csvLoading=false; }
if(type==='working') this.form=copy(this.doc.working_interval);
if(type==='person') this.form=copy(data);
if (type === 'rotation' && !data.id) this.form = { id: crypto.randomUUID(), name: '', type: 'rotation', anchor: this.today(), shift_days: 7, handover:'11:00:00', participants: this.members.map(m => m.user.id) };
if (type === 'priority') this.form = { id: crypto.randomUUID(), name: '', type: 'priority', handover:'11:00:00', participants: this.members.slice(0,2).map(m=>m.user.id), fallback: this.doc.schedules.find(s => s.type === 'rotation')?.id, ...data };
if (type === 'absence') {this.absenceResult=null;this.absenceError='';this.absenceRevision=this.workspace.revision;this.showAllAbsence=false;this.form = { id: crypto.randomUUID(), user_id: this.user.id, start: this.today(), end: this.today(), ranges:[],force:false,reason:'',...data };this.absenceChanged();}
if (type === 'holiday') this.form = { date: this.today(), name: '', country: this.user.country || 'CY', ...data };
if (type === 'member-permissions') this.form = { permissions: [...(this.doc.member_permissions || ['view','request_overrides','edit_schedules'])] };
if (type === 'admin-user') this.form = { username: '', display_name: '', password: '', email: '', groups: [] };
if (type === 'account') this.form = { language:this.user.language || '', display_name: this.user.display_name, country: this.user.country || 'CY', timezone:this.user.timezone || '', current_password: '', password: '' };
if (type === 'policy') this.form = { id: crypto.randomUUID(), name: '', steps: [{ delay_minutes: 0, condition: 'if-not-acked', target: `schedule:${this.doc.schedules[0]?.id}` }], repeat: { count: 0, wait_minutes: 0, reset_recipient_states: false, close_alert_after_all: false }, ...data };
if (type === 'policy' && data.steps) this.form.steps = data.steps.map(s => ({ ...s, target: s.recipient.id ? `${s.recipient.type}:${s.recipient.id}` : s.recipient.type }));
if (type === 'route') this.form = { id: crypto.randomUUID(), name: '', conditions: [], escalation_id: '', working: false, ...data, position:data.id?this.orderedRoutes.findIndex(r=>r.id===data.id)+1:this.doc.routes.length+1 };
if (type === 'route' && data.window) Object.assign(this.form, { working: true, ...data.window });
if (type === 'integration') this.form = { provider: 'mock', cloud_id: '', team_id: '', advanced_escalations: false, users: {}, ...copy(this.doc.integration) };
if (type === 'preferences') this.form = { name: this.doc.name, horizon_days: this.doc.horizon_days };
this.$nextTick(() => this.$refs.dialog?.querySelector('input,select,button')?.focus());
},
choosePerson(target, index=null) { this.picker={target,index}; this.personQuery=''; this.pickerEditing=null; this.pickerName=''; this.pickerEmail=''; this.pickerCountry='CY'; this.$nextTick(()=>this.$refs.personSearch?.focus()); },
pickPerson(person) {
const p=copy(person); delete p.username;
if(!this.doc.people?.some(x=>x.id===p.id) && !this.draftPeople.some(x=>x.id===p.id)) this.draftPeople.push(p);
if(this.picker.target==='participants') this.addParticipant(p.id); else if(this.picker.target==='absenceReplacement')this.form.ranges[this.picker.index].replacement=p.id;else this.form[this.picker.target]=p.id;
this.picker=null;
},
addEmailPerson() {
const email=this.personQuery.trim().toLowerCase();
if(!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {this.error=tr('enter_a_complete_email_address_5f5f50');return;}
const existing=this.people.find(p=>p.email?.toLowerCase()===email);
if(existing) {this.pickPerson(existing);return;}
this.pickPerson({id:crypto.randomUUID(),user_id:null,email,display_name:this.pickerName.trim() || email,country:this.pickerCountry});
},
editPerson(id) {
const p=this.people.find(p=>p.id===id); if(!p)return;
this.picker={target:'edit'};this.pickerEditing=p.id;this.personQuery='';this.pickerName=p.display_name;this.pickerEmail=p.email || '';this.pickerCountry=p.country || 'CY';
},
savePersonDetails() {
const p=copy(this.people.find(p=>p.id===this.pickerEditing));delete p.username;
Object.assign(p,{display_name:this.pickerName,email:this.pickerEmail.trim().toLowerCase() || null,country:this.pickerCountry});
this.draftPeople=this.draftPeople.filter(x=>x.id!==p.id);this.draftPeople.push(p);this.picker=null;
},
async previewHolidays() {
const sequence=++this.csvSequence;const csv=this.csvText; this.csvResult=null;this.csvError='';
if(!csv.trim()){this.csvLoading=false;return;}
this.csvLoading=true;
try { const result=await this.api(this.endpoint('/holiday-preview'),'POST',{csv}); if(sequence===this.csvSequence && this.modal==='holiday-import') this.csvResult={...result,csv}; }
catch(e){if(sequence===this.csvSequence)this.csvError=e.message;}
finally{if(sequence===this.csvSequence)this.csvLoading=false;}
},
csvChanged() { this.csvSequence++;this.csvResult=null;this.csvLoading=true;clearTimeout(this.csvTimer);this.csvTimer=setTimeout(()=>this.previewHolidays(),300); },
get canImport() { return !this.csvLoading && this.csvResult?.csv===this.csvText && !this.csvResult.preview.errors.length && this.csvResult.preview.additions.length>0; },
absencePayload() {
const people=new Map(this.draftPeople.map(p=>[p.id,copy(p)]));
for(const id of [this.form.user_id,...this.form.ranges.map(r=>r.replacement)]) {
if(id && !this.doc.people.some(p=>p.id===id) && !people.has(id)){const p=copy(this.people.find(p=>p.id===id));delete p.username;people.set(id,p);}
}
return {revision:this.absenceRevision,absence:{id:this.form.id,user_id:this.form.user_id,start:this.form.start,end:this.form.end},ranges:this.form.ranges.map(r=>({start:r.start,end:r.end,replacement:r.replacement,replace_projects:r.replace_projects,local:{schedule_id:r.schedule_id||null,start:r.start+'T'+timeValue(r.start_time),end:r.end+'T'+timeValue(r.end_time)}})),people:[...people.values()],force:this.form.force,reason:this.form.reason};
},
absenceChanged() {this.absenceSequence++;this.absenceResult=null;this.absenceError='';this.absenceLoading=true;clearTimeout(this.absenceTimer);this.absenceTimer=setTimeout(()=>this.previewAbsence(),300);},
async previewAbsence() {
const seq=++this.absenceSequence;
if(!this.form.start || !this.form.end || !this.form.user_id || this.form.ranges.some(r=>!r.start || !r.end || !r.start_time || !r.end_time || !r.replacement)){this.absenceLoading=false;this.absenceError=tr('complete_the_dates_and_choose_a_person_for_each_repl_6187c4');return;}
const payload=this.absencePayload();
try{const result=await this.api(this.endpoint('/absence-preview'),'POST',payload);if(seq===this.absenceSequence && this.modal==='absence')this.absenceResult={...result,key:JSON.stringify(payload)};}
catch(e){if(seq===this.absenceSequence)this.absenceError=e.message;}
finally{if(seq===this.absenceSequence)this.absenceLoading=false;}
},
get canSaveAbsence(){return this.modal==='absence' && !this.absenceLoading && this.absenceResult && !this.absenceResult.plan.errors.length && this.absenceResult.key===JSON.stringify(this.absencePayload());},
newAbsenceRange(values={}) { const clock=(this.doc?.working_interval?.start||'11:00:00');return {id:crypto.randomUUID(),start:this.form.start,end:this.addDays(this.form.end,1),start_time:clock,end_time:clock,schedule_id:'',replacement:'',replace_projects:false,...values}; },
coverAbsenceRow(row){
const clock=iso=>new Intl.DateTimeFormat('en-GB',{timeZone:this.doc.timezone,hour:'2-digit',minute:'2-digit',second:'2-digit',hourCycle:'h23'}).format(new Date(iso));
const values={schedule_id:row.schedule_id,start:this.dayKey(new Date(row.start),this.doc.timezone),end:this.dayKey(new Date(row.end),this.doc.timezone),start_time:clock(row.start),end_time:clock(row.end)};
let index=this.form.ranges.findIndex(r=>Object.entries(values).every(([k,v])=>r[k]===v));
if(index<0){this.form.ranges.push(this.newAbsenceRange(values));index=this.form.ranges.length-1;}
this.choosePerson('absenceReplacement',index);
},
addAbsenceRange(){const last=this.form.ranges.at(-1);this.form.ranges.push(this.newAbsenceRange(last?{start:last.end,start_time:last.end_time}:{}));},
clickDay(day) {
const assignments=this.onDay(day);
if(assignments.length===1){this.openDuty(day,assignments[0]);return;}
this.open('day',{day,assignments});
},
openDuty(day,assignment) {
const start=new Date(Math.max(Date.parse(day.start),Date.parse(assignment.start))).toISOString(),end=new Date(Math.min(Date.parse(day.end),Date.parse(assignment.end))).toISOString();
this.open('request',{revision:this.workspace.revision,schedule_id:assignment.schedule_id,from_user:assignment.user_id,to_user:assignment.user_id===this.user.id?'':this.user.id,start,end,start_day:this.dayKey(new Date(start),this.doc.timezone),end_day:this.dayKey(new Date(end),this.doc.timezone),start_time:this.workspaceClock(start),end_time:this.workspaceClock(end),return_start:this.addDays(day.date,7),return_end:this.addDays(day.date,8),return_start_time:this.doc?.working_interval?.start?.slice(0,5)||'11:00',return_end_time:this.doc?.working_interval?.start?.slice(0,5)||'11:00',reason:'',swap:false,custom_range:false,force_mode:false});
this.coverQuery='';this.requestResult=null;this.requestError='';this.requestChanged();
},
nextOwnDuty() {
const day=this.calendar.days.find(d=>Date.parse(d.end)>Date.now() && this.onDay(d).some(a=>a.user_id===this.user.id));
if(day){this.openDuty(day,this.onDay(day).find(a=>a.user_id===this.user.id));const time=this.doc?.working_interval?.start?.slice(0,5)||'11:00';Object.assign(this.form,{start_day:day.date,end_day:this.addDays(day.date,1),start_time:time,end_time:time,custom_range:true});}
else {this.view='calendar';this.toast=tr('choose_the_day_you_want_to_change_6ce301');setTimeout(()=>this.toast='',4500);}
},
workspaceClock(value){return new Intl.DateTimeFormat('en-GB',{timeZone:this.doc.timezone,hour:'2-digit',minute:'2-digit',hourCycle:'h23'}).format(new Date(value));},
coverRangeChanged(){this.form.custom_range=true;},
requestPayload() {
const f=this.form,p={revision:f.revision,reason:f.reason.trim(),force:!!f.force_mode,remaining:true};
if(f.custom_range || f.swap)Object.assign(p,{schedule_id:f.schedule_id,from_user:f.from_user,to_user:f.to_user,start_day:f.start_day,end_day:f.end_day,start_time:f.start_time,end_time:f.end_time,swap:f.swap,return_start:f.return_start,return_end:f.return_end,return_start_time:f.return_start_time,return_end_time:f.return_end_time});
else p.legs=[{schedule_id:f.schedule_id,from_user:f.from_user,to_user:f.to_user,start:f.start,end:f.end}];
return p;
},
requestChanged() {this.requestSequence++;this.requestResult=null;this.requestError='';clearTimeout(this.requestTimer);this.requestLoading=!!this.form.to_user;this.requestTimer=setTimeout(()=>this.previewRequest(),250);},
async previewRequest() {
const seq=++this.requestSequence;
if(this.modal!=='request' || !this.form.to_user || !this.form.start_day || !this.form.end_day){this.requestLoading=false;return;}
const payload=this.requestPayload();
try {const result=await this.api(this.endpoint('/request-preview'),'POST',payload);if(seq===this.requestSequence && this.modal==='request')this.requestResult={...result,key:JSON.stringify(payload)};}
catch(e){if(seq===this.requestSequence)this.requestError=e.message;}
finally{if(seq===this.requestSequence)this.requestLoading=false;}
},
get canSendRequest(){return this.modal==='request' && !!this.form.to_user && !this.requestLoading && this.requestResult?.key===JSON.stringify(this.requestPayload());},
async sendQuickRequest(){
if(this.matchingRequest && !this.form.force_mode && !this.form.custom_range && !this.form.swap){await this.decide(this.matchingRequest,'approve');if(!this.error){this.modal='';this.toast=tr('replacement_accepted_publication_is_queued_afea8f');}return;}
if(!this.canSendRequest)return;
const force=this.form.force_mode;
await this.run(async()=>{await this.api(this.endpoint('/requests'),'POST',this.requestPayload());await this.loadWorkspace();this.modal='';},force?tr('replacement_applied_publication_is_queued_89e283'):tr('request_sent_waiting_for_your_colleague_b70b64'));
},
requestTitle(r){const leg=r.legs[0];return leg.to_user===r.requested_by?this.shortName(r.requested_by)+tr('offers_to_cover_935d75')+this.shortName(leg.from_user):this.shortName(r.requested_by)+tr('needs_cover_9e1ff7');},
async locateCoverage(gap){this.view='calendar';this.scheduleId=gap.schedule_id;const local=this.dayKey(new Date(gap.start),this.doc.timezone);this.startDay=this.addDays(local,-1);await this.run(()=>this.loadCalendar());const day=this.calendar.days.find(d=>Date.parse(d.start)<=Date.parse(gap.start) && Date.parse(gap.start)<Date.parse(d.end));const assignment=this.calendar.assignments.find(a=>a.schedule_id===gap.schedule_id && a.user_id===gap.user_id && Date.parse(a.start)<=Date.parse(gap.start) && Date.parse(gap.start)<Date.parse(a.end));if(day && assignment)this.openDuty(day,assignment);},
async chooseSchedule(id){this.scheduleId=id;this.schedulePicker=false;this.scheduleSearch='';await this.run(()=>this.loadCalendar());},
moveParticipant(index, delta) { const other = index + delta; if (other < 0 || other >= this.form.participants.length) return; [this.form.participants[index], this.form.participants[other]] = [this.form.participants[other], this.form.participants[index]]; },
addParticipant(id) { if (id && !this.form.participants.includes(id)) this.form.participants.push(id); },
async save(force = false) {
if(this.bindingEditor || this.rulePeopleTarget){this.closeRuleEditor();return;}
if(this.modal==='request'){await this.sendQuickRequest();return;}
if(['variable','generation-rule'].includes(this.modal)){this.saveGenerationModal();return;}
await this.run(async () => {
const f = copy(this.form); const kind = this.modal; const revision = this.workspace?.revision;
if (kind === 'account') { const r = await this.api('/api/account', 'PUT', f); this.user = r.user; }
else if(kind==='working') await this.api(this.endpoint('/working-interval'),'PUT',{revision,interval:{...f,weekdays:f.weekdays.map(Number),start:timeValue(f.start),end:timeValue(f.end)}});
else if(kind==='holiday-import') { if(!this.canImport)throw new Error(tr('preview_the_csv_and_fix_any_errors_before_importing_2ccc4c'));await this.api(this.endpoint('/holiday-import'),'POST',{revision:this.csvResult.revision,csv:this.csvText}); }
else if(kind==='remove-schedule') { if(!this.canRemoveSchedule)throw new Error(tr('resolve_the_listed_dependencies_before_removing_this_fa1445'));await this.api(this.endpoint('/schedules/'+f.id+'/remove'),'POST',{revision:this.removal.revision,redirect_to:f.redirect_to || null}); }
else if(kind==='absence') {if(!this.canSaveAbsence)throw new Error(tr('wait_for_a_valid_absence_preview_before_saving_c743bf'));await this.api(this.endpoint('/absences'),'POST',this.absencePayload());}
else if (kind === 'member-permissions') await this.api(this.endpoint('/member-permissions'), 'PUT', { revision, permissions: ['view', ...f.permissions.filter(p=>p!=='view')] });
else if (kind === 'admin-user') { const body={username:f.username,display_name:f.display_name,password:f.password,groups:f.groups};if(f.email.trim())body.email=f.email.trim();await this.api('/api/admin/users','POST',body);await this.loadAdmin(); }
else if (kind === 'admin-groups') { await this.api(`/api/admin/users/${f.id}/groups`,'PUT',{groups:f.groups});await this.loadAdmin(); }
else if (kind === 'admin-password') { await this.api(`/api/admin/users/${f.id}/password`,'POST',{password:f.password});await this.loadAdmin(); }
else if (kind === 'admin-delete') { await this.api(`/api/admin/workspaces/${f.id}`,'DELETE',{confirm:f.confirm});await this.loadAdmin(); }
else if (kind === 'integration') await this.api(this.endpoint('/integration'), 'PUT', { revision, integration: f.provider === 'mock' ? { provider: 'mock' } : {...f,users:Object.fromEntries(Object.entries(f.users).filter(([id,value])=>value?.trim()).map(([id,value])=>[id,value.trim()]))} });
else if (kind === 'force') await this.api(this.endpoint(`/requests/${f.id}`), 'POST', { revision, action: 'force', reason: f.reason });
else {
const doc = copy(this.doc);
const people=new Map((doc.people || []).map(p=>[p.id,p]));
for(const p of this.draftPeople)people.set(p.id,p);
if(kind==='person'){ const p=copy(f);delete p.username;people.set(p.id,p); }
// Snapshot selected workspace users so their scheduling country and external identity are explicit.
const selectedIds=['rotation','priority'].includes(kind)?f.participants:kind==='absence'?[f.user_id]:[];
for(const id of selectedIds){if(!people.has(id)){const p=copy(this.people.find(p=>p.id===id));delete p.username;people.set(id,p);}}
doc.people=[...people.values()];
const upsert = (list, entry) => { const i = list.findIndex(x => x.id === entry.id); i === -1 ? list.push(entry) : list.splice(i, 1, entry); };
if (['rotation','priority'].includes(kind)) { if(kind==='rotation')f.shift_days = Number(f.shift_days); f.handover=timeValue(f.handover || '11:00'); upsert(doc.schedules, f); }
if (kind === 'absence') upsert(doc.absences, f);
if (kind === 'holiday') { if(doc.holidays.some(h=>h.date===f.date && h.country===f.country))throw new Error(tr('this_country_already_has_a_holiday_on_that_date_7a457a'));doc.holidays.push(f); }
if (kind === 'preferences') Object.assign(doc, f, { horizon_days: Number(f.horizon_days) });
if (kind === 'policy') { f.steps = f.steps.map(s => { const [type, id] = s.target.split(':'); return { delay_minutes: Number(s.delay_minutes), condition: s.condition, recipient: id ? { type, id } : { type } }; }); f.repeat.count = Number(f.repeat.count); f.repeat.wait_minutes = Number(f.repeat.wait_minutes); upsert(doc.escalations, f); }
if (kind === 'route') {
const position=Number(f.position);delete f.position;f.escalation_id ||= null;f.window = f.working ? copy(this.doc.working_interval) : null;
doc.routes=this.orderedRoutes.filter(r=>r.id!==f.id).map(copy);doc.routes.splice(position-1,0,f);doc.routes.forEach((r,index)=>r.order=index);
}
await this.api(this.endpoint('/config'), 'PUT', { revision, document: doc });
}
await this.loadWorkspaces(); this.modal = ''; this.draftPeople=[];
}, force ? tr('replacement_applied_publication_is_queued_89e283') : this.savedMessage(this.modal));
},
savedMessage(kind){const key={'admin-user':'access_user_created','admin-groups':'access_groups_saved','admin-password':'access_password_reset_done','admin-delete':'access_workspace_deleted'}[kind];return tr(key||'changes_saved_5ffbaf');},
async decide(request, action) { await this.run(async () => { await this.api(this.endpoint(`/requests/${request.id}`), 'POST', { revision: this.workspace.revision, action }); await this.loadWorkspace(); }, tr('request_updated_c52ad2')); },
async removeItem(collection, id) {
if(collection==='schedules') { await this.run(async()=>{const result=await this.api(this.endpoint('/schedules/'+id+'/removal'));this.open('remove-schedule',{id,redirect_to:''});this.removal=result;});return; }
await this.run(async () => { const doc = copy(this.doc); if (collection === 'holidays') doc.holidays=doc.holidays.filter(h=>h.country+':'+h.date!==id); else doc[collection] = doc[collection].filter(x => x.id !== id); if(collection==='routes')doc.routes.sort((a,b)=>a.order-b.order).forEach((r,index)=>r.order=index); await this.api(this.endpoint('/config'), 'PUT', { revision: this.workspace.revision, document: doc }); await this.loadWorkspace(); }, tr('configuration_updated_a81653'));
},
startRouteDrag(event,id) {
if(!this.can('edit_schedules') || this.busy){event.preventDefault();return;}
this.draggedRoute=id;this.dragRevision=this.workspace.revision;this.dragWorkspace=this.workspace.id;
event.dataTransfer.effectAllowed='move';event.dataTransfer.setData('text/plain',id);
},
overRoute(event,id) {
if(!this.draggedRoute || this.draggedRoute===id || this.dragWorkspace!==this.workspace.id)return;
event.dataTransfer.dropEffect='move';const box=event.currentTarget.getBoundingClientRect();
this.routeDropTarget=id;this.routeDropSide=event.clientY<box.top+box.height/2?'before':'after';
},
clearRouteDrag() { this.draggedRoute='';this.routeDropTarget='';this.dragRevision=null;this.dragWorkspace=''; },
async dropRoute(event,id) {
if(!this.draggedRoute || this.draggedRoute===id || this.dragWorkspace!==this.workspace.id){this.clearRouteDrag();return;}
const moving=this.draggedRoute,revision=this.dragRevision;
const box=event.currentTarget.getBoundingClientRect(),after=event.clientY>=box.top+box.height/2;
const ids=this.orderedRoutes.map(r=>r.id).filter(r=>r!==moving);const block=ids.filter(r=>this.generated(r));const target=this.generated(id)?(after?block.at(-1):block[0]):id;ids.splice(ids.indexOf(target)+(after?1:0),0,moving);
this.clearRouteDrag();await this.saveRouteOrder(ids,revision);
},
async moveRoute(id,delta) {
const ids=this.orderedRoutes.map(r=>r.id),from=ids.indexOf(id);let to=from+delta;
if(to<0 || to>=ids.length)return;if(this.generated(ids[to])){while(to+delta>=0 && to+delta<ids.length && this.generated(ids[to+delta]))to+=delta;}ids.splice(from,1);ids.splice(to,0,id);
await this.saveRouteOrder(ids,this.workspace.revision);
this.$nextTick(()=>this.$refs.routingList?.querySelector('[data-route-id="'+id+'"] .move-route')?.focus());
},
async saveRouteOrder(ids,revision) {
if(ids.join(',')===this.orderedRoutes.map(r=>r.id).join(','))return;
await this.run(async()=>{await this.api(this.endpoint('/routes/order'),'PUT',{revision,ids});await this.loadWorkspace();},tr('routing_order_saved_publication_is_queued_cfb972'));
},
async setDrift(value) { await this.run(async () => { await this.api(this.endpoint(), 'PATCH', {revision:this.workspace.revision, overwrite_drift: value }); await this.loadWorkspaces(); }, tr('drift_policy_updated_be40fd')); },
async retry() { await this.run(async () => { await this.api(this.endpoint('/sync'), 'POST', {}); await this.loadWorkspace(); }, tr('publication_queued_8355e1')); },
async preview() { await this.run(async () => { const plan = await this.api(this.endpoint('/preview')); const url = URL.createObjectURL(new Blob([JSON.stringify(plan, null, 2)], { type: 'application/json' })); const link = document.createElement('a'); link.href = url; link.download = 'meguri-publication.json'; link.click(); URL.revokeObjectURL(url); }, tr('publication_preview_downloaded_afd91a')); },
auditDetails(details){const localize=(value,key='')=>{if(Array.isArray(value))return value.map(item=>localize(item,key));if(value && typeof value==='object')return Object.fromEntries(Object.entries(value).map(([k,v])=>[k,localize(v,k)]));if(typeof value==='string' && ['start','end','created_at','applied_at','effective_at'].includes(key) && /^\d{4}-\d{2}-\d{2}T/.test(value) && Number.isFinite(Date.parse(value)))return this.formatTime(value);return value;};return JSON.stringify(localize(details),null,2);},
describeRecipient(recipient) { return recipient.type === 'schedule' ? this.scheduleName(recipient.id) : recipient.type === 'user' ? this.person(recipient.id) : recipient.type === 'team_all' ? tr('everyone_on_the_team_235b55') : tr('a_random_team_member_8f35f3'); },
secretPrefix() { return 'MEGURI_JSM_' + this.workspace.id.replaceAll('-', '').toUpperCase(); },
}));
});