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
This commit is contained in:
@@ -547,6 +547,13 @@ class Admin extends CI_Controller {
|
||||
$data['selectedItem'] = $this->Service_model->getWorkerById($worker_id);
|
||||
// Verticals are derived from the worker's service category, not stored.
|
||||
$data['workerVerticals'] = $this->Service_model->getVerticalsForWorker($worker_id);
|
||||
|
||||
// Capability grid: which of the category's services this worker performs.
|
||||
// An empty stored set means unrestricted, which the view renders as
|
||||
// everything ticked - see the note in the form.
|
||||
$data['categoryServices'] = $this->Service_model->getServicesByCategoryId($data['selectedItem']->service_category_id);
|
||||
$data['workerServiceIds'] = $this->Service_model->getWorkerServiceIds($worker_id);
|
||||
|
||||
$this->load->view('admin/update-worker', $data);
|
||||
}
|
||||
else{
|
||||
@@ -554,6 +561,48 @@ class Admin extends CI_Controller {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate the PHP upload error code for the worker profile picture into
|
||||
* an admin-facing message.
|
||||
*
|
||||
* The upload blocks below used to gate on tmp_name alone, which made a
|
||||
* REJECTED upload indistinguishable from "no file chosen": PHP empties
|
||||
* tmp_name in both cases, so an oversized photo was skipped in silence and
|
||||
* the worker was saved with an empty worker_profile_img. Reading the error
|
||||
* code is the only way to tell the two apart.
|
||||
*
|
||||
* @return string '' when there is nothing to report (no file chosen, or a
|
||||
* file that arrived intact), otherwise the message to show.
|
||||
*/
|
||||
private function _workerImgUploadError(){
|
||||
// No file part at all - nothing was submitted, nothing to report.
|
||||
if(!isset($_FILES['worker_img']) || !isset($_FILES['worker_img']['error'])){
|
||||
return '';
|
||||
}
|
||||
|
||||
switch($_FILES['worker_img']['error']){
|
||||
case UPLOAD_ERR_OK:
|
||||
case UPLOAD_ERR_NO_FILE:
|
||||
// Intact, or the admin simply left the field empty.
|
||||
return '';
|
||||
|
||||
case UPLOAD_ERR_INI_SIZE:
|
||||
case UPLOAD_ERR_FORM_SIZE:
|
||||
return 'A kép túl nagy! A megengedett legnagyobb méret: '.ini_get('upload_max_filesize').'.';
|
||||
|
||||
case UPLOAD_ERR_PARTIAL:
|
||||
return 'A kép csak részben töltődött fel. Kérlek próbáld újra!';
|
||||
|
||||
case UPLOAD_ERR_NO_TMP_DIR:
|
||||
case UPLOAD_ERR_CANT_WRITE:
|
||||
case UPLOAD_ERR_EXTENSION:
|
||||
return 'A kép feltöltése szerverhiba miatt nem sikerült. Szólj az üzemeltetőnek!';
|
||||
|
||||
default:
|
||||
return 'Ismeretlen hiba a kép feltöltése közben.';
|
||||
}
|
||||
}
|
||||
|
||||
public function worker_process(){
|
||||
$this->load->helper('url');
|
||||
$this->load->model('User_model');
|
||||
@@ -566,6 +615,15 @@ class Admin extends CI_Controller {
|
||||
$data['message'] = '';
|
||||
$data['message_class'] = '';
|
||||
|
||||
// A POST body larger than post_max_size is discarded WHOLESALE by PHP:
|
||||
// $_POST and $_FILES both arrive empty, so none of the branches below
|
||||
// fire and the form appears to do nothing at all. CONTENT_LENGTH still
|
||||
// reports the real size, which is the only surviving evidence.
|
||||
if($_SERVER['REQUEST_METHOD'] === 'POST' && empty($_POST) && !empty($_SERVER['CONTENT_LENGTH'])){
|
||||
$data['message'] = 'A beküldött adat túl nagy volt, ezért a mentés nem történt meg. A kép legfeljebb '.ini_get('upload_max_filesize').' lehet.';
|
||||
$data['message_class'] = 'errorMessage';
|
||||
}
|
||||
|
||||
if(isset($_GET['delete-worker'])){
|
||||
$selected_worker = $this->Service_model->getWorkerById($_GET['delete-worker']);
|
||||
$workerArray = array(
|
||||
@@ -581,7 +639,12 @@ class Admin extends CI_Controller {
|
||||
|
||||
$data['worker_img'] = '';
|
||||
|
||||
if($_FILES["worker_img"]["tmp_name"] != ''){
|
||||
$workerImgError = $this->_workerImgUploadError();
|
||||
|
||||
if($workerImgError !== ''){
|
||||
$data['message'] = $workerImgError;
|
||||
$data['message_class'] = 'errorMessage';
|
||||
} elseif($_FILES["worker_img"]["tmp_name"] != ''){
|
||||
$target_dir = getcwd()."/assets/img/";
|
||||
$target_file = $target_dir . 'worker_'.str_replace(' ','_', $_POST['worker_name']);
|
||||
$imageFileType = strtolower(pathinfo(basename($_FILES["worker_img"]["name"]),PATHINFO_EXTENSION));
|
||||
@@ -632,7 +695,44 @@ class Admin extends CI_Controller {
|
||||
$this->Service_model->updateWorker($_POST['worker_id'], $workerArray);
|
||||
$this->Log_model->addLog('workers', $data['currentUser']->username.' módosította a(z) '.$_POST['worker_name'].' nevű dolgozót ('.implode(',',$workerArray).')', $data['currentUser']->username);
|
||||
|
||||
if($_FILES["worker_img"]["tmp_name"] != ''){
|
||||
// Capability set. The grid posts the ticked service ids; anything not
|
||||
// belonging to the worker's (possibly just-changed) category is dropped
|
||||
// so a category switch cannot leave orphaned rows behind.
|
||||
$postedServiceIds = isset($_POST['worker_services']) && is_array($_POST['worker_services'])
|
||||
? $_POST['worker_services']
|
||||
: array();
|
||||
|
||||
$categoryServices = $this->Service_model->getServicesByCategoryId($_POST['service_category_id']);
|
||||
$categoryServiceIds = array();
|
||||
if(is_array($categoryServices)){
|
||||
foreach($categoryServices as $svc){
|
||||
$categoryServiceIds[] = (int)$svc->service_id;
|
||||
}
|
||||
}
|
||||
|
||||
$allowedServiceIds = array();
|
||||
foreach($postedServiceIds as $postedId){
|
||||
if(in_array((int)$postedId, $categoryServiceIds, TRUE)){
|
||||
$allowedServiceIds[] = (int)$postedId;
|
||||
}
|
||||
}
|
||||
|
||||
// Every service ticked means "no restriction": store nothing, so the
|
||||
// worker keeps performing services added in the future too. Storing the
|
||||
// full set instead would silently freeze them out of every new service.
|
||||
if(count($allowedServiceIds) === count($categoryServiceIds)){
|
||||
$allowedServiceIds = array();
|
||||
}
|
||||
|
||||
$this->Service_model->setWorkerServiceIds($_POST['worker_id'], $allowedServiceIds);
|
||||
$this->Log_model->addLog('workers', $data['currentUser']->username.' beállította a(z) '.$_POST['worker_name'].' nevű dolgozó végezhető szolgáltatásait ('.(empty($allowedServiceIds) ? 'nincs korlátozás' : implode(',', $allowedServiceIds)).')', $data['currentUser']->username);
|
||||
|
||||
$workerImgError = $this->_workerImgUploadError();
|
||||
|
||||
if($workerImgError !== ''){
|
||||
$data['message'] = $workerImgError;
|
||||
$data['message_class'] = 'errorMessage';
|
||||
} elseif($_FILES["worker_img"]["tmp_name"] != ''){
|
||||
$target_dir = getcwd()."/assets/img/";
|
||||
$target_file = $target_dir . 'worker_'.str_replace(' ','_', $_POST['worker_name']);
|
||||
$imageFileType = strtolower(pathinfo(basename($_FILES["worker_img"]["name"]),PATHINFO_EXTENSION));
|
||||
|
||||
@@ -137,7 +137,15 @@ class Pages extends CI_Controller {
|
||||
$data['pageTitle'] = '';
|
||||
|
||||
if(isset($_POST['action']) && $_POST['action'] == 'getAvailableWorkersByServiceCategory' && isset($_POST['serv_cat_slug'])){
|
||||
$workers = $this->Service_model->getWorkersByCategorySlug($_POST['serv_cat_slug']);
|
||||
// The picker must only offer workers who can perform EVERY service the
|
||||
// guest has ticked. service_ids is supplied by the booking form; when it
|
||||
// is absent (older cached JS) the call degrades to the category-only
|
||||
// behaviour rather than returning nobody.
|
||||
$requestedServiceIds = isset($_POST['service_ids']) && is_array($_POST['service_ids'])
|
||||
? $_POST['service_ids']
|
||||
: array();
|
||||
|
||||
$workers = $this->Service_model->getWorkersByCategorySlug($_POST['serv_cat_slug'], $requestedServiceIds);
|
||||
$returnedWorkers = '';
|
||||
$workerIndex = 0;
|
||||
$workerListShow = '';
|
||||
@@ -445,6 +453,15 @@ class Pages extends CI_Controller {
|
||||
}
|
||||
}
|
||||
|
||||
// Per-worker capability. The category check above only proves the
|
||||
// services belong to the worker's category - not that this particular
|
||||
// worker performs them. The picker already hides incapable workers, but
|
||||
// that is a UI affordance, so re-check before the booking is written.
|
||||
if (!$this->Service_model->workerCanPerformServices($bookingArray['worker_id'], $selectedServiceArray)) {
|
||||
$this->_booking_error('service_not_offered', 403);
|
||||
return;
|
||||
}
|
||||
|
||||
// Single-service verticals: the client disables the other checkboxes,
|
||||
// but that is a UI affordance only. The vertical is derived from the
|
||||
// services themselves rather than $_POST['subpage'], which is
|
||||
@@ -883,7 +900,7 @@ class Pages extends CI_Controller {
|
||||
}
|
||||
}
|
||||
if($categorySlug !== ''){
|
||||
$data['workers'] = $this->Service_model->getWorkersByCategorySlug($categorySlug);
|
||||
$data['workers'] = $this->Service_model->getWorkersByCategorySlug($categorySlug, is_array($serviceIds) ? $serviceIds : array());
|
||||
} else {
|
||||
$data['workers'] = $this->Service_model->getActiveWorkers($subpage);
|
||||
}
|
||||
@@ -952,6 +969,15 @@ class Pages extends CI_Controller {
|
||||
}
|
||||
}
|
||||
|
||||
// Per-worker capability, same reasoning as booking_process(): the
|
||||
// category check proves only that the services belong to the worker's
|
||||
// category, not that this worker performs them.
|
||||
if(!$this->Service_model->workerCanPerformServices($newWorkerId, $selectedServiceArray)){
|
||||
$this->output->set_status_header(403)->set_content_type('application/json')
|
||||
->set_output(json_encode(['error' => 'Selected worker does not perform the chosen services.']));
|
||||
return;
|
||||
}
|
||||
|
||||
// Schedule check
|
||||
$schedule = $this->Service_model->getWorkerScheduleForDate($newWorkerId, $newBookingDate);
|
||||
if(!$schedule){
|
||||
|
||||
@@ -127,20 +127,28 @@ class Service_model extends CI_Model {
|
||||
|
||||
$this->_applyLegacyVerticalFlags($workerArray, $workerArray['service_category_id']);
|
||||
|
||||
// is_active was previously omitted from this INSERT, so the column
|
||||
// default (1) always won and a worker created as inactive silently
|
||||
// came back active. The admin's choice is honoured here; callers that
|
||||
// do not supply it still get the old default.
|
||||
$is_active = isset($workerArray['is_active']) ? (int)$workerArray['is_active'] : 1;
|
||||
|
||||
$query = $this->db->query('INSERT INTO workers (
|
||||
worker_name,
|
||||
worker_profile_img,
|
||||
worker_info,
|
||||
is_beauty,
|
||||
is_barber,
|
||||
service_category_id
|
||||
) VALUES(?, ?, ?, ?, ?, ?)', array(
|
||||
service_category_id,
|
||||
is_active
|
||||
) VALUES(?, ?, ?, ?, ?, ?, ?)', array(
|
||||
$workerArray['worker_name'],
|
||||
$workerArray['worker_profile_img'],
|
||||
$workerArray['worker_info'],
|
||||
$workerArray['is_beauty'],
|
||||
$workerArray['is_barber'],
|
||||
$workerArray['service_category_id']
|
||||
$workerArray['service_category_id'],
|
||||
$is_active
|
||||
));
|
||||
}
|
||||
|
||||
@@ -243,6 +251,25 @@ class Service_model extends CI_Model {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Live services belonging to one category, ordered for display as the
|
||||
* admin capability grid (sub-heading, then name).
|
||||
*
|
||||
* @param int $service_category_id
|
||||
* @return array
|
||||
*/
|
||||
public function getServicesByCategoryId($service_category_id){
|
||||
$this->load->database();
|
||||
$query = $this->db->query(
|
||||
'SELECT * FROM services
|
||||
WHERE service_category_id = ? AND is_deleted != "1"
|
||||
ORDER BY service_type ASC, service_category_no ASC, service_id ASC',
|
||||
array($service_category_id)
|
||||
);
|
||||
|
||||
return $query->result();
|
||||
}
|
||||
|
||||
public function getAllServiceCategories(){
|
||||
$this->load->database();
|
||||
$query = $this->db->query('SELECT * FROM service_categories WHERE is_deleted != "1" ORDER BY service_category_id;');
|
||||
@@ -300,9 +327,161 @@ class Service_model extends CI_Model {
|
||||
return $query->result();
|
||||
}
|
||||
|
||||
public function getWorkersByCategorySlug($serv_cat_slug){
|
||||
/**
|
||||
* The service ids one worker is allowed to perform.
|
||||
*
|
||||
* An EMPTY result means "unrestricted" - the worker performs every live
|
||||
* service in their category, which is how every worker behaved before
|
||||
* worker_services existed. Restrictions are therefore opt-in per worker,
|
||||
* and a missing migration degrades to the old behaviour instead of
|
||||
* hiding everybody from the booking flow.
|
||||
*
|
||||
* @param int $worker_id
|
||||
* @return array list of service_id ints (empty = unrestricted)
|
||||
*/
|
||||
public function getWorkerServiceIds($worker_id){
|
||||
$this->load->database();
|
||||
$query = $this->db->query('SELECT * FROM workers JOIN service_categories ON workers.service_category_id = service_categories.service_category_id WHERE workers.is_deleted != "1" AND serv_cat_slug = ? ORDER BY worker_name ASC', array($serv_cat_slug));
|
||||
$query = $this->db->query(
|
||||
'SELECT service_id FROM worker_services WHERE worker_id = ? ORDER BY service_id ASC',
|
||||
array($worker_id)
|
||||
);
|
||||
|
||||
$ids = array();
|
||||
foreach($query->result() as $row){
|
||||
$ids[] = (int)$row->service_id;
|
||||
}
|
||||
|
||||
return $ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace a worker's allowed-service set.
|
||||
*
|
||||
* Passing an empty array clears every row, which restores the worker to
|
||||
* the unrestricted default rather than making them unable to do anything.
|
||||
*
|
||||
* @param int $worker_id
|
||||
* @param array $service_ids
|
||||
* @return void
|
||||
*/
|
||||
public function setWorkerServiceIds($worker_id, $service_ids){
|
||||
$this->load->database();
|
||||
|
||||
$this->db->query('DELETE FROM worker_services WHERE worker_id = ?', array($worker_id));
|
||||
|
||||
if(!is_array($service_ids) || empty($service_ids)){
|
||||
return;
|
||||
}
|
||||
|
||||
// De-duplicate: the PK would reject repeats and abort the whole save.
|
||||
$clean = array();
|
||||
foreach($service_ids as $service_id){
|
||||
$service_id = (int)$service_id;
|
||||
if($service_id > 0){
|
||||
$clean[$service_id] = TRUE;
|
||||
}
|
||||
}
|
||||
|
||||
foreach(array_keys($clean) as $service_id){
|
||||
$this->db->query(
|
||||
'INSERT INTO worker_services (worker_id, service_id) VALUES (?, ?)',
|
||||
array($worker_id, $service_id)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Can this worker perform EVERY one of these services?
|
||||
*
|
||||
* Used as the server-side guard behind the worker picker: the picker only
|
||||
* offers capable workers, but that is a UI affordance and a crafted POST
|
||||
* must not get past it.
|
||||
*
|
||||
* @param int $worker_id
|
||||
* @param array $service_ids
|
||||
* @return bool
|
||||
*/
|
||||
public function workerCanPerformServices($worker_id, $service_ids){
|
||||
if(!is_array($service_ids) || empty($service_ids)){
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
$allowed = $this->getWorkerServiceIds($worker_id);
|
||||
|
||||
// No rows at all = unrestricted worker.
|
||||
if(empty($allowed)){
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
foreach($service_ids as $service_id){
|
||||
if(!in_array((int)$service_id, $allowed, TRUE)){
|
||||
return FALSE;
|
||||
}
|
||||
}
|
||||
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bookable workers for one service category, optionally narrowed to those
|
||||
* able to perform a specific set of services.
|
||||
*
|
||||
* Both callers are guest-facing (the booking worker picker and the
|
||||
* manage-booking worker list), so an INACTIVE worker must not appear:
|
||||
* this used to filter on is_deleted alone, which meant switching a
|
||||
* worker to inactive in the admin had no effect on the public flow.
|
||||
* getActiveWorkers() - the fallback used by the very same caller when
|
||||
* no category is known - has always filtered on is_active; the two
|
||||
* paths now agree.
|
||||
*
|
||||
* @param string $serv_cat_slug
|
||||
* @param array $service_ids selected services; empty = no capability filter
|
||||
* @return array
|
||||
*/
|
||||
public function getWorkersByCategorySlug($serv_cat_slug, $service_ids = array()){
|
||||
$this->load->database();
|
||||
|
||||
$sql = 'SELECT w.* FROM workers w
|
||||
JOIN service_categories sc ON w.service_category_id = sc.service_category_id
|
||||
WHERE w.is_deleted != "1" AND w.is_active = "1" AND sc.serv_cat_slug = ?';
|
||||
$params = array($serv_cat_slug);
|
||||
|
||||
// Capability filter. A worker qualifies when they are unrestricted (no
|
||||
// worker_services rows at all) OR their allowed set covers EVERY
|
||||
// selected service - counting matched rows against the number asked
|
||||
// for, so a worker who can do 2 of the 3 chosen services is excluded.
|
||||
$clean = array();
|
||||
if(is_array($service_ids)){
|
||||
foreach($service_ids as $service_id){
|
||||
$service_id = (int)$service_id;
|
||||
if($service_id > 0){
|
||||
$clean[$service_id] = TRUE;
|
||||
}
|
||||
}
|
||||
}
|
||||
$clean = array_keys($clean);
|
||||
|
||||
if(!empty($clean)){
|
||||
$placeholders = implode(',', array_fill(0, count($clean), '?'));
|
||||
|
||||
$sql .= ' AND (
|
||||
NOT EXISTS (SELECT 1 FROM worker_services wsx WHERE wsx.worker_id = w.worker_id)
|
||||
OR (
|
||||
SELECT COUNT(*) FROM worker_services ws
|
||||
WHERE ws.worker_id = w.worker_id
|
||||
AND ws.service_id IN ('.$placeholders.')
|
||||
) = ?
|
||||
)';
|
||||
|
||||
foreach($clean as $service_id){
|
||||
$params[] = $service_id;
|
||||
}
|
||||
$params[] = count($clean);
|
||||
}
|
||||
|
||||
$sql .= ' ORDER BY w.worker_name ASC';
|
||||
|
||||
$query = $this->db->query($sql, $params);
|
||||
return $query->result();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,144 @@
|
||||
<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">
|
||||
@@ -72,6 +213,76 @@
|
||||
<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>
|
||||
@@ -79,3 +290,38 @@
|
||||
</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 + ' – 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>
|
||||
|
||||
@@ -100,6 +100,21 @@
|
||||
<img src="<?php echo SITEURL;?>assets/img/barber/evelin/4.jpg">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dolgozo-nev">
|
||||
<hr>
|
||||
<img src="<?php echo SITEURL;?>assets/img/barber/amarildo/amarildo.jpg" class="dolgozo-kep">
|
||||
<h4>AMARILDO Champimpi <span>- Barber</span></h4>
|
||||
</div>
|
||||
<div class="bemutatkozas-dolgozo no-gallery">
|
||||
<div class="bem-dolgozo-bal">
|
||||
<p>Amarildo here, originally from Albania. I have more than six years of experience as a barber, and throughout my career I have worked in both Greece and England. I have been living in Norway for the past three years, and I am now happy to join the Studio Beve team.</p>
|
||||
<p>I specialise in both modern and classic haircuts, as well as skin fades and taper fades. Precision and attention to detail are very important to me, and I always strive to achieve the best possible result for every client.</p>
|
||||
<p>Since I started working as a barber, my profession has become a true passion of mine. I continuously develop my skills, learn new techniques, and keep myself up to date with the latest trends and styles.</p>
|
||||
<p>For me, barbering is not simply about giving someone a haircut. It is important to create a style that truly suits each individual and to make sure every client leaves my chair feeling confident and satisfied.</p>
|
||||
<p>I am excited to bring the experience I have gained over the years, my dedication to the profession, and my creativity to Studio Beve, and I look forward to welcoming you to my chair soon.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -76,6 +76,21 @@
|
||||
<img src="<?php echo SITEURL;?>assets/img/barber/evelin/4.jpg">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dolgozo-nev">
|
||||
<hr>
|
||||
<img src="<?php echo SITEURL;?>assets/img/barber/amarildo/amarildo.jpg" class="dolgozo-kep">
|
||||
<h4>AMARILDO Champimpi <span>- Barber</span></h4>
|
||||
</div>
|
||||
<div class="bemutatkozas-dolgozo no-gallery">
|
||||
<div class="bem-dolgozo-bal">
|
||||
<p>Amarildo vagyok, és Albániából érkeztem. Több mint hatéves tapasztalattal rendelkezem barberként, pályafutásom során Görögországban és Angliában is dolgoztam. Három éve élek Norvégiában, és most örömmel csatlakozom a Studio Beve csapatához.</p>
|
||||
<p>Modern és klasszikus hajvágásokra, valamint skin fade és taper fade technikákra specializálódtam. A munkámban fontos számomra a precizitás és a részletekre való odafigyelés, és mindig arra törekszem, hogy minden vendégem számára a lehető legjobb eredményt érjem el.</p>
|
||||
<p>Amióta barberként dolgozom, a szakmám valódi szenvedélyemmé vált. Folyamatosan fejlesztem a tudásomat, új technikákat tanulok, és igyekszem naprakész maradni a legújabb trendekkel és stílusokkal.</p>
|
||||
<p>Számomra a barbering nem egyszerűen egy hajvágás elkészítéséről szól. Fontos, hogy olyan stílust alakítsak ki, amely valóban illik az adott vendéghez, és hogy mindenki magabiztosan és elégedetten távozzon a székemből.</p>
|
||||
<p>Örömmel hozom a Studio Bevébe az elmúlt években megszerzett tapasztalataimat, a szakmám iránti elkötelezettségemet és a kreativitásomat, és várom, hogy hamarosan téged is üdvözölhesselek a székemben.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -77,6 +77,21 @@
|
||||
<img src="<?php echo SITEURL;?>assets/img/barber/evelin/4.jpg">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dolgozo-nev">
|
||||
<hr>
|
||||
<img src="<?php echo SITEURL;?>assets/img/barber/amarildo/amarildo.jpg" class="dolgozo-kep">
|
||||
<h4>AMARILDO Champimpi <span>- Barber</span></h4>
|
||||
</div>
|
||||
<div class="bemutatkozas-dolgozo no-gallery">
|
||||
<div class="bem-dolgozo-bal">
|
||||
<p>Jeg heter Amarildo og kommer opprinnelig fra Albania. Jeg har mer enn seks års erfaring som barber, og gjennom karrieren min har jeg jobbet både i Hellas og England. Jeg har bodd i Norge de siste tre årene, og nå er jeg glad for å bli en del av teamet hos Studio Beve.</p>
|
||||
<p>Jeg spesialiserer meg på både moderne og klassiske hårklipper, samt skin fade og taper fade. Presisjon og sans for detaljer er svært viktig for meg, og jeg streber alltid etter å oppnå best mulig resultat for hver enkelt kunde.</p>
|
||||
<p>Siden jeg begynte å jobbe som barber, har yrket blitt en ekte lidenskap for meg. Jeg utvikler meg kontinuerlig, lærer nye teknikker og holder meg oppdatert på de nyeste trendene og stilene.</p>
|
||||
<p>For meg handler barberfaget ikke bare om å klippe håret. Det er viktig å skape en stil som virkelig passer den enkelte, og sørge for at hver kunde forlater stolen med en følelse av selvtillit og tilfredshet.</p>
|
||||
<p>Jeg gleder meg til å ta med meg erfaringen jeg har opparbeidet gjennom årene, engasjementet mitt for faget og kreativiteten min til Studio Beve, og jeg ser frem til å ønske deg velkommen i stolen min.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
'after_hours' => 'The treatment would not finish before closing time. Please choose an earlier time.',
|
||||
'too_far' => 'Bookings can only be made up to 3 months in advance.',
|
||||
'category_mismatch' => 'The selected staff member does not perform the chosen services. Please start again.',
|
||||
'service_not_offered' => 'The selected staff member does not perform all of the chosen treatments. Please pick another staff member or change the treatments.',
|
||||
'single_service_only' => 'Only one treatment can be booked at a time. Please select just one.',
|
||||
'invalid_email' => 'That e-mail address does not look valid. Please check it - your confirmation and the link to manage your booking are sent there.',
|
||||
'no_service' => 'No service was selected. Please choose at least one service.',
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
'after_hours' => 'A kezelés nem érne véget zárásig. Kérjük, válasszon korábbi időpontot.',
|
||||
'too_far' => 'Foglalás legfeljebb 3 hónappal előre adható le.',
|
||||
'category_mismatch' => 'A kiválasztott kolléga nem végzi a kiválasztott szolgáltatásokat. Kérjük, válasszon újra.',
|
||||
'service_not_offered' => 'A kiválasztott kolléga nem végzi el az összes kiválasztott szolgáltatást. Kérjük, válasszon másik kollégát, vagy módosítsa a szolgáltatásokat.',
|
||||
'single_service_only' => 'Egyszerre csak egy kezelés foglalható. Kérjük, csak egyet válasszon.',
|
||||
'invalid_email' => 'Az e-mail cím nem tűnik érvényesnek. Kérjük, ellenőrizze - a visszaigazolást és a foglalás kezelésére szolgáló linket erre a címre küldjük.',
|
||||
'no_service' => 'Nem választott ki szolgáltatást. Kérjük, válasszon legalább egyet.',
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
'after_hours' => 'Behandlingen rekker ikke å bli ferdig innen stengetid. Vennligst velg et tidligere tidspunkt.',
|
||||
'too_far' => 'Du kan kun bestille time inntil 3 måneder frem i tid.',
|
||||
'category_mismatch' => 'Den valgte medarbeideren utfører ikke de valgte tjenestene. Vennligst velg på nytt.',
|
||||
'service_not_offered' => 'Den valgte medarbeideren utfører ikke alle de valgte behandlingene. Vennligst velg en annen medarbeider eller endre behandlingene.',
|
||||
'single_service_only' => 'Du kan kun bestille én behandling om gangen. Vennligst velg kun én.',
|
||||
'invalid_email' => 'E-postadressen ser ikke ut til å være gyldig. Vennligst kontroller den - bekreftelsen og lenken for å endre bestillingen sendes dit.',
|
||||
'no_service' => 'Du har ikke valgt noen tjeneste. Vennligst velg minst én tjeneste.',
|
||||
|
||||
@@ -364,12 +364,19 @@
|
||||
}
|
||||
|
||||
function getAvailableWorkersByServiceCategorySlug(categorySlug){
|
||||
// Only workers who can perform EVERY ticked service may be offered.
|
||||
// The checkbox id IS the service_id (see the service list markup).
|
||||
var selectedServiceIds = $('.service:checked').map(function(){
|
||||
return this.id;
|
||||
}).get();
|
||||
|
||||
$.ajax({
|
||||
url: '<?php echo base_url();?>ajax',
|
||||
type: 'POST',
|
||||
data: {
|
||||
action:'getAvailableWorkersByServiceCategory',
|
||||
serv_cat_slug:categorySlug
|
||||
serv_cat_slug:categorySlug,
|
||||
service_ids:selectedServiceIds
|
||||
},
|
||||
error: function() {
|
||||
},
|
||||
|
||||
@@ -365,12 +365,19 @@
|
||||
}
|
||||
|
||||
function getAvailableWorkersByServiceCategorySlug(categorySlug){
|
||||
// Only workers who can perform EVERY ticked service may be offered.
|
||||
// The checkbox id IS the service_id (see the service list markup).
|
||||
var selectedServiceIds = $('.service:checked').map(function(){
|
||||
return this.id;
|
||||
}).get();
|
||||
|
||||
$.ajax({
|
||||
url: '<?php echo base_url();?>ajax',
|
||||
type: 'POST',
|
||||
data: {
|
||||
action:'getAvailableWorkersByServiceCategory',
|
||||
serv_cat_slug:categorySlug
|
||||
serv_cat_slug:categorySlug,
|
||||
service_ids:selectedServiceIds
|
||||
},
|
||||
error: function() {
|
||||
},
|
||||
|
||||
@@ -363,12 +363,19 @@ if (is_array($services) && count($services) > 0) {
|
||||
}
|
||||
|
||||
function getAvailableWorkersByServiceCategorySlug(categorySlug){
|
||||
// Only workers who can perform EVERY ticked service may be offered.
|
||||
// The checkbox id IS the service_id (see the service list markup).
|
||||
var selectedServiceIds = $('.service:checked').map(function(){
|
||||
return this.id;
|
||||
}).get();
|
||||
|
||||
$.ajax({
|
||||
url: '<?php echo base_url();?>ajax',
|
||||
type: 'POST',
|
||||
data: {
|
||||
action:'getAvailableWorkersByServiceCategory',
|
||||
serv_cat_slug:categorySlug
|
||||
serv_cat_slug:categorySlug,
|
||||
service_ids:selectedServiceIds
|
||||
},
|
||||
error: function() {
|
||||
},
|
||||
|
||||
@@ -3364,6 +3364,11 @@ input, select, textarea {
|
||||
|
||||
}
|
||||
|
||||
/* Worker bio with no photo strip alongside - let the text use the full width. */
|
||||
.bemutatkozas-dolgozo.no-gallery .bem-dolgozo-bal{
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* Treatments - Our Services */
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.7 MiB |
Reference in New Issue
Block a user