2026-09-24 14:35:05 +03:00
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 (),
2026-10-07 15:32:20 +01:00
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 ,
2026-10-07 13:28:43 +01:00
loginForm : { username : '' , password : '' },
2026-09-24 14:35:05 +03:00
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' ; },
2026-10-07 13:28:43 +01: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 : '⬢' }];},
2026-09-24 14:35:05 +03:00
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 ); },
2026-10-07 13:28:43 +01:00
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 ;},
2026-10-07 15:06:12 +01:00
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 ?? '' )};});},
2026-10-07 13:28:43 +01:00
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 || '' ;}},
2026-09-24 14:35:05 +03:00
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 ); },
2026-10-07 13:28:43 +01:00
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' ); },
2026-09-24 14:35:05 +03:00
shortName ( id ) { return this . person ( id ). split ( ' ' )[ 0 ]; },
initials ( id ) { return this . person ( id ). split ( ' ' ). map ( s => s [ 0 ]). slice ( 0 , 2 ). join ( '' ); },
2026-10-07 13:28:43 +01:00
color ( id ) { return [ 'teal' , 'purple' , 'amber' , 'blue' , 'rose' ][ Math . max ( 0 , this . people . findIndex ( p => p . id === id || p . aliases ? . includes ( id ))) % 5 ]; },
2026-09-24 14:35:05 +03:00
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 ());},
2026-10-07 13:28:43 +01:00
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 );}},
2026-09-24 14:35:05 +03:00
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 ();}});
2026-10-07 13:28:43 +01:00
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 ();
2026-10-07 15:06:12 +01:00
{ const query = new URLSearchParams ( location . search ); this . eventSearch = ( query . get ( 'ref' ) || query . get ( 'search' ) || '' ). slice ( 0 , 200 );}
2026-09-24 14:35:05 +03:00
const initial = this . readNavigation (); this . selected = initial . workspace ; this . view = initial . view ; window . addEventListener ( 'popstate' ,()=> this . restoreNavigation ());
2026-10-07 15:32:20 +01:00
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 (); } });
2026-09-24 14:35:05 +03:00
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 );
},
2026-10-07 13:28:43 +01:00
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 = '' ; }); },
2026-09-24 14:35:05 +03:00
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 };
2026-10-07 13:28:43 +01:00
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 : [] };
2026-09-24 14:35:05 +03:00
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 ;
2026-10-07 13:28:43 +01:00
if ( kind === 'account' ) { const r = await this . api ( '/api/account' , 'PUT' , f ); this . user = r . user ; }
2026-09-24 14:35:05 +03:00
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 ());}
2026-10-07 13:28:43 +01:00
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 (); }
2026-09-24 14:35:05 +03:00
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 = [];
2026-10-07 13:28:43 +01:00
}, force ? tr ( 'replacement_applied_publication_is_queued_89e283' ) : this . savedMessage ( this . modal ));
2026-09-24 14:35:05 +03:00
},
2026-10-07 13:28:43 +01:00
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' );},
2026-09-24 14:35:05 +03:00
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 (); },
}));
});