.
This commit is contained in:
@@ -16,6 +16,170 @@ function _close_shift_modal_targets(){
|
||||
<form method="post" hx-post="{{ form_action }}" hx-target="#modal" hx-swap="innerHTML">
|
||||
{% csrf_token %}
|
||||
{{ form|crispy }}
|
||||
|
||||
<!-- Structured constraint editor: renders a friendly UI and keeps the
|
||||
hidden `constraints` textarea (form field) in sync as JSON. -->
|
||||
<div class="box" id="constraints-editor">
|
||||
<h4 class="title is-6">Shift constraints</h4>
|
||||
<p class="help">Add constraint entries. The form stores a JSON list; this editor helps build that list.</p>
|
||||
<div id="constraint-list"></div>
|
||||
<div style="margin-top:0.5em;">
|
||||
<button type="button" class="button is-small" id="add-constraint">Add constraint</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
// Constraint type schemas: name -> fields metadata
|
||||
const SCHEMAS = {
|
||||
"night": {label: "Night constraint", fields: [ {name: "options", label: "Options (free JSON)", type: "json"} ]},
|
||||
"pre": {label: "Pre-shift", fields: [ {name: "days", label: "Days (comma-separated)", type: "list"}, {name: "ignore_shifts", label: "Ignore shifts (comma-separated)", type: "list"} ]},
|
||||
"post": {label: "Post-shift", fields: [ {name: "days", label: "Days (comma-separated)", type: "list"}, {name: "ignore_shifts", label: "Ignore shifts (comma-separated)", type: "list"} ]},
|
||||
"min_summed_grade_by_shifts_per_day": {label: "Min summed grade by shifts per day", fields: [ {name: "shifts", label: "Shifts (comma-separated)", type: "list"}, {name: "min_grade_sum", label: "Minimum grade sum", type: "int"}, {name: "days", label: "Days (comma-separated)", type: "list"} ]},
|
||||
"limit_grade_number": {label: "Limit grade number", fields: [ {name: "grade", label: "Grade", type: "int"}, {name: "max_number", label: "Max number", type: "int"} ]},
|
||||
"minimum_grade_number": {label: "Minimum grade number", fields: [ {name: "grade", label: "Grade", type: "int"}, {name: "min_number", label: "Min number", type: "int"} ]},
|
||||
"require_remote_site_presence": {label: "Require remote site presence", fields: [ {name: "site", label: "Site", type: "text"}, {name: "required_number", label: "Required number", type: "int"} ]},
|
||||
"max_shifts_per_week": {label: "Max shifts per week", fields: [ {name: "max_shifts", label: "Max shifts", type: "int"} ]},
|
||||
"max_shifts_per_week_block": {label: "Max shifts per week block", fields: [ {name: "week_block", label: "Week block", type: "int"}, {name: "max_shifts", label: "Max shifts", type: "int"} ]}
|
||||
};
|
||||
|
||||
const textarea = document.getElementById('id_constraints');
|
||||
const listEl = document.getElementById('constraint-list');
|
||||
const addBtn = document.getElementById('add-constraint');
|
||||
|
||||
function parseInitial(){
|
||||
let val = textarea.value || '';
|
||||
if(!val.trim()) return [];
|
||||
try{ return JSON.parse(val); }catch(e){ return []; }
|
||||
}
|
||||
|
||||
function render(){
|
||||
listEl.innerHTML = '';
|
||||
const data = parseInitial();
|
||||
data.forEach((c, idx)=>{
|
||||
listEl.appendChild(renderConstraintRow(c, idx));
|
||||
});
|
||||
syncTextarea();
|
||||
}
|
||||
|
||||
function renderConstraintRow(c, idx){
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'box';
|
||||
const header = document.createElement('div');
|
||||
header.style.display = 'flex';
|
||||
header.style.gap = '0.5em';
|
||||
// Type select
|
||||
const selDiv = document.createElement('div'); selDiv.className='select';
|
||||
const sel = document.createElement('select');
|
||||
sel.name = `constraint_type_${idx}`;
|
||||
const optEmpty = document.createElement('option'); optEmpty.value=''; optEmpty.text='-- type --'; sel.appendChild(optEmpty);
|
||||
Object.keys(SCHEMAS).forEach(k=>{ const o=document.createElement('option'); o.value=k; o.text=SCHEMAS[k].label; sel.appendChild(o); });
|
||||
sel.value = c.name || '';
|
||||
sel.onchange = ()=>{ renderFields(); syncToData(); };
|
||||
selDiv.appendChild(sel);
|
||||
header.appendChild(selDiv);
|
||||
|
||||
// Remove button
|
||||
const rm = document.createElement('button'); rm.type='button'; rm.className='button is-small is-danger'; rm.textContent='Remove';
|
||||
rm.onclick = ()=>{ removeAt(idx); };
|
||||
header.appendChild(rm);
|
||||
wrapper.appendChild(header);
|
||||
|
||||
const fieldsContainer = document.createElement('div'); fieldsContainer.style.marginTop='0.5em'; wrapper.appendChild(fieldsContainer);
|
||||
|
||||
function renderFields(){
|
||||
fieldsContainer.innerHTML='';
|
||||
const t = sel.value;
|
||||
if(!t || !(t in SCHEMAS)){
|
||||
// free-form options
|
||||
const ta = document.createElement('textarea'); ta.className='textarea'; ta.rows=3;
|
||||
ta.placeholder='Free JSON options for this constraint (optional)';
|
||||
ta.value = c.options ? JSON.stringify(c.options) : (c.options===undefined?'':JSON.stringify(c.options||{}));
|
||||
ta.oninput = syncToData;
|
||||
fieldsContainer.appendChild(ta);
|
||||
return;
|
||||
}
|
||||
SCHEMAS[t].fields.forEach(f=>{
|
||||
const fieldWrap = document.createElement('div'); fieldWrap.className='field';
|
||||
const label = document.createElement('label'); label.className='label'; label.textContent = f.label;
|
||||
fieldWrap.appendChild(label);
|
||||
const control = document.createElement('div'); control.className='control';
|
||||
let input;
|
||||
if(f.type==='text' || f.type==='int' || f.type==='float' || f.type==='list'){
|
||||
input = document.createElement('input'); input.className='input'; input.type='text';
|
||||
const cur = (c.options && c.options[f.name]!==undefined) ? c.options[f.name] : (c[f.name]!==undefined?c[f.name]:'');
|
||||
if(Array.isArray(cur) && f.type==='list') input.value = cur.join(','); else input.value = cur===null? '': String(cur);
|
||||
input.oninput = syncToData;
|
||||
} else if(f.type==='json'){
|
||||
input = document.createElement('textarea'); input.className='textarea'; input.rows=3;
|
||||
const cur = (c.options && c.options[f.name]!==undefined) ? c.options[f.name] : (c[f.name]!==undefined?c[f.name]:'');
|
||||
try{ input.value = JSON.stringify(cur); }catch(e){ input.value = String(cur); }
|
||||
input.oninput = syncToData;
|
||||
} else {
|
||||
input = document.createElement('input'); input.className='input'; input.type='text'; input.oninput = syncToData;
|
||||
}
|
||||
input.setAttribute('data-field-name', f.name);
|
||||
control.appendChild(input);
|
||||
fieldWrap.appendChild(control);
|
||||
fieldsContainer.appendChild(fieldWrap);
|
||||
});
|
||||
}
|
||||
|
||||
function syncToData(){
|
||||
// read values from fields and update underlying data array in textarea
|
||||
const data = parseInitial();
|
||||
const obj = {name: sel.value, options: {}};
|
||||
const t = sel.value;
|
||||
if(t in SCHEMAS){
|
||||
SCHEMAS[t].fields.forEach(f=>{
|
||||
const input = fieldsContainer.querySelector('[data-field-name="'+f.name+'"]');
|
||||
if(!input) return;
|
||||
let v = input.value;
|
||||
if(f.type==='int'){
|
||||
v = v===''?null:parseInt(v,10);
|
||||
} else if(f.type==='float'){
|
||||
v = v===''?null:parseFloat(v);
|
||||
} else if(f.type==='list'){
|
||||
v = v===''?[]:v.split(',').map(x=>x.trim()).filter(Boolean);
|
||||
} else if(f.type==='json'){
|
||||
try{ v = v===''?{}:JSON.parse(v); }catch(e){ v = input.value; }
|
||||
}
|
||||
obj.options[f.name] = v;
|
||||
});
|
||||
} else {
|
||||
const ta = fieldsContainer.querySelector('textarea');
|
||||
if(ta){ try{ obj.options = ta.value?JSON.parse(ta.value):{} }catch(e){ obj.options = ta.value } }
|
||||
}
|
||||
data[idx] = obj;
|
||||
textarea.value = JSON.stringify(data);
|
||||
}
|
||||
|
||||
function removeAt(i){
|
||||
const data = parseInitial(); data.splice(i,1); textarea.value = JSON.stringify(data); render();
|
||||
}
|
||||
|
||||
// initialize fields
|
||||
renderFields();
|
||||
// listen for updates to synchronize
|
||||
// expose removeAt to outer scope via element
|
||||
wrapper._removeAt = removeAt;
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
function removeAt(i){ const data = parseInitial(); data.splice(i,1); textarea.value = JSON.stringify(data); render(); }
|
||||
|
||||
addBtn.addEventListener('click', ()=>{
|
||||
const data = parseInitial(); data.push({name:'', options:{}}); textarea.value = JSON.stringify(data); render();
|
||||
});
|
||||
|
||||
// Initial render
|
||||
try{ render(); }catch(e){ /* fail silently */ }
|
||||
|
||||
// Also sync before form submit (in case some inputs haven't fired)
|
||||
const form = textarea.closest('form'); if(form){ form.addEventListener('submit', ()=>{ /* already synced via inputs */ }); }
|
||||
|
||||
})();
|
||||
</script>
|
||||
<div class="field">
|
||||
<div class="control">
|
||||
<button class="button is-primary" type="submit">Save</button>
|
||||
|
||||
@@ -334,9 +334,32 @@ class ShiftForm(forms.Form):
|
||||
help_text="Maximum allowed difference in allocated shifts for this shift (leave blank for default)",
|
||||
)
|
||||
|
||||
# Allow entering shift-specific constraints as JSON. This should be a
|
||||
# list of constraint dicts compatible with the scheduler's SingleShift
|
||||
# `constraints` field. Example: `[{"name": "night", "options": {...}}]`
|
||||
constraints = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.Textarea(attrs={"rows": 4, "class": "textarea"}),
|
||||
help_text="Optional JSON list of constraint objects for this shift (e.g. [{'name':'night','options':{}}])",
|
||||
)
|
||||
|
||||
def clean_sites(self):
|
||||
val = self.cleaned_data["sites"]
|
||||
sites = [s.strip() for s in val.split(",") if s.strip()]
|
||||
if not sites:
|
||||
raise forms.ValidationError("At least one site is required")
|
||||
return sites
|
||||
|
||||
def clean_constraints(self):
|
||||
val = self.cleaned_data.get("constraints")
|
||||
if not val:
|
||||
return []
|
||||
try:
|
||||
parsed = json.loads(val)
|
||||
if not isinstance(parsed, list):
|
||||
raise forms.ValidationError("Constraints must be a JSON list of constraint objects")
|
||||
return parsed
|
||||
except forms.ValidationError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise forms.ValidationError(f"Invalid JSON for constraints: {exc}")
|
||||
|
||||
@@ -882,6 +882,7 @@ def shift_add(request, rota_id):
|
||||
"workers_required": int(data["workers_required"]),
|
||||
"assign_as_block": bool(data["assign_as_block"]),
|
||||
"balance_offset": data.get("balance_offset"),
|
||||
"constraints": data.get("constraints", []),
|
||||
}
|
||||
|
||||
current = rota.shifts or []
|
||||
@@ -930,6 +931,7 @@ def shift_edit(request, rota_id, idx):
|
||||
"days": data["days"],
|
||||
"workers_required": int(data["workers_required"]),
|
||||
"assign_as_block": bool(data["assign_as_block"]),
|
||||
"constraints": data.get("constraints", []),
|
||||
}
|
||||
current = rota.shifts or []
|
||||
current[idx] = shift_dict
|
||||
@@ -958,6 +960,7 @@ def shift_edit(request, rota_id, idx):
|
||||
"workers_required": shift.get("workers_required"),
|
||||
"assign_as_block": shift.get("assign_as_block", False),
|
||||
"balance_offset": shift.get("balance_offset", None),
|
||||
"constraints": json.dumps(shift.get("constraints", [])) if shift.get("constraints") is not None else "",
|
||||
}
|
||||
form = ShiftForm(initial=initial)
|
||||
modal_html = render_to_string("rota/partials/shift_form_modal.html", {"form": form, "rota": rota, "form_action": request.path}, request=request)
|
||||
|
||||
Reference in New Issue
Block a user