diff --git a/application/controllers/Admin.php b/application/controllers/Admin.php index 8db679c..1c4ba56 100755 --- a/application/controllers/Admin.php +++ b/application/controllers/Admin.php @@ -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'); @@ -565,7 +614,16 @@ class Admin extends CI_Controller { $data['pageTitle'] = ''; $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)); diff --git a/application/controllers/Pages.php b/application/controllers/Pages.php index a63be1d..68f9662 100755 --- a/application/controllers/Pages.php +++ b/application/controllers/Pages.php @@ -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){ diff --git a/application/models/Service_model.php b/application/models/Service_model.php index 9672d99..f5eb020 100755 --- a/application/models/Service_model.php +++ b/application/models/Service_model.php @@ -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(); } diff --git a/application/views/admin/includes/update-worker-form.php b/application/views/admin/includes/update-worker-form.php index 3a8b4a3..d56c215 100755 --- a/application/views/admin/includes/update-worker-form.php +++ b/application/views/admin/includes/update-worker-form.php @@ -1,3 +1,144 @@ + +
| + | |
|---|---|
| + > + | ++ + | +
+
+
+ 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.
+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.
+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.
+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.
+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.
+
+
+
+ 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.
+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.
+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.
+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.
+Ö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.
+
+
+
+ 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.
+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.
+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.
+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.
+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.
+