.
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">
|
<form method="post" hx-post="{{ form_action }}" hx-target="#modal" hx-swap="innerHTML">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{{ form|crispy }}
|
{{ 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="field">
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<button class="button is-primary" type="submit">Save</button>
|
<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)",
|
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):
|
def clean_sites(self):
|
||||||
val = self.cleaned_data["sites"]
|
val = self.cleaned_data["sites"]
|
||||||
sites = [s.strip() for s in val.split(",") if s.strip()]
|
sites = [s.strip() for s in val.split(",") if s.strip()]
|
||||||
if not sites:
|
if not sites:
|
||||||
raise forms.ValidationError("At least one site is required")
|
raise forms.ValidationError("At least one site is required")
|
||||||
return sites
|
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"]),
|
"workers_required": int(data["workers_required"]),
|
||||||
"assign_as_block": bool(data["assign_as_block"]),
|
"assign_as_block": bool(data["assign_as_block"]),
|
||||||
"balance_offset": data.get("balance_offset"),
|
"balance_offset": data.get("balance_offset"),
|
||||||
|
"constraints": data.get("constraints", []),
|
||||||
}
|
}
|
||||||
|
|
||||||
current = rota.shifts or []
|
current = rota.shifts or []
|
||||||
@@ -930,6 +931,7 @@ def shift_edit(request, rota_id, idx):
|
|||||||
"days": data["days"],
|
"days": data["days"],
|
||||||
"workers_required": int(data["workers_required"]),
|
"workers_required": int(data["workers_required"]),
|
||||||
"assign_as_block": bool(data["assign_as_block"]),
|
"assign_as_block": bool(data["assign_as_block"]),
|
||||||
|
"constraints": data.get("constraints", []),
|
||||||
}
|
}
|
||||||
current = rota.shifts or []
|
current = rota.shifts or []
|
||||||
current[idx] = shift_dict
|
current[idx] = shift_dict
|
||||||
@@ -958,6 +960,7 @@ def shift_edit(request, rota_id, idx):
|
|||||||
"workers_required": shift.get("workers_required"),
|
"workers_required": shift.get("workers_required"),
|
||||||
"assign_as_block": shift.get("assign_as_block", False),
|
"assign_as_block": shift.get("assign_as_block", False),
|
||||||
"balance_offset": shift.get("balance_offset", None),
|
"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)
|
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)
|
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