Files
studiobeve.no/application/views/admin/includes/update-worker-form.php
T
UbuntuandClaude Opus 5 13e1b1a672 Add Amarildo to the barber page and gate workers by service
New barber Amarildo Champimpi is introduced on the barber page in all three
languages, and workers can now be restricted to a subset of the services in
their category.

Amarildo cannot perform beard colouring, any waxing, or the all-in package, and
he is a barber only - but category 1 "Kozmetika" holds 19 barber AND 27 beauty
services, so the category-derived vertical wrongly made him beauty-capable.
Because the worker picker is only ever fetched AFTER services are ticked, one
per-service capability filter solves both problems: excluding him from every
beauty service removes him from that vertical entirely.

worker_services(worker_id, service_id) is an allow-list where an EMPTY set means
UNRESTRICTED. That default is deliberate: a missing migration degrades to the
previous behaviour instead of hiding every worker from the booking flow, and
existing workers keep working untouched. Ticking every box in the admin grid
stores nothing at all, so an unrestricted worker also picks up services added
later; unticking even one makes the worker restricted, and new services must
then be granted explicitly.

Enforcement is in three places. The picker offers only workers who can perform
EVERY selected service, and both booking paths re-check server-side, since the
picker is only a UI affordance - a crafted POST now gets service_not_offered/403
rather than a booking the worker cannot honour.

Fixed alongside, all found while building the above:

- getWorkersByCategorySlug() never filtered is_active, so marking a worker
  inactive had NO effect on the public booking flow. Both of its callers are
  guest-facing. The sibling fallback getActiveWorkers() had always filtered it.
- Worker profile picture uploads failed SILENTLY above PHP's upload_max_filesize.
  Both upload blocks gated on tmp_name alone, which cannot distinguish a rejected
  upload from "no file chosen" - PHP empties tmp_name in both cases - so the
  worker was saved with an empty worker_profile_img and no error shown. The
  upload error code is now read and reported, and a separate guard catches
  post_max_size overflow, where $_POST and $_FILES both arrive empty and the form
  silently did nothing at all.
- createWorker() omitted is_active from its INSERT, so the column default (1)
  always won and a worker created as inactive silently came back active.

Migration - the table MUST be created before this code is deployed, because the
picker query subselects it whenever service ids are passed:

    CREATE TABLE worker_services (
      worker_id  INT NOT NULL,
      service_id INT NOT NULL,
      PRIMARY KEY (worker_id, service_id),
      KEY idx_worker_services_worker (worker_id)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

Already applied on test, dev and prod. Server-side, upload_max_filesize/
post_max_size were raised to 8M/12M on all three environments (php.ini on test,
.user.ini on the shared-host dev and prod docroots) - not carried by this commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TYGSbK1erKv7VG1pvdPEjG
2026-08-24 12:07:07 +00:00

328 lines
13 KiB
PHP
Executable File

<style>
/* Capability grid. Scoped with a ws- prefix so it cannot leak into the other
admin forms. Colours follow the existing admin palette: #9e8462 borders and
headings, #cacaca body text on the dark background (see .tableClass). */
.wsCapabilityRow {
max-width: 760px;
margin: 0 auto 20px auto;
}
.wsSectionTitle {
font-size: 15px;
text-transform: uppercase;
color: #9e8462;
text-align: center;
margin-bottom: 8px;
}
.wsHint {
font-size: 12px;
line-height: 1.6;
color: #cacaca;
text-align: center;
margin: 0 auto 12px auto;
max-width: 700px;
}
.wsToolbar {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 10px;
}
.wsCount {
font-size: 13px;
color: #cacaca;
}
/* main.css styles every <button> with height:3.5em, letter-spacing:.35em,
font-weight:900 and an inset #585858 box-shadow, and forces the colour with
!important - so each of those has to be undone explicitly here. */
.wsMiniBtn {
background: transparent;
border: 0;
box-shadow: inset 0 0 0 1px #9e8462;
color: #9e8462 !important;
border-radius: 20px;
height: auto;
line-height: 1.4;
padding: 5px 16px;
font-size: 12px;
font-weight: normal;
letter-spacing: normal;
text-transform: none;
cursor: pointer;
}
.wsMiniBtn:hover {
background: #9e8462;
color: #fff !important;
}
.wsBox {
max-height: 360px;
overflow-y: auto;
border: 1px solid #9e8462;
background: rgba(0, 0, 0, 0.45);
padding: 10px 14px;
text-align: left;
}
/* The shared .tableClass centres itself with a 30px margin, which stacks badly
when several of them sit inside one scroll box. */
.wsTable {
width: 100%;
margin: 0 0 14px 0;
}
.wsTable th {
color: #9e8462;
background: transparent;
border: 0;
border-bottom: 1px solid #9e8462;
text-align: left;
padding: 6px 4px;
font-size: 13px;
text-transform: uppercase;
}
.wsTable td {
border: 0;
border-bottom: 1px solid rgba(158, 132, 98, 0.25);
padding: 5px 4px;
text-align: left;
vertical-align: middle;
line-height: 1.35;
color: #cacaca;
font-size: 14px;
}
.wsTable .wsCheckCell {
width: 34px;
text-align: center;
}
/* modules.css already re-enables the native checkbox for .tableClass tables
(main.css hides every checkbox with opacity:0 and draws a fake one via
"+ label:before", which cannot work when the label sits in another cell).
That rule outranks a plain .wsTable selector, so match its shape rather than
fight it - only accent-color and height are added on top. */
.wsTable > tbody > tr > td > input[type="checkbox"] {
height: 18px;
accent-color: #9e8462;
cursor: pointer;
}
/* The global label rule carries a 12.6px bottom margin, which was inflating
every row to ~54px. */
.wsNameCell label {
display: block;
cursor: pointer;
line-height: 1.35;
margin: 0;
padding: 0;
font-weight: normal;
}
.wsId {
color: #7d7d7d;
font-size: 12px;
margin-left: 4px;
}
@media screen and (max-width: 600px) {
.wsBox { max-height: 300px; padding: 8px; }
.wsTable td { font-size: 13px; }
}
</style>
<div class="mainContentContainer" style="top:100px">
<div class="pageTitle"><?php echo $pageTitle;?></div>
<form method="post" action="<?php echo base_url();?>workers/worker-process" enctype="multipart/form-data">
<input type="hidden" name ="worker_id" value="<?php echo htmlspecialchars($selectedItem->worker_id, ENT_QUOTES, 'UTF-8');?>">
<div class="formRow">
<label class="formTitle">Név</label>
<input type="text" class="formInputBox" name="worker_name" value="<?php echo htmlspecialchars($selectedItem->worker_name, ENT_QUOTES, 'UTF-8');?>">
</div>
<div class="formRow">
<img src="<?php echo SITEURL.'assets/img/workers/'.$selectedItem->worker_profile_img;?>" width="100px">
</div>
<div class="formRow">
<label class="formTitle">Profilkép</label>
<input type="file" class="formInputBox" name="worker_img">
</div>
<div class="formRow">
<label class="formTitle">Leírás</label>
<input type="text" class="formInputBox" name="worker_info" value="<?php echo htmlspecialchars($selectedItem->worker_info, ENT_QUOTES, 'UTF-8');?>">
</div>
<div class="formRow">
<label class="formTitle">Vertikálok</label>
<div class="formInputBox" style="border:0;padding-left:0;">
<?php
$workerVerticals = isset($workerVerticals) ? $workerVerticals : array();
echo $workerVerticals
? htmlspecialchars(implode(', ', $workerVerticals), ENT_QUOTES, 'UTF-8')
: '<span style="color:#b00;">nincs szolgáltatás ebben a kategóriában</span>';
?>
<div style="font-size:11px;color:#888;margin-top:4px;">
A szolgáltatás kategóriából származtatva - a kategória módosításával változik.
</div>
</div>
</div>
<div class="formRow">
<label class="formTitle">Szolgáltatás kategória</label>
<select class="formDropdownBox" style="padding:0;" name="service_category_id">
<?php
if(!empty($serviceCategories)){
foreach($serviceCategories as $serviceCategoryItem){
echo '<option value="'.$serviceCategoryItem->service_category_id.'" '.($selectedItem->service_category_id == $serviceCategoryItem->service_category_id?' selected':'').'>'.$serviceCategoryItem->serv_cat_name.'</option>';
}
}
?>
</select>
</div>
<div class="formRow">
<label class="formTitle">Aktív</label>
<select class="formDropdownBox" style="padding:0;" name="is_active">
<option value="1" <?php echo $selectedItem->is_active?'selected':'';?>>igen</option>
<option value="0" <?php echo !$selectedItem->is_active?'selected':'';?>>nem</option>
</select>
</div>
<div class="formRow">
<label class="formTitle">Ebédszünet ablak kezdete</label>
<input type="text" class="formInputBox" name="lunch_window_start" value="<?php echo isset($selectedItem->lunch_window_start) ? substr($selectedItem->lunch_window_start, 0, 5) : ''; ?>" placeholder="HH:MM (pl. 11:00)" pattern="[0-2][0-9]:[0-5][0-9]" inputmode="numeric">
</div>
<div class="formRow">
<label class="formTitle">Ebédszünet ablak vége</label>
<input type="text" class="formInputBox" name="lunch_window_end" value="<?php echo isset($selectedItem->lunch_window_end) ? substr($selectedItem->lunch_window_end, 0, 5) : ''; ?>" placeholder="HH:MM (pl. 14:00)" pattern="[0-2][0-9]:[0-5][0-9]" inputmode="numeric">
</div>
<div class="formRow">
<label class="formTitle">Ebédszünet preferált időpont</label>
<input type="text" class="formInputBox" name="lunch_preferred_time" value="<?php echo isset($selectedItem->lunch_preferred_time) ? substr($selectedItem->lunch_preferred_time, 0, 5) : ''; ?>" placeholder="HH:MM (pl. 12:00)" pattern="[0-2][0-9]:[0-5][0-9]" inputmode="numeric">
</div>
<div class="formRow">
<label class="formTitle">Google Calendar ID</label>
<input type="text" class="formInputBox" name="google_calendar_id" value="<?php echo htmlspecialchars(isset($selectedItem->google_calendar_id) ? $selectedItem->google_calendar_id : '', ENT_QUOTES, 'UTF-8');?>">
</div>
<div class="formRow">
<label class="formTitle">ntfy Topic</label>
<input type="text" class="formInputBox" name="ntfy_topic" value="<?php echo htmlspecialchars(isset($selectedItem->ntfy_topic) ? $selectedItem->ntfy_topic : '', ENT_QUOTES, 'UTF-8');?>">
</div>
<div class="formRow wsCapabilityRow">
<div class="wsSectionTitle">Végezhető szolgáltatások</div>
<div class="wsHint">
Csak a bepipált szolgáltatásokat ajánlja fel a foglalási rendszer ennél a dolgozónál.
Ha <strong>mindegyik</strong> be van pipálva, a dolgozó korlátozás nélkül dolgozik, és a
később felvett új szolgáltatásokat is automatikusan végezheti. Ha akár egyet is kiveszel,
a dolgozó korlátozott lesz, és az ezután létrehozott új szolgáltatásokat külön be kell
majd pipálni neki.
</div>
<?php
// No stored rows = unrestricted, so show every box ticked. worker_services
// is written back the same way: all ticked saves nothing at all.
$wsIds = isset($workerServiceIds) && is_array($workerServiceIds) ? $workerServiceIds : array();
$wsUnrestricted = empty($wsIds);
$wsHasServices = isset($categoryServices) && is_array($categoryServices) && count($categoryServices) > 0;
if($wsHasServices){
// Group first so each heading can be rendered as its own table.
$wsGroups = array();
foreach($categoryServices as $svc){
$wsGroups[$svc->service_type.' - '.$svc->service_category_no][] = $svc;
}
?>
<div class="wsToolbar">
<span class="wsCount" id="wsCount"></span>
<button type="button" class="wsMiniBtn" onclick="wsSetAll(true)">Mindet bepipál</button>
<button type="button" class="wsMiniBtn" onclick="wsSetAll(false)">Mindet kivesz</button>
</div>
<div class="wsBox">
<?php
foreach($wsGroups as $wsHeading => $wsRows){
?>
<table class="tableClass wsTable">
<tr>
<th colspan="2"><?php echo htmlspecialchars($wsHeading, ENT_QUOTES, 'UTF-8');?></th>
</tr>
<?php
foreach($wsRows as $svc){
$checked = $wsUnrestricted || in_array((int)$svc->service_id, $wsIds, TRUE);
$svcName = $svc->service_name_hu !== '' ? $svc->service_name_hu : $svc->service_name_no;
?>
<tr>
<td class="wsCheckCell">
<input type="checkbox" class="wsService" id="wsService<?php echo (int)$svc->service_id;?>" name="worker_services[]" value="<?php echo (int)$svc->service_id;?>"<?php echo $checked ? ' checked' : '';?>>
</td>
<td class="wsNameCell">
<label for="wsService<?php echo (int)$svc->service_id;?>">
<?php echo htmlspecialchars($svcName, ENT_QUOTES, 'UTF-8');?>
<span class="wsId">#<?php echo (int)$svc->service_id;?></span>
</label>
</td>
</tr>
<?php
}
?>
</table>
<?php
}
?>
</div>
<?php
}
else{
?>
<div class="wsHint">Ehhez a kategóriához nincs szolgáltatás.</div>
<?php
}
?>
</div>
<div class="formRow">
<input type="submit" class="submitButton" name="updateWorker" value="Módosítás">
<a href="<?php echo base_url();?>workers" class="cancelButton">Mégsem</a>
</div>
</form>
</div>
<script>
// Keeps the counter honest and powers the two bulk buttons. All ticked is
// meaningful state, not just a convenience: the controller stores it as
// "no restriction" so the worker keeps getting future services too.
function wsRefreshCount(){
var boxes = document.querySelectorAll('.wsService');
var ticked = document.querySelectorAll('.wsService:checked').length;
var el = document.getElementById('wsCount');
if(!el){ return; }
if(boxes.length > 0 && ticked === boxes.length){
el.innerHTML = ticked + ' / ' + boxes.length + ' &ndash; korlátozás nélkül';
}
else{
el.innerHTML = ticked + ' / ' + boxes.length + ' kiválasztva';
}
}
function wsSetAll(state){
var boxes = document.querySelectorAll('.wsService');
for(var i = 0; i < boxes.length; i++){
boxes[i].checked = state;
}
wsRefreshCount();
}
document.addEventListener('DOMContentLoaded', function(){
var boxes = document.querySelectorAll('.wsService');
for(var i = 0; i < boxes.length; i++){
boxes[i].addEventListener('change', wsRefreshCount);
}
wsRefreshCount();
});
</script>