Files
Aleksandr BogomiakovandClaude 38105c65c2 Merge NEXT: planning, generation map and portable configuration on top of the OIDC access model
NEXT predates the access work, so the identity layer follows master: access comes from
configured groups, instance administrators replace the site administration, and accounts are
created by SSO or by an administrator. Kept from NEXT: manual publication with the Review
changes panel and shared generator drafts, portable import/export (meguri/v2), the layered
generation map with binding provenance, JSM provider changes, contact/approval semantics and
the account-owned Person. Dropped: site administration, local sign-up, and the mandatory
unique email with its contact/account migrations. The manual-publication migration is
renumbered m_0010 after m_0009_access.

Accounts without an email still get a Person in every workspace they reach, a changed sign-in
email updates the account's person, and an account that loses access keeps its linked person so
schedules stay valid. Instance administrators may use Sync and shared drafts.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-07 16:06:10 +01:00

358 lines
47 KiB
JavaScript
Raw Permalink 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(),
syncSubmitting:false,workspacePollAt:0,
user: null, csrf: '', version: '', ready: false, busy: false, error: '', toast: '', demo: false, demoLifecycle:null, auth:{oidc:false,password:true}, loginError:null, admin:null, events:[], eventsMore:false, eventSearch:'', eventsLoading:false,
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:u.email,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');await this.loadEvents();},
async loadEvents(){const query=this.eventSearch.trim(),sequence=++this.eventSequence;this.eventsLoading=true;try{const result=await this.api('/api/admin/events'+(query?'?search='+encodeURIComponent(query):''));if(sequence===this.eventSequence){this.events=result.events;this.eventsMore=!!result.more;}}finally{if(sequence===this.eventSequence)this.eventsLoading=false;}},
eventSequence:0,
isFailedLogin(event){return event.action==='login.failed';},
eventLabel(key){return MEGURI_MESSAGES['access_event_key_'+key]?tr('access_event_key_'+key):key;},
eventDetails(event){const details=event.details;if(!details || typeof details!=='object')return [];const order=['reference','code','detail','username','email','subject','issuer','groups'];const keys=[...order.filter(k=>k in details),...Object.keys(details).filter(k=>!order.includes(k))];return keys.map(key=>{const value=details[key];return Array.isArray(value)?{key,chips:value.map(String),empty:!value.length}:{key,value:typeof value==='object' && value!==null?JSON.stringify(value):String(value??'')};});},
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) || !p.user_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(){await this.run(async()=>{await this.saveSharedGeneration();this.modal='';this.scheduleId='';this.startDay='';this.writeNavigation();await this.loadWorkspace();});if(this.error && this.workspace)this.selected=this.workspace.id;this.writeNavigation();},
async restoreNavigation(){const epoch=++this.navigationEpoch;this.navigationRestoring++;try{const route=this.readNavigation();await this.saveSharedGeneration();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 query=new URLSearchParams(location.search);this.eventSearch=(query.get('ref')||query.get('search')||'').slice(0,200);}
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.version = session.version || ''; 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.modal && !this.generationSaving && !this.generationSaveError && !this.busy && !this.draggedRoute && (this.publicationPending() || this.detail?.sync?.publication_busy || Date.now()-this.workspacePollAt>=20000)){this.workspacePollAt=Date.now();this.run(() => this.loadWorkspace());} }, 2000);
},
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.saveSharedGeneration();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(); 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;}
},
absenceRowPending(row){return !this.form.force && (this.absenceResult?.approval_groups||[]).some(g=>g.required_approvers.length && g.legs.some(l=>l.schedule_id===row.schedule_id && Date.parse(l.start)<Date.parse(row.end) && Date.parse(row.start)<Date.parse(l.end)));},
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 immediate=this.form.force_mode || this.requestResult?.applies_immediately;
await this.run(async()=>{await this.api(this.endpoint('/requests'),'POST',this.requestPayload());await this.loadWorkspace();this.modal='';},immediate?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.modal==='generation-transfer'){await this.applyTransfer();return;}
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')); },
publicationPending(){return this.detail?.sync?.state==='pending' && this.detail.sync.authorized_revision!=null;},
async retry() { if(this.busy || this.syncSubmitting || this.publicationPending() || this.detail?.sync?.publication_busy)return;this.syncSubmitting=true;try{await this.run(async () => { await this.stageGeneration();await this.api(this.endpoint('/sync'), 'POST', {revision:this.workspace.revision});this.detail.sync={...this.detail.sync,state:'pending',authorized_revision:this.workspace.revision};await this.loadWorkspace(); }, tr('publication_queued_8355e1'));}finally{this.syncSubmitting=false;} },
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(); },
}));
});