Files
meguri/static/generation.js
T

249 lines
31 KiB
JavaScript
Raw Normal View History

2026-09-24 14:35:05 +03:00
// Visual data and binding editors. Only typed data is sent to the server; no user code is evaluated.
function generationUi(){return {
...generationMapUi(),
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:''};},
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};
this.generationResult=null;this.generationAll=false;
}
if(this.can('edit_schedules'))this.generationChanged();
},
generationPayload(){
if(!this.generationDraft)return null;
const payload=copy(this.generationDraft);
if(this.modal==='variable'){const at=payload.variables.findIndex(v=>v.id===this.form.id);at<0?payload.variables.push(copy(this.form)):payload.variables.splice(at,1,copy(this.form));}
if(this.modal==='generation-rule'){const at=payload.rules.findIndex(v=>v.id===this.form.id);at<0?payload.rules.push(copy(this.form)):payload.rules.splice(at,1,copy(this.form));}
const normalize=value=>{if(!value || typeof value!=='object')return;for(const [key,child] of Object.entries(value)){if(['variable','iteration','output'].includes(key)&&child==='')value[key]=null;else normalize(child);}};normalize(payload);return payload;
},
generationChanged(){this.generationSequence++;this.generationResult=null;this.generationError='';this.generationLoading=true;clearTimeout(this.generationTimer);this.generationTimer=setTimeout(()=>this.previewGeneration(),300);},
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)};}
catch(e){if(sequence===this.generationSequence)this.generationError=e.message;}
finally{if(sequence===this.generationSequence)this.generationLoading=false;}
},
validationState(scope='form'){
let loading=false,checked=false,issues=[];
const messages=items=>(items||[]).map(message=>({message}));
if(scope==='generation' || ['variable','generation-rule'].includes(this.modal)){loading=this.generationLoading;checked=!!this.generationResult;issues=[...(this.generationResult?.issues||[]),...messages(this.generationError?[this.generationError]:[])];}
else if(this.modal==='absence'){loading=this.absenceLoading;checked=!!this.absenceResult;issues=messages([...(this.absenceResult?.plan.errors||[]),...(this.absenceError?[this.absenceError]:[])]);}
else if(this.modal==='holiday-import'){loading=this.csvLoading;checked=!!this.csvResult;issues=messages([...(this.csvResult?.preview.errors||[]),...(this.csvError?[this.csvError]:[])]);}
else if(this.modal==='request'){loading=this.requestLoading;checked=!!this.requestResult;issues=messages(this.requestError?[this.requestError]:[]);}
if(scope==='form' && this.error && !issues.some(i=>i.message===this.error))issues.push({message:this.error});
return {loading,checked,issues,label:loading?tr('cover_checking'):issues.length?tr('diagnostics_count',{count:issues.length}):checked?tr('generation_checked'):tr('validation_not_checked')};
},
issueActions(issue){
const payload=this.generationPayload(),actions=[];if(!payload)return actions;
const rule=payload.rules.find(r=>r.id===issue.rule_id);
if(rule)actions.push({kind:'rule',id:rule.id,block:issue.block_id,name:rule.name});
const variables=new Set(issue.variable_id?[issue.variable_id]:[]);
const scan=value=>{if(!value || typeof value!=='object')return;if(value.variable)variables.add(value.variable);Object.values(value).forEach(scan);};
if(rule){scan(rule.blocks.find(b=>b.id===issue.block_id));scan(rule.iterations);}
for(const id of variables){const variable=payload.variables.find(v=>v.id===id);if(variable)actions.push({kind:'variable',id,name:variable.name});}
return actions;
},
async openIssue(action){
// Switch editors only after unmounting the old form; preserve the local draft first.
const app=Alpine.$data(document.body);
app.generationDraft=app.generationPayload();
const item=(action.kind==='rule'?app.generationDraft.rules:app.generationDraft.variables).find(v=>v.id===action.id);
if(!item)return;
app.modal='';app.bindingEditor=null;app.rulePeopleTarget=null;
await app.$nextTick();
action.kind==='rule'?app.editGenerationRule(item):app.editVariable(item);
await app.$nextTick();
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||[];},
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'));},
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');},
ruleOverview(rule){
const parts=[tr(rule.enabled?'enabled_df174a':'disabled_f4f447')];
if(rule.iterations.length)parts.push(tr('rule_iterations_short',{count:rule.iterations.length})+' ('+rule.iterations.map(i=>'{ '+this.bindingAddress(i.source,0,rule.iterations)+' }').join(', ')+')');
const kinds=new Map();for(const block of rule.blocks)kinds.set(block.kind,(kinds.get(block.kind)||0)+1);
parts.push(tr('rule_blocks_short',{count:rule.blocks.length})+' ('+[...kinds].map(([kind,count])=>(['rotation','priority'].includes(kind)?tr('schedule_type_named',{name:this.label(kind)}):this.label(kind))+(count>1?' ×'+count:'')).join(', ')+')');
return parts.join(' · ');
},
resourceIcon(kind){
return ({schedule:'M5 5h14v16H5z M8 3v4 M16 3v4 M5 10h14 M8 14h2 M14 14h2 M8 17h2',rotation:'M20 7v5h-5 M4 17v-5h5 M6 7a7 7 0 0 1 12-1l2 3 M4 15l2 3a7 7 0 0 0 12-1',person:'M16 7a4 4 0 1 1-8 0 4 4 0 0 1 8 0 M4 21v-2a8 8 0 0 1 16 0v2',clock:'M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0 M12 7v5l3 2',fallback:'M5 4v9a4 4 0 0 0 4 4h10 M15 13l4 4-4 4',escalation:'M6 20v-5h6v-5h6V4 M14 8l4-4 4 4',route:'M5 4v12a4 4 0 0 0 4 4h10 M5 8h14 M15 4l4 4-4 4 M15 16l4 4-4 4',condition:'M4 6h16 M7 12h10 M10 18h4',end:'M5 12h14 M12 5l7 7-7 7'})[kind] || 'M12 4v16 M4 12h16';
},
resourceBrief(resource,kind,document=this.doc){
const person=id=>document.people?.find(p=>p.id===id)?.display_name || this.person(id);
const scheduleName=id=>document.schedules.find(s=>s.id===id)?.name || this.scheduleName(id);
const recipient=r=>r.type==='schedule'?scheduleName(r.id):r.type==='user'?person(r.id):this.describeRecipient(r);
if(kind==='schedule'){
const people=resource.participants.slice(0,3).map(id=>person(id)).join(', ')+(resource.participants.length>3?'…':'');
return resource.type==='rotation'?tr('rotation_brief',{days:resource.shift_days,time:resource.handover.slice(0,5),zone:document.timezone})+' · '+people:this.label(resource.type)+' · '+people+' · '+tr('fallback_brief',{name:scheduleName(resource.fallback)});
}
if(kind==='escalation')return resource.steps.map(step=>tr('escalation_step_brief',{minutes:step.delay_minutes,recipient:recipient(step.recipient)})).join(' → ');
const conditions=resource.conditions.length?resource.conditions.map(c=>this.conditionText(c)).join(' · '):tr('any_alert_4496c3');
return conditions+(resource.window?' · '+tr('workspace_working_hours_b8ec6f'):'')+' → '+(document.escalations.find(p=>p.id===resource.escalation_id)?.name || tr('do_not_notify_1a59a6'));
},
editGenerated(id){this.view='configuration';this.loadGenerationDraft();const owner=this.doc.generation.managed.find(m=>m.id===id);const rule=this.generationDraft.rules.find(r=>r.id===owner?.rule);if(rule)this.editGenerationRule(rule);},
editVariable(variable=null){this.loadGenerationDraft();this.open('variable',variable || {id:crypto.randomUUID(),name:'',node:this.newNode('list')});this.bindingEditor=null;this.bulkNode=null;this.dataAdvanced=!variable;this.dataExpanded={[this.form.node.id]:true};this.dataPersonTarget=null;this.generationChanged();},
simpleData(node){return node.kind==='list' && node.entries.every(n=>['text','person'].includes(n.kind)) || ['dictionary'].includes(node.kind) && node.entries.every(n=>n.kind==='list' && n.entries.every(p=>p.kind==='person'));},
dataPersonName(value){return this.people.find(p=>p.id===value)?.display_name || value || tr('choose_person_f20051');},
editDataPeople(node,entry=null){this.dataPersonTarget=node;this.dataPersonEntry=entry;this.dataPersonQuery='';},
addDataPerson(value){if(!value.trim())return;if(this.dataPersonEntry){this.dataPersonEntry.value=value.trim();this.dataPersonTarget=null;this.dataPersonEntry=null;}else this.dataPersonTarget.entries.push(this.newNode('person',value.trim()));this.dataPersonQuery='';},
addDataGroup(){this.form.node.entries.push(this.newNode('list',null,''));},
editGenerationRule(rule=null){this.loadGenerationDraft();this.open('generation-rule',rule || {id:crypto.randomUUID(),name:'',enabled:true,iterations:[],when:null,blocks:[]});for(const block of this.form.blocks)if(block.kind==='escalation')for(const key of ['repeat_count','repeat_wait'])if(!Object.hasOwn(block.fields,key))block.fields[key]=this.literal(0);this.bindingEditor=null;this.ruleAdvanced=false;this.rulePeopleTarget=null;this.generationIssuesOpen=false;this.generationChanged();},
manualResources(type){return (type==='schedule'?this.doc.schedules:this.doc.escalations).filter(resource=>!this.generated(resource.id));},
defaultScheduleSource(){const output=this.matchingOutputs('schedule')[0];return output?{kind:'output',output:output.id,path:[]}:this.literal(this.manualResources('schedule')[0]?.id||'');},
sourceOptions(type){const options=[];for(const v of this.generationDraft.variables){if(type==='list' && ['dictionary'].includes(v.node.kind)){for(const i of this.form.iterations)options.push({id:'lookup:'+v.id+':'+i.id,name:tr('owners_by_project',{name:v.name}),kind:'lookup',variable:v.id,iteration:i.id});}if(type==='collection'?['list','dictionary'].includes(v.node.kind):type==='list'?v.node.kind==='list':type==='person'?v.node.kind==='person':type==='number'?['number','duration'].includes(v.node.kind):v.node.kind===type)options.push({id:'variable:'+v.id,name:v.name,kind:'variable',variable:v.id});}if(type==='text')for(const i of this.form.iterations)options.push({id:'item:'+i.id,name:tr('each_project_name',{name:i.name}),kind:'item',iteration:i.id});if(['schedule','policy'].includes(type)){for(const b of this.matchingOutputs(type))options.push({id:'output:'+b.id,name:this.generatedResourceLabel(b)+' · '+this.bindingSummary(b.fields.name),kind:'output',output:b.id});for(const r of this.manualResources(type))options.push({id:'saved:'+r.id,name:tr(type==='schedule'?'source_saved_resource':'source_saved_policy',{name:r.name}),kind:'literal',value:r.id});}return options.map(option=>{if(['variable','item','key','lookup'].includes(option.kind)){const binding={...option};if(option.kind==='lookup'){const loop=this.form.iterations.find(i=>i.id===option.iteration),variable=this.generationDraft.variables.find(v=>v.id===loop?.source.variable);binding.key={kind:variable?.node.kind==='list'?'item':'key',iteration:option.iteration};}option.name=this.bindingSummary(binding);}return option;});},
simpleSource(binding,type){if(!binding)return '';if(binding.path?.length)return 'advanced';const option=this.sourceOptions(type).find(o=>o.kind===binding.kind && (o.kind==='lookup'?binding.variable===o.variable && binding.key?.iteration===o.iteration && !binding.key.path?.length:o.kind==='literal'?o.value===binding.value:o.kind==='variable'?o.variable===binding.variable:o.kind==='output'?o.output===binding.output:o.iteration===binding.iteration));return option?.id || (binding.kind==='literal'?'literal':'advanced');},
selectSimpleSource(binding,id,type){if(id==='advanced'){this.editBinding(binding,type,tr('value_source_c0ae15'));return;}const option=this.sourceOptions(type).find(o=>o.id===id);let next=option?{kind:option.kind,path:[],...(option.variable?{variable:option.variable}:{}),...(option.output?{output:option.output}:{}),...(option.iteration && option.kind!=='lookup'?{iteration:option.iteration}:{}),...(option.kind==='literal'?{value:option.value}:{})}:this.literal(type==='list'?[]:type==='number'?0:type==='boolean'?false:'');if(option?.kind==='lookup'){const loop=this.form.iterations.find(i=>i.id===option.iteration);const variable=this.generationDraft.variables.find(v=>v.id===loop?.source.variable);next.key={kind:variable?.node.kind==='list'?'item':'key',iteration:option.iteration,path:[]};}for(const key of Object.keys(binding))delete binding[key];Object.assign(binding,next);},
addRulePerson(value){if(!value || !this.rulePeopleTarget)return;const list=this.rulePeopleTarget.value;if(!list.includes(value))list.push(value);this.rulePeopleQuery='';},
sampleNode(node){if(node.kind==='list')return node.entries.map(n=>this.sampleNode(n));if(['dictionary'].includes(node.kind))return Object.fromEntries(node.entries.map(n=>[n.key,this.sampleNode(n)]));return node.value;},
sampleBinding(binding,context){if(!binding)return '';const variable=id=>{const v=this.generationDraft.variables.find(v=>v.id===id);return v?this.sampleNode(v.node):undefined;};let value;if(binding.kind==='list')value=binding.items.map(item=>this.sampleBinding(item,context));else if(binding.kind==='literal')value=binding.value;else if(binding.kind==='variable')value=variable(binding.variable);else if(binding.kind==='item')value=context[binding.iteration]?.value;else if(binding.kind==='key')value=context[binding.iteration]?.key;else if(binding.kind==='lookup')value=variable(binding.variable)?.[this.sampleBinding(binding.key,context)];else if(binding.kind==='output'){const block=this.matchingOutputs('all').find(b=>b.id===binding.output);return block?this.sampleBinding(block.fields.name,context):'';}for(const key of binding.path||[])value=value?.[Array.isArray(value)?Number(key)-1:key];if(value===undefined)throw new Error(tr('sample_missing'));return binding.prefix || binding.suffix?(binding.prefix||'')+value+(binding.suffix||''):value;},
// Presentation only: build a scoped reference index once, then walk nested values.
previewValue(value){
const references=new Map();
const formatters={
person:p=>({type:'person',name:p.display_name,...(p.username?{username:p.username}:{}),...(p.email?{email:p.email}:{}),id:p.id}),
schedule:s=>({type:'schedule',name:s.name,kind:s.type,id:s.id}),
escalation:p=>({type:'escalation',name:p.name,id:p.id}),
route:r=>({type:'route',name:r.name,id:r.id}),
};
for(const [kind,items] of [['person',this.people],['schedule',this.doc?.schedules||[]],['escalation',this.doc?.escalations||[]],['route',this.doc?.routes||[]]]){
for(const item of items){const brief=formatters[kind](item);references.set(item.id,brief);if(kind==='person' && item.email)references.set(item.email,brief);}
}
const visit=(item,depth=0)=>{
if(depth>32)return '…';
if(typeof item==='string')return references.get(item)||item;
if(Array.isArray(item))return item.map(child=>visit(child,depth+1));
if(item && typeof item==='object')return Object.fromEntries(Object.entries(item).map(([key,child])=>[key,visit(child,depth+1)]));
return item;
};
return visit(value);
},
bindingValuePreview(){
const binding=this.bindingEditor?.binding;
if(!binding)return {text:'',context:'',error:''};
try{
const context={},names=[];
// Preview one row only; unrelated incomplete loops must not hide literal values.
for(const iteration of this.form.iterations||[]){
try{const source=this.sampleBinding(iteration.source,context);if(!source || typeof source!=='object')break;const entry=Object.entries(source)[0];if(!entry)break;const [key,value]=entry;context[iteration.id]={key:Array.isArray(source)?'':key,value};names.push(iteration.name+': '+(typeof value==='string'?value:key));}catch{break;}
}
let value;
if(binding.kind==='output'){
const block=this.matchingOutputs('all').find(b=>b.id===binding.output);
if(!block)throw new Error(tr('sample_missing'));
value={type:block.kind,name:this.sampleBinding(block.fields.name,context),block_id:block.id};
}else value=this.sampleBinding(binding,context);
if(value===undefined)throw new Error(tr('sample_missing'));
const text=JSON.stringify(this.previewValue(value),null,2);
return {text:text.length>20000?text.slice(0,20000)+'\n…':text,context:binding.kind==='output'?tr('binding_preview_resource'):names.length?tr('binding_preview_row',{row:names.join(' · ')}):tr('binding_preview_hint'),error:''};
}catch(error){return {text:'',context:'',error:error.message||tr('sample_missing')};}
},
ruleResultDocument(){return this.generationResult?.result;},
ruleResultResources(){const doc=this.ruleResultDocument();if(!doc)return [];return doc.managed.filter(m=>m.rule===this.form.id).flatMap(m=>{const kind=['rotation','priority'].includes(m.kind)?'schedule':m.kind;const resource=(kind==='schedule'?doc.schedules:kind==='escalation'?doc.escalations:doc.routes).find(r=>r.id===m.id);return resource?[{...m,kind,resource}]:[];});},
ruleResultStatus(){if(this.generationLoading)return tr('cover_checking');if(this.generationError || this.generationResult?.issues.length)return tr('rule_result_invalid');if(!this.generationResult)return tr('rule_result_waiting');if(!this.form.enabled)return tr('rule_result_disabled');const count=this.ruleResultResources().length;return count?tr('rule_result_count',{count}):tr('rule_result_empty');},
saveGenerationModal(){
if(!this.form.name.trim()){this.error=tr('enter_a_name_86c810');return;}
// An incomplete cross-reference may remain in the local draft, but activation is always blocked.
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);},
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];},
dataContainer(node){return ['list','dictionary'].includes(node.kind);},
dataCaption(node){if(node.kind==='dictionary'){const lists=node.entries.filter(n=>n.kind==='list');return lists.map(n=>this.nodeSummary(n)).join(' · ');}return this.nodeSummary(node);},
duplicateDataNode(parent,index){const renew=n=>{n.id=crypto.randomUUID();n.entries.forEach(renew);};const n=copy(parent.entries[index]);renew(n);parent.entries.splice(index+1,0,n);this.dataExpanded[parent.id]=true;},
addNode(parent){this.dataExpanded[parent.id]=true;const kind=parent.entries[0]?.kind||'text';parent.entries.push(this.newNode(kind));},
moveNode(parent,index,delta){const to=index+delta;if(to<0 || to>=parent.entries.length)return;[parent.entries[index],parent.entries[to]]=[parent.entries[to],parent.entries[index]];},
nodeSummary(node,depth=0){
const entries=node.entries||[];
if(node.kind==='list')return depth>=2?entries.length+tr('entries_7c3fe9'):entries.slice(0,4).map(n=>this.nodeSummary(n,depth+1)).join(', ')+(entries.length>4?'…':'');
if(node.kind==='dictionary'){
const name=['name','title'].map(key=>entries.find(n=>n.key===key && n.kind==='text' && String(n.value??'').trim())).find(Boolean);
return name?String(name.value):entries.length+tr('entries_7c3fe9')+(entries.length?' · '+entries.slice(0,3).map(n=>n.key).join(', ')+(entries.length>3?'…':''):'');
}
return node.kind==='person'?this.dataPersonName(node.value):node.kind==='date'?this.formatDate(node.value):String(node.value??'');
},
appendBulk(){
const lines=this.bulkText.split(/\r?\n/).map(s=>s.trim()).filter(Boolean),target=this.bulkNode;
if(!target || !lines.length)return;
if(target.kind==='list')target.entries.push(...lines.map(s=>this.newNode(target.entries[0]?.kind||'text',s)));
else {
const parsed=lines.map((line,i)=>{const cells=line.split('\t');if(cells.length!==2 || !cells[0].trim() || !cells[1].trim())throw new Error(tr('line_2b754a')+(i+1)+tr('paste_a_project_key_a_tab_and_comma_separated_emails_2b81fc'));const n=this.newNode('list',null,cells[0].trim());n.entries=cells[1].split(',').map(s=>this.newNode('person',s.trim()));return n;});target.entries.push(...parsed);
}
this.bulkNode=null;this.bulkText='';
},
openBulk(node){this.bulkNode=node;this.bulkText='';},
addIteration(){if(this.form.iterations.length>=4)return;const v=this.generationDraft.variables.find(v=>['list','dictionary'].includes(v.node.kind));this.form.iterations.push({id:crypto.randomUUID(),name:tr('item_284e12')+(this.form.iterations.length+1),source:{kind:'variable',variable:v?.id||null,path:[]}});},
fieldsFor(kind){
const common=[{key:'name',label:tr('name_709a23'),type:'text'}],handover={key:'handover',label:tr('handover_time_c9e315'),type:'time'};
if(kind==='rotation')return [...common,{key:'participants',label:tr('participants_in_rotation_order_4edee0'),type:'list'},{key:'anchor',label:tr('anchor_date_e77bd6'),type:'date'},{key:'shift_days',label:tr('shift_length_days_0b28fa'),type:'number'},handover];
if(kind==='priority')return [...common,{key:'participants',label:tr('responsible_people_in_priority_order_7205a5'),type:'list'},{key:'fallback',label:tr('fallback_schedule_7b7a21'),type:'schedule'},handover];
if(kind==='escalation')return [...common,{key:'repeat_count',label:tr('repeat_count_912e08'),type:'number'},{key:'repeat_wait',label:tr('minutes_between_repeats_f17ab0'),type:'number'}];
return [...common,{key:'escalation',label:tr('escalation_policy_bbb0f7'),type:'policy'},{key:'working',label:tr('only_during_working_hours_fa1eb6'),type:'boolean'}];
},
newBlock(kind){
const fields={};for(const field of this.fieldsFor(kind)){const val=field.key==='name'?tr('new_61bd57')+this.label(kind):field.key==='anchor'?this.today():field.key==='handover'?'11:00':field.key==='shift_days'?7:field.type==='number'?0:field.type==='boolean'?false:field.type==='list'?[this.user.id]:field.type==='person'?this.user.id:'';fields[field.key]=this.literal(val);}
return {id:crypto.randomUUID(),kind,fields,steps:kind==='escalation'?[{delay:this.literal(0),recipient:this.defaultScheduleSource(),user:false,condition:'if-not-acked'}]:[],conditions:[]};
},
addBlock(kind){this.form.blocks.push(this.newBlock(kind));this.$nextTick(()=>this.$refs.dialog.querySelector('.generation-block:last-of-type')?.scrollIntoView({block:'center',behavior:'smooth'}));},
moveBlock(index,delta){const to=index+delta;if(to<0 || to>=this.form.blocks.length)return;[this.form.blocks[index],this.form.blocks[to]]=[this.form.blocks[to],this.form.blocks[index]];},
addGenStep(block){block.steps.push({delay:this.literal(10),recipient:this.defaultScheduleSource(),user:false,condition:'if-not-acked'});},
addGenCondition(block){block.conditions.push({field:'tags',operation:'contains',value:this.literal(''),negate:false});},
templatePath(base,key){return /^[\p{L}_$][\p{L}\p{N}_$]*$/u.test(key)?base+'.'+key:base+'['+JSON.stringify(key)+']';},
bindingAddress(b,depth=0,iterations=this.form.iterations||[]){
if(!b || depth>16)return '?';
const variable=this.generationDraft?.variables.find(v=>v.id===b.variable)?.name;
const iteration=iterations.find(i=>i.id===b.iteration)?.name;
let address='?';
if(b.kind==='literal')address=JSON.stringify(b.value??null);
if(b.kind==='variable' || b.kind==='lookup')address=variable?this.templatePath('var',variable):'var[?]';
if(b.kind==='lookup')address+='['+this.bindingAddress(b.key,depth+1,iterations)+']';
if(['item','key'].includes(b.kind)){const name=iteration?(/^[\p{L}_$][\p{L}\p{N}_$]*$/u.test(iteration)?iteration:'iteration['+JSON.stringify(iteration)+']'):'iteration[?]';address=name+(b.kind==='key'?'.key':'.value');}
if(b.kind==='list')address='['+(b.items||[]).map(item=>this.bindingAddress(item,depth+1,iterations)).join(', ')+']';
for(const key of b.path||[])address=/^[1-9][0-9]*$/.test(key)?address+'['+key+']':this.templatePath(address,key);
return address;
},
bindingSummary(b,type='',depth=0){
if(!b || depth>16)return tr('choose_a_value_e0b399');
if(b.kind==='output'){const resource=this.matchingOutputs('all').find(o=>o.id===b.output);return this.generatedResourceLabel(resource)+' · '+(resource?this.bindingSummary(resource.fields.name,'text',depth+1):tr('choose_an_output_747632'));}
let text;
if(b.kind==='literal' && !b.path?.length){if(type==='date')text=this.formatDate(b.value);else if(Array.isArray(b.value))text=b.value.map(v=>this.people.some(p=>p.id===v)?this.person(v):v).join(', ');else text=this.people.some(p=>p.id===b.value)?this.person(b.value):String(b.value??tr('choose_a_value_e0b399'));}
else text='{ '+this.bindingAddress(b)+' }';
return (b.prefix||'')+text+(b.suffix||'');
},
editBinding(binding,type,label){this.rememberEditorOrigin();this.bindingEditor={binding,type,label};this.$nextTick(()=>{const popup=document.querySelector('.binding-popover');if(!popup || !this.bindingEditor)return;popup.showPopover();this.positionBindingPopover();popup.querySelector('select')?.focus({preventScroll:true});});},
positionBindingPopover(){const popup=document.querySelector('.binding-popover');if(!popup || !this.bindingEditor)return;const rect=this.editorOrigin?.getBoundingClientRect()||{left:16,right:16,top:80,bottom:100},width=Math.min(390,innerWidth-24),height=Math.min(560,innerHeight-32);popup.style.width=width+'px';popup.style.maxHeight=height+'px';const right=rect.right+10,left=rect.left-width-10;popup.style.left=Math.max(12,Math.min(right+width<=innerWidth-12?right:left>=12?left:innerWidth-width-12,innerWidth-width-12))+'px';popup.style.top=Math.max(12,Math.min(rect.top,innerHeight-Math.min(popup.scrollHeight,height)-16))+'px';},
rememberEditorOrigin(){this.editorScroll=this.$refs.dialog.scrollTop;this.editorOrigin=document.activeElement;},
closeRuleEditor(restoreFocus=true){
if(this.bindingEditor){const popup=document.querySelector('.binding-popover');if(popup?.matches(':popover-open'))popup.hidePopover();this.bindingEditor=null;if(restoreFocus)this.editorOrigin?.focus({preventScroll:true});return;}
const dialog=this.$refs.dialog,scroll=this.editorScroll,origin=this.editorOrigin,formId=this.form.id;
this.bindingEditor=null;this.rulePeopleTarget=null;
// Alpine restores x-show sections on the next frame; scrolling before layout clamps to the shorter editor.
this.$nextTick(()=>requestAnimationFrame(()=>requestAnimationFrame(()=>{
if(!dialog?.isConnected || this.modal!=='generation-rule' || this.form.id!==formId || this.bindingEditor || this.rulePeopleTarget)return;
if(origin?.isConnected)origin.focus({preventScroll:true});
dialog.scrollTop=scroll;
})));
},
openRulePeople(binding){this.rememberEditorOrigin();this.rulePeopleTarget=binding;this.rulePeopleQuery='';this.$nextTick(()=>{const dialog=this.$refs.dialog;if(!dialog || !this.rulePeopleTarget)return;dialog.scrollTop=0;dialog.querySelector('.binding-editor input')?.focus({preventScroll:true});});},
bindingRows(){const rows=[];const walk=(binding,type,label,level=0,parent=null,index=0)=>{if(!binding || level>15)return;rows.push({binding,type,label,parent,index,depth:rows.length});if(binding.kind==='lookup')walk(binding.key,'text',tr('dictionary_key_source_b4a40b'),level+1);if(binding.kind==='list')binding.items.forEach((item,index)=>walk(item,'person',String(index+1),level+1,binding,index));};walk(this.bindingEditor?.binding,this.bindingEditor?.type,tr('value_source_c0ae15'));return rows;},
changeBindingKind(binding,kind){binding.kind=kind;binding.path=[];if(kind==='lookup' && !binding.key)binding.key={kind:'item',iteration:this.form.iterations[0]?.id||null,path:[]};if(kind==='literal' && binding.value===undefined)binding.value='';},
generatedResourceLabel(block){return tr(block?.kind==='escalation'?'source_generated_policy':['rotation','priority'].includes(block?.kind)?'source_generated_schedule':'source_generated_resource');},
matchingOutputs(type){return [...(this.form.blocks||[]),...(this.generationDraft?.rules||[]).filter(r=>r.id!==this.form.id && r.enabled && !r.iterations.length).flatMap(r=>r.blocks)].filter(b=>type==='policy'?b.kind==='escalation':type==='schedule'?['rotation','priority'].includes(b.kind):true);},
toggleGenCondition(enabled){this.form.when=enabled?{left:this.literal(''),operation:'equals',right:this.literal('')}:null;},
};}