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>
This commit is contained in:
commit
38105c65c2
57 files changed
+6890
-431
No files matched your search
@@ -224,3 +224,13 @@ a.button{display:inline-block;text-decoration:none;border:1px solid #dbe3de;bord
|
||||
.brand .app-version{align-self:flex-end;margin-bottom:7px;font-size:11px;font-weight:500;letter-spacing:0;color:var(--muted)}
|
||||
@media(max-width:950px){.sidebar .brand{flex-direction:column;gap:2px}.sidebar .brand .app-version{align-self:center;margin:0}}
|
||||
@media(max-width:700px){.sidebar{gap:4px;padding:8px}.sidebar nav :is(button,a){padding:8px 4px}.sidebar .brand .app-version{margin:0;font-size:10px}}
|
||||
.publication-review{border:1px solid var(--line);border-radius:9px;padding:16px;margin-bottom:22px;background:#fff}.publication-review-heading{display:flex;justify-content:space-between;align-items:center;gap:12px}.publication-sync{font-weight:650}.publication-sync.sync-needed{background:#ffd65a;color:#493500;border:1px solid #c79a14;box-shadow:0 0 0 2px #ffe99c}.publication-draft-note{font-size:12px;color:#76643d;margin:9px 0}.publication-status{display:flex;flex-wrap:wrap;gap:10px;font-size:12px;margin-bottom:12px}.publication-status .sync-error{width:100%}.publication-review .compact-preview{width:100%;margin-top:8px}
|
||||
|
||||
.publication-actions{flex-wrap:wrap;justify-content:flex-end}.publication-workflow{font-size:12px;line-height:1.5}.publication-review-heading{flex-wrap:wrap}@media(max-width:650px){.publication-actions{justify-content:flex-start}}
|
||||
.map-address{display:inline-flex;flex-wrap:wrap;align-items:baseline;font:11px/1.8 ui-monospace,monospace;color:#7c827f}.map-address button,.map-resource-name button,.map-value-link{border:0;background:transparent;padding:1px 0!important;font:inherit!important;cursor:pointer;text-align:left;text-decoration:underline;text-decoration-style:dotted;text-underline-offset:3px}.map-resource-name{white-space:pre-wrap}.map-probe{position:fixed;inset:auto;margin:0;width:min(390px,calc(100vw - 24px));max-height:min(550px,calc(100dvh - 32px));overflow:auto;border:1px solid #b9cfc3;border-radius:12px;padding:16px;background:#fff;color:var(--ink);box-shadow:0 14px 55px #183a3438;font-size:12px}.map-probe h3{margin:0;font-size:15px}.map-probe h4{margin:16px 0 8px}.map-probe .muted{margin:10px 0;overflow-wrap:anywhere}.map-probe-chain{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin:0;padding:0;list-style:none}.map-probe-chain li{display:flex;align-items:center;gap:6px;min-width:0}.map-probe-chain li~li::before{content:"→";color:var(--muted)}.map-probe-chain button{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;overflow-wrap:anywhere;text-align:left}.map-probe-fields{display:flex;flex-wrap:wrap;gap:6px}.map-probe-actions{margin-top:16px;padding-top:12px;border-top:1px solid var(--line);display:flex;justify-content:flex-end}.map-probe button{font-size:11px;padding:6px 8px}.map-probe-chain .active{background:#e1eee5}.map-probe-example{border-top:1px solid var(--line);padding:10px 0;max-height:220px;overflow:auto}.map-probe-example .map-input-token{display:flex;flex-wrap:wrap;gap:5px;overflow-wrap:anywhere}.map-resource-name button{font-weight:650!important}.map-inspector{width:auto}.map-inspector .map-value-link{font-weight:600!important}
|
||||
.config-transfer .config-text{font:12px/1.6 ui-monospace,SFMono-Regular,Consolas,monospace;tab-size:2;resize:vertical;min-height:220px;white-space:pre;overflow:auto}.config-transfer .transfer-preview{max-height:320px;overflow:auto;margin-top:16px}.config-transfer .compact-change{overflow-wrap:anywhere}.config-transfer .actions{margin:12px 0}
|
||||
|
||||
.config-guide{min-width:0}.config-guide>.section-line{align-items:flex-start;gap:12px}.config-guide>.section-line button{flex-shrink:0}.config-guide h3{font-size:13px;margin:18px 0 7px}.config-guide pre{font:12px/1.65 ui-monospace,SFMono-Regular,Consolas,monospace;white-space:pre-wrap;overflow-wrap:anywhere;margin:0;padding:12px;background:#f4f7f5;border:1px solid var(--line);border-radius:8px}.config-guide .guide-example{background:#edf4ef}@media(max-width:600px){.config-guide>.section-line{flex-wrap:wrap}.config-guide pre{font-size:11px;padding:9px}}
|
||||
|
||||
|
||||
@media(max-width:700px){.sidebar nav{min-width:0;flex-wrap:wrap}.sidebar nav :is(button,a){gap:5px;padding:6px 4px}.sidebar-bottom{flex-shrink:0}}
|
||||
+14
-10
@@ -18,6 +18,7 @@ document.addEventListener('alpine:init', () => {
|
||||
}));
|
||||
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: [] },
|
||||
@@ -39,7 +40,7 @@ document.addEventListener('alpine:init', () => {
|
||||
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}]));
|
||||
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()];
|
||||
},
|
||||
@@ -81,7 +82,7 @@ document.addEventListener('alpine:init', () => {
|
||||
useDemoAccount(account){this.loginForm.username=account.username;this.loginForm.password=this.demoLifecycle?.password||'';},
|
||||
async copyDemoPassword(){try{await navigator.clipboard.writeText(this.demoLifecycle?.password||'');this.toast=tr('access_demo_password_copied');setTimeout(()=>this.toast='',4500);}catch(e){this.loginForm.password=this.demoLifecycle?.password||'';}},
|
||||
get setupView() { return ['configuration','settings'].includes(this.view); },
|
||||
get coverPeople() { const q=this.coverQuery.toLowerCase(); return this.people.filter(p=>p.id!==this.form.from_user && (this.members.some(m=>m.user.id===p.id) || this.form.force_mode) && [p.display_name,p.email,p.username].some(v=>v?.toLowerCase().includes(q))); },
|
||||
get 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(); },
|
||||
@@ -119,8 +120,8 @@ document.addEventListener('alpine:init', () => {
|
||||
menuHref(view,workspace=this.selected){const url=new URL(location.href);url.searchParams.set('view',view);if(workspace)url.searchParams.set('workspace',workspace);else url.searchParams.delete('workspace');return url.pathname+url.search+url.hash;},
|
||||
writeNavigation(replace=false){if(!this.ready || this.navigationRestoring)return;const href=this.menuHref(this.view);if(href!==location.pathname+location.search+location.hash)history[replace?'replaceState':'pushState']({},'',href);},
|
||||
menuClick(event,view){if(event.button!==0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey)return;event.preventDefault();this.view=view;},
|
||||
async changeWorkspace(){this.modal='';this.scheduleId='';this.startDay='';this.writeNavigation();await this.run(()=>this.loadWorkspace());},
|
||||
async restoreNavigation(){const epoch=++this.navigationEpoch;this.navigationRestoring++;try{const route=this.readNavigation();this.modal='';this.error='';this.view=route.view==='instance' && this.user && !this.user.instance_admin?'calendar':route.view;if(this.user){const selected=this.directoryState.workspaces.some(t=>t.id===route.workspace)?route.workspace:this.directoryState.workspaces[0]?.id||'';if(selected!==this.selected){this.selected=selected;this.scheduleId='';this.startDay='';await this.loadWorkspace();}}else this.selected=route.workspace;await this.$nextTick();}catch(e){if(epoch===this.navigationEpoch)this.error=e.message;}finally{this.navigationRestoring--;if(!this.navigationRestoring)this.writeNavigation(true);}},
|
||||
async 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();});
|
||||
@@ -133,13 +134,13 @@ document.addEventListener('alpine:init', () => {
|
||||
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.view!=='configuration' && !this.modal && !this.busy && !this.draggedRoute) this.run(() => this.loadWorkspace()); }, 20000);
|
||||
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.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 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(); if(this.view==='configuration')this.loadGenerationDraft(); },
|
||||
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();}},
|
||||
@@ -212,6 +213,7 @@ document.addEventListener('alpine:init', () => {
|
||||
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){
|
||||
@@ -258,8 +260,8 @@ document.addEventListener('alpine:init', () => {
|
||||
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'));
|
||||
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);},
|
||||
@@ -267,6 +269,7 @@ document.addEventListener('alpine:init', () => {
|
||||
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;}
|
||||
@@ -343,7 +346,8 @@ document.addEventListener('alpine:init', () => {
|
||||
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')); },
|
||||
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'); },
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<label x-show="['item','key'].includes(row.binding.kind)"><span x-text="t('iteration_18905a')"></span><select :aria-label="t('binding_iteration_f1778d')" x-model="row.binding.iteration"><option value="" x-text="t('choose_an_iteration_5d2162')"></option><template x-for="i in form.iterations" :key="i.id"><option :value="i.id" :selected="row.binding.iteration===i.id" x-text="bindingSummary({kind:row.binding.kind==='key'?'key':'item',iteration:i.id})"></option></template></select></label>
|
||||
<p class="info-note" x-show="row.binding.kind==='output'" x-text="t('generated_reference_hint')"></p>
|
||||
<label x-show="row.binding.kind==='output'"><span x-text="t('output_block_3853ab')"></span><select :aria-label="t('binding_output_block_e2b0ab')" x-model="row.binding.output"><option value="" x-text="t('choose_an_output_747632')"></option><template x-for="b in matchingOutputs(row.type)" :key="b.id"><option :value="b.id" :selected="row.binding.output===b.id" x-text="generatedResourceLabel(b)+' · '+bindingSummary(b.fields.name)"></option></template></select></label>
|
||||
<label x-show="!['literal','output','key'].includes(row.binding.kind)"><span x-text="t('nested_field_list_position_optional_bac49a')"></span><input :aria-label="t('nested_field_or_position_eaeb07')" :value="(row.binding.path||[]).join(' / ')" @input="row.binding.path=$event.target.value.split('/').map(s=>s.trim()).filter(Boolean)" :placeholder="t('primary_1_4b34b8')"><small><span x-text="t('leave_empty_to_use_the_whole_value_list_positions_st_8da342')"></span></small></label>
|
||||
<label x-show="!['literal','output','key'].includes(row.binding.kind)"><span x-text="t('nested_field_list_position_optional_bac49a')"></span><input x-data="{pathText:(row.binding.path||[]).join(' / '),pathBinding:row.binding,pathKind:row.binding.kind}" x-effect="if(pathBinding!==row.binding || pathKind!==row.binding.kind){pathBinding=row.binding;pathKind=row.binding.kind;pathText=(row.binding.path||[]).join(' / ')}" :aria-label="t('nested_field_or_position_eaeb07')" :value="pathText" @input="pathText=$event.target.value;row.binding.path=pathText.split('/').map(s=>s.trim()).filter(Boolean)" :placeholder="t('primary_1_4b34b8')"><small><span x-text="t('leave_empty_to_use_the_whole_value_list_positions_st_8da342')"></span></small></label>
|
||||
<div class="two-columns" x-show="row.type==='text'"><label><span x-text="t('prefix_90eceb')"></span><input :aria-label="t('text_prefix_62b30b')" x-model="row.binding.prefix" :placeholder="t('optional_0c6c41')"></label><label><span x-text="t('suffix_885db9')"></span><input :aria-label="t('text_suffix_f191fa')" x-model="row.binding.suffix" :placeholder="t('optional_0c6c41')"></label></div>
|
||||
</div></template>
|
||||
<div class="binding-value-preview" x-data="{sample:{text:'',context:'',error:''}}" x-effect="sample=bindingValuePreview()">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<dialog class="generation-map" x-ref="generationMap" @cancel.prevent="closeGenerationMap()">
|
||||
<dialog class="generation-map" x-ref="generationMap" @cancel.prevent="mapProbe?mapProbeClose():closeGenerationMap()" @click="if($event.target===$el){const r=$el.getBoundingClientRect();if($event.clientX<r.left || $event.clientX>r.right || $event.clientY<r.top || $event.clientY>r.bottom)closeGenerationMap()}">
|
||||
<header class="map-heading"><div><h2 x-text="t('map_title')"></h2><p x-text="t('map_hint')"></p></div><button type="button" @click="closeGenerationMap()" :aria-label="t('close_dialog_7b2902')" x-text="t('map_close')"></button></header>
|
||||
<div class="map-toolbar"><span class="map-status" :class="{'map-status-error':mapSnapshot?.report.issues.length}" x-text="mapBusy?t('cover_checking'):mapSnapshot?.report.issues.length?t('diagnostics_count',{count:mapSnapshot.report.issues.length}):t('generation_checked')"></span><button @click="refreshGenerationMap()" :disabled="mapBusy" x-text="t('map_refresh')"></button><div class="grow"></div><button @click="mapZoom(.8)" :aria-label="t('map_zoom_out')" x-text="t('map_minus')"></button><button @click="mapZoom(1.25)" :aria-label="t('map_zoom_in')" x-text="t('map_plus')"></button><button @click="mapFit()" x-text="t('map_fit')"></button></div>
|
||||
<p class="validation-error" x-show="mapError" x-text="mapError"></p>
|
||||
@@ -6,11 +6,24 @@
|
||||
<label><span x-text="t('map_selected')"></span><select :aria-label="t('map_selected')" :value="mapSelected" @change="mapSelect($event.target.value)"><template x-for="n in mapNodes" :key="n.id"><option :value="n.id" x-text="n.title"></option></template></select></label>
|
||||
<template x-if="mapCurrent()"><div><h3 x-text="mapCurrent()?.title"></h3><p class="muted" x-text="mapCurrent()?.subtitle"></p><div class="actions"><button x-show="mapCurrent()?.iteration" @click="mapToggle()" x-text="mapExpanded[mapCurrent()?.rule]?t('map_collapse'):t('map_expand')"></button><button @click="mapEdit()" x-text="t('map_edit')"></button></div>
|
||||
<label x-show="mapRows().length"><span x-text="t('map_pass')"></span><select :aria-label="t('map_pass')" x-model="mapRow"><option value="all" x-text="t('map_all')"></option><template x-for="(row,index) in mapRows()" :key="index"><option :value="String(index)" x-text="mapRowLabel(row,index)"></option></template></select></label>
|
||||
<details class="map-inputs" open><summary x-text="t('map_inputs')"></summary><div class="map-input-content"><template x-if="mapRow==='all'"><div class="map-source-list"><p class="muted" x-show="!mapInputSources().length" x-text="t('map_no_sources')"></p><template x-for="source in mapInputSources()" :key="source.id"><div class="map-source-card"><div class="map-source-title"><span class="badge" x-text="(source.generated?t('generated_symbol')+' ':'')+source.type"></span><strong x-text="source.name"></strong><small x-show="source.count>1" x-text="source.count"></small></div><div class="map-source-addresses"><template x-for="address in source.addresses" :key="address"><code x-text="address"></code></template></div><button class="text" x-show="source.target && source.target!==mapSelected" @click="mapSelect(source.target)" x-text="t('map_view_source')"></button></div></template><p class="muted" x-show="mapRows().length" x-text="t('map_choose_pass_values')"></p></div></template><div x-show="mapRow!=='all'"><p class="muted" x-show="!mapInputSections().length" x-text="t('map_no_inputs')"></p><template x-for="(section,sectionIndex) in mapInputSections()" :key="sectionIndex"><section class="map-input-section"><h4 x-text="section.title"></h4><template x-for="(input,inputIndex) in section.rows" :key="inputIndex"><div class="map-input-field"><div class="map-input-label"><span x-text="input.label"></span><small x-show="input.values.length>1" x-text="t('map_distinct_values',{count:input.values.length})"></small></div><template x-for="(value,valueIndex) in input.values" :key="valueIndex"><div class="map-input-value"><template x-for="(token,tokenIndex) in mapInputTokens(value)" :key="tokenIndex"><div class="map-input-token" :class="{'map-reference':token.kind}"><small x-show="token.path" x-text="token.path"></small><span class="map-reference-kind" x-show="token.kind" x-text="(token.generated?t('generated_symbol')+' ':'')+(token.kind||'')"></span><strong x-text="token.text"></strong></div></template></div></template></div></template></section></template></div></div></details>
|
||||
<details class="map-inputs" open><summary x-text="t('map_inputs')"></summary><div class="map-input-content"><template x-if="mapRow==='all'"><div class="map-source-list"><p class="muted" x-show="!mapInputSources().length" x-text="t('map_no_sources')"></p><template x-for="source in mapInputSources()" :key="source.id"><div class="map-source-card"><div class="map-source-title"><span class="badge" x-text="(source.generated?t('generated_symbol')+' ':'')+source.type"></span><button class="map-value-link" type="button" :style="{color:mapColor(source.id)}" @click="mapExplore({rule:mapCurrent()?.rule,binding:source.id.startsWith('v:')?{kind:'variable',variable:source.id.slice(2)}:source.id.startsWith('o:')?{kind:'output',output:source.id.slice(2)}:{kind:'literal',value:source.id.slice(6)},title:source.name,value:source.id.startsWith('v:')?sampleNode(mapSnapshot.payload.variables.find(v=>v.id===source.id.slice(2)).node):source.name},$event)" x-text="source.name"></button><small x-show="source.count>1" x-text="source.count"></small></div><div class="map-source-addresses"><template x-for="address in source.addresses" :key="address"><code class="map-address"><span><span x-text="t('symbol_brace_open')"></span> </span><template x-for="(part,partIndex) in mapAddressParts(address)" :key="partIndex"><span><span x-show="part.dot" x-text="part.text"></span><button x-show="!part.dot" type="button" :style="{color:part.color}" @click="mapExploreAddressPart(address,partIndex,$event)" x-text="part.text"></button></span></template><span> <span x-text="t('symbol_brace_close')"></span></span></code></template></div><button class="text" x-show="source.target && source.target!==mapSelected" @click="mapSelect(source.target)" x-text="t('map_view_source')"></button></div></template><p class="muted" x-show="mapRows().length" x-text="t('map_choose_pass_values')"></p></div></template><div x-show="mapRow!=='all'"><p class="muted" x-show="!mapInputSections().length" x-text="t('map_no_inputs')"></p><template x-for="(section,sectionIndex) in mapInputSections()" :key="sectionIndex"><section class="map-input-section"><h4 x-text="section.title"></h4><template x-for="(input,inputIndex) in section.rows" :key="inputIndex"><div class="map-input-field"><div class="map-input-label"><span x-text="input.label"></span><small x-show="input.values.length>1" x-text="t('map_distinct_values',{count:input.values.length})"></small></div><template x-for="(value,valueIndex) in input.values" :key="valueIndex"><div class="map-input-value"><template x-for="(token,tokenIndex) in mapInputTokens(value)" :key="tokenIndex"><div class="map-input-token" :class="{'map-reference':token.kind}"><small x-show="token.path" x-text="token.path"></small><span class="map-reference-kind" x-show="token.kind" x-text="(token.generated?t('generated_symbol')+' ':'')+(token.kind||'')"></span><button type="button" class="map-value-link" :style="{color:mapColor(input.label+':'+token.path)}" @click="mapExplore({rule:mapCurrent()?.rule,row:Number(mapRow),binding:input.binding,title:input.label,value},$event)" x-text="token.text"></button></div></template></div></template></div></template></section></template></div></div></details>
|
||||
<div class="map-issues"><template x-for="(issue,index) in mapIssues()" :key="index"><p class="validation-error" x-text="l(issue.message)"></p></template></div>
|
||||
<h4 x-text="t('map_resources',{count:mapResources().length})"></h4><p class="muted" x-show="!mapResources().length" x-text="mapSnapshot?.report.issues.length?t('map_unverified'):t('map_empty')"></p>
|
||||
<template x-for="r in mapResources()" :key="r.id"><details class="map-resource"><summary><span class="badge" x-text="label(r.kind)"></span><strong x-text="r.name"></strong></summary><div class="map-resource-fields"><template x-for="(field,fieldIndex) in mapResourceFields(r)" :key="fieldIndex"><div class="map-input-field"><div class="map-input-label" x-text="field.label"></div><small class="map-field-hint" x-show="field.hint" x-text="field.hint"></small><template x-for="(token,tokenIndex) in mapInputTokens(field.value)" :key="tokenIndex"><div class="map-input-token" :class="{'map-reference':token.kind}"><small x-show="token.path" x-text="token.path"></small><span class="map-reference-kind" x-show="token.kind" x-text="(token.generated?t('generated_symbol')+' ':'')+(token.kind||'')"></span><strong x-text="token.text"></strong></div></template></div></template></div></details></template>
|
||||
<h4 x-text="t(mapCurrent()?.manual?'map_manual_resources':'map_generated_resources',{count:mapResources().length})"></h4><p class="muted" x-show="!mapResources().length" x-text="mapSnapshot?.report.issues.length?t('map_unverified'):t('map_empty')"></p>
|
||||
<template x-for="r in mapResources()" :key="r.id"><details class="map-resource"><summary><span class="badge" x-text="label(r.kind)"></span><strong class="map-resource-name"><template x-for="(part,index) in mapNameParts(r)" :key="index"><button type="button" :style="{color:part.color}" @click.stop.prevent="mapExplore(part.origin,$event)" x-text="part.text"></button></template></strong></summary><div class="map-resource-fields"><template x-for="(field,fieldIndex) in mapResourceFields(r)" :key="fieldIndex"><div class="map-input-field"><div class="map-input-label" x-text="field.label"></div><small class="map-field-hint" x-show="field.hint" x-text="field.hint"></small><template x-for="(token,tokenIndex) in mapInputTokens(field.value)" :key="tokenIndex"><div class="map-input-token" :class="{'map-reference':token.kind}"><small x-show="token.path" x-text="token.path"></small><span class="map-reference-kind" x-show="token.kind" x-text="(token.generated?t('generated_symbol')+' ':'')+(token.kind||'')"></span><button type="button" class="map-value-link" :style="{color:mapColor(r.block+':'+field.key+':'+token.path)}" @click="mapExploreToken(r,field,token,$event)" x-text="token.text"></button></div></template></div></template></div></details></template>
|
||||
</div></template></aside></div>
|
||||
<footer class="map-legend"><span class="map-legend-variable" x-text="t('map_variables')"></span><span class="map-legend-rule" x-text="t('map_rules')"></span><span class="map-legend-iteration" x-text="t('map_iterations')"></span><span x-text="t('map_readonly')"></span></footer>
|
||||
<footer class="map-legend"><span class="map-legend-variable" x-text="t('map_variables')"></span><span class="map-legend-rule" x-text="t('map_generators')"></span><span class="map-legend-iteration" x-text="t('map_resource_lane')"></span><span x-text="t('map_readonly')"></span></footer>
|
||||
|
||||
<section class="map-probe" popover="manual" x-ref="mapProbe" :style="mapProbeStyle" role="region" :aria-label="t('map_explore')" @keydown.escape.stop.prevent="mapProbeClose()" @click.outside="if(mapProbe && !$event.target.closest('.map-address,.map-value-link,.map-resource-name'))mapProbeClose()">
|
||||
<template x-if="mapProbe"><div><div class="section-line"><h3 x-text="t('map_explore')"></h3><button type="button" @click="mapProbeClose()" x-text="t('done_e9b450')"></button></div>
|
||||
<button type="button" x-show="mapProbeHistory.length" @click="mapProbeBack()" x-text="t('map_explore_back')"></button>
|
||||
<p class="muted" x-text="mapProbe.title"></p><code class="map-address"><span><span x-text="t('symbol_brace_open')"></span> </span><template x-for="(part,index) in mapAddressParts(mapProbeAddress())" :key="index"><span :style="part.dot?{}:{color:part.color}" x-text="part.text"></span></template><span> <span x-text="t('symbol_brace_close')"></span></span></code>
|
||||
<h4 x-text="t('map_source_chain')"></h4><ol class="map-probe-chain">
|
||||
<template x-for="(parent,index) in mapProbeParents()" :key="'parent:'+index"><li><button type="button" @click="mapProbeFollow(parent,$event)" x-text="bindingAddress(parent.binding,0,mapSnapshot.payload.rules.find(r=>r.id===mapProbe.rule)?.iterations||[])"></button></li></template>
|
||||
<template x-for="(stage,index) in mapProbeStages()" :key="'stage:'+index"><li><button type="button" :class="{'active':mapProbe.stage===index}" :aria-current="mapProbe.stage===index?'step':null" @click="mapProbe.stage=index" x-text="stage.path.length?stage.path[stage.path.length-1]:bindingAddress({...mapProbe.binding,path:[],prefix:'',suffix:''},0,mapSnapshot.payload.rules.find(r=>r.id===mapProbe.rule)?.iterations||[])"></button></li></template>
|
||||
<li x-show="mapProbe.traces.length"><button type="button" :class="{'active':mapProbe.stage==null}" :aria-current="mapProbe.stage==null?'step':null" @click="mapProbe.stage=null" x-text="t('map_resolved_value')"></button></li></ol>
|
||||
<div x-show="mapProbeOutputSources().length"><h4 x-text="t('map_source_fields')"></h4><div class="map-probe-fields"><template x-for="(source,index) in mapProbeOutputSources()" :key="index"><button type="button" @click="mapExplore(source,$event)" x-text="source.title"></button></template></div></div>
|
||||
<h4 x-text="t('map_value_examples')"></h4><template x-for="(sample,index) in mapProbeValues()" :key="index"><div class="map-probe-example"><small x-text="sample.label"></small><p class="validation-error" x-show="sample.error" x-text="l(sample.error||'')"></p><template x-for="(token,tokenIndex) in mapInputTokens(sample.value)" :key="tokenIndex"><div class="map-input-token"><small x-text="token.path"></small><span class="badge" x-show="token.kind" x-text="token.kind"></span><strong x-text="token.text"></strong></div></template></div></template>
|
||||
<p class="muted" x-show="mapProbe.traces.length>20" x-text="t('map_examples_limited')"></p><footer class="map-probe-actions" x-show="mapProbeTarget()"><button type="button" @click="mapSelect(mapProbeTarget());mapProbeClose()" x-text="t('map_view_source')"></button></footer></div></template>
|
||||
</section>
|
||||
</dialog>
|
||||
|
||||
+86
-34
@@ -1,13 +1,36 @@
|
||||
// Canvas graph is kept outside Alpine's reactive proxy. It never mutates scheduling state.
|
||||
function generationMapUi(){let graph=null,origin=null,request=0;return {
|
||||
function generationMapUi(){let graph=null,origin=null,request=0;const colors=new Map();return {
|
||||
mapProbe:null,mapProbeHistory:[],mapProbeStyle:{},
|
||||
mapBindingKey(b){if(!b)return '';return JSON.stringify([b.kind,b.variable||null,b.iteration||null,b.output||null,b.value??null,b.path||[],b.prefix||'',b.suffix||'',(b.items||[]).map(v=>this.mapBindingKey(v)),b.key?this.mapBindingKey(b.key):null]);},
|
||||
mapColor(key){if(!colors.has(key))colors.set(key,`hsl(${(colors.size*137.508)%360} 58% 32%)`);return colors.get(key);},
|
||||
mapAddressParts(address){const text=address.replace(/^\{\s*|\s*\}$/g,''),parts=[];let start=0,quote='',escape=false,depth=0;for(let i=0;i<text.length;i++){const c=text[i];if(quote){if(escape)escape=false;else if(c==='\\')escape=true;else if(c===quote)quote='';continue;}if(c==='"'||c==="'"){quote=c;continue;}if(c==='[')depth++;if(c===']')depth--;if(c==='.'&&depth===0){parts.push({text:text.slice(start,i),dot:false});parts.push({text:'.',dot:true});start=i+1;}}parts.push({text:text.slice(start),dot:false});return parts.map((p,i)=>({...p,color:this.mapColor(parts.slice(0,i+1).map(p=>p.text).join(''))}));},
|
||||
mapAllBindings(rule){const list=[];const walk=v=>{if(!v||typeof v!=='object')return;if(['literal','variable','item','key','lookup','output','list'].includes(v.kind)){list.push(v);if(v.key)walk(v.key);(v.items||[]).forEach(walk);return;}Object.values(v).forEach(walk);};walk(rule);return list;},
|
||||
mapAddressBinding(address){const rule=this.mapSnapshot.payload.rules.find(r=>r.id===this.mapCurrent()?.rule);return {rule:rule?.id,binding:this.mapAllBindings(rule).find(b=>'{ '+this.bindingAddress(b,0,rule.iterations)+' }'===address)};},
|
||||
mapTraceBinding(rule,binding,row){const key=this.mapBindingKey(binding),rows=this.mapTrace(rule)?.rows||[],found=[];for(const [index,pass] of rows.entries()){if(row!=null&&index!==row)continue;const scan=p=>{if(!p)return null;if(this.mapBindingKey(p.binding)===key)return p;for(const parent of p.parents||[]){const match=scan(parent);if(match)return match;}return null;};const trace=(pass.bindings||[]).map(scan).find(Boolean);if(trace)found.push({row:index,trace});}return found;},
|
||||
mapResourceOrigin(item,key){if(item.manual)return {manual:true,title:item.name,field:key,value:item.resource[key]};const rule=this.mapSnapshot.payload.rules.find(r=>r.id===item.rule),block=rule?.blocks.find(b=>b.id===item.block),row=this.mapTrace(item.rule)?.rows.findIndex(r=>r.resources.includes(item.id));let binding=block?.fields[key];const step=/^steps\.(\d+)\.(delay|recipient)$/.exec(key);if(step){const s=block.steps[Number(step[1])];binding=step[2]==='recipient'&&(s.team_all||s.team_random)?this.literal(tr(s.team_random?'a_random_team_member_8f35f3':'everyone_on_the_team_235b55')):s[step[2]];}const condition=/^conditions\.(\d+)$/.exec(key);if(condition)binding=block.conditions[Number(condition[1])]?.value;return {rule:item.rule,row:row>=0?row:null,binding,title:item.name,field:key,value:item.resource[key]};},
|
||||
mapNameParts(item){const origin=this.mapResourceOrigin(item,'name'),b=origin.binding;if(!b||b.kind==='literal')return [{text:item.name,origin,color:this.mapColor(this.mapBindingKey(b)||item.id)}];const prefix=b.prefix||'',suffix=b.suffix||'';return [{text:prefix,origin:{...origin,binding:this.literal(prefix)},color:this.mapColor('prefix')},{text:item.name.slice(prefix.length,suffix?item.name.length-suffix.length:undefined),origin,color:this.mapColor(this.mapBindingKey(b))},{text:suffix,origin:{...origin,binding:this.literal(suffix)},color:this.mapColor('suffix')}].filter(p=>p.text);},
|
||||
mapSampleElement(value,path){if(!path)return value;for(const key of path.split(' / ')){value=Array.isArray(value)?value[Number(key)-1]:value?.[key];}return value;},
|
||||
async mapExploreAddressPart(address,index,event){await this.mapExploreAddress(address,event);if(!this.mapProbe)return;const parts=this.mapAddressParts(address).filter(p=>!p.dot),selected=this.mapAddressParts(address).slice(0,index+1).filter(p=>!p.dot).length,stages=this.mapProbeStages();this.mapProbe.stage=selected===parts.length?Math.max(0,stages.length-1):Math.max(0,selected-(parts.length-stages.length)-1);},
|
||||
async mapExploreAddress(address,event){const source=this.mapAddressBinding(address);if(!source.binding)return;const row=this.mapRow==='all'?null:Number(this.mapRow);await this.mapExplore({...source,row,title:address},event);},
|
||||
async mapExplore(origin,event){const traces=origin.binding?this.mapTraceBinding(origin.rule,origin.binding,origin.row):[];const probe={...origin,traces,stage:null};if(this.mapProbe)this.mapProbeHistory.push(this.mapProbe);else this.mapProbeHistory=[];this.mapProbe=probe;const rect=event?.currentTarget?.getBoundingClientRect()||{left:innerWidth/2,top:80};this.mapProbeStyle={left:Math.max(12,Math.min(rect.left,innerWidth-402))+'px',top:Math.max(12,Math.min(rect.top,innerHeight-570))+'px'};await this.$nextTick();const popup=document.querySelector('.generation-map .map-probe');if(this.mapOpen && this.mapProbe && popup && !popup.matches(':popover-open'))popup.showPopover();},
|
||||
mapProbeClose(){const el=document.querySelector('.generation-map .map-probe');if(el?.matches(':popover-open'))el.hidePopover();this.mapProbe=null;this.mapProbeHistory=[];},
|
||||
mapProbeBack(){if(this.mapProbeHistory.length)this.mapProbe=this.mapProbeHistory.pop();},
|
||||
mapProbeAddress(){const p=this.mapProbe,rule=this.mapSnapshot?.payload.rules.find(r=>r.id===p?.rule);return p?.binding?'{ '+this.bindingAddress(p.binding,0,rule?.iterations||[])+' }':p?.field||'';},
|
||||
mapProbeStages(){return this.mapProbe?.traces[0]?.trace.stages||[];},
|
||||
mapProbeValues(){const p=this.mapProbe;if(!p)return [];if(!p.traces.length){return [{label:tr(p.manual?'map_manual':p.binding?.kind==='literal'?'fixed_value_191429':'map_no_trace'),value:p.binding?.kind==='literal'?p.binding.value:p.value}];}return p.traces.slice(0,20).map(({row,trace})=>{const result=p.stage==null?trace.result:trace.stages[p.stage]?.result;return {label:tr('map_pass_number',{number:row+1}),value:this.mapSampleElement(result?.value,p.stage==null?p.elementPath:null),error:result?.error};});},
|
||||
mapProbeOutputSources(){const b=this.mapProbe?.binding;if(b?.kind!=='output')return [];const rule=this.mapSnapshot.payload.rules.find(r=>r.blocks.some(block=>block.id===b.output)),block=rule?.blocks.find(block=>block.id===b.output);return block?Object.entries(block.fields).map(([field,binding])=>({title:this.fieldsFor(block.kind).find(f=>f.key===field)?.label||field,binding,rule:rule.id,row:rule.id===this.mapProbe.rule?this.mapProbe.row:null})):[];},
|
||||
mapProbeParents(){return this.mapProbe?.traces[0]?.trace.parents||[];},
|
||||
mapProbeFollow(trace,event){this.mapExplore({rule:this.mapProbe.rule,row:this.mapProbe.row,binding:trace.binding,title:tr('map_source_chain')},event);},
|
||||
mapProbeTarget(){const b=this.mapProbe?.binding;if(!b)return '';if(b.variable)return 'v:'+b.variable;if(b.iteration)return 'i:'+b.iteration;if(b.output)return 'b:'+b.output;if(b.kind==='literal'&&this.mapNodes.some(n=>n.id==='m:'+b.value))return 'm:'+b.value;return '';},
|
||||
mapExploreToken(item,field,token,event){const origin=this.mapResourceOrigin(item,field.key);if(!origin.binding)origin.value=field.value;const index=Number(token.path?.split(' / ')[0])-1;if(origin.binding?.kind==='list' && origin.binding.items[index])origin.binding=origin.binding.items[index];else origin.elementPath=token.path;this.mapExplore(origin,event);},
|
||||
mapOpen:false,mapBusy:false,mapError:'',mapSnapshot:null,mapNodes:[],mapSelected:'',mapExpanded:{},mapRow:'all',
|
||||
async openGenerationMap(){origin=document.activeElement;this.mapOpen=true;this.mapBusy=true;this.mapSnapshot=null;this.mapNodes=[];this.mapSelected='';this.mapExpanded={};this.mapRow='all';await this.$nextTick();this.$refs.generationMap.showModal();await this.refreshGenerationMap();},
|
||||
closeGenerationMap(){request++;graph?.destroy();graph=null;this.$refs.generationMap.close();this.mapOpen=false;origin?.focus();},
|
||||
async refreshGenerationMap(){const sequence=++request;this.mapBusy=true;this.mapError='';try{const payload=this.generationPayload(),workspace=this.workspace.id;const report=await this.api(this.endpoint('/generation-preview'),'POST',payload);if(sequence!==request || !this.mapOpen || workspace!==this.workspace.id)return;this.mapSnapshot={payload,report};await this.$nextTick();this.drawGenerationMap();}catch(e){if(sequence===request)this.mapError=e.message;}finally{if(sequence===request)this.mapBusy=false;}},
|
||||
closeGenerationMap(){this.mapProbeClose();request++;graph?.destroy();graph=null;this.$refs.generationMap.close();this.mapOpen=false;origin?.focus();},
|
||||
async refreshGenerationMap(){const sequence=++request;this.mapBusy=true;this.mapError='';try{const payload=this.generationPayload(),workspace=this.workspace.id,document=copy(this.doc);const report=await this.api(this.endpoint('/generation-preview'),'POST',payload);if(sequence!==request || !this.mapOpen || workspace!==this.workspace.id)return;this.mapSnapshot={payload,report,document};await this.$nextTick();this.drawGenerationMap();}catch(e){if(sequence===request)this.mapError=e.message;}finally{if(sequence===request)this.mapBusy=false;}},
|
||||
mapSelect(id){this.mapSelected=id;this.mapRow='all';if(graph){graph.elements().removeClass('selected related');const n=graph.getElementById(id);n.addClass('selected');n.closedNeighborhood().addClass('related');}},
|
||||
mapCurrent(){return this.mapNodes.find(n=>n.id===this.mapSelected);},
|
||||
mapTrace(rule){return this.mapSnapshot?.report.trace?.find(t=>t.rule===rule);},
|
||||
mapResources(){const n=this.mapCurrent(),report=this.mapSnapshot?.report;if(this.mapBusy || this.mapError || !n || n.variable || !report?.result)return [];const row=this.mapRow==='all'?null:this.mapTrace(n.rule)?.rows[Number(this.mapRow)];return report.result.managed.filter(m=>(!n.rule || m.rule===n.rule)&&(!n.block || m.block===n.block)&&(!row || row.resources.includes(m.id))).map(m=>{const kind=['rotation','priority'].includes(m.kind)?'schedules':m.kind==='escalation'?'escalations':'routes';const resource=report.result[kind].find(r=>r.id===m.id);return {...m,resource,name:resource?.name||m.id};});},
|
||||
mapResources(){const n=this.mapCurrent(),report=this.mapSnapshot?.report;if(this.mapBusy || this.mapError || !n || n.variable)return [];if(n.manual)return [{id:n.resourceId,kind:n.manualKind,resource:n.source,name:n.source.name,manual:true}];if(!report?.result)return [];const row=this.mapRow==='all'?null:this.mapTrace(n.rule)?.rows[Number(this.mapRow)];return report.result.managed.filter(m=>(!n.rule || m.rule===n.rule)&&(!n.block || m.block===n.block)&&(!n.resourceId || m.id===n.resourceId)&&(!row || row.resources.includes(m.id))).map(m=>{const kind=['rotation','priority'].includes(m.kind)?'schedules':m.kind==='escalation'?'escalations':'routes';const resource=report.result[kind].find(r=>r.id===m.id);return {...m,resource,name:resource?.name||m.id};});},
|
||||
mapIssues(){const n=this.mapCurrent();return (this.mapSnapshot?.report.issues||[]).filter(i=>!n || (n.variable?i.variable_id===n.variable:!i.rule_id || i.rule_id===n.rule)&&(!n.block || !i.block_id || i.block_id===n.block));},
|
||||
mapRows(){return this.mapTrace(this.mapCurrent()?.rule)?.rows||[];},
|
||||
mapRowLabel(row,index){const rule=this.mapSnapshot.payload.rules.find(r=>r.id===this.mapCurrent()?.rule);const names=(rule?.iterations||[]).map((iteration,i)=>{const v=row.inputs?.[iteration.id];return typeof v==='object'&&v!==null?(v.name||v.title||row.labels?.[i]||iteration.name):String(v??iteration.name);});return (index+1)+'. '+(names.join(' / ')||tr('map_once'))+' · '+tr('map_'+row.status,{count:1});},
|
||||
@@ -22,8 +45,8 @@ function generationMapUi(){let graph=null,origin=null,request=0;return {
|
||||
const producer=payload.rules.find(r=>r.blocks.some(b=>b.id===value.output)),block=producer?.blocks.find(b=>b.id===value.output);
|
||||
if(block && (n.block || producer.id!==n.rule)){const managed=report.result?.managed.filter(m=>m.block===block.id)||[],resources=[...(report.result?.schedules||[]),...(report.result?.escalations||[]),...(report.result?.routes||[])],resource=managed.length===1?resources.find(r=>r.id===managed[0].id):null,key='o:'+block.id;const target=this.mapNodes.find(v=>v.block===block.id)||this.mapNodes.find(v=>v.id==='g:'+producer.id)||this.mapNodes.find(v=>v.id==='r:'+producer.id);sources.set(key,{id:key,name:resource?.name||producer.name,type:['rotation','priority'].includes(block.kind)?tr('resource_schedule'):this.label(block.kind),count:managed.length,generated:true,addresses:[],target:target?.id});}
|
||||
}
|
||||
if(value.kind==='literal'&&typeof value.value==='string'){const id=value.value,resource=[...this.doc.schedules,...this.doc.escalations,...this.doc.routes].find(r=>r.id===id);if(resource)sources.set('saved:'+id,{id:'saved:'+id,name:resource.name,type:tr('source_saved_resource',{name:''}),addresses:[]});}
|
||||
Object.values(value).forEach(child=>scan(child,owner,depth+1));
|
||||
if(value.kind==='literal'&&typeof value.value==='string'){const id=value.value,resource=[...this.doc.schedules,...this.doc.escalations,...this.doc.routes].find(r=>r.id===id);if(resource)sources.set('saved:'+id,{id:'saved:'+id,name:resource.name,type:tr('source_saved_resource',{name:''}),addresses:[],target:'m:'+id});}
|
||||
Object.entries(value).forEach(([key,child])=>{if(key!=='recipient'||!(value.team_all||value.team_random))scan(child,owner,depth+1);});
|
||||
};
|
||||
if(n.variable)variable(n.variable);else if(n.kind==='iteration')scan(n.source.source);else if(n.kind==='condition')scan(rule?.when);else if(rule){if(!n.block)scan(rule.iterations);scan(rule.blocks.filter(b=>!n.block||b.id===n.block));if(!n.block)scan(rule.when);}
|
||||
return [...sources.values()];
|
||||
@@ -37,7 +60,7 @@ function generationMapUi(){let graph=null,origin=null,request=0;return {
|
||||
if(n.kind==='condition')return [{title:tr('map_condition'),rows:[{label:tr('map_condition_left'),values:unique(rows.map(r=>r.condition?.left))},{label:tr('map_condition_operation'),values:[this.label(rule.when.operation)]},{label:tr('map_condition_right'),values:unique(rows.map(r=>r.condition?.right))}]}];
|
||||
return rule.blocks.filter(b=>!n.block||n.block===b.id).map(block=>{
|
||||
const inputs=rows.flatMap(r=>(r.blocks||[]).filter(b=>b.block===block.id)),fields=this.fieldsFor(block.kind),out=[];
|
||||
for(const field of fields){const values=unique(inputs.map(b=>b.fields[field.key]));if(values.length)out.push({label:field.label,values});}
|
||||
for(const field of fields){const values=unique(inputs.map(b=>b.fields[field.key]));if(values.length)out.push({label:field.label,values,binding:block.fields[field.key]});}
|
||||
const maxSteps=Math.max(0,...inputs.map(b=>b.steps.length));for(let i=0;i<maxSteps;i++){out.push({label:tr('map_step_delay',{step:i+1}),values:unique(inputs.map(b=>b.steps[i]?.delay))},{label:tr('map_step_recipient',{step:i+1}),values:unique(inputs.map(b=>b.steps[i]?.recipient))});}
|
||||
const conditions=unique(inputs.flatMap(b=>b.conditions));for(const c of conditions)out.push({label:this.label(c.field)+' · '+(c.negate?tr('map_not')+' ':'')+this.label(c.operation),values:[c.value]});
|
||||
if(block.kind==='route'&&inputs.length&&!conditions.length)out.push({label:tr('map_condition'),values:[tr('map_matches_all')]});
|
||||
@@ -45,20 +68,18 @@ function generationMapUi(){let graph=null,origin=null,request=0;return {
|
||||
}).filter(section=>section.rows.length);
|
||||
},
|
||||
mapResourceFields(item){
|
||||
const r=item.resource;if(!r)return [];const fields=[],add=(key,value)=>fields.push({label:tr(key),value});
|
||||
const r=item.resource;if(!r)return [];const fields=[],add=(key,label,value,hint='')=>fields.push({key,label:tr(label),value,hint});
|
||||
if(['rotation','priority'].includes(item.kind)){
|
||||
add(item.kind==='priority'?'responsible_people_in_priority_order_7205a5':'participants_in_rotation_order_4edee0',r.participants);
|
||||
if(item.kind==='priority')add('fallback_schedule_7b7a21',r.fallback);
|
||||
else {add('anchor_date_e77bd6',this.formatDate(r.anchor));add('shift_length_days_0b28fa',r.shift_days);}
|
||||
add('handover_time_c9e315',r.handover.slice(0,5)+' · '+this.mapSnapshot.report.result.timezone);
|
||||
add('participants',item.kind==='priority'?'responsible_people_in_priority_order_7205a5':'participants_in_rotation_order_4edee0',r.participants);
|
||||
if(item.kind==='priority')add('fallback','fallback_schedule_7b7a21',r.fallback);
|
||||
else {add('anchor','anchor_date_e77bd6',this.formatDate(r.anchor));add('shift_days','shift_length_days_0b28fa',r.shift_days);}
|
||||
add('handover','handover_time_c9e315',r.handover.slice(0,5));
|
||||
}else if(item.kind==='escalation'){
|
||||
r.steps.forEach((step,index)=>{fields.push({label:tr('map_step_recipient',{step:index+1}),value:step.recipient.id||(step.recipient.type==='team_all'?tr('everyone_on_the_team_235b55'):tr('a_random_team_member_8f35f3')),hint:(step.delay_minutes===0?tr('immediately_21b60a'):step.delay_minutes+tr('min_after_alert_8adeb2'))+' · '+tr(step.condition==='if-not-acked'?'if_unacknowledged_407d03':'if_still_open_c34949')});});
|
||||
add('repeat_count_912e08',r.repeat.count&&r.repeat.wait_minutes?tr('repeat_654579')+r.repeat.count+tr('times_wait_e1b163')+r.repeat.wait_minutes+tr('min_f4e8a8'):tr('end_of_policy_no_repeats_c2ac4f'));
|
||||
if(r.repeat.count&&r.repeat.wait_minutes){add('reset_recipient_states_on_repeat_8152c6',r.repeat.reset_recipient_states);add('close_alert_after_all_repeats_2bec22',r.repeat.close_alert_after_all);}
|
||||
r.steps.forEach((step,i)=>{add('steps.'+i+'.recipient','escalation_recipient_9dfd31',step.recipient.id||(step.recipient.type==='team_all'?tr('everyone_on_the_team_235b55'):tr('a_random_team_member_8f35f3')),(step.delay_minutes===0?tr('immediately_21b60a'):step.delay_minutes+tr('min_after_alert_8adeb2'))+' · '+tr(step.condition==='if-not-acked'?'if_unacknowledged_407d03':'if_still_open_c34949'));add('steps.'+i+'.delay','delay_in_minutes_e18c95',step.delay_minutes);});
|
||||
add('repeat_count','repeat_count_912e08',r.repeat.count);add('repeat_wait','minutes_between_repeats_f17ab0',r.repeat.wait_minutes);
|
||||
}else if(item.kind==='route'){
|
||||
add('resource_all_conditions',r.conditions.length?r.conditions.map(c=>this.conditionText(c)):tr('any_alert_4496c3'));
|
||||
if(r.window){const w=r.window,days=['mon_24b2a0','tue_529541','wed_23408b','thu_3593cc','fri_bbd6e3','sat_6b782d','sun_48c98c'];add('workspace_working_interval_c7d250',w.weekdays.map(d=>tr(days[d-1])).join(', ')+' · '+w.start.slice(0,5)+'–'+w.end.slice(0,5)+' · '+w.timezone);}
|
||||
add('resource_detail_destination',r.escalation_id||tr('do_not_notify_1a59a6'));
|
||||
r.conditions.forEach((c,i)=>add('conditions.'+i,'value_8dce17',c.value,this.conditionText(c)));
|
||||
add('working','only_during_working_hours_fa1eb6',!!r.window);add('escalation','resource_detail_destination',r.escalation_id||tr('do_not_notify_1a59a6'));
|
||||
}
|
||||
return fields;
|
||||
},
|
||||
@@ -69,27 +90,58 @@ function generationMapUi(){let graph=null,origin=null,request=0;return {
|
||||
const tokens=[];const visit=(v,path='',depth=0)=>{if(tokens.length>=80||depth>10)return;if(typeof v==='string'&&refs.has(v)){tokens.push({...refs.get(v),path});return;}if(Array.isArray(v)){if(!v.length)tokens.push({text:tr('map_empty_value'),path});else v.forEach((item,i)=>visit(item,path?path+' / '+(i+1):String(i+1),depth+1));return;}if(v&&typeof v==='object'){Object.entries(v).forEach(([key,item])=>visit(item,path?path+' / '+key:key,depth+1));return;}tokens.push({path,text:v===null?tr('map_missing_value'):typeof v==='boolean'?tr(v?'true_88b33e':'false_97cdbd'):typeof v==='string'&&/^[0-9a-f]{8}-[0-9a-f-]{27}$/i.test(v)?tr('map_missing_reference'):String(v)});};visit(value);return tokens;
|
||||
},
|
||||
mapToggle(){const n=this.mapCurrent();if(!n?.rule)return;this.mapExpanded[n.rule]=!this.mapExpanded[n.rule];this.drawGenerationMap();},
|
||||
mapFit(){graph?.fit(undefined,45);},mapZoom(factor){if(graph)graph.zoom({level:Math.max(.2,Math.min(2,graph.zoom()*factor)),renderedPosition:{x:graph.width()/2,y:graph.height()/2}});},
|
||||
async mapEdit(){const n=this.mapCurrent();if(!n)return;this.closeGenerationMap();await this.openIssue(n.variable?{kind:'variable',id:n.variable}:{kind:'rule',id:n.rule,block:n.block});},
|
||||
mapFit(){if(!graph)return;graph.resize();graph.fit(undefined,35);const box=graph.elements().boundingBox(),zoom=graph.zoom();graph.pan({x:35-box.x1*zoom,y:35-box.y1*zoom});},mapZoom(factor){if(graph)graph.zoom({level:Math.max(.2,Math.min(2,graph.zoom()*factor)),renderedPosition:{x:graph.width()/2,y:graph.height()/2}});},
|
||||
async mapEdit(){const n=this.mapCurrent();if(!n)return;if(n.manual){this.closeGenerationMap();this.open(n.manualKind==='escalation'?'policy':n.manualKind,n.source);return;}this.closeGenerationMap();await this.openIssue(n.variable?{kind:'variable',id:n.variable}:{kind:'rule',id:n.rule,block:n.block});},
|
||||
drawGenerationMap(){
|
||||
if(!this.mapSnapshot || !this.mapOpen)return;graph?.destroy();const {payload,report}=this.mapSnapshot,nodes=[],edges=[],ids=new Set(),blockTargets=new Map();
|
||||
const add=(id,title,subtitle,kind,x,y,meta={})=>{ids.add(id);nodes.push({id,title,subtitle,kind,...meta,position:{x,y}});};
|
||||
const edge=(source,target,role='flow')=>{if(source!==target)edges.push({data:{id:role+':'+source+'>'+target,source,target,role}});};
|
||||
payload.variables.forEach((v,i)=>add('v:'+v.id,v.name,this.nodeSummary(v.node),'variable',i*300,-160,{variable:v.id,source:v.node}));
|
||||
for(const [ruleIndex,rule] of payload.rules.entries()){const trace=this.mapTrace(rule.id),count=report.result?.managed.filter(m=>m.rule===rule.id).length||0,iterated=rule.iterations.length>0;const x=ruleIndex*350,y=0;
|
||||
const issue=report.issues.some(i=>i.rule_id===rule.id),status=!rule.enabled?tr('disabled_f4f447'):issue?tr('map_invalid'):!report.result?tr('map_unverified'):tr('map_count',{count});
|
||||
add('r:'+rule.id,rule.name,status,!rule.enabled?'disabled':issue?'error':'rule',x,y,{rule:rule.id,source:rule,step:ruleIndex+1});
|
||||
if(ruleIndex)edge('r:'+payload.rules[ruleIndex-1].id,'r:'+rule.id,'order');
|
||||
const references=value=>{const out=[];const walk=v=>{if(!v||typeof v!=='object')return;if(v.kind==='variable'||v.kind==='lookup')out.push('v:'+v.variable);if(v.kind==='output')out.push('b:'+v.output);if(v.kind==='literal'&&typeof v.value==='string')out.push('m:'+v.value);if(v.kind==='literal')return;Object.entries(v).forEach(([key,child])=>{if(key!=='recipient'||!(v.team_all||v.team_random))walk(child);});};walk(value);return [...new Set(out)];};
|
||||
const firstUse=new Map();payload.rules.forEach((r,i)=>references(r).forEach(id=>{if(!firstUse.has(id))firstUse.set(id,i);}));
|
||||
const variables=[...payload.variables].sort((a,b)=>(firstUse.get('v:'+a.id)??Infinity)-(firstUse.get('v:'+b.id)??Infinity));
|
||||
const columns=[],varX=new Map();let cursor=0;
|
||||
payload.rules.forEach((r,i)=>{const vars=variables.filter(v=>firstUse.get('v:'+v.id)===i),width=Math.max(300,vars.length*260);columns.push(cursor+width/2);vars.forEach((v,j)=>varX.set(v.id,cursor+130+j*260));cursor+=width+40;});
|
||||
variables.filter(v=>!varX.has(v.id)).forEach(v=>{varX.set(v.id,cursor+130);cursor+=280;});
|
||||
const resourceY=420+Math.max(0,...payload.rules.map(r=>r.iterations.length+(r.when?1:0)))*90;
|
||||
for(const v of variables)add('v:'+v.id,v.name,this.nodeSummary(v.node),'variable',varX.get(v.id),40,{variable:v.id,source:v.node,lane:0});
|
||||
const resources=report.result||this.mapSnapshot.document,managed=report.result?.managed||[],all=[...resources.schedules,...resources.escalations,...resources.routes];
|
||||
for(const [i,rule] of payload.rules.entries()){
|
||||
const x=columns[i],trace=this.mapTrace(rule.id),count=managed.filter(m=>m.rule===rule.id).length,issue=report.issues.some(v=>v.rule_id===rule.id);
|
||||
add('r:'+rule.id,rule.name,!rule.enabled?tr('disabled_f4f447'):issue?tr('map_invalid'):tr('map_count',{count}),'rule',x,240,{rule:rule.id,source:rule,step:i+1,lane:1,disabled:!rule.enabled,error:issue});
|
||||
if(i)edge('r:'+payload.rules[i-1].id,'r:'+rule.id,'order');
|
||||
let parent='r:'+rule.id;
|
||||
if(iterated){for(const [k,iteration] of rule.iterations.entries()){const id='i:'+iteration.id;add(id,iteration.name+' · '+tr('map_loop',{count:trace?.stages.find(s=>s.iteration===iteration.id)?.count||0}),'{ '+this.bindingAddress(iteration.source,0,rule.iterations)+' }','iteration',x,120+k*110,{rule:rule.id,source:iteration,iteration:true});edge(parent,id);parent=id;}}
|
||||
const outputY=120+rule.iterations.length*110;
|
||||
if(rule.when){const id='c:'+rule.id;add(id,tr('map_condition'),tr('map_filtered',{count:trace?.rows.filter(r=>r.status==='filtered').length||0}),'condition',x,outputY,{rule:rule.id,source:rule.when});edge(parent,id);parent=id;}
|
||||
if(iterated&&!this.mapExpanded[rule.id]){const id='g:'+rule.id;add(id,tr('map_group'),report.result?tr('map_count',{count}):tr('map_unverified'),'batch',x,outputY+(rule.when?110:0),{rule:rule.id,source:rule.blocks,iteration:true});edge(parent,id);parent=id;}
|
||||
for(const [j,b] of rule.blocks.entries()){if(iterated&&!this.mapExpanded[rule.id]){blockTargets.set(b.id,parent);continue;}const count=report.result?.managed.filter(m=>m.block===b.id).length||0;const id='b:'+b.id;blockTargets.set(b.id,id);const produced=report.result?.managed.filter(m=>m.block===b.id)||[],resource=produced.length===1?[...report.result.schedules,...report.result.escalations,...report.result.routes].find(r=>r.id===produced[0].id):null;const kind=['rotation','priority'].includes(b.kind)?tr('schedule_type_named',{name:this.label(b.kind)}):this.label(b.kind);add(id,resource?resource.name:kind,report.result?(resource?kind:tr('map_count',{count})):tr('map_unverified'),iterated?'batch':'output',x,outputY+(rule.when?110:0)+j*110,{rule:rule.id,block:b.id,source:b});edge(parent,id);parent=id;}
|
||||
rule.iterations.forEach((it,j)=>{const id='i:'+it.id;add(id,it.name,tr('map_loop',{count:trace?.stages.find(s=>s.iteration===it.id)?.count||0}),'iteration',x,335+j*90,{rule:rule.id,source:it,iteration:true,lane:1});edge(parent,id);parent=id;});
|
||||
if(rule.when){const id='c:'+rule.id;add(id,tr('map_condition'),tr('map_filtered',{count:trace?.rows.filter(r=>r.status==='filtered').length||0}),'condition',x,335+rule.iterations.length*90,{rule:rule.id,source:rule.when,lane:1});edge(parent,id);}
|
||||
let row=0;
|
||||
for(const block of rule.blocks){
|
||||
const produced=managed.filter(m=>m.block===block.id),id='b:'+block.id,kind=['rotation','priority'].includes(block.kind)?'schedule':block.kind;
|
||||
blockTargets.set(block.id,id);
|
||||
const single=produced.length===1?all.find(r=>r.id===produced[0].id):null;
|
||||
add(id,single?.name||this.label(block.kind),report.result?tr('map_count',{count:produced.length}):tr('map_unverified'),'output',x,resourceY+row++*100,{rule:rule.id,block:block.id,source:block,lane:2,resourceKind:kind,iteration:produced.length>1});edge('r:'+rule.id,id);
|
||||
if(this.mapExpanded[rule.id]&&produced.length>1)for(const m of produced){const resource=all.find(r=>r.id===m.id);add('o:'+m.id,resource?.name||m.id,this.label(block.kind),'output',x,resourceY+row++*100,{rule:rule.id,block:block.id,resourceId:m.id,source:block,lane:2,resourceKind:kind});edge(id,'o:'+m.id);}
|
||||
}
|
||||
}
|
||||
const scan=(value,target)=>{if(!value||typeof value!=='object')return;if(value.kind==='variable'||value.kind==='lookup'){if(ids.has('v:'+value.variable))edge('v:'+value.variable,target,'dependency');}if(value.kind==='output'&&blockTargets.has(value.output))edge(blockTargets.get(value.output),target,'dependency');Object.values(value).forEach(v=>scan(v,target));};
|
||||
for(const rule of payload.rules){rule.iterations.forEach(i=>scan(i.source,'i:'+i.id));if(rule.when)scan(rule.when,'c:'+rule.id);rule.blocks.forEach(b=>scan(b,blockTargets.get(b.id)));}
|
||||
const sheets=nodes.filter(n=>n.kind==='batch').flatMap(n=>[2,1].map(layer=>({data:{id:n.id+':sheet:'+layer,owner:n.id,layer,kind:'sheet'},classes:'stack-sheet',position:{x:n.position.x+layer*8,y:n.position.y-layer*8},selectable:false,grabbable:false})));
|
||||
this.mapNodes=nodes;graph=cytoscape({container:this.$refs.generationCanvas,elements:[...sheets,...nodes.map(n=>({data:{id:n.id,label:(n.step?n.step+'. ':'')+n.title+'\n'+n.subtitle,kind:n.kind},position:n.position})),...Array.from(new Map(edges.filter(e=>ids.has(e.data.source)&&ids.has(e.data.target)).map(e=>[e.data.id,e])).values())],layout:{name:'preset'},minZoom:.2,maxZoom:2,wheelSensitivity:.2,style:[{selector:'node',style:{shape:'round-rectangle',width:220,height:66,'background-color':'#fff','border-color':'#cad9cf','border-width':1.5,label:'data(label)','text-wrap':'wrap','text-max-width':195,'text-valign':'center',color:'#234236','font-family':'system-ui','font-size':11,'line-height':1.6,'z-index-compare':'manual','z-index':5}},{selector:'node[kind="variable"]',style:{'background-color':'#f4f0e7','border-color':'#d8c9aa'}},{selector:'node[kind="iteration"],node[kind="batch"]',style:{'background-color':'#edf2fb','border-color':'#94add2','border-style':'dashed'}},{selector:'.stack-sheet',style:{label:'','background-color':'#f4f7fc','border-color':'#94add2','border-style':'solid','border-width':1.5,'z-index':1,events:'no'}},{selector:'node[kind="batch"]',style:{'border-style':'solid','border-width':2}},{selector:'node[kind="condition"]',style:{'background-color':'#fff5df','border-color':'#d4b773'}},{selector:'node[kind="rule"]',style:{'background-color':'#e8f3ec','border-color':'#79a68a'}},{selector:'node[kind="disabled"]',style:{opacity:.5}},{selector:'node[kind="error"]',style:{'background-color':'#fcece7','border-color':'#ce7863'}},{selector:'edge',style:{width:1.5,'line-color':'#a4b6ab','target-arrow-color':'#839e8e','target-arrow-shape':'triangle','curve-style':'bezier'}},{selector:'edge[role="order"]',style:{width:3,'line-color':'#428465','target-arrow-color':'#428465','curve-style':'straight','z-index':3}},{selector:'edge[role="dependency"]',style:{'line-style':'dashed',opacity:.2,'curve-style':'unbundled-bezier','control-point-distances':-45,'control-point-weights':.5}},{selector:'edge.related',style:{opacity:1}},{selector:'.related',style:{'border-color':'#428465','line-color':'#428465','target-arrow-color':'#428465'}},{selector:'node.selected',style:{'border-width':3,'border-color':'#27694a'}}]});graph.on('tap','node:not(.stack-sheet)',e=>this.mapSelect(e.target.id()));graph.on('position','node[kind="batch"]',e=>{const p=e.target.position();for(const layer of [1,2])graph.getElementById(e.target.id()+':sheet:'+layer).position({x:p.x+layer*8,y:p.y-layer*8});});graph.fit(undefined,45);if(!ids.has(this.mapSelected))this.mapSelected=nodes.find(n=>n.rule)?.id||nodes[0]?.id||'';this.mapSelect(this.mapSelected);
|
||||
const manual=all.filter(r=>!(resources.managed||resources.generation?.managed||[]).some(m=>m.id===r.id));
|
||||
manual.forEach((r,i)=>{const kind=resources.schedules.includes(r)?r.type:resources.escalations.includes(r)?'escalation':'route';add('m:'+r.id,r.name,tr('map_manual'),'output',cursor+150,resourceY+i*100,{manual:true,resourceId:r.id,source:r,lane:2,resourceKind:['priority','rotation'].includes(kind)?'schedule':kind,manualKind:kind});});
|
||||
payload.rules.forEach(rule=>{for(const ref of references(rule)){const target=ref.startsWith('b:')?blockTargets.get(ref.slice(2)):ref;if(target)edge(target,'r:'+rule.id,'dependency');}});
|
||||
const maxX=Math.max(400,...nodes.map(n=>n.position.x))+170,minX=-180;
|
||||
const guides=[];for(const [i,y] of [140,resourceY-60].entries()){guides.push({data:{id:'line-left-'+i,kind:'guide',label:''},position:{x:minX,y},selectable:false,grabbable:false},{data:{id:'line-right-'+i,kind:'guide',label:''},position:{x:maxX,y},selectable:false,grabbable:false},{data:{id:'line-'+i,source:'line-left-'+i,target:'line-right-'+i,role:'boundary'}});}
|
||||
[ ['map_variables',40],['map_generators',240],['map_resource_lane',resourceY] ].forEach(([key,y],i)=>guides.push({data:{id:'lane-'+i,kind:'lane',label:tr(key)},position:{x:minX,y},selectable:false,grabbable:false}));
|
||||
this.mapNodes=nodes;graph=cytoscape({container:this.$refs.generationCanvas,elements:[...guides,...nodes.map(n=>({data:{id:n.id,label:(n.step?n.step+'. ':'')+n.title+'\n'+n.subtitle,kind:n.kind,resourceKind:n.resourceKind||'',disabled:!!n.disabled,error:!!n.error},position:n.position})),...edges.filter(e=>ids.has(e.data.source)&&ids.has(e.data.target))],layout:{name:'preset'},minZoom:.15,maxZoom:2,wheelSensitivity:.2,style:[
|
||||
{selector:'node',style:{shape:'round-rectangle',width:220,height:66,'background-color':'#fff','border-color':'#92a89a','border-width':1.5,label:'data(label)','text-wrap':'wrap','text-max-width':185,'text-valign':'center',color:'#243c32','font-family':'system-ui','font-size':11}},
|
||||
{selector:'node[kind="variable"]',style:{shape:'ellipse','background-color':'#f5eedf'}},
|
||||
{selector:'node[kind="rule"],node[kind="iteration"],node[kind="condition"]',style:{shape:'hexagon','background-color':'#e4eee9',width:240}},
|
||||
{selector:'node[resourceKind="schedule"]',style:{'background-color':'#e5efff','border-color':'#799dd1'}},
|
||||
{selector:'node[resourceKind="escalation"]',style:{'background-color':'#f2e8fb','border-color':'#b18ace'}},
|
||||
{selector:'node[resourceKind="route"]',style:{'background-color':'#fff0d9','border-color':'#c5a46c'}},
|
||||
{selector:'node[?disabled]',style:{opacity:.5}},{selector:'node[?error]',style:{'border-color':'#bd4343','border-width':3}},
|
||||
{selector:'node[kind="guide"]',style:{width:1,height:1,opacity:0,events:'no'}},
|
||||
{selector:'node[kind="lane"]',style:{width:150,height:35,shape:'rectangle','border-width':0,'background-opacity':0,'font-weight':700,'text-max-width':145,events:'no'}},
|
||||
{selector:'edge',style:{width:1.5,'line-color':'#96a99d','target-arrow-color':'#739681','target-arrow-shape':'triangle','curve-style':'bezier'}},
|
||||
{selector:'edge[role="order"]',style:{width:2.5,'line-color':'#428465','curve-style':'straight'}},
|
||||
{selector:'edge[role="dependency"]',style:{'line-style':'dashed',opacity:.5}},
|
||||
{selector:'edge[role="boundary"]',style:{'target-arrow-shape':'none','curve-style':'straight','line-color':'#b9c9be',events:'no'}},
|
||||
{selector:'.related',style:{'border-color':'#286448',opacity:1}},{selector:'node.selected',style:{'border-width':3,'border-color':'#286448'}}
|
||||
]});graph.on('tap','node',e=>{if(ids.has(e.target.id()))this.mapSelect(e.target.id());});graph.on('tap',e=>{if(e.target===graph){this.mapSelected='';graph.elements().removeClass('selected related');}});this.mapFit();if(!ids.has(this.mapSelected))this.mapSelected=nodes.find(n=>n.rule)?.id||nodes[0]?.id||'';if(this.mapSelected)this.mapSelect(this.mapSelected);
|
||||
}
|
||||
};}
|
||||
@@ -54,8 +54,8 @@
|
||||
</div></template>
|
||||
</div></template><button type="button" class="text binding-tweak" @click="editBinding(block.fields[field.key] ||= literal(''),field.type,field.label)" :aria-label="t('bind_891f4e')+field.label" x-text="t('binding_picker_button')" :title="t('value_source_c0ae15')"></button>
|
||||
</div></template></div>
|
||||
<template x-if="block.kind==='escalation'"><div><template x-for="(step,stepIndex) in block.steps" :key="stepIndex"><div class="generation-step"><span class="flow-arrow"><span x-text="t('symbol_fc4866')"></span></span><div class="inline-binding inline-delay"><template x-if="step.delay.kind==='literal'"><input type="number" min="0" :aria-label="t('delay_in_minutes_e18c95')" :value="step.delay.value" @input="step.delay.value=$event.target.value===''?null:Number($event.target.value)"></template><button type="button" class="binding-button" x-show="step.delay.kind!=='literal'" @click="editBinding(step.delay,'number',t('delay_in_minutes_e18c95'))" x-text="bindingSummary(step.delay)"></button><span x-text="t('min_f4e8a8')"></span><button type="button" class="binding-popup-trigger" :aria-label="t('bind_891f4e')+t('delay_in_minutes_e18c95')" @click="editBinding(step.delay,'number',t('delay_in_minutes_e18c95'))" x-text="t('binding_picker_button')"></button></div><select :aria-label="t('escalation_condition_5b4c13')" x-model="step.condition"><option value="if-not-acked" x-text="t('if_not_acknowledged_5fc93a')"></option><option value="if-not-closed" x-text="t('if_not_closed_4369ad')"></option></select><select :aria-label="t('recipient_type_32b1f5')" :value="step.user?'person':'schedule'" @change="step.user=$event.target.value==='person'"><option value="schedule" x-text="t('schedule_0a8ada')"></option><option value="person" x-text="t('person_8c41ae')"></option></select><button type="button" class="binding-button grow" @click="editBinding(step.recipient,step.user?'person':'schedule',t('escalation_recipient_9dfd31'))" x-text="bindingSummary(step.recipient)"></button><button type="button" class="text danger" @click="block.steps.splice(stepIndex,1)"><span x-text="t('symbol_67fba2')"></span></button></div></template><button type="button" class="text" @click="addGenStep(block)"><span x-text="t('escalation_step_72b706')"></span></button></div></template>
|
||||
<template x-if="block.kind==='route'"><div><p class="muted" x-show="!block.conditions.length"><span x-text="t('matches_any_alert_add_conditions_to_target_this_proj_299abf')"></span></p><template x-for="(condition,conditionIndex) in block.conditions" :key="conditionIndex"><div class="generation-step"><span class="logic-key" x-text="conditionIndex?t('and_c2f934'):t('if_7be63e')"></span><select :aria-label="t('routing_field_a63fdc')" x-model="condition.field"><option value="tags" x-text="t('tags_848eed')"></option><option value="message" x-text="t('message_68f414')"></option><option value="priority" x-text="t('priority_886cbf')"></option><option value="description" x-text="t('description_55f8eb')"></option><option value="source" x-text="t('source_6da13a')"></option></select><select :aria-label="t('routing_operation_01689c')" x-model="condition.operation"><option value="contains" x-text="t('contains_33e15d')"></option><option value="equals" x-text="t('equals_09b6a6')"></option><option value="starts-with" x-text="t('starts_with_2a61f5')"></option><option value="matches" x-text="t('matches_ee2dbd')"></option></select><button type="button" class="binding-button grow" @click="editBinding(condition.value,'text',t('routing_condition_value_f76e2d'))" x-text="bindingSummary(condition.value)"></button><label class="check"><input type="checkbox" x-model="condition.negate"><span x-text="t('not_ca1dd3')"></span></label><button type="button" class="text danger" @click="block.conditions.splice(conditionIndex,1)"><span x-text="t('symbol_67fba2')"></span></button></div></template><button type="button" class="text" @click="addGenCondition(block)"><span x-text="t('and_condition_63ce48')"></span></button></div></template>
|
||||
<template x-if="block.kind==='escalation'"><div><template x-for="(step,stepIndex) in block.steps" :key="stepIndex"><div class="generation-step"><span class="flow-arrow"><span x-text="t('symbol_fc4866')"></span></span><div class="inline-binding inline-delay"><template x-if="step.delay.kind==='literal'"><input type="number" min="0" :aria-label="t('delay_in_minutes_e18c95')" :value="step.delay.value" @input="step.delay.value=$event.target.value===''?null:Number($event.target.value)"></template><button type="button" class="binding-button" x-show="step.delay.kind!=='literal'" @click="editBinding(step.delay,'number',t('delay_in_minutes_e18c95'))" x-text="bindingSummary(step.delay)"></button><span x-text="t('min_f4e8a8')"></span><button type="button" class="binding-popup-trigger" :aria-label="t('bind_891f4e')+t('delay_in_minutes_e18c95')" @click="editBinding(step.delay,'number',t('delay_in_minutes_e18c95'))" x-text="t('binding_picker_button')"></button></div><select :aria-label="t('escalation_condition_5b4c13')" x-model="step.condition"><option value="if-not-acked" x-text="t('if_not_acknowledged_5fc93a')"></option><option value="if-not-closed" x-text="t('if_not_closed_4369ad')"></option></select><select :aria-label="t('recipient_type_32b1f5')" :value="step.team_all?'team_all':step.team_random?'team_random':step.user?'person':'schedule'" @change="step.team_all=$event.target.value==='team_all';step.team_random=$event.target.value==='team_random';step.user=$event.target.value==='person'"><option value="schedule" x-text="t('schedule_0a8ada')"></option><option value="person" x-text="t('person_8c41ae')"></option><option value="team_all" x-text="t('everyone_on_the_team_235b55')"></option><option value="team_random" x-text="t('a_random_team_member_8f35f3')"></option></select><button x-show="!step.team_all&&!step.team_random" type="button" class="binding-button grow" @click="editBinding(step.recipient,step.user?'person':'schedule',t('escalation_recipient_9dfd31'))" x-text="bindingSummary(step.recipient)"></button><button type="button" class="text danger" @click="block.steps.splice(stepIndex,1)"><span x-text="t('symbol_67fba2')"></span></button></div></template><button type="button" class="text" @click="addGenStep(block)"><span x-text="t('escalation_step_72b706')"></span></button></div></template>
|
||||
<template x-if="block.kind==='route'"><div><p class="muted" x-show="!block.conditions.length"><span x-text="t('matches_any_alert_add_conditions_to_target_this_proj_299abf')"></span></p><template x-for="(condition,conditionIndex) in block.conditions" :key="conditionIndex"><div class="generation-step"><span class="logic-key" x-text="conditionIndex?t('and_c2f934'):t('if_7be63e')"></span><select :aria-label="t('routing_field_a63fdc')" x-model="condition.field"><option value="tags" x-text="t('tags_848eed')"></option><option value="message" x-text="t('message_68f414')"></option><option value="priority" x-text="t('priority_886cbf')"></option><option value="description" x-text="t('description_55f8eb')"></option><option value="source" x-text="t('source_6da13a')"></option></select><select :aria-label="t('routing_operation_01689c')" x-model="condition.operation"><option value="greater-than" x-text="t('routing_greater_than')"></option><option value="less-than" x-text="t('routing_less_than')"></option><option value="contains" x-text="t('contains_33e15d')"></option><option value="equals" x-text="t('equals_09b6a6')"></option><option value="starts-with" x-text="t('starts_with_2a61f5')"></option><option value="matches" x-text="t('matches_ee2dbd')"></option></select><button type="button" class="binding-button grow" @click="editBinding(condition.value,'text',t('routing_condition_value_f76e2d'))" x-text="bindingSummary(condition.value)"></button><label class="check"><input type="checkbox" x-model="condition.negate"><span x-text="t('not_ca1dd3')"></span></label><button type="button" class="text danger" @click="block.conditions.splice(conditionIndex,1)"><span x-text="t('symbol_67fba2')"></span></button></div></template><button type="button" class="text" @click="addGenCondition(block)"><span x-text="t('and_condition_63ce48')"></span></button></div></template>
|
||||
</section></template>
|
||||
</section>
|
||||
<details class="recipe-section rule-result" open>
|
||||
@@ -71,3 +71,20 @@
|
||||
<div x-show="!rulePeopleTarget"><!-- GENERATION_PREVIEW --></div>
|
||||
|
||||
</div></template>
|
||||
<template x-if="modal==='generation-transfer'"><div class="config-transfer">
|
||||
<p class="muted" x-text="t('transfer_hint')"></p>
|
||||
<div class="actions"><button type="button" :class="{primary:transferMode==='export'}" @click="transferSwitch('export')" x-text="t('transfer_export')"></button><button type="button" :class="{primary:transferMode==='import'}" @click="transferSwitch('import')" x-text="t('transfer_import')"></button><button type="button" :class="{primary:transferMode==='guide'}" :aria-pressed="transferMode==='guide'" @click="transferSwitch('guide')" x-text="t('transfer_guide')"></button></div>
|
||||
<section class="config-guide" x-show="transferMode==='guide'" :aria-label="t('transfer_guide')"><div class="section-line"><p class="muted" x-text="t('transfer_guide_hint')"></p><button type="button" @click="copyTransferGuide()" x-text="t('transfer_guide_copy')"></button></div><template x-for="section in transferGuideSections()" :key="section.id"><section><h3 x-text="section.title"></h3><pre :class="{'guide-example':section.code}" x-text="section.body"></pre></section></template></section>
|
||||
<div x-show="transferMode==='export'"><label><span x-text="t('transfer_document')"></span><textarea class="config-text" rows="16" readonly :value="transferExport" spellcheck="false"></textarea></label><div class="actions"><button type="button" @click="copyTransfer()" :disabled="!transferExport || transferLoading" x-text="t('transfer_copy')"></button><button type="button" @click="downloadTransfer()" :disabled="!transferExport || transferLoading" x-text="t('transfer_download')"></button></div></div>
|
||||
<div x-show="transferMode==='import'"><label><span x-text="t('transfer_paste')"></span><textarea class="config-text" rows="16" x-model="transferText" @input="transferChanged()" :disabled="busy" spellcheck="false"></textarea></label>
|
||||
<p class="muted" x-text="t('transfer_identity_hint')"></p>
|
||||
<div class="transfer-preview" aria-live="polite"><template x-if="transferResult"><div>
|
||||
<h3 x-text="t('transfer_inputs')"></h3><template x-for="(change,index) in transferResult.inputs" :key="index"><div class="compact-change"><span class="badge" x-text="label(change.action)"></span><strong x-text="change.name"></strong><small x-text="t(change.kind==='variable'?'map_variables':'map_generators')"></small></div></template>
|
||||
<div x-show="transferResult.contacts?.length"><h3 x-text="t('transfer_contacts')"></h3><template x-for="contact in transferResult.contacts" :key="contact.id"><p x-text="contact.email"></p></template></div>
|
||||
<h3 x-text="t('map_generated_resources')"></h3><p x-show="!transferResult.preview.changes.length" x-text="t('transfer_no_resources')"></p><template x-for="change in transferResult.preview.changes" :key="change.id"><div class="compact-change"><span class="badge" x-text="label(change.action)"></span><strong x-text="change.name"></strong><small x-text="label(change.kind)"></small></div></template>
|
||||
<template x-for="(issue,index) in transferResult.preview.issues" :key="index"><p class="validation-error"><strong x-text="issue.path"></strong><span x-text="l(issue.message)"></span></p></template>
|
||||
<p class="validation-warning" x-show="transferResult.preview.cancelled_requests" x-text="transferResult.preview.cancelled_requests+t('pending_requests_will_be_cancelled_because_their_sch_ea9b18')"></p>
|
||||
</div></template></div></div>
|
||||
<p role="status" x-show="transferLoading" x-text="t('cover_checking')"></p><p role="alert" class="validation-error" x-show="transferError" x-text="l(transferError)"></p>
|
||||
<div class="modal-footer"><button type="button" @click="modal=''" x-text="t('close_dialog_7b2902')"></button><button type="button" class="primary" x-show="transferMode==='import'" :disabled="busy || !transferCanApply()" @click="applyTransfer()" x-text="t('transfer_apply')"></button></div>
|
||||
</div></template>
|
||||
@@ -53,6 +53,6 @@
|
||||
</div></section>
|
||||
</div>
|
||||
|
||||
<div class="page-validation" x-data="{validationScope:'generation'}"><!-- VALIDATION_STATUS --></div><div class="generation-review-bar"><!-- GENERATION_PREVIEW --><div class="actions review-actions"><button @click="loadGenerationDraft(true)"><span x-text="t('reload_saved_state_1b1cb9')"></span></button><button class="primary" @click="applyGeneration()" :disabled="busy || !generationCanApply()" x-show="can('edit_schedules')"><span x-text="t('apply_checked_changes_efa2a4')"></span></button></div></div>
|
||||
<div class="page-validation" x-data="{validationScope:'generation'}"><!-- VALIDATION_STATUS --></div>
|
||||
</div></template>
|
||||
</section>
|
||||
@@ -1,8 +1,13 @@
|
||||
<details class="generation-preview generation-review compact-preview" aria-live="polite">
|
||||
<summary><strong x-text="t(modal?'server_preview_09022a':'setup_review_changes')"></strong><span class="preview-totals" x-show="generationResult"><span class="preview-status status-create" x-text="t('preview_created',{count:generationCount('create')})"></span><span class="preview-status status-update" x-text="t('preview_updated',{count:generationCount('update')})"></span><span class="preview-status status-remove" x-text="t('preview_removed',{count:generationCount('remove')})"></span></span><small x-show="generationLoading" x-text="t('cover_checking')"></small></summary>
|
||||
<div class="compact-preview-body"><template x-if="generationResult"><div>
|
||||
<p class="validation-warning" x-show="generationResult.cancelled_requests" x-text="generationResult.cancelled_requests+t('pending_requests_will_be_cancelled_because_their_sch_ea9b18')"></p>
|
||||
<details class="generation-preview generation-review compact-preview" aria-live="polite" :open="!modal && ['failed','conflict'].includes(detail?.sync?.state)">
|
||||
<summary><strong x-text="t(modal?'server_preview_09022a':'publication_details')"></strong><span class="preview-totals" ><span class="preview-status status-create" x-text="t('preview_created',{count:generationCount('create')})"></span><span class="preview-status status-update" x-text="t('preview_updated',{count:generationCount('update')})"></span><span class="preview-status status-remove" x-text="t('preview_removed',{count:generationCount('remove')})"></span></span><small x-show="generationLoading" x-text="t('cover_checking')"></small></summary>
|
||||
<div class="compact-preview-body"><div class="publication-status" x-show="!modal">
|
||||
<span x-text="t('publication_fe5f07')+label(detail?.sync?.state || 'draft')"></span>
|
||||
<span x-text="doc.integration.provider==='mock'?t('local_preview_provider_aca82c'):t('jira_service_management_071f90')"></span>
|
||||
<span class="sync-error" x-text="l(detail?.sync?.last_error || '')"></span>
|
||||
<small x-show="detail?.sync?.window_end" x-text="t('publication_coverage_end',{date:detail?.sync?.window_end?new Date(detail.sync.window_end).toLocaleString(language):''})"></small>
|
||||
</div><div>
|
||||
<p class="validation-warning" x-show="generationResult?.cancelled_requests" x-text="(generationResult?.cancelled_requests||0)+t('pending_requests_will_be_cancelled_because_their_sch_ea9b18')"></p>
|
||||
<div class="compact-change-list"><template x-for="change in generationVisibleChanges()" :key="change.id"><div class="compact-change"><span class="badge preview-status" :class="'status-'+change.action" x-text="label(change.action)"></span><div><strong x-text="change.name"></strong><small x-text="label(change.kind)+(change.source?' · '+change.source:'')"></small></div></div></template></div>
|
||||
<div class="preview-list-footer"><small class="preview-status status-unchanged" x-text="t('preview_unchanged',{count:generationCount('unchanged')})"></small><button class="text preview-list-toggle" type="button" x-show="generationChanges().length>generationVisibleChanges().length || generationAll" @click="generationAll=!generationAll" x-text="generationAll?t('show_fewer_resources_3953fb'):t('show_all_382ebd')+generationChanges().length+t('resources_015def')"></button></div>
|
||||
</div></template></div>
|
||||
</div></div>
|
||||
</details>
|
||||
+68
-7
@@ -1,19 +1,71 @@
|
||||
// Visual data and binding editors. Only typed data is sent to the server; no user code is evaluated.
|
||||
function generationUi(){return {
|
||||
...generationMapUi(),
|
||||
transferMode:'export',transferText:'',transferExport:'',transferError:'',transferResult:null,transferLoading:false,transferSequence:0,transferBase:null,
|
||||
async openTransfer(){
|
||||
await this.run(async()=>{await this.saveSharedGeneration();this.transferSequence++;clearTimeout(this.transferTimer);this.transferBase={revision:this.workspace.revision,shared_revision:this.generationDraftVersion};this.transferMode='export';this.transferText='';this.transferExport='';this.transferError='';this.transferResult=null;this.modal='generation-transfer';await this.exportTransfer();});
|
||||
},
|
||||
async exportTransfer(){
|
||||
const workspace=this.workspace.id;this.transferLoading=true;this.transferError='';
|
||||
try{const result=await this.api(this.endpoint('/generation-export'),'POST',this.transferBase);if(this.modal==='generation-transfer' && this.workspace.id===workspace){this.transferExport=result.text;this.transferText=result.text;}}
|
||||
catch(e){if(this.modal==='generation-transfer')this.transferError=e.message;}
|
||||
finally{this.transferLoading=false;}
|
||||
},
|
||||
transferSwitch(mode){this.transferSequence++;clearTimeout(this.transferTimer);this.transferLoading=false;this.transferMode=mode;this.transferError='';if(mode==='import')this.transferChanged();},
|
||||
transferChanged(){this.transferSequence++;this.transferResult=null;this.transferError='';this.transferLoading=true;clearTimeout(this.transferTimer);this.transferTimer=setTimeout(()=>this.previewTransfer(),350);},
|
||||
async previewTransfer(){
|
||||
const seq=++this.transferSequence,text=this.transferText,workspace=this.workspace.id;
|
||||
if(this.modal!=='generation-transfer'||this.transferMode!=='import')return;
|
||||
try{const result=await this.api(this.endpoint('/generation-import-preview'),'POST',{...this.transferBase,text});if(seq===this.transferSequence&&this.modal==='generation-transfer'&&workspace===this.workspace.id)this.transferResult={...result,text};}
|
||||
catch(e){if(seq===this.transferSequence&&this.modal==='generation-transfer')this.transferError=e.message;}
|
||||
finally{if(seq===this.transferSequence)this.transferLoading=false;}
|
||||
},
|
||||
transferCanApply(){return this.transferMode==='import'&&!this.transferLoading&&!this.transferError&&this.transferResult?.text===this.transferText&&!this.transferResult?.preview.issues.length;},
|
||||
async applyTransfer(){
|
||||
if(!this.transferCanApply())return;
|
||||
await this.run(async()=>{const saved=await this.api(this.endpoint('/generation-import'),'POST',{...this.transferBase,text:this.transferText,review_id:this.transferResult.review_id});this.detail.generation_draft=saved;this.modal='';await this.loadWorkspace();this.loadGenerationDraft(true);},tr('transfer_applied'));
|
||||
},
|
||||
transferGuideSections(){return ['structure','variables','bindings','generators','schedules','escalations','routes','merge','example'].map(id=>({id,title:tr('transfer_guide_'+id+'_title'),body:tr('transfer_guide_'+id),code:id==='example'}));},
|
||||
transferGuideText(){return '# Meguri · meguri/v2\n\n'+this.transferGuideSections().map(s=>'## '+s.title+'\n\n'+(s.code?'```json\n'+s.body+'\n```':s.body)).join('\n\n');},
|
||||
async copyTransferGuide(){await this.run(()=>navigator.clipboard.writeText(this.transferGuideText()),tr('transfer_guide_copied'));},
|
||||
async copyTransfer(){await this.run(()=>navigator.clipboard.writeText(this.transferExport),tr('transfer_copied'));},
|
||||
downloadTransfer(){const url=URL.createObjectURL(new Blob([this.transferExport+'\n'],{type:'application/json'})),a=document.createElement('a');a.href=url;a.download='meguri-config.json';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);},
|
||||
|
||||
generationSaveTask:null,generationSaveError:'',generationSavedKey:'',generationDraftVersion:0,generationSaving:false,
|
||||
generationDraft:null,generationWorkspace:'',generationResult:null,generationError:'',generationLoading:false,generationSequence:0,bindingEditor:null,bulkNode:null,bulkText:'',generationAll:false,dataAdvanced:false,dataExpanded:{},dataPersonTarget:null,dataPersonQuery:'',ruleAdvanced:false,rulePeopleTarget:null,rulePeopleQuery:'',generationIssuesOpen:false,editorScroll:0,editorOrigin:null,
|
||||
nodeKinds:['text','person','number','boolean','date','time','duration','list','dictionary'],
|
||||
newNode(kind='text',value=null,key=''){return {id:crypto.randomUUID(),kind,key,value:value??(kind==='boolean'?false:['number','duration'].includes(kind)?0:kind==='time'?'11:00':kind==='date'?this.today():''),entries:[]};},
|
||||
literal(value){return {kind:'literal',value,path:[],prefix:'',suffix:''};},
|
||||
generationContent(payload){return JSON.stringify({variables:payload?.variables||[],rules:payload?.rules||[],route_position:payload?.route_position||0});},
|
||||
generationDirty(){return !!this.generationDraft && this.generationContent(this.generationPayload())!==this.generationContent(this.doc?.generation);},
|
||||
generationUnsaved(){return !!this.generationDraft && this.generationContent(this.generationPayload())!==this.generationSavedKey;},
|
||||
publicationDirty(){return !!this.detail?.sync?.has_changes || this.generationDirty();},
|
||||
loadGenerationDraft(reset=false){
|
||||
if(!this.workspace)return;
|
||||
const clean=this.generationDraft && JSON.stringify(this.generationDraft.variables)===JSON.stringify(this.doc.generation.variables||[]) && JSON.stringify(this.generationDraft.rules)===JSON.stringify(this.doc.generation?.rules||[]) && this.generationDraft.route_position===(this.doc.generation?.route_position||0);
|
||||
if(reset || !this.generationDraft || this.generationWorkspace!==this.workspace.id || (clean && this.generationDraft.revision!==this.workspace.revision)){
|
||||
this.generationWorkspace=this.workspace.id;this.generationDraft={revision:this.workspace.revision,variables:copy(this.doc.generation.variables||[]),rules:copy(this.doc.generation?.rules||[]),route_position:this.doc.generation?.route_position||0};
|
||||
const shared=this.detail.generation_draft||{revision:0,payload:null};
|
||||
const localClean=!this.generationDraft || this.generationContent(this.generationPayload())===this.generationSavedKey;
|
||||
if(reset || !this.generationDraft || this.generationWorkspace!==this.workspace.id || (localClean && !this.modal && (this.generationDraftVersion!==shared.revision || this.generationDraft.revision!==this.workspace.revision))){
|
||||
const data=shared.payload || this.doc.generation;
|
||||
this.generationWorkspace=this.workspace.id;this.generationDraft={revision:this.workspace.revision,variables:copy(data.variables||[]),rules:copy(data.rules||[]),route_position:data.route_position||0};
|
||||
this.generationDraftVersion=shared.revision;this.generationSavedKey=this.generationContent(this.generationDraft);this.generationSaveError='';
|
||||
this.generationResult=null;this.generationAll=false;
|
||||
}
|
||||
if(this.can('edit_schedules'))this.generationChanged();
|
||||
},
|
||||
async reloadSharedGeneration(){await this.run(async()=>{await this.loadWorkspace();this.loadGenerationDraft(true);});},
|
||||
async saveSharedGeneration(){
|
||||
if(!this.can('edit_schedules') || !this.generationDraft)return;
|
||||
while(this.generationSaveTask)await this.generationSaveTask;
|
||||
if(this.generationSaveError)throw new Error(this.generationSaveError);
|
||||
const workspace=this.workspace.id,payload=this.generationPayload(),key=this.generationContent(payload);
|
||||
if(key===this.generationSavedKey)return;
|
||||
this.generationSaving=true;
|
||||
const task=this.api(this.endpoint('/generation-draft'),'PUT',{revision:this.generationDraftVersion,payload});
|
||||
this.generationSaveTask=task;
|
||||
try{const saved=await task;if(this.workspace?.id===workspace){this.generationDraftVersion=saved.revision;this.generationSavedKey=key;this.detail.generation_draft=saved;}}
|
||||
catch(e){if(this.workspace?.id===workspace)this.generationSaveError=e.message;throw e;}
|
||||
finally{if(this.generationSaveTask===task){this.generationSaveTask=null;this.generationSaving=false;}}
|
||||
},
|
||||
generationPayload(){
|
||||
if(!this.generationDraft)return null;
|
||||
const payload=copy(this.generationDraft);
|
||||
@@ -25,7 +77,7 @@ function generationUi(){return {
|
||||
async previewGeneration(){
|
||||
const sequence=++this.generationSequence,payload=this.generationPayload();
|
||||
if(!payload || !this.can('edit_schedules')){this.generationLoading=false;return;}
|
||||
try{const result=await this.api(this.endpoint('/generation-preview'),'POST',payload);if(sequence===this.generationSequence)this.generationResult={...result,key:JSON.stringify(payload)};}
|
||||
try{await this.saveSharedGeneration();if(sequence!==this.generationSequence)return;const result=await this.api(this.endpoint('/generation-preview'),'POST',payload);if(sequence===this.generationSequence)this.generationResult={...result,key:JSON.stringify(payload)};}
|
||||
catch(e){if(sequence===this.generationSequence)this.generationError=e.message;}
|
||||
finally{if(sequence===this.generationSequence)this.generationLoading=false;}
|
||||
},
|
||||
@@ -62,10 +114,19 @@ function generationUi(){return {
|
||||
requestAnimationFrame(()=>{const target=action.block?document.querySelector('[role="dialog"] [data-block-id="'+action.block+'"]'):null;if(target)target.scrollIntoView({block:'start'});});
|
||||
},
|
||||
generationCanApply(){return !this.generationLoading && this.generationResult && !this.generationResult.issues.length && this.generationResult.key===JSON.stringify(this.generationPayload());},
|
||||
generationChanges(){return this.generationResult?.changes||[];},
|
||||
generationChanges(){return this.modal?(this.generationResult?.changes||[]):(this.generationDirty()?(this.generationResult?.publication_changes||[]):(this.detail?.sync?.changes||[]));},
|
||||
generationVisibleChanges(){const changes=this.generationChanges();return this.generationAll?changes:changes.filter(c=>c.action!=='unchanged').slice(0,8);},
|
||||
generationCount(action){return this.generationChanges().filter(c=>c.action===action).length;},
|
||||
async applyGeneration(){if(!this.generationCanApply())return;await this.run(async()=>{await this.api(this.endpoint('/generation'),'POST',{...this.generationPayload(),review_id:this.generationResult.review_id});await this.loadWorkspaces();this.loadGenerationDraft(true);},tr('workspace_data_and_generated_schedules_saved_publica_e4ae3d'));},
|
||||
async stageGeneration(){
|
||||
await this.saveSharedGeneration();
|
||||
if(!this.generationDirty())return;
|
||||
const payload={...this.generationPayload(),shared_revision:this.generationDraftVersion};
|
||||
const review=await this.api(this.endpoint('/generation-preview'),'POST',payload);
|
||||
if(review.issues.length)throw new Error(review.issues.map(i=>i.message).join('; '));
|
||||
await this.api(this.endpoint('/generation'),'POST',{...payload,review_id:review.review_id});
|
||||
this.modal='';await this.loadWorkspace();this.loadGenerationDraft(true);
|
||||
},
|
||||
async applyGeneration(){await this.run(()=>this.stageGeneration(),tr('workspace_data_and_generated_schedules_saved_publica_e4ae3d'));},
|
||||
generated(id){return (this.doc?.generation?.managed||[]).some(m=>m.id===id);},
|
||||
moveGenerationRule(index,delta){const list=this.generationDraft.rules,to=index+delta;if(to<0||to>=list.length)return;[list[index],list[to]]=[list[to],list[index]];},
|
||||
sourceRuleName(id){const owner=this.doc?.generation?.managed?.find(m=>m.id===id);return this.doc?.generation?.rules?.find(r=>r.id===owner?.rule)?.name || this.generationDraft?.rules?.find(r=>r.id===owner?.rule)?.name || tr('generated_resource');},
|
||||
@@ -160,7 +221,7 @@ function generationUi(){return {
|
||||
const collection=this.modal==='variable'?'variables':'rules',list=this.generationDraft[collection],at=list.findIndex(v=>v.id===this.form.id);
|
||||
at<0?list.push(copy(this.form)):list.splice(at,1,copy(this.form));this.modal='';this.bindingEditor=null;this.generationChanged();
|
||||
},
|
||||
removeGenerationItem(collection,id){this.generationDraft[collection]=this.generationDraft[collection].filter(v=>v.id!==id);},
|
||||
removeGenerationItem(collection,id){const item=this.generationDraft[collection].find(v=>v.id===id);if(!item || !window.confirm(tr(collection==='variables'?'confirm_remove_variable':'confirm_remove_generation_rule',{name:item.name})))return;this.generationDraft[collection]=this.generationDraft[collection].filter(v=>v.id!==id);},
|
||||
nodeRows(root){const rows=[];const visit=(node,parent,depth,index)=>{rows.push({node,parent,depth,index});if(depth<9 && this.dataExpanded[node.id])node.entries.forEach((child,i)=>visit(child,node,depth+1,i));};visit(root,null,0,0);return rows;},
|
||||
setNodeKind(node,kind){const previous=node.kind;node.kind=kind;node.entries=[];if(['list','dictionary'].includes(kind))node.value=null;else if(kind==='boolean')node.value=false;else if(['number','duration'].includes(kind))node.value=0;else if(kind==='date')node.value=this.today();else if(kind==='time')node.value='11:00';else if(typeof node.value!=='string' || ['date','time'].includes(previous))node.value='';},
|
||||
toggleDataNode(node){this.dataExpanded[node.id]=!this.dataExpanded[node.id];},
|
||||
|
||||
+26
-25
@@ -233,16 +233,14 @@
|
||||
</div>
|
||||
<div class="actions" x-show="view==='calendar'"><button @click="open('absence')" x-show="can('request_overrides')"><span x-text="t('plan_time_off_33c0fc')"></span></button><button class="primary" x-show="can('request_overrides') || can('force_overrides')" @click="nextOwnDuty()"><span x-text="t('ask_for_cover_53c1cd')"></span></button></div>
|
||||
</div>
|
||||
<div class="sync-bar" x-show="setupView || ['failed','conflict'].includes(detail?.sync?.state)" :class="{'sync-problem':['failed','conflict'].includes(detail?.sync?.state)}">
|
||||
<span class="status-dot" :class="detail?.sync?.state==='synced'?'green':'amber-dot'">
|
||||
</span>
|
||||
<span x-text="detail?.sync?.state==='synced' ? t('schedule_is_in_sync_be394a') : t('publication_fe5f07')+label(detail?.sync?.state || 'pending')">
|
||||
</span>
|
||||
<span class="muted" x-text="doc.integration.provider==='mock'?t('local_preview_provider_aca82c'):t('jira_service_management_071f90')">
|
||||
</span>
|
||||
<span class="sync-error" x-text="l(detail?.sync?.last_error || '')">
|
||||
</span>
|
||||
<button class="text" x-show="can('manage_integration')" @click="retry" :disabled="busy"><span x-text="t('sync_now_2b7d93')"></span></button>
|
||||
<div class="publication-review" data-publication-review>
|
||||
<div class="publication-review-heading"><strong x-text="t('setup_review_changes')"></strong><div class="actions publication-actions"><button type="button" @click="openTransfer()" :disabled="busy || generationSaving" x-show="can('edit_schedules')" x-text="t('transfer_title')"></button><button @click="reloadSharedGeneration()" :disabled="busy || generationSaving" x-show="can('edit_schedules')" x-text="t('reload_saved_state_1b1cb9')"></button><button @click="applyGeneration()" :disabled="busy || !generationCanApply()" x-show="can('edit_schedules')" x-text="t('apply_checked_changes_efa2a4')"></button><button class="publication-sync" :class="{'sync-needed':publicationDirty()}" x-show="can('manage_integration')" @click="retry" :disabled="busy || generationSaving || !!generationSaveError || syncSubmitting || publicationPending() || detail?.sync?.publication_busy"><span x-text="t(syncSubmitting?'publication_submitting':publicationPending()?'publication_pending_button':detail?.sync?.publication_busy?'publication_running_button':'sync_now_2b7d93')"></span></button></div></div>
|
||||
<div class="publication-draft-note"><span x-text="generationSaveError || ((generationSaving || generationUnsaved())?t('shared_draft_saving'):publicationDirty()?t('publication_waiting_click'):t('publication_no_changes'))"></span></div>
|
||||
<p class="muted publication-workflow" x-text="t('publication_workflow_hint')"></p>
|
||||
<p class="publication-draft-note" x-show="detail?.sync?.publication_busy" x-text="t('publication_lease_wait',{date:detail?.sync?.lease_until?formatTime(detail.sync.lease_until):''})"></p>
|
||||
<p class="publication-draft-note" x-show="detail?.sync?.state==='publishing' && !detail?.sync?.publication_busy" x-text="t('publication_lease_expired')"></p>
|
||||
<p class="publication-draft-note" role="status" x-show="publicationPending()" x-text="t('publication_pending_hint')"></p>
|
||||
<!-- GENERATION_PREVIEW -->
|
||||
</div>
|
||||
<section class="coverage-problems panel" x-show="detail?.coverage?.total">
|
||||
<h2 x-text="t('coverage_attention')"></h2>
|
||||
@@ -516,12 +514,12 @@
|
||||
</main>
|
||||
</div>
|
||||
<div class="modal-backdrop" x-cloak x-show="modal" @keydown.escape.window="picker?picker=null:(bindingEditor || rulePeopleTarget)?closeRuleEditor():modal=''" @click.self="modal=''">
|
||||
<section class="modal" :class="{'wide-modal':['variable','generation-rule','absence'].includes(modal),'constructor-modal':modal==='generation-rule'}" role="dialog" aria-modal="true" aria-labelledby="dialog-title" @keydown.tab="trapDialog($event)" x-ref="dialog">
|
||||
<section class="modal" :class="{'wide-modal':['variable','generation-rule','generation-transfer','absence'].includes(modal),'constructor-modal':modal==='generation-rule'}" role="dialog" aria-modal="true" aria-labelledby="dialog-title" @keydown.tab="trapDialog($event)" x-ref="dialog">
|
||||
<form @submit.prevent="save()">
|
||||
<div class="modal-heading">
|
||||
<div>
|
||||
<span class="eyebrow" x-text="modal.startsWith('admin-')?t('access_instance_eyebrow'):['request','day','force'].includes(modal)?scheduleName(form.schedule_id || scheduleId):doc?.name || 'MEGURI'"></span>
|
||||
<h2 id="dialog-title" x-text="modal==='request'?(form.force_mode?t('apply_without_approval_d72926'):form.from_user===user?.id?t('who_can_cover_for_you_6e894c'):t('cover_person',{name:shortName(form.from_user)})):modal==='day'?t('choose_a_shift_330348'):({variable:t('workspace_variable_7abc57'),'generation-rule':t('generation_rule_d34ccf'),rotation:t('rotation_906fe5'),priority:t('ordered_project_coverage_cf12c3'),coverage:t('project_coverage_536388'),request:t('request_a_change_3d465f'),force:t('apply_without_confirmation_f0350d'),absence:t('planned_absence_bfcea7'),holiday:t('public_holiday_49ec03'),'holiday-import':t('import_public_holidays_8098e3'),'remove-schedule':t('remove_schedule_ef0a8a'),person:t('scheduling_person_1273c6'),working:t('workspace_working_interval_c7d250'),'member-permissions':t('access_employee_permissions_title'),'admin-user':t('access_new_user_title'),'admin-groups':t('access_edit_groups_title'),'admin-password':t('access_reset_password_title'),'admin-delete':t('access_delete_workspace_title'),account:t('your_account_4ab291'),policy:t('escalation_policy_bbb0f7'),route:t('routing_rule_b00b22'),integration:t('connected_provider_9702c0'),preferences:t('team_preferences_7fcab5')})[modal]">
|
||||
<h2 id="dialog-title" x-text="modal==='request'?(form.force_mode?t('apply_without_approval_d72926'):form.from_user===user?.id?t('who_can_cover_for_you_6e894c'):t('cover_person',{name:shortName(form.from_user)})):modal==='day'?t('choose_a_shift_330348'):({'generation-transfer':t('transfer_title'),variable:t('workspace_variable_7abc57'),'generation-rule':t('generation_rule_d34ccf'),rotation:t('rotation_906fe5'),priority:t('ordered_project_coverage_cf12c3'),coverage:t('project_coverage_536388'),request:t('request_a_change_3d465f'),force:t('apply_without_confirmation_f0350d'),absence:t('planned_absence_bfcea7'),holiday:t('public_holiday_49ec03'),'holiday-import':t('import_public_holidays_8098e3'),'remove-schedule':t('remove_schedule_ef0a8a'),person:t('scheduling_person_1273c6'),working:t('workspace_working_interval_c7d250'),'member-permissions':t('access_employee_permissions_title'),'admin-user':t('access_new_user_title'),'admin-groups':t('access_edit_groups_title'),'admin-password':t('access_reset_password_title'),'admin-delete':t('access_delete_workspace_title'),account:t('your_account_4ab291'),policy:t('escalation_policy_bbb0f7'),route:t('routing_rule_b00b22'),integration:t('connected_provider_9702c0'),preferences:t('team_preferences_7fcab5')})[modal]">
|
||||
</h2>
|
||||
</div>
|
||||
<button type="button" class="icon-button" @click="modal=''" :aria-label="t('close_dialog_7b2902')"><span x-text="t('symbol_67fba2')"></span></button>
|
||||
@@ -569,11 +567,11 @@
|
||||
</div>
|
||||
</div>
|
||||
<div x-show="pickerEditing">
|
||||
<label><span x-text="t('name_709a23')"></span><input x-model="pickerName">
|
||||
<label><span x-text="t('name_709a23')"></span><input x-model="pickerName" :readonly="!!people.find(p=>p.id===pickerEditing)?.user_id">
|
||||
</label>
|
||||
<label><span x-text="t('email_for_jsm_lookup_193cfb')"></span><input type="email" x-model="pickerEmail">
|
||||
<label><span x-text="t('email_for_jsm_lookup_193cfb')"></span><input type="email" x-model="pickerEmail" :readonly="!!people.find(p=>p.id===pickerEditing)?.user_id">
|
||||
</label>
|
||||
<label><span x-text="t('scheduling_country_393322')"></span><select :aria-label="t('scheduling_country_393322')" x-model="pickerCountry">
|
||||
<label><span x-text="t('scheduling_country_393322')"></span><select :aria-label="t('scheduling_country_393322')" x-model="pickerCountry" :disabled="!!people.find(p=>p.id===pickerEditing)?.user_id">
|
||||
<template x-for="country in countries" :key="country.code">
|
||||
<option :value="country.code" :selected="pickerCountry===country.code" x-text="country.name">
|
||||
</option>
|
||||
@@ -581,12 +579,14 @@
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" class="primary" @click="savePersonDetails()"><span x-text="t('use_these_details_775a68')"></span></button>
|
||||
<p class="info-note" x-show="people.find(p=>p.id===pickerEditing)?.user_id" x-text="t('account_person_authoritative')"></p>
|
||||
</div>
|
||||
</div>
|
||||
</template><div x-show="!picker">
|
||||
<template x-if="modal==='account'">
|
||||
<div>
|
||||
<label><span x-text="t('language')"></span><select x-model="form.language" :aria-label="t('language')"><option value="" x-text="t('language_auto')"></option><template x-for="code in I18n.supported"><option :value="code" :selected="form.language===code" x-text="t('language_'+code)"></option></template></select></label>
|
||||
<label x-show="user?.email"><span x-text="t('account_email')"></span><input type="email" :value="user?.email" readonly></label>
|
||||
<label><span x-text="t('display_name_c7874a')"></span><input x-model="form.display_name" required>
|
||||
</label>
|
||||
<label><span x-text="t('country_of_work_88453b')"></span><select :aria-label="t('country_of_work_88453b')" x-model="form.country">
|
||||
@@ -613,17 +613,18 @@
|
||||
</template>
|
||||
<template x-if="modal==='person'">
|
||||
<div>
|
||||
<label><span x-text="t('name_709a23')"></span><input x-model="form.display_name" required>
|
||||
<label><span x-text="t('name_709a23')"></span><input x-model="form.display_name" required :readonly="!!form.user_id">
|
||||
</label>
|
||||
<label><span x-text="t('email_for_jsm_lookup_193cfb')"></span><input type="email" x-model="form.email" :required="!form.user_id">
|
||||
<label><span x-text="t('email_for_jsm_lookup_193cfb')"></span><input type="email" x-model="form.email" required :readonly="!!form.user_id">
|
||||
</label>
|
||||
<label><span x-text="t('scheduling_country_393322')"></span><select :aria-label="t('scheduling_country_393322')" x-model="form.country">
|
||||
<label><span x-text="t('scheduling_country_393322')"></span><select :aria-label="t('scheduling_country_393322')" x-model="form.country" :disabled="!!form.user_id">
|
||||
<template x-for="country in countries" :key="country.code">
|
||||
<option :value="country.code" :selected="form.country===country.code" x-text="country.name">
|
||||
</option>
|
||||
</template>
|
||||
</select>
|
||||
</label>
|
||||
<p class="info-note" x-show="form.user_id" x-text="t('account_person_authoritative')"></p>
|
||||
<p class="info-note"><span x-text="t('an_email_contact_can_be_scheduled_without_logging_in_34d74f')"></span></p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -704,7 +705,7 @@
|
||||
<div class="cover-range"><small x-text="t('cover_timezone',{zone:doc.timezone})"></small><div class="cover-boundary"><label><span x-text="t('cover_start_date')"></span><span class="date-field" x-data="dateField" x-modelable="value" x-model="form.start_day"><input type="text" :aria-label="t('cover_start_date')" x-ref="textDate" :value="text" @input="edit($event.target);coverRangeChanged()" :placeholder="t('dd_mm_yyyy_d7e5fe')" maxlength="10" required><button type="button" :aria-label="t('open_calendar_41547e')" @click="$refs.calendar.showPicker()"><span x-text="t('symbol_21887f')"></span></button><input class="native-date" type="date" x-ref="calendar" x-model="value" @input="picked();coverRangeChanged()" tabindex="-1" aria-hidden="true"></span></label><label><span x-text="t('cover_start_time')"></span><input type="time" :aria-label="t('cover_start_time')" x-model="form.start_time" @input="coverRangeChanged()" required></label></div><div class="cover-boundary"><label><span x-text="t('cover_end_date')"></span><span class="date-field" x-data="dateField" x-modelable="value" x-model="form.end_day"><input type="text" :aria-label="t('cover_end_date')" x-ref="textDate" :value="text" @input="edit($event.target);coverRangeChanged()" :placeholder="t('dd_mm_yyyy_d7e5fe')" maxlength="10" required><button type="button" :aria-label="t('open_calendar_41547e')" @click="$refs.calendar.showPicker()"><span x-text="t('symbol_21887f')"></span></button><input class="native-date" type="date" x-ref="calendar" x-model="value" @input="picked();coverRangeChanged()" tabindex="-1" aria-hidden="true"></span></label><label><span x-text="t('cover_end_time')"></span><input type="time" :aria-label="t('cover_end_time')" x-model="form.end_time" @input="coverRangeChanged()" required></label></div></div><details class="range-options"><summary x-text="t('cover_swap_optional')"></summary><label class="check"><input type="checkbox" x-model="form.swap"><span x-text="t('i_ll_take_a_shift_in_return_b47888')"></span></label><template x-if="form.swap"><div><div class="cover-boundary"><label><span x-text="t('cover_return_date')"></span><span class="date-field" x-data="dateField" x-modelable="value" x-model="form.return_start"><input type="text" :aria-label="t('cover_return_date')" x-ref="textDate" :value="text" @input="edit($event.target);coverRangeChanged()" :placeholder="t('dd_mm_yyyy_d7e5fe')" maxlength="10" required><button type="button" :aria-label="t('open_calendar_41547e')" @click="$refs.calendar.showPicker()"><span x-text="t('symbol_21887f')"></span></button><input class="native-date" type="date" x-ref="calendar" x-model="value" @input="picked();coverRangeChanged()" tabindex="-1" aria-hidden="true"></span></label><label><span x-text="t('cover_return_time')"></span><input type="time" :aria-label="t('cover_return_time')" x-model="form.return_start_time" @input="coverRangeChanged()" required></label></div><div class="cover-boundary"><label><span x-text="t('cover_return_end_date')"></span><span class="date-field" x-data="dateField" x-modelable="value" x-model="form.return_end"><input type="text" :aria-label="t('cover_return_end_date')" x-ref="textDate" :value="text" @input="edit($event.target);coverRangeChanged()" :placeholder="t('dd_mm_yyyy_d7e5fe')" maxlength="10" required><button type="button" :aria-label="t('open_calendar_41547e')" @click="$refs.calendar.showPicker()"><span x-text="t('symbol_21887f')"></span></button><input class="native-date" type="date" x-ref="calendar" x-model="value" @input="picked();coverRangeChanged()" tabindex="-1" aria-hidden="true"></span></label><label><span x-text="t('cover_return_end_time')"></span><input type="time" :aria-label="t('cover_return_end_time')" x-model="form.return_end_time" @input="coverRangeChanged()" required></label></div></div></template></details>
|
||||
<label x-show="!matchingRequest || form.force_mode || form.custom_range || form.swap"><span x-text="t('comment_optional_f28415')"></span><input :aria-label="t('comment_optional_f28415')" x-model="form.reason" maxlength="1000" :placeholder="t('e_g_ops_123_vacation_befe39')"></label>
|
||||
<p class="validation-error" role="status" x-show="requestError" x-text="l(requestError)"></p><p class="muted" x-show="requestLoading"><span x-text="t('checking_availability_24f241')"></span></p>
|
||||
<div class="request-ready" x-show="requestResult && !requestLoading"><template x-for="leg in requestResult?.legs || []"><small x-text="person(leg.from_user)+' → '+person(leg.to_user)+' · '+formatTime(leg.start)+' → '+formatTime(leg.end)"></small></template><span x-text="form.force_mode?t('applies_immediately_no_approval_needed_842779'):matchingRequest?t('your_acceptance_completes_this_request_3713f7'):(form.from_user===user?.id?shortName(form.to_user):shortName(form.from_user))+t('will_confirm_in_meguri_a4a9b6')"></span></div>
|
||||
<div class="request-ready" x-show="requestResult && !requestLoading"><template x-for="leg in requestResult?.legs || []"><small x-text="person(leg.from_user)+' → '+person(leg.to_user)+' · '+formatTime(leg.start)+' → '+formatTime(leg.end)"></small></template><span x-text="(form.force_mode||requestResult?.applies_immediately)?t('applies_immediately_no_approval_needed_842779'):matchingRequest?t('your_acceptance_completes_this_request_3713f7'):(requestResult?.required_approvers||[]).map(id=>shortName(id)).join(', ')+t('will_confirm_in_meguri_a4a9b6')"></span></div>
|
||||
</div></template>
|
||||
</div></template>
|
||||
<template x-if="modal==='force'">
|
||||
@@ -753,7 +754,7 @@
|
||||
<template x-for="warning in absenceResult.plan.warnings"><p class="validation-warning" x-text="l(warning)"></p></template>
|
||||
<p class="muted" x-show="!absenceResult.plan.rows.length"><span x-text="t('no_scheduled_shifts_change_in_this_interval_98915a')"></span></p>
|
||||
<div class="impact-scroll" x-show="absenceResult.plan.rows.length"><table class="impact-table"><thead><tr><th><span x-text="t('schedule_interval_3cf85c')"></span></th><th><span x-text="t('coverage_80e135')"></span></th><th><span x-text="t('action_97c89a')"></span></th></tr></thead><tbody>
|
||||
<template x-for="row in (showAllAbsence?absenceResult.plan.rows:absenceResult.plan.rows.slice(0,8))" :key="row.schedule_id+row.start"><tr><td class="impact-interval"><strong x-text="row.schedule_name"></strong><small><span x-text="formatTime(row.start)"></span><span x-text="t('symbol_213edd')"></span><span x-text="formatTime(row.end)"></span></small></td><td><span x-text="person(row.before)"></span> <span x-text="t('symbol_213edd')"></span> <strong x-text="row.after?person(row.after):t('uncovered_88710a')"></strong></td><td><button type="button" class="text" x-show="row.status==='uncovered'" @click="coverAbsenceRow(row)" x-text="t('choose_a_substitute_be8f67')"></button><span x-show="row.status!=='uncovered'" x-text="row.status==='replacement'?(form.force?t('apply_now_a356cc'):t('needs_confirmation_722b99')):t('automatic_secondary_fallback_7197c5')"></span></td></tr></template>
|
||||
<template x-for="row in (showAllAbsence?absenceResult.plan.rows:absenceResult.plan.rows.slice(0,8))" :key="row.schedule_id+row.start"><tr><td class="impact-interval"><strong x-text="row.schedule_name"></strong><small><span x-text="formatTime(row.start)"></span><span x-text="t('symbol_213edd')"></span><span x-text="formatTime(row.end)"></span></small></td><td><span x-text="person(row.before)"></span> <span x-text="t('symbol_213edd')"></span> <strong x-text="row.after?person(row.after):t('uncovered_88710a')"></strong></td><td><button type="button" class="text" x-show="row.status==='uncovered'" @click="coverAbsenceRow(row)" x-text="t('choose_a_substitute_be8f67')"></button><span x-show="row.status!=='uncovered'" x-text="row.status==='replacement'?(absenceRowPending(row)?t('needs_confirmation_722b99'):t('apply_now_a356cc')):t('automatic_secondary_fallback_7197c5')"></span></td></tr></template>
|
||||
</tbody></table></div>
|
||||
<button class="text" type="button" x-show="absenceResult.plan.rows.length>8" @click="showAllAbsence=!showAllAbsence" x-text="showAllAbsence?t('show_fewer_intervals_6d52e6'):t('show_all_382ebd')+absenceResult.plan.rows.length+t('intervals_d03402')"></button>
|
||||
<p class="info-note" x-show="absenceResult.plan.groups.length && !form.force"><span x-text="t('absence_takes_effect_when_saved_each_replacement_con_a55d09')"></span></p>
|
||||
@@ -1008,7 +1009,7 @@
|
||||
<option value="message" x-text="t('message_68f414')"></option>
|
||||
<option value="description" x-text="t('description_55f8eb')"></option>
|
||||
</select>
|
||||
<select :aria-label="t('operation_430d32')" x-model="c.operation">
|
||||
<select :aria-label="t('operation_430d32')" x-model="c.operation"><option value="greater-than" x-text="t('routing_greater_than')"></option><option value="less-than" x-text="t('routing_less_than')"></option>
|
||||
<option value="contains" x-text="t('contains_33e15d')"></option>
|
||||
<option value="equals" x-text="t('equals_09b6a6')"></option>
|
||||
<option value="starts-with" x-text="t('starts_with_2a61f5')"></option>
|
||||
@@ -1061,17 +1062,17 @@
|
||||
<!-- GENERATION_MODALS -->
|
||||
<div class="modal-footer cover-footer" x-show="modal==='request' && Date.parse(form.end)>Date.now() && (can('request_overrides') || can('force_overrides'))"><div class="form-validation" x-data="{validationScope:'form'}"><!-- VALIDATION_STATUS --></div>
|
||||
<button type="button" @click="modal=''"><span x-text="t('cancel_77dfd2')"></span></button>
|
||||
<button type="button" class="force-action" x-show="can('force_overrides') && !form.force_mode" @click="form.force_mode=true"><span x-text="t('apply_without_approval_d72926')"></span></button>
|
||||
<button type="button" class="force-action" x-show="can('force_overrides') && !form.force_mode && !requestResult?.applies_immediately" @click="form.force_mode=true"><span x-text="t('apply_without_approval_d72926')"></span></button>
|
||||
<button type="button" x-show="form.force_mode && can('request_overrides')" @click="form.force_mode=false;form.to_user=form.from_user===user?.id?'':user?.id"><span x-text="t('request_approval_instead_b84265')"></span></button>
|
||||
<button type="button" class="primary" @click="sendQuickRequest()" :disabled="busy || !canSendRequest" x-show="can('request_overrides') || form.force_mode" x-text="form.force_mode?t('apply_now_a356cc'):matchingRequest && !form.custom_range && !form.swap?t('accept_cover_d9a527'):form.from_user===user?.id?t('send_request_a89d64'):t('offer_to_cover_054c95')"></button>
|
||||
<button type="button" class="primary" @click="sendQuickRequest()" :disabled="busy || !canSendRequest" x-show="can('request_overrides') || form.force_mode" x-text="form.force_mode || requestResult?.applies_immediately?t('apply_now_a356cc'):matchingRequest && !form.custom_range && !form.swap?t('accept_cover_d9a527'):form.from_user===user?.id?t('send_request_a89d64'):t('offer_to_cover_054c95')"></button>
|
||||
</div>
|
||||
<div class="modal-footer" x-show="!['request','day'].includes(modal)">
|
||||
<div class="modal-footer" x-show="!['request','day','generation-transfer'].includes(modal)">
|
||||
<div class="form-validation" x-data="{validationScope:'form'}"><!-- VALIDATION_STATUS --></div>
|
||||
<button type="button" x-show="modal==='account'" @click="modal='';signOut()"><span x-text="t('sign_out_dc1649')"></span></button>
|
||||
<button type="button" @click="modal=''"><span x-text="t('cancel_77dfd2')"></span></button>
|
||||
|
||||
<button type="button" class="primary" x-show="rulePeopleTarget" @click="closeRuleEditor()" x-text="t('done_e9b450')"></button>
|
||||
<button x-show="!rulePeopleTarget" class="primary" :class="{'remove-confirm':modal==='remove-schedule'}" :disabled="busy || (modal==='holiday-import' && !canImport) || (modal==='absence' && !canSaveAbsence) || (modal==='remove-schedule' && !canRemoveSchedule) || (modal==='admin-delete' && form.confirm!==form.expected)" x-text="busy?t('saving_56a228'):modal==='admin-user'?t('access_create_user'):modal==='admin-password'?t('access_reset_password'):modal==='admin-delete'?t('access_delete_permanently'):modal==='remove-schedule'?(form.redirect_to?t('remove_redirect_references_f1a241'):removal?.plan.escalation_steps.length?t('remove_schedule_affected_steps_7348af'):t('remove_schedule_ef0a8a')):modal==='holiday-import'?t('import_new_holidays_b36db6'):modal==='request'?t('send_for_confirmation_f3df0f'):modal==='force'?t('apply_replacement_9b0845'):['variable','generation-rule'].includes(modal)?t('use_in_draft_a8f78c'):t('save_changes_179359')">
|
||||
<button x-show="!rulePeopleTarget" class="primary" :class="{'remove-confirm':modal==='remove-schedule'}" :disabled="busy || (modal==='holiday-import' && !canImport) || (modal==='absence' && !canSaveAbsence) || (modal==='remove-schedule' && !canRemoveSchedule) || (modal==='admin-delete' && form.confirm!==form.expected)" x-text="busy?t('saving_56a228'):modal==='admin-user'?t('access_create_user'):modal==='admin-password'?t('access_reset_password'):modal==='admin-delete'?t('access_delete_permanently'):modal==='remove-schedule'?(form.redirect_to?t('remove_redirect_references_f1a241'):removal?.plan.escalation_steps.length?t('remove_schedule_affected_steps_7348af'):t('remove_schedule_ef0a8a')):modal==='holiday-import'?t('import_new_holidays_b36db6'):modal==='request'?(requestResult?.applies_immediately?t('apply_replacement_9b0845'):t('send_for_confirmation_f3df0f')):modal==='force'?t('apply_replacement_9b0845'):['variable','generation-rule'].includes(modal)?t('use_in_draft_a8f78c'):t('save_changes_179359')">
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+506
-31
@@ -1670,9 +1670,9 @@
|
||||
"fr": " périodes"
|
||||
},
|
||||
"absence_takes_effect_when_saved_each_replacement_con_a55d09": {
|
||||
"en": "Absence takes effect when saved. Each replacement confirms all their affected shifts in one request per range. Until confirmed, uncovered intervals block publication; the provider keeps its last published configuration.",
|
||||
"ru": "Отсутствие учитывается сразу после сохранения. Подменяющий подтверждает все свои затронутые смены одним запросом на интервал. Пока нет подтверждения, смены без дежурного блокируют публикацию; внешний сервис сохраняет прежнюю конфигурацию.",
|
||||
"fr": "L’absence est prise en compte dès l’enregistrement. Chaque remplaçant confirme toutes ses astreintes dans une demande par période. Les périodes non couvertes bloquent la publication ; le fournisseur conserve sa dernière configuration."
|
||||
"en": "Absence takes effect when saved. Only participants with workspace accounts need to approve replacements; contacts do not. Replacements with no required approvals apply immediately.",
|
||||
"ru": "Отпуск сохраняется сразу. Подтверждение замены требуется только от участников с аккаунтом и доступом к workspace. Контакты без аккаунта не подтверждают; если подтверждения не нужны, замена применяется сразу.",
|
||||
"fr": "L’absence prend effet à l’enregistrement. Seuls les participants disposant d’un compte et d’un accès approuvent les remplacements ; les contacts n’approuvent pas. Sans approbation requise, le remplacement s’applique immédiatement."
|
||||
},
|
||||
"some_shifts_have_no_available_replacement_they_are_s_c20d79": {
|
||||
"en": "Some shifts have no available replacement. They are shown as uncovered and block publication.",
|
||||
@@ -2190,9 +2190,9 @@
|
||||
"fr": "Recharger l’état enregistré"
|
||||
},
|
||||
"apply_checked_changes_efa2a4": {
|
||||
"en": "Apply checked changes",
|
||||
"ru": "Применить проверенные изменения",
|
||||
"fr": "Appliquer les modifications vérifiées"
|
||||
"en": "Save checked changes",
|
||||
"ru": "Сохранить проверенные изменения",
|
||||
"fr": "Enregistrer les modifications vérifiées"
|
||||
},
|
||||
"workspace_variables_2c8617": {
|
||||
"en": "Workspace variables",
|
||||
@@ -3125,9 +3125,9 @@
|
||||
"fr": "Remplacement accepté. Publication en attente."
|
||||
},
|
||||
"replacement_applied_publication_is_queued_89e283": {
|
||||
"en": "Replacement applied. Publication is queued.",
|
||||
"ru": "Подмена применена. Публикация в очереди.",
|
||||
"fr": "Remplacement appliqué. Publication en attente."
|
||||
"en": "Replacement applied. Synchronize to publish.",
|
||||
"ru": "Замена применена. Синхронизируйте для публикации.",
|
||||
"fr": "Remplacement appliqué. Synchronisez pour publier."
|
||||
},
|
||||
"request_sent_waiting_for_your_colleague_b70b64": {
|
||||
"en": "Request sent. Waiting for your colleague.",
|
||||
@@ -3185,9 +3185,9 @@
|
||||
"fr": "\"] .move-route"
|
||||
},
|
||||
"routing_order_saved_publication_is_queued_cfb972": {
|
||||
"en": "Routing order saved. Publication is queued.",
|
||||
"ru": "Порядок правил сохранён. Публикация в очереди.",
|
||||
"fr": "Ordre de routage enregistré. Publication en attente."
|
||||
"en": "Routing order saved. Synchronize to publish.",
|
||||
"ru": "Порядок маршрутов сохранён. Синхронизируйте для публикации.",
|
||||
"fr": "Ordre enregistré. Synchronisez pour publier."
|
||||
},
|
||||
"drift_policy_updated_be40fd": {
|
||||
"en": "Drift policy updated.",
|
||||
@@ -3205,9 +3205,9 @@
|
||||
"fr": "Aperçu de publication téléchargé."
|
||||
},
|
||||
"workspace_data_and_generated_schedules_saved_publica_e4ae3d": {
|
||||
"en": "Workspace data and generated schedules saved. Publication is queued.",
|
||||
"ru": "Данные пространства и расписания сохранены. Публикация в очереди.",
|
||||
"fr": "Données de l’espace et plannings générés enregistrés. Publication en attente."
|
||||
"en": "Workspace changes saved. Synchronize when ready.",
|
||||
"ru": "Изменения workspace сохранены. Синхронизируйте, когда всё будет готово.",
|
||||
"fr": "Modifications enregistrées. Synchronisez lorsque vous êtes prêt."
|
||||
},
|
||||
"choose_person_f20051": {
|
||||
"en": "Choose person",
|
||||
@@ -3700,9 +3700,9 @@
|
||||
"fr": "La valeur de routage doit contenir de 1 à 500 caractères"
|
||||
},
|
||||
"err_jsm_cloud_and_team_ids_must_be_uuids_f75b5f": {
|
||||
"en": "JSM cloud and team IDs must be UUIDs",
|
||||
"ru": "ID облака и команды JSM должны быть UUID",
|
||||
"fr": "Les ID du cloud et de l’équipe JSM doivent être des UUID"
|
||||
"en": "JSM cloud ID must be a UUID; team ID must be a UUID, optionally prefixed with og-",
|
||||
"ru": "ID облака JSM должен быть UUID; ID команды — UUID, возможно с префиксом og-",
|
||||
"fr": "L’ID du cloud JSM doit être un UUID ; l’ID de l’équipe doit être un UUID, éventuellement précédé de og-"
|
||||
},
|
||||
"err_jsm_account_ids_must_contain_1_200_characters_9fb430": {
|
||||
"en": "JSM account IDs must contain 1–200 characters",
|
||||
@@ -3924,11 +3924,6 @@
|
||||
"ru": "Выберите существующего пользователя пространства из справочника",
|
||||
"fr": "Choisissez un utilisateur existant de l’espace dans l’annuaire"
|
||||
},
|
||||
"err_a_selected_replacement_has_no_team_access_to_con_dd538a": {
|
||||
"en": "A selected replacement has no workspace access to confirm. Grant access or ask a lead to apply without confirmation.",
|
||||
"ru": "У выбранного сотрудника нет доступа к пространстве для подтверждения. Выдайте доступ или попросите тимлида применить без согласования.",
|
||||
"fr": "Le remplaçant choisi n’a pas accès à l’espace pour confirmer. Accordez-lui l’accès ou demandez à un responsable d’appliquer sans validation."
|
||||
},
|
||||
"err_comment_must_be_at_most_1000_characters_e5d5ef": {
|
||||
"en": "Comment must be at most 1000 characters",
|
||||
"ru": "Комментарий должен быть не длиннее 1000 символов",
|
||||
@@ -3944,11 +3939,6 @@
|
||||
"ru": "Можно отдать свою смену или предложить подменить коллегу",
|
||||
"fr": "Vous pouvez céder votre astreinte ou proposer de remplacer un collègue"
|
||||
},
|
||||
"err_both_people_need_team_access_to_approve_ask_a_le_7b0fc5": {
|
||||
"en": "Both people need workspace access to approve. Ask a lead to apply without approval for a contact.",
|
||||
"ru": "Оба участника должны иметь доступ к пространстве для подтверждения. Для контакта попросите тимлида применить без согласования.",
|
||||
"fr": "Les deux personnes doivent avoir accès à l’espace pour approuver. Pour un contact, demandez à un responsable d’appliquer sans accord."
|
||||
},
|
||||
"err_this_request_has_already_been_resolved_26b69e": {
|
||||
"en": "This request has already been resolved",
|
||||
"ru": "Этот запрос уже обработан",
|
||||
@@ -5605,9 +5595,9 @@
|
||||
"fr": "Carte de génération"
|
||||
},
|
||||
"map_hint": {
|
||||
"en": "Rules run from left to right. Their resources appear below; stacked cards represent repeated outputs.",
|
||||
"ru": "Правила выполняются слева направо. Результаты — под своим правилом; стопки карточек обозначают повторяющиеся ресурсы.",
|
||||
"fr": "Les règles s’exécutent de gauche à droite. Leurs ressources sont en dessous ; les cartes empilées indiquent des sorties répétées."
|
||||
"en": "Variables → generators → resources. Select a colored value to explore its origin.",
|
||||
"ru": "Переменные → генераторы → ресурсы. Нажмите на цветное значение, чтобы исследовать его происхождение.",
|
||||
"fr": "Variables → générateurs → ressources. Sélectionnez une valeur colorée pour explorer sa provenance."
|
||||
},
|
||||
"map_close": {
|
||||
"en": "Close",
|
||||
@@ -6403,5 +6393,490 @@
|
||||
"en": "v{version}",
|
||||
"ru": "v{version}",
|
||||
"fr": "v{version}"
|
||||
},
|
||||
"jsm_invalid_coverage_interval": {
|
||||
"en": "JSM coverage requires valid intervals of at least one millisecond and an unambiguous base start.",
|
||||
"ru": "Для покрытия JSM нужны интервалы не короче миллисекунды и однозначное начало базовой ротации.",
|
||||
"fr": "La couverture JSM nécessite des intervalles d’au moins une milliseconde et un début de rotation de base non ambigu."
|
||||
},
|
||||
"jsm_coverage_conflict": {
|
||||
"en": "JSM schedule overrides changed outside Meguri. Publication paused.",
|
||||
"ru": "Замены в расписании JSM изменились вне Meguri. Публикация приостановлена.",
|
||||
"fr": "Les remplacements du planning JSM ont changé en dehors de Meguri. Publication suspendue."
|
||||
},
|
||||
"jsm_coverage_response_invalid": {
|
||||
"en": "JSM coverage read-back does not match the requested interval.",
|
||||
"ru": "Сохранённое покрытие JSM не совпадает с запрошенным интервалом.",
|
||||
"fr": "La couverture relue depuis JSM ne correspond pas à l’intervalle demandé."
|
||||
},
|
||||
"err_shared_draft_limit": {
|
||||
"en": "The shared draft exceeds the workspace limits.",
|
||||
"ru": "Общий черновик превышает ограничения workspace.",
|
||||
"fr": "Le brouillon partagé dépasse les limites de l’espace."
|
||||
},
|
||||
"err_shared_draft_stale": {
|
||||
"en": "A colleague changed this draft. Reload the shared draft before saving.",
|
||||
"ru": "Коллега изменил черновик. Загрузите общий черновик заново перед сохранением.",
|
||||
"fr": "Un collègue a modifié ce brouillon. Rechargez le brouillon partagé avant de sauvegarder."
|
||||
},
|
||||
"err_publication_busy_or_stale": {
|
||||
"en": "The workspace changed or publication is running. Refresh before synchronizing.",
|
||||
"ru": "Workspace изменился или публикация уже выполняется. Обновите состояние перед синхронизацией.",
|
||||
"fr": "L’espace a changé ou une publication est en cours. Actualisez avant de synchroniser."
|
||||
},
|
||||
"enum_draft": {
|
||||
"en": "Awaiting synchronization",
|
||||
"ru": "Ожидает синхронизации",
|
||||
"fr": "En attente de synchronisation"
|
||||
},
|
||||
"shared_draft_saving": {
|
||||
"en": "Saving shared draft…",
|
||||
"ru": "Сохраняем общий черновик…",
|
||||
"fr": "Enregistrement du brouillon partagé…"
|
||||
},
|
||||
"publication_waiting_click": {
|
||||
"en": "Changes are shared with your workspace. Synchronize when ready to publish.",
|
||||
"ru": "Изменения доступны коллегам. Нажмите «Синхронизировать», когда всё будет готово.",
|
||||
"fr": "Les modifications sont partagées. Synchronisez quand vous êtes prêt à publier."
|
||||
},
|
||||
"publication_no_changes": {
|
||||
"en": "No unpublished changes.",
|
||||
"ru": "Нет неопубликованных изменений.",
|
||||
"fr": "Aucune modification non publiée."
|
||||
},
|
||||
"publication_coverage_end": {
|
||||
"en": "Published coverage through {date}",
|
||||
"ru": "Покрытие опубликовано до {date}",
|
||||
"fr": "Couverture publiée jusqu’au {date}"
|
||||
},
|
||||
"publication_details": {
|
||||
"en": "Resource changes and publication",
|
||||
"ru": "Изменения ресурсов и публикация",
|
||||
"fr": "Modifications des ressources et publication"
|
||||
},
|
||||
"enum_generation.draft_saved": {
|
||||
"en": "Shared generator draft saved",
|
||||
"ru": "Общий черновик генератора сохранён",
|
||||
"fr": "Brouillon partagé du générateur enregistré"
|
||||
},
|
||||
"confirm_remove_variable": {
|
||||
"en": "Remove variable “{name}” from the shared draft? Rules using it may become invalid.",
|
||||
"ru": "Удалить переменную «{name}» из общего черновика? Правила, использующие её, могут стать невалидными.",
|
||||
"fr": "Supprimer la variable « {name} » du brouillon partagé ? Les règles qui l’utilisent peuvent devenir invalides."
|
||||
},
|
||||
"confirm_remove_generation_rule": {
|
||||
"en": "Remove rule “{name}” from the shared draft?",
|
||||
"ru": "Удалить правило «{name}» из общего черновика?",
|
||||
"fr": "Supprimer la règle « {name} » du brouillon partagé ?"
|
||||
},
|
||||
"publication_workflow_hint": {
|
||||
"en": "Drafts are shared and autosaved. Save checked changes applies a validated draft in Meguri; it is unavailable while checking or when errors remain. Reload retrieves the shared draft. Sync saves and publishes in one step — saving first is optional.",
|
||||
"ru": "Общий черновик сохраняется автоматически. «Сохранить проверенные изменения» применяет проверенный черновик в Meguri; кнопка недоступна во время проверки или при ошибках. Восстановление загружает общий черновик. Синхронизация сохраняет и публикует за один шаг — отдельно сохранять перед ней необязательно.",
|
||||
"fr": "Le brouillon partagé est enregistré automatiquement. Enregistrer l’état applique le brouillon validé dans Meguri ; ce bouton est indisponible pendant la vérification ou en cas d’erreur. Recharger récupère le brouillon partagé. Synchroniser enregistre et publie en une étape ; aucun enregistrement préalable n’est nécessaire."
|
||||
},
|
||||
"routing_greater_than": {
|
||||
"en": "Higher than",
|
||||
"ru": "Выше",
|
||||
"fr": "Supérieur à"
|
||||
},
|
||||
"routing_less_than": {
|
||||
"en": "Lower than",
|
||||
"ru": "Ниже",
|
||||
"fr": "Inférieur à"
|
||||
},
|
||||
"enum_greater-than": {
|
||||
"en": "Higher than",
|
||||
"ru": "Выше",
|
||||
"fr": "Supérieur à"
|
||||
},
|
||||
"enum_less-than": {
|
||||
"en": "Lower than",
|
||||
"ru": "Ниже",
|
||||
"fr": "Inférieur à"
|
||||
},
|
||||
"publication_lease_wait": {
|
||||
"en": "Publication is reserved until {date}. After a restart, recovery waits for this lock to expire to avoid simultaneous writes.",
|
||||
"ru": "Публикация заблокирована для других запусков до {date}. После перезапуска восстановление ждёт истечения блокировки, чтобы избежать одновременной записи.",
|
||||
"fr": "La publication est réservée jusqu’au {date}. Après un redémarrage, la reprise attend l’expiration du verrou pour éviter des écritures simultanées."
|
||||
},
|
||||
"publication_lease_expired": {
|
||||
"en": "The publication lock has expired. The worker can resume; you can also request synchronization again.",
|
||||
"ru": "Блокировка публикации истекла. Воркер может продолжить работу; также можно снова нажать «Синхронизировать».",
|
||||
"fr": "Le verrou de publication a expiré. Le worker peut reprendre ; vous pouvez aussi relancer la synchronisation."
|
||||
},
|
||||
"publication_submitting": {
|
||||
"en": "Submitting…",
|
||||
"ru": "Отправка…",
|
||||
"fr": "Envoi…"
|
||||
},
|
||||
"publication_pending_button": {
|
||||
"en": "Queued…",
|
||||
"ru": "В очереди…",
|
||||
"fr": "En attente…"
|
||||
},
|
||||
"publication_running_button": {
|
||||
"en": "Synchronizing…",
|
||||
"ru": "Синхронизация…",
|
||||
"fr": "Synchronisation…"
|
||||
},
|
||||
"publication_pending_hint": {
|
||||
"en": "Synchronization requested. Waiting for the worker; status updates automatically.",
|
||||
"ru": "Синхронизация запрошена. Ожидаем запуска воркера; статус обновляется автоматически.",
|
||||
"fr": "Synchronisation demandée. En attente du worker ; le statut se met à jour automatiquement."
|
||||
},
|
||||
"map_generators": {
|
||||
"en": "Generators",
|
||||
"ru": "Генераторы",
|
||||
"fr": "Générateurs"
|
||||
},
|
||||
"map_resource_lane": {
|
||||
"en": "Resources",
|
||||
"ru": "Ресурсы",
|
||||
"fr": "Ressources"
|
||||
},
|
||||
"map_manual": {
|
||||
"en": "Manual resource",
|
||||
"ru": "Ручной ресурс",
|
||||
"fr": "Ressource manuelle"
|
||||
},
|
||||
"map_manual_resources": {
|
||||
"en": "Manual resources · {count}",
|
||||
"ru": "Ручные ресурсы · {count}",
|
||||
"fr": "Ressources manuelles · {count}"
|
||||
},
|
||||
"map_generated_resources": {
|
||||
"en": "Generated resources · {count}",
|
||||
"ru": "Сгенерированные ресурсы · {count}",
|
||||
"fr": "Ressources générées · {count}"
|
||||
},
|
||||
"map_explore": {
|
||||
"en": "Explore value origin",
|
||||
"ru": "Исследовать происхождение",
|
||||
"fr": "Explorer la provenance"
|
||||
},
|
||||
"map_explore_back": {
|
||||
"en": "Back",
|
||||
"ru": "Назад",
|
||||
"fr": "Retour"
|
||||
},
|
||||
"map_source_fields": {
|
||||
"en": "Source fields",
|
||||
"ru": "Поля источника",
|
||||
"fr": "Champs de la source"
|
||||
},
|
||||
"map_source_chain": {
|
||||
"en": "Source chain",
|
||||
"ru": "Цепочка источников",
|
||||
"fr": "Chaîne des sources"
|
||||
},
|
||||
"map_source_value": {
|
||||
"en": "Source value",
|
||||
"ru": "Исходное значение",
|
||||
"fr": "Valeur source"
|
||||
},
|
||||
"map_resolved_value": {
|
||||
"en": "Final value",
|
||||
"ru": "Итоговое значение",
|
||||
"fr": "Valeur finale"
|
||||
},
|
||||
"map_value_examples": {
|
||||
"en": "Value examples",
|
||||
"ru": "Примеры значений",
|
||||
"fr": "Exemples de valeurs"
|
||||
},
|
||||
"map_pass_number": {
|
||||
"en": "Pass {number}",
|
||||
"ru": "Итерация {number}",
|
||||
"fr": "Passage {number}"
|
||||
},
|
||||
"map_no_trace": {
|
||||
"en": "No evaluated example in this snapshot",
|
||||
"ru": "В этом снимке нет вычисленного примера",
|
||||
"fr": "Aucun exemple calculé dans cet instantané"
|
||||
},
|
||||
"map_examples_limited": {
|
||||
"en": "Showing the first 20 passes. Select a pass to inspect another.",
|
||||
"ru": "Показаны первые 20 итераций. Выберите итерацию для просмотра остальных.",
|
||||
"fr": "Les 20 premiers passages sont affichés. Sélectionnez un passage pour voir les autres."
|
||||
},
|
||||
"transfer_contacts": {
|
||||
"en": "New scheduling contacts (no login access)",
|
||||
"ru": "Новые контакты дежурств (без доступа к входу)",
|
||||
"fr": "Nouveaux contacts d’astreinte (sans accès de connexion)"
|
||||
},
|
||||
"transfer_title": {
|
||||
"en": "Import / Export",
|
||||
"ru": "Импорт / Экспорт",
|
||||
"fr": "Importer / Exporter"
|
||||
},
|
||||
"transfer_export": {
|
||||
"en": "Export",
|
||||
"ru": "Экспорт",
|
||||
"fr": "Exporter"
|
||||
},
|
||||
"transfer_import": {
|
||||
"en": "Import",
|
||||
"ru": "Импорт",
|
||||
"fr": "Importer"
|
||||
},
|
||||
"transfer_document": {
|
||||
"en": "Configuration · meguri/v2 JSON",
|
||||
"ru": "Конфигурация · meguri/v2 JSON",
|
||||
"fr": "Configuration · JSON meguri/v2"
|
||||
},
|
||||
"transfer_paste": {
|
||||
"en": "Paste or edit configuration",
|
||||
"ru": "Вставьте или отредактируйте конфигурацию",
|
||||
"fr": "Coller ou modifier la configuration"
|
||||
},
|
||||
"transfer_hint": {
|
||||
"en": "Exports variables and generators from the shared draft. Import adds new names and updates matching names; omitted items remain. Applying saves the shared draft. Use Sync separately to publish.",
|
||||
"ru": "Экспортирует переменные и генераторы общего черновика. Импорт добавляет новые имена и обновляет совпадающие; отсутствующие элементы остаются. Применение сохраняет общий черновик. Для публикации отдельно нажмите Sync.",
|
||||
"fr": "Exporte les variables et générateurs du brouillon partagé. L’import ajoute les nouveaux noms et met à jour les noms correspondants ; les éléments absents restent. L’application enregistre le brouillon partagé. Synchronisez séparément pour publier."
|
||||
},
|
||||
"transfer_identity_hint": {
|
||||
"en": "Use ordinary values and email addresses. Keep output keys when editing. Named list entries and people are matched before positions; review resource changes after renaming or reordering. Existing v1 files are still accepted.",
|
||||
"ru": "Используйте обычные значения и email. Сохраняйте ключи выходных блоков при редактировании. Имена элементов списка и люди сопоставляются до позиций; проверяйте изменения ресурсов после переименования или перестановки. Старые файлы v1 поддерживаются.",
|
||||
"fr": "Utilisez des valeurs ordinaires et des emails. Conservez les clés de sortie. Les noms et personnes des listes sont rapprochés avant les positions ; vérifiez les changements après renommage ou réorganisation. Les fichiers v1 restent acceptés."
|
||||
},
|
||||
"transfer_copy": {
|
||||
"en": "Copy",
|
||||
"ru": "Копировать",
|
||||
"fr": "Copier"
|
||||
},
|
||||
"transfer_download": {
|
||||
"en": "Download JSON",
|
||||
"ru": "Скачать JSON",
|
||||
"fr": "Télécharger le JSON"
|
||||
},
|
||||
"transfer_inputs": {
|
||||
"en": "Variables and generators",
|
||||
"ru": "Переменные и генераторы",
|
||||
"fr": "Variables et générateurs"
|
||||
},
|
||||
"transfer_no_resources": {
|
||||
"en": "No resource changes.",
|
||||
"ru": "Изменений ресурсов нет.",
|
||||
"fr": "Aucune modification de ressource."
|
||||
},
|
||||
"transfer_apply": {
|
||||
"en": "Apply to shared draft",
|
||||
"ru": "Применить к общему черновику",
|
||||
"fr": "Appliquer au brouillon partagé"
|
||||
},
|
||||
"transfer_applied": {
|
||||
"en": "Configuration imported into the shared draft.",
|
||||
"ru": "Конфигурация импортирована в общий черновик.",
|
||||
"fr": "Configuration importée dans le brouillon partagé."
|
||||
},
|
||||
"transfer_copied": {
|
||||
"en": "Configuration copied.",
|
||||
"ru": "Конфигурация скопирована.",
|
||||
"fr": "Configuration copiée."
|
||||
},
|
||||
"transfer_invalid": {
|
||||
"en": "Invalid configuration: {detail}",
|
||||
"ru": "Некорректная конфигурация: {detail}",
|
||||
"fr": "Configuration invalide : {detail}"
|
||||
},
|
||||
"transfer_duplicate": {
|
||||
"en": "Duplicate or ambiguous name: {name}",
|
||||
"ru": "Повторяющееся или неоднозначное имя: {name}",
|
||||
"fr": "Nom en double ou ambigu : {name}"
|
||||
},
|
||||
"transfer_reference": {
|
||||
"en": "Source is missing or ambiguous: {name}",
|
||||
"ru": "Источник отсутствует или неоднозначен: {name}",
|
||||
"fr": "Source absente ou ambiguë : {name}"
|
||||
},
|
||||
"transfer_email_required": {
|
||||
"en": "Set a valid scheduling email before exporting this person: {person}",
|
||||
"ru": "Укажите корректный email контакта перед экспортом этого человека: {person}",
|
||||
"fr": "Définissez un email d’astreinte valide avant d’exporter cette personne : {person}"
|
||||
},
|
||||
"transfer_limit": {
|
||||
"en": "Configuration exceeds the size or nesting limit.",
|
||||
"ru": "Конфигурация превышает ограничение размера или вложенности.",
|
||||
"fr": "La configuration dépasse la limite de taille ou d’imbrication."
|
||||
},
|
||||
"transfer_version": {
|
||||
"en": "Expected format: meguri/v2 (or legacy meguri/v1).",
|
||||
"ru": "Ожидаемый формат: meguri/v2 (или прежний meguri/v1).",
|
||||
"fr": "Format attendu : meguri/v2 (ou ancien meguri/v1)."
|
||||
},
|
||||
"transfer_fix_errors": {
|
||||
"en": "Fix the preview errors before importing.",
|
||||
"ru": "Исправьте ошибки предпросмотра перед импортом.",
|
||||
"fr": "Corrigez les erreurs de prévisualisation avant l’import."
|
||||
},
|
||||
"transfer_v2_binding": {
|
||||
"en": "Invalid compact value or reference. Check its type, arguments and required fields.",
|
||||
"ru": "Некорректное компактное значение или ссылка. Проверьте тип, аргументы и обязательные поля.",
|
||||
"fr": "Valeur ou référence compacte invalide. Vérifiez le type, les arguments et les champs requis."
|
||||
},
|
||||
"transfer_guide": {
|
||||
"en": "Guide",
|
||||
"ru": "Guide",
|
||||
"fr": "Guide"
|
||||
},
|
||||
"transfer_guide_copy": {
|
||||
"en": "Copy full guide",
|
||||
"ru": "Скопировать весь гайд",
|
||||
"fr": "Copier le guide complet"
|
||||
},
|
||||
"transfer_guide_copied": {
|
||||
"en": "Full guide copied as Markdown.",
|
||||
"ru": "Полный гайд скопирован в Markdown.",
|
||||
"fr": "Guide complet copié en Markdown."
|
||||
},
|
||||
"transfer_guide_hint": {
|
||||
"en": "Complete v2 reference for hand editing or an LLM. Copy includes every section and the example.",
|
||||
"ru": "Полная справка v2 для ручного редактирования или LLM. Копируются все разделы и пример.",
|
||||
"fr": "Référence v2 complète pour édition manuelle ou LLM. La copie inclut toutes les sections et l’exemple."
|
||||
},
|
||||
"transfer_guide_structure_title": {
|
||||
"en": "1. Document and notation",
|
||||
"ru": "1. Документ и обозначения",
|
||||
"fr": "1. Document et notation"
|
||||
},
|
||||
"transfer_guide_structure": {
|
||||
"en": "Produce strict JSON: no comments, trailing commas, executable code or string interpolation. All three root fields are required. Only variables and generators are transferred. B below means a literal value or a binding; ? marks an optional field in schemas (do not put ? in JSON keys). Names, keys and enum values are literal strings, not bindings. Unknown options are errors. Export uses v2; old v1 imports remain supported.\n\n{\"format\":\"meguri/v2\", \"variables\":{}, \"generators\":[]}",
|
||||
"ru": "Создавайте строгий JSON: без комментариев, запятых в конце списков, исполняемого кода и подстановок внутри строк. Все три корневых поля обязательны. Переносятся только переменные и генераторы. Ниже B — обычное значение или привязка; ? отмечает необязательное поле схемы (в JSON знак ? не пишется). Имена, ключи и значения перечислений — обычные строки, не привязки. Неизвестные опции — ошибка. Экспорт использует v2; импорт старого v1 поддерживается.\n\n{\"format\":\"meguri/v2\", \"variables\":{}, \"generators\":[]}",
|
||||
"fr": "Produisez du JSON strict : sans commentaires, virgules finales, code exécutable ni interpolation de chaînes. Les trois champs racine sont obligatoires. Seuls les variables et générateurs sont transférés. B désigne une valeur littérale ou une liaison ; ? marque un champ facultatif du schéma (sans ? dans les clés JSON). Noms, clés et énumérations sont des chaînes littérales. Les options inconnues sont des erreurs. Export en v2 ; import v1 conservé.\n\n{\"format\":\"meguri/v2\", \"variables\":{}, \"generators\":[]}"
|
||||
},
|
||||
"transfer_guide_variables_title": {
|
||||
"en": "2. Variable values and types",
|
||||
"ru": "2. Значения и типы переменных",
|
||||
"fr": "2. Valeurs et types des variables"
|
||||
},
|
||||
"transfer_guide_variables": {
|
||||
"en": "variables maps unique names to values. JSON strings/numbers/booleans/arrays/objects become text/number/boolean/list/dictionary. A valid email string becomes a person; dates and times are not inferred. Null is not a variable type. Existing people resolve by exact email; unknown emails become scheduling contacts (country CY), never logins or workspace members. Provider accounts are resolved separately before publication. A dictionary uses alphabetical key order; use $object pairs when order matters or keys start with $. Variable wrappers are not binding operators.\n\n\"on_call\": [\"alice@example.com\", \"bob@example.com\"]\n\"projects\": [{\"name\":\"alpha\", \"owners\":[\"alice@example.com\"]}]\n{\"$person\":\"alice@example.com\"}\n{\"$text\":\"email-looking-text@example.com\"}\n{\"$date\":\"2026-10-01\"}\n{\"$time\":\"09:00\"}\n{\"$duration\":30}\n{\"$object\":[[\"z\",1],[\"a\",2],[\"$custom\",true]]}",
|
||||
"ru": "variables сопоставляет уникальные имена значениям. Строки/числа/булевы/массивы/объекты JSON становятся текстом/числом/булевым/списком/словарём. Валидный email становится персоной; даты и время не угадываются. Null не является типом переменной. Люди сопоставляются по точному email; неизвестные email создают контакты дежурств (страна CY), без учётной записи и доступа в workspace. Аккаунты провайдера проверяются отдельно перед публикацией. Ключи обычного словаря идут по алфавиту; для заданного порядка или ключей с $ используйте пары $object. Обёртки типов переменных не являются операторами привязок.\n\n\"on_call\": [\"alice@example.com\", \"bob@example.com\"]\n\"projects\": [{\"name\":\"alpha\", \"owners\":[\"alice@example.com\"]}]\n{\"$person\":\"alice@example.com\"}\n{\"$text\":\"email-looking-text@example.com\"}\n{\"$date\":\"2026-10-01\"}\n{\"$time\":\"09:00\"}\n{\"$duration\":30}\n{\"$object\":[[\"z\",1],[\"a\",2],[\"$custom\",true]]}",
|
||||
"fr": "variables associe des noms uniques à des valeurs. Chaînes/nombres/booléens/tableaux/objets JSON deviennent texte/nombre/booléen/liste/dictionnaire. Un email valide devient une personne ; dates et heures ne sont pas déduites. Null n’est pas un type de variable. Les personnes sont rapprochées par email exact ; les nouveaux emails créent des contacts d’astreinte (pays CY), sans compte ni accès. Les comptes fournisseur sont vérifiés avant publication. Les dictionnaires ordinaires suivent l’ordre alphabétique ; utilisez les paires $object pour imposer l’ordre ou pour des clés commençant par $. Ces enveloppes ne sont pas des opérateurs de liaison.\n\n\"on_call\": [\"alice@example.com\", \"bob@example.com\"]\n\"projects\": [{\"name\":\"alpha\", \"owners\":[\"alice@example.com\"]}]\n{\"$person\":\"alice@example.com\"}\n{\"$text\":\"email-looking-text@example.com\"}\n{\"$date\":\"2026-10-01\"}\n{\"$time\":\"09:00\"}\n{\"$duration\":30}\n{\"$object\":[[\"z\",1],[\"a\",2],[\"$custom\",true]]}"
|
||||
},
|
||||
"transfer_guide_bindings_title": {
|
||||
"en": "3. Bindings (B)",
|
||||
"ru": "3. Привязки (B)",
|
||||
"fr": "3. Liaisons (B)"
|
||||
},
|
||||
"transfer_guide_bindings": {
|
||||
"en": "Ordinary scalars and arrays are literal bindings; an array containing references does NOT evaluate them: use $list. A literal object needs $literal. Each binding object has exactly one $ operator. Optional prefix/suffix require a text result. Optional path is an array of exact dictionary keys or one-based list indexes written as strings. Dots, spaces and slashes inside a segment stay literal. $var/$item/$key accept a name or [name,...path]; do not also supply path when the shorthand already includes a path. $lookup keys resolve to text. A $person marker inside a generic $literal resolves an email to the workspace person identity.\n\n{\"$var\":\"projects\"}\n{\"$var\":[\"settings\",\"owners\",\"1\"]}\n{\"$item\":[\"Project\",\"name\"],\"prefix\":\"MG: \",\"suffix\":\" primary\"}\n{\"$key\":\"Project\"}\n{\"$output\":[\"Generator name\",\"output-key\"]}\n{\"$lookup\":[\"tiers\",{\"$item\":[\"Project\",\"tier\"]}],\"path\":[\"delay\"]}\n{\"$list\":[{\"$item\":[\"Project\",\"owners\",\"1\"]},\"bob@example.com\"]}\n{\"$literal\":{\"custom\":\"object\"}}\n{\"$literal\":{\"$person\":\"alice@example.com\"}}\n{\"$literal\":\"text\",\"prefix\":\"A \",\"suffix\":\" B\"}",
|
||||
"ru": "Обычные скаляры и массивы — константы; массив со ссылками НЕ вычисляет их: нужен $list. Для объекта-константы нужен $literal. В объекте привязки ровно один оператор $. Необязательные prefix/suffix требуют текстовый результат. Необязательный path — массив точных ключей словаря или индексов списка от единицы, записанных строками. Точки, пробелы и слеши внутри сегмента — часть ключа. $var/$item/$key принимают имя или [имя,...путь]; если путь уже в массиве, отдельный path не добавляйте. Ключ $lookup должен давать текст. Маркер $person внутри общего $literal преобразует email в идентификатор человека текущего workspace.\n\n{\"$var\":\"projects\"}\n{\"$var\":[\"settings\",\"owners\",\"1\"]}\n{\"$item\":[\"Project\",\"name\"],\"prefix\":\"MG: \",\"suffix\":\" primary\"}\n{\"$key\":\"Project\"}\n{\"$output\":[\"Generator name\",\"output-key\"]}\n{\"$lookup\":[\"tiers\",{\"$item\":[\"Project\",\"tier\"]}],\"path\":[\"delay\"]}\n{\"$list\":[{\"$item\":[\"Project\",\"owners\",\"1\"]},\"bob@example.com\"]}\n{\"$literal\":{\"custom\":\"object\"}}\n{\"$literal\":{\"$person\":\"alice@example.com\"}}\n{\"$literal\":\"text\",\"prefix\":\"A \",\"suffix\":\" B\"}",
|
||||
"fr": "Les scalaires et tableaux ordinaires sont littéraux ; les références dans un tableau ne sont PAS évaluées : utilisez $list. Un objet littéral exige $literal. Chaque objet de liaison contient exactement un opérateur $. prefix/suffix facultatifs exigent un résultat texte. path est une liste de clés exactes ou d’indices de liste à partir de un écrits en chaînes. Points, espaces et barres obliques restent littéraux. $var/$item/$key acceptent un nom ou [nom,...chemin] ; ne doublez pas un chemin abrégé avec path. La clé de $lookup doit produire du texte. Un marqueur $person dans un $literal générique résout l’email vers l’identité de la personne du workspace.\n\n{\"$var\":\"projects\"}\n{\"$var\":[\"settings\",\"owners\",\"1\"]}\n{\"$item\":[\"Project\",\"name\"],\"prefix\":\"MG: \",\"suffix\":\" primary\"}\n{\"$key\":\"Project\"}\n{\"$output\":[\"Generator name\",\"output-key\"]}\n{\"$lookup\":[\"tiers\",{\"$item\":[\"Project\",\"tier\"]}],\"path\":[\"delay\"]}\n{\"$list\":[{\"$item\":[\"Project\",\"owners\",\"1\"]},\"bob@example.com\"]}\n{\"$literal\":{\"custom\":\"object\"}}\n{\"$literal\":{\"$person\":\"alice@example.com\"}}\n{\"$literal\":\"text\",\"prefix\":\"A \",\"suffix\":\" B\"}"
|
||||
},
|
||||
"transfer_guide_generators_title": {
|
||||
"en": "4. Generators, loops and filters",
|
||||
"ru": "4. Генераторы, циклы и фильтры",
|
||||
"fr": "4. Générateurs, boucles et filtres"
|
||||
},
|
||||
"transfer_guide_generators": {
|
||||
"en": "A generator needs name and outputs. enabled defaults to true. for is an ordered list of nested loops over lists/dictionaries; an empty list of loops generates once. Each loop needs a unique as alias and an in binding; later loops may use earlier aliases. when filters each row. equals/not_equals compare equal JSON types; contains means list membership or text substring; not_empty ignores the third operand (use null). false and 0 are nonempty. Output keys are unique within a generator and must remain stable. Define producers before consumers. $output may reference an earlier output in the same row; outputs of generators without loops are shared across generators. Iterated outputs cannot be referenced across generators.\n\n{name, enabled?:true, for?:[{as,in:B}], when?:[B,operation,B], outputs:[]}\nwhen operations: equals | not_equals | contains | not_empty\n\"when\": [{\"$item\":[\"Project\",\"enabled\"]},\"equals\",true]\n\"when\": [{\"$item\":[\"Project\",\"owners\"]},\"not_empty\",null]\nEvery output: {key, type, name:B, ...type-specific fields}",
|
||||
"ru": "Генератору нужны name и outputs. enabled по умолчанию true. for — упорядоченный список вложенных циклов по спискам/словарям; без циклов генератор выполняется один раз. Каждый цикл задаёт уникальное имя as и привязку in; следующие циклы могут ссылаться на предыдущие. when фильтрует каждую итерацию. equals/not_equals сравнивают одинаковые JSON-типы; contains проверяет наличие в списке или подстроку; not_empty игнорирует третий операнд (пишите null). false и 0 считаются непустыми. Ключи выходов уникальны внутри генератора; сохраняйте их при правках. Объявляйте источники перед потребителями. $output может ссылаться на предыдущий выход той же итерации; выходы генератора без циклов общие для других генераторов. Ссылаться на выходы чужих циклов нельзя.\n\n{name, enabled?:true, for?:[{as,in:B}], when?:[B,operation,B], outputs:[]}\nОперации when: equals | not_equals | contains | not_empty\n\"when\": [{\"$item\":[\"Project\",\"enabled\"]},\"equals\",true]\n\"when\": [{\"$item\":[\"Project\",\"owners\"]},\"not_empty\",null]\nЛюбой выход: {key, type, name:B, ...type-specific fields}",
|
||||
"fr": "Un générateur exige name et outputs. enabled vaut true par défaut. for est une liste ordonnée de boucles imbriquées sur listes/dictionnaires ; sans boucle, une seule exécution. Chaque boucle exige un alias as unique et une liaison in ; les suivantes peuvent utiliser les alias précédents. when filtre chaque ligne. equals/not_equals comparent des types JSON identiques ; contains teste l’appartenance ou une sous-chaîne ; not_empty ignore le troisième opérande (utilisez null). false et 0 sont non vides. Les clés de sortie sont uniques par générateur et doivent rester stables. Placez les producteurs avant les consommateurs. $output peut viser une sortie antérieure de la même ligne ; les sorties sans boucle sont partagées entre générateurs. Les sorties itérées ne traversent pas les générateurs.\n\n{name, enabled?:true, for?:[{as,in:B}], when?:[B,operation,B], outputs:[]}\nOpérations when : equals | not_equals | contains | not_empty\n\"when\": [{\"$item\":[\"Project\",\"enabled\"]},\"equals\",true]\n\"when\": [{\"$item\":[\"Project\",\"owners\"]},\"not_empty\",null]\nChaque sortie : {key, type, name:B, ...type-specific fields}"
|
||||
},
|
||||
"transfer_guide_schedules_title": {
|
||||
"en": "5. Schedule outputs",
|
||||
"ru": "5. Выходы-расписания",
|
||||
"fr": "5. Sorties planning"
|
||||
},
|
||||
"transfer_guide_schedules": {
|
||||
"en": "All listed data fields accept B; key/type are fixed strings. participants must resolve to an ordered list of 1–100 distinct people/emails. rotation continuously cycles through them from anchor; shift_days counts local calendar days (1–366). priority chooses the first available person, respecting absences/holidays, then fallback. It does not alternate primary/secondary people. fallback must reference a schedule. Literal references use an exact existing schedule name; generated references use $output. handover defaults to 11:00 in the workspace IANA zone; ambiguous/nonexistent local times are rejected. Anchor is an ISO date, years 2000–2100. Workspace holidays and availability are not part of this file.\n\nrotation: {key,type:\"rotation\",name:B,participants:B,anchor:B,shift_days?:7,handover?:\"11:00\"}\npriority: {key,type:\"priority\",name:B,participants:B,fallback:B,handover?:\"11:00\"}",
|
||||
"ru": "Все поля данных ниже принимают B; key/type — фиксированные строки. participants должен дать упорядоченный список 1–100 разных людей/email. rotation циклически меняет их от anchor; shift_days — число местных календарных дней (1–366). priority выбирает первого доступного человека с учётом отсутствий/праздников, затем fallback. Это не чередование primary/secondary. fallback ссылается на расписание. Константа-ссылка — точное имя существующего расписания; ссылка на генерируемое — $output. handover по умолчанию 11:00 в IANA-зоне workspace; неоднозначное или несуществующее местное время отклоняется. Anchor — ISO-дата, год 2000–2100. Праздники и доступность workspace в этот файл не входят.\n\nrotation: {key,type:\"rotation\",name:B,participants:B,anchor:B,shift_days?:7,handover?:\"11:00\"}\npriority: {key,type:\"priority\",name:B,participants:B,fallback:B,handover?:\"11:00\"}",
|
||||
"fr": "Tous les champs de données ci-dessous acceptent B ; key/type sont fixes. participants doit produire une liste ordonnée de 1 à 100 personnes/emails distincts. rotation les alterne depuis anchor ; shift_days compte les jours calendaires locaux (1–366). priority choisit la première personne disponible selon absences/jours fériés, puis fallback. Ce n’est pas une alternance primary/secondary. fallback référence un planning. Une référence littérale utilise le nom exact d’un planning existant ; une référence générée utilise $output. handover vaut 11:00 dans le fuseau IANA du workspace ; les heures ambiguës/inexistantes sont refusées. Anchor est une date ISO, année 2000–2100. Disponibilités et jours fériés ne sont pas transférés.\n\nrotation: {key,type:\"rotation\",name:B,participants:B,anchor:B,shift_days?:7,handover?:\"11:00\"}\npriority: {key,type:\"priority\",name:B,participants:B,fallback:B,handover?:\"11:00\"}"
|
||||
},
|
||||
"transfer_guide_escalations_title": {
|
||||
"en": "6. Escalation outputs",
|
||||
"ru": "6. Выходы-эскалации",
|
||||
"fr": "6. Sorties escalade"
|
||||
},
|
||||
"transfer_guide_escalations": {
|
||||
"en": "Steps notify a schedule, person, or every member of the provider team configured in the workspace. Exactly one recipient option per step. after is an integer delay in minutes AFTER ALERT CREATION, not after the previous step (0–10080, default 0). if defaults to not-acked; not-closed is also supported. after, schedule and person accept B; team and if are fixed strings. There are 1–20 steps. repeat_count is 0–20 (default 0, disabled); repeat_wait is 0–10080 minutes (default 0). Both repeat fields accept B. Providers execute notification timers; Meguri only publishes configuration. Unsupported provider capabilities block publication.\n\n{key,type:\"escalation\",name:B,repeat_count?:0,repeat_wait?:0,steps:[]}\nstep: {after?:0,if?:\"not-acked\",schedule:B}\n or {after?:0,if?:\"not-acked\",person:B}\n or {after?:0,if?:\"not-acked\",team:\"all\"|\"random\"}\n\"steps\": [{\"schedule\":{\"$output\":[\"Owners\",\"primary\"]}},{\"after\":30,\"team\":\"all\"}]\n\nRandom selects one member of the configured provider team when the provider executes the step; it does not call each colleague in turn.\n{\"after\":30,\"team\":\"random\"}",
|
||||
"ru": "Шаги уведомляют расписание, человека или всех участников команды провайдера, заданной в workspace. В шаге ровно один вариант получателя. after — целое число минут ПОСЛЕ СОЗДАНИЯ АЛЕРТА, не после предыдущего шага (0–10080, по умолчанию 0). if по умолчанию not-acked; также поддерживается not-closed. after, schedule и person принимают B; team и if — фиксированные строки. Шагов 1–20. repeat_count — 0–20 (по умолчанию 0, повторы выключены); repeat_wait — 0–10080 минут (по умолчанию 0). Оба поля повторения принимают B. Таймеры уведомлений выполняет провайдер; Meguri публикует конфигурацию. Неподдерживаемые возможности провайдера блокируют публикацию.\n\n{key,type:\"escalation\",name:B,repeat_count?:0,repeat_wait?:0,steps:[]}\nШаг: {after?:0,if?:\"not-acked\",schedule:B}\n или {after?:0,if?:\"not-acked\",person:B}\n или {after?:0,if?:\"not-acked\",team:\"all\"|\"random\"}\n\"steps\": [{\"schedule\":{\"$output\":[\"Owners\",\"primary\"]}},{\"after\":30,\"team\":\"all\"}]\n\nRandom выбирает одного участника команды при выполнении шага провайдером; это не последовательный обзвон коллег.\n{\"after\":30,\"team\":\"random\"}",
|
||||
"fr": "Les étapes notifient un planning, une personne ou toute l’équipe fournisseur configurée dans le workspace. Une seule option de destinataire par étape. after est un entier en minutes APRÈS LA CRÉATION DE L’ALERTE, non après l’étape précédente (0–10080, défaut 0). if vaut not-acked par défaut ; not-closed est aussi accepté. after, schedule et person acceptent B ; team et if sont fixes. De 1 à 20 étapes. repeat_count : 0–20 (défaut 0, désactivé) ; repeat_wait : 0–10080 minutes (défaut 0). Les deux acceptent B. Le fournisseur exécute les temporisations ; Meguri publie la configuration. Une capacité non prise en charge bloque la publication.\n\n{key,type:\"escalation\",name:B,repeat_count?:0,repeat_wait?:0,steps:[]}\nÉtape : {after?:0,if?:\"not-acked\",schedule:B}\n ou {after?:0,if?:\"not-acked\",person:B}\n ou {after?:0,if?:\"not-acked\",team:\"all\"|\"random\"}\n\"steps\": [{\"schedule\":{\"$output\":[\"Owners\",\"primary\"]}},{\"after\":30,\"team\":\"all\"}]\n\nRandom sélectionne un membre de l’équipe lors de l’exécution par le fournisseur ; il ne contacte pas les collègues à tour de rôle.\n{\"after\":30,\"team\":\"random\"}"
|
||||
},
|
||||
"transfer_guide_routes_title": {
|
||||
"en": "7. Routing outputs",
|
||||
"ru": "7. Выходы-маршруты",
|
||||
"fr": "7. Sorties routage"
|
||||
},
|
||||
"transfer_guide_routes": {
|
||||
"en": "escalation is optional: omission creates a route with no escalation destination (no notification). working accepts B resolving to boolean; true uses the workspace working-days/hours/timezone, which cannot be changed in this file. Conditions are ANDed; an empty list is a catch-all. Conditions contain a literal field and operation, a B that resolves to nonempty text (1–500 characters), and optionally the literal \"not\". matches is a regular expression; escape backslashes in JSON. Routing comparison semantics are those of the provider (P1 is highest alert priority). Routing order matters: first matching rule wins; put specific routes before catch-alls.\n\n{key,type:\"route\",name:B,escalation?:B,working?:false,conditions?:[]}\ncondition: [field,operation,B] or [field,operation,B,\"not\"]\nfields: tags | priority | message | description | source | alias\noperations: matches | contains | equals | greater-than | less-than | starts-with | ends-with\n\"conditions\": [[\"tags\",\"contains\",{\"$item\":[\"Project\",\"tag\"]}]]",
|
||||
"ru": "escalation необязателен: если его нет, маршрут не направляет в эскалацию (без уведомления). working принимает B с булевым результатом; true использует рабочие дни/часы/зону workspace, которые этим файлом не меняются. Условия объединяются через И; пустой список — маршрут для всех алертов. Условие содержит фиксированные field и operation, B с непустым текстом (1–500 символов) и необязательную строку \"not\". matches — регулярное выражение; экранируйте обратные слеши в JSON. Семантику сравнений задаёт провайдер (P1 — высший приоритет алерта). Порядок важен: срабатывает первый подходящий маршрут; частные правила ставьте перед общими.\n\n{key,type:\"route\",name:B,escalation?:B,working?:false,conditions?:[]}\nУсловие: [field,operation,B] или [field,operation,B,\"not\"]\nПоля: tags | priority | message | description | source | alias\nОперации: matches | contains | equals | greater-than | less-than | starts-with | ends-with\n\"conditions\": [[\"tags\",\"contains\",{\"$item\":[\"Project\",\"tag\"]}]]",
|
||||
"fr": "escalation est facultatif : son omission crée une route sans destination d’escalade (sans notification). working accepte B produisant un booléen ; true utilise jours/heures/fuseau de travail du workspace, non modifiables ici. Les conditions sont combinées par ET ; une liste vide est une règle générale. Chaque condition contient field et operation littéraux, B produisant du texte non vide (1–500 caractères), puis éventuellement \"not\". matches est une expression régulière ; échappez les antislashs en JSON. Les comparaisons suivent le fournisseur (P1 est la priorité maximale). La première route correspondante gagne ; placez les règles spécifiques avant les générales.\n\n{key,type:\"route\",name:B,escalation?:B,working?:false,conditions?:[]}\nCondition : [field,operation,B] ou [field,operation,B,\"not\"]\nChamps : tags | priority | message | description | source | alias\nOpérations : matches | contains | equals | greater-than | less-than | starts-with | ends-with\n\"conditions\": [[\"tags\",\"contains\",{\"$item\":[\"Project\",\"tag\"]}]]"
|
||||
},
|
||||
"transfer_guide_merge_title": {
|
||||
"en": "8. Import behavior and limits",
|
||||
"ru": "8. Поведение импорта и ограничения",
|
||||
"fr": "8. Import et limites"
|
||||
},
|
||||
"transfer_guide_merge": {
|
||||
"en": "Merge matches variable/generator names case-insensitively after trimming: update matches, append new names, retain omitted names. An included generator replaces its entire definition, so removed outputs can remove resources. Existing generator order and routing insertion position remain unchanged; new generators append. Keep output keys stable. Dictionary fields and uniquely named/equal list entries retain identity; ambiguous combined renames/reorders can change resource identity—review creates/removes. Manual resources resolve by exact name and are never imported. Preview shows errors, input/resource changes, contacts and cancellations. Apply saves the shared draft; Sync separately publishes. Other edits invalidate a preview. An LLM should return ONLY a complete v2 JSON document, with real supplied emails/names and no invented options; ask for missing required data.\n\nLimits: JSON 1,000,000 bytes; 100 variables; 10,000 value nodes; nesting depth 8; 1,000 entries/collection.\n50 generators; 4 loops and 20 outputs/generator; 500 expanded rows/generator.\nWorkspace totals: 200 schedules, 200 escalations, 500 routes; up to 900 generated resources.\nNames: variable 1–80 characters; generator/resource 1–120; output key 1–120; dictionary key 1–120.\nText variable ≤2000 characters. Routing conditions ≤20/route. Binding nesting ≤16.\nNumbers must be finite; durations nonnegative; day counts, delays and repeat values are integers.\nDates: YYYY-MM-DD. Times: HH:MM or HH:MM:SS. Scheduling uses the workspace IANA timezone.",
|
||||
"ru": "Сопоставление имён переменных/генераторов — без учёта регистра и крайних пробелов: совпадающие обновляются, новые добавляются, отсутствующие остаются. Включённый генератор заменяется целиком, поэтому удалённые выходы могут удалить ресурсы. Порядок существующих генераторов и позиция вставки маршрутов не меняются; новые генераторы добавляются в конец. Сохраняйте ключи выходов. Поля словаря и однозначно именованные/совпадающие элементы списка сохраняют идентичность; неоднозначные переименования с перестановкой могут её изменить — проверяйте создания/удаления. Ручные ресурсы разрешаются по точному имени и не импортируются. Предпросмотр показывает ошибки, изменения, контакты и отмены запросов. Apply сохраняет общий черновик; Sync отдельно публикует. Чужие правки делают предпросмотр устаревшим. LLM должна вернуть ТОЛЬКО полный JSON v2, с предоставленными реальными email/именами, без выдуманных опций; недостающие обязательные данные нужно запросить.\n\nЛимиты: JSON 1 000 000 байт; 100 переменных; 10 000 узлов значений; глубина 8; 1000 элементов в коллекции.\n50 генераторов; 4 цикла и 20 выходов на генератор; 500 развёрнутых итераций на генератор.\nВсего в workspace: 200 расписаний, 200 эскалаций, 500 маршрутов; до 900 генерируемых ресурсов.\nИмена: переменная 1–80 символов; генератор/ресурс 1–120; ключ выхода 1–120; ключ словаря 1–120.\nТекст переменной ≤2000 символов. Условия маршрутизации ≤20 на маршрут. Вложенность привязок ≤16.\nЧисла конечные; длительности неотрицательные; дни, задержки и параметры повторения — целые числа.\nДаты: YYYY-MM-DD. Время: HH:MM или HH:MM:SS. Расчёт использует IANA-зону workspace.",
|
||||
"fr": "Fusion par noms de variables/générateurs sans casse ni espaces périphériques : mise à jour, ajout des nouveaux noms, conservation des absents. Un générateur inclus remplace toute sa définition ; supprimer des sorties peut supprimer des ressources. Ordre des générateurs existants et position d’insertion des routes conservés ; nouveaux générateurs ajoutés à la fin. Gardez les clés de sortie. Champs de dictionnaire et éléments de liste identifiables conservent leur identité ; renommages/réordonnancements ambigus peuvent la modifier : vérifiez créations/suppressions. Les ressources manuelles sont résolues par nom exact, jamais importées. La prévisualisation montre erreurs, changements, contacts et annulations. Apply enregistre le brouillon partagé ; Sync publie séparément. Les autres modifications invalident la revue. Un LLM doit renvoyer UNIQUEMENT un document JSON v2 complet avec les emails/noms fournis, sans options inventées, et demander les données obligatoires manquantes.\n\nLimites : JSON 1 000 000 octets ; 100 variables ; 10 000 nœuds ; profondeur 8 ; 1000 éléments par collection.\n50 générateurs ; 4 boucles et 20 sorties par générateur ; 500 lignes développées par générateur.\nTotaux du workspace : 200 plannings, 200 escalades, 500 routes ; jusqu’à 900 ressources générées.\nNoms : variable 1–80 caractères ; générateur/ressource 1–120 ; clé de sortie 1–120 ; clé de dictionnaire 1–120.\nTexte de variable ≤2000 caractères. Conditions de routage ≤20 par route. Imbrication des liaisons ≤16.\nNombres finis ; durées non négatives ; jours, délais et paramètres de répétition entiers.\nDates : YYYY-MM-DD. Heures : HH:MM ou HH:MM:SS. Calcul dans le fuseau IANA du workspace."
|
||||
},
|
||||
"transfer_guide_example_title": {
|
||||
"en": "9. Complete importable example",
|
||||
"ru": "9. Полный пример для импорта",
|
||||
"fr": "9. Exemple complet importable"
|
||||
},
|
||||
"transfer_guide_example": {
|
||||
"en": "{\n \"format\": \"meguri/v2\",\n \"variables\": {\n \"on_call\": [\"alice@example.com\", \"bob@example.com\"],\n \"projects\": [{\"name\":\"alpha\",\"owners\":[\"alice@example.com\",\"bob@example.com\"],\"tag\":\"project:alpha\"}]\n },\n \"generators\": [\n {\"name\":\"General\",\"outputs\":[\n {\"key\":\"weekly\",\"type\":\"rotation\",\"name\":\"General on-call\",\"participants\":{\"$var\":\"on_call\"},\"anchor\":\"2026-09-28\",\"shift_days\":7,\"handover\":\"11:00\"}\n ]},\n {\"name\":\"Owners\",\"for\":[{\"as\":\"Project\",\"in\":{\"$var\":\"projects\"}}],\"outputs\":[\n {\"key\":\"primary\",\"type\":\"priority\",\"name\":{\"$item\":[\"Project\",\"name\"],\"suffix\":\" owners\"},\"participants\":{\"$item\":[\"Project\",\"owners\"]},\"fallback\":{\"$output\":[\"General\",\"weekly\"]}},\n {\"key\":\"escalation\",\"type\":\"escalation\",\"name\":{\"$item\":[\"Project\",\"name\"],\"suffix\":\" escalation\"},\"steps\":[\n {\"schedule\":{\"$output\":[\"Owners\",\"primary\"]}},\n {\"after\":30,\"team\":\"all\"}\n ]},\n {\"key\":\"route\",\"type\":\"route\",\"name\":{\"$item\":[\"Project\",\"name\"],\"suffix\":\" route\"},\"escalation\":{\"$output\":[\"Owners\",\"escalation\"]},\"working\":true,\"conditions\":[[\"tags\",\"contains\",{\"$item\":[\"Project\",\"tag\"]}]]}\n ]}\n ]\n}",
|
||||
"ru": "{\n \"format\": \"meguri/v2\",\n \"variables\": {\n \"on_call\": [\"alice@example.com\", \"bob@example.com\"],\n \"projects\": [{\"name\":\"alpha\",\"owners\":[\"alice@example.com\",\"bob@example.com\"],\"tag\":\"project:alpha\"}]\n },\n \"generators\": [\n {\"name\":\"General\",\"outputs\":[\n {\"key\":\"weekly\",\"type\":\"rotation\",\"name\":\"General on-call\",\"participants\":{\"$var\":\"on_call\"},\"anchor\":\"2026-09-28\",\"shift_days\":7,\"handover\":\"11:00\"}\n ]},\n {\"name\":\"Owners\",\"for\":[{\"as\":\"Project\",\"in\":{\"$var\":\"projects\"}}],\"outputs\":[\n {\"key\":\"primary\",\"type\":\"priority\",\"name\":{\"$item\":[\"Project\",\"name\"],\"suffix\":\" owners\"},\"participants\":{\"$item\":[\"Project\",\"owners\"]},\"fallback\":{\"$output\":[\"General\",\"weekly\"]}},\n {\"key\":\"escalation\",\"type\":\"escalation\",\"name\":{\"$item\":[\"Project\",\"name\"],\"suffix\":\" escalation\"},\"steps\":[\n {\"schedule\":{\"$output\":[\"Owners\",\"primary\"]}},\n {\"after\":30,\"team\":\"all\"}\n ]},\n {\"key\":\"route\",\"type\":\"route\",\"name\":{\"$item\":[\"Project\",\"name\"],\"suffix\":\" route\"},\"escalation\":{\"$output\":[\"Owners\",\"escalation\"]},\"working\":true,\"conditions\":[[\"tags\",\"contains\",{\"$item\":[\"Project\",\"tag\"]}]]}\n ]}\n ]\n}",
|
||||
"fr": "{\n \"format\": \"meguri/v2\",\n \"variables\": {\n \"on_call\": [\"alice@example.com\", \"bob@example.com\"],\n \"projects\": [{\"name\":\"alpha\",\"owners\":[\"alice@example.com\",\"bob@example.com\"],\"tag\":\"project:alpha\"}]\n },\n \"generators\": [\n {\"name\":\"General\",\"outputs\":[\n {\"key\":\"weekly\",\"type\":\"rotation\",\"name\":\"General on-call\",\"participants\":{\"$var\":\"on_call\"},\"anchor\":\"2026-09-28\",\"shift_days\":7,\"handover\":\"11:00\"}\n ]},\n {\"name\":\"Owners\",\"for\":[{\"as\":\"Project\",\"in\":{\"$var\":\"projects\"}}],\"outputs\":[\n {\"key\":\"primary\",\"type\":\"priority\",\"name\":{\"$item\":[\"Project\",\"name\"],\"suffix\":\" owners\"},\"participants\":{\"$item\":[\"Project\",\"owners\"]},\"fallback\":{\"$output\":[\"General\",\"weekly\"]}},\n {\"key\":\"escalation\",\"type\":\"escalation\",\"name\":{\"$item\":[\"Project\",\"name\"],\"suffix\":\" escalation\"},\"steps\":[\n {\"schedule\":{\"$output\":[\"Owners\",\"primary\"]}},\n {\"after\":30,\"team\":\"all\"}\n ]},\n {\"key\":\"route\",\"type\":\"route\",\"name\":{\"$item\":[\"Project\",\"name\"],\"suffix\":\" route\"},\"escalation\":{\"$output\":[\"Owners\",\"escalation\"]},\"working\":true,\"conditions\":[[\"tags\",\"contains\",{\"$item\":[\"Project\",\"tag\"]}]]}\n ]}\n ]\n}"
|
||||
},
|
||||
"generation_recipient_conflict": {
|
||||
"en": "Choose exactly one escalation recipient type.",
|
||||
"ru": "Выберите ровно один тип получателя эскалации.",
|
||||
"fr": "Choisissez un seul type de destinataire d’escalade."
|
||||
},
|
||||
"enum_site.workspace_updated": {
|
||||
"en": "Workspace updated by site administrator",
|
||||
"ru": "Workspace изменён администратором сайта",
|
||||
"fr": "Espace modifié par l’administrateur du site"
|
||||
},
|
||||
"replacement_account_access": {
|
||||
"en": "Participants with login accounts need workspace access to approve.",
|
||||
"ru": "Участникам с аккаунтом нужен доступ к workspace для подтверждения.",
|
||||
"fr": "Les participants disposant d’un compte doivent avoir accès à l’espace pour approuver."
|
||||
},
|
||||
"account_email": {
|
||||
"en": "Email address",
|
||||
"ru": "Электронная почта",
|
||||
"fr": "Adresse e-mail"
|
||||
},
|
||||
"account_identity_taken": {
|
||||
"en": "This username or email address is already registered.",
|
||||
"ru": "Это имя пользователя или почта уже зарегистрированы.",
|
||||
"fr": "Ce nom d’utilisateur ou cette adresse e-mail est déjà enregistré."
|
||||
},
|
||||
"account_person_authoritative": {
|
||||
"en": "This person belongs to an account. Identity and profile details are managed in account settings.",
|
||||
"ru": "Этот человек связан с аккаунтом. Его данные управляются в настройках аккаунта.",
|
||||
"fr": "Cette personne appartient à un compte. Ses informations sont gérées dans les paramètres du compte."
|
||||
},
|
||||
"enum_person.profile_changed": {
|
||||
"en": "Person profile updated",
|
||||
"ru": "Профиль человека обновлён",
|
||||
"fr": "Profil de la personne mis à jour"
|
||||
},
|
||||
"enum_account.people_migrated": {
|
||||
"en": "Account people migrated",
|
||||
"ru": "Данные людей связаны с аккаунтами",
|
||||
"fr": "Profils des comptes migrés"
|
||||
},
|
||||
"enum_account.contacts_linked": {
|
||||
"en": "Planning contacts linked to accounts",
|
||||
"ru": "Контакты планирования связаны с аккаунтами",
|
||||
"fr": "Contacts de planification associés aux comptes"
|
||||
},
|
||||
"symbol_brace_open": {
|
||||
"en": "{",
|
||||
"ru": "{",
|
||||
"fr": "{"
|
||||
},
|
||||
"symbol_brace_close": {
|
||||
"en": "}",
|
||||
"ru": "}",
|
||||
"fr": "}"
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user