This commit is contained in:
Ross
2025-12-12 22:31:07 +00:00
parent 00f09d5e5b
commit f6dd9285a5
3 changed files with 190 additions and 0 deletions
@@ -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>
+23
View File
@@ -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}")
+3
View File
@@ -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)