From 3fefd34864a211151ba1bda871ef1b2615f92735 Mon Sep 17 00:00:00 2001 From: Ollama Date: Mon, 20 Apr 2026 06:36:24 +0000 Subject: [PATCH] [Feature]: Add person attributes support with unified attribute system - Add person_id column to attribute_links table for person attributes - Add person_attribute column to attribute_definitions to distinguish item vs person attributes - Add visibility flags: SHOW_IN_CUSTOMERS, SHOW_IN_EMPLOYEES, SHOW_IN_SUPPLIERS - Add person attribute methods to Attribute model (getAttributesByPerson, savePersonAttributeLink, etc.) - Refactor Persons controller with shared attribute handling (getPersonAttributes, savePersonAttributes) - Update Customers, Employees, Suppliers controllers with PSR-12 naming and attribute integration - Create attributes/person.php view for rendering person attributes - Add person attributes container to customer/employee/supplier forms Relates to #3161 --- app/Controllers/Attributes.php | 2 +- app/Controllers/Customers.php | 245 ++++++----------- app/Controllers/Employees.php | 176 +++++------- app/Controllers/Persons.php | 116 +++++--- app/Controllers/Suppliers.php | 118 +++----- ...260402000000_AddPersonToAttributeLinks.php | 61 +++++ .../20260403000000_AddPersonAttributeFlag.php | 18 ++ app/Language/en/Attributes.php | 6 + app/Models/Attribute.php | 259 +++++++++++++++++- app/Views/attributes/person.php | 169 ++++++++++++ app/Views/customers/form.php | 6 + app/Views/employees/form.php | 6 + app/Views/suppliers/form.php | 6 + 13 files changed, 816 insertions(+), 372 deletions(-) create mode 100644 app/Database/Migrations/20260402000000_AddPersonToAttributeLinks.php create mode 100644 app/Database/Migrations/20260403000000_AddPersonAttributeFlag.php create mode 100644 app/Views/attributes/person.php diff --git a/app/Controllers/Attributes.php b/app/Controllers/Attributes.php index c8da0467c..548d6211e 100644 --- a/app/Controllers/Attributes.php +++ b/app/Controllers/Attributes.php @@ -246,7 +246,7 @@ class Attributes extends Secure_Controller $data['definition_group'][''] = lang('Common.none_selected_text'); $data['definition_info'] = $info; - $show_all = Attribute::SHOW_IN_ITEMS | Attribute::SHOW_IN_RECEIVINGS | Attribute::SHOW_IN_SALES; + $show_all = Attribute::SHOW_IN_ITEMS | Attribute::SHOW_IN_RECEIVINGS | Attribute::SHOW_IN_SALES | Attribute::SHOW_IN_SEARCH | Attribute::SHOW_IN_CUSTOMERS | Attribute::SHOW_IN_EMPLOYEES | Attribute::SHOW_IN_SUPPLIERS; $data['definition_flags'] = $this->get_attributes($show_all); $selected_flags = $info->definition_flags === '' ? $show_all : $info->definition_flags; $data['selected_definition_flags'] = $this->get_attributes($selected_flags); diff --git a/app/Controllers/Customers.php b/app/Controllers/Customers.php index b4adfb455..0df47fe3d 100644 --- a/app/Controllers/Customers.php +++ b/app/Controllers/Customers.php @@ -3,7 +3,7 @@ namespace App\Controllers; use App\Libraries\Mailchimp_lib; - +use App\Models\Attribute; use App\Models\Customer; use App\Models\Customer_rewards; use App\Models\Tax_code; @@ -15,34 +15,31 @@ use stdClass; class Customers extends Persons { - private string $_list_id; - private Mailchimp_lib $mailchimp_lib; - private Customer_rewards $customer_rewards; + private string $listId; + private Mailchimp_lib $mailchimpLib; + private Customer_rewards $customerRewards; private Customer $customer; - private Tax_code $tax_code; - private array $config; + private Tax_code $taxCode; + private array $appConfig; public function __construct() { parent::__construct('customers'); - $this->mailchimp_lib = new Mailchimp_lib(); - $this->customer_rewards = model(Customer_rewards::class); + $this->mailchimpLib = new Mailchimp_lib(); + $this->customerRewards = model(Customer_rewards::class); $this->customer = model(Customer::class); - $this->tax_code = model(Tax_code::class); - $this->config = config(OSPOS::class)->settings; + $this->taxCode = model(Tax_code::class); + $this->appConfig = config(OSPOS::class)->settings; $encrypter = Services::encrypter(); - if (!empty($this->config['mailchimp_list_id'])) { - $this->_list_id = $encrypter->decrypt($this->config['mailchimp_list_id']); + if (!empty($this->appConfig['mailchimp_list_id'])) { + $this->listId = $encrypter->decrypt($this->appConfig['mailchimp_list_id']); } else { - $this->_list_id = ''; + $this->listId = ''; } } - /** - * @return string - */ public function getIndex(): string { $data['table_headers'] = get_customer_manage_table_headers(); @@ -50,19 +47,13 @@ class Customers extends Persons return view('people/manage', $data); } - /** - * Gets one row for a customer manage table. This is called using AJAX to update one row. - * @return ResponseInterface - */ - public function getRow(int $row_id): ResponseInterface + public function getRow(int $rowId): ResponseInterface { - $person = $this->customer->get_info($row_id); + $person = $this->customer->get_info($rowId); - // Retrieve the total amount the customer spent so far together with min, max and average values - $stats = $this->customer->get_stats($person->person_id); // TODO: This and the next 11 lines are duplicated in search(). Extract a method. + $stats = $this->customer->get_stats($person->person_id); if (empty($stats)) { - // Create object with empty properties. $stats = new stdClass(); $stats->total = 0; $stats->min = 0; @@ -72,17 +63,11 @@ class Customers extends Persons $stats->quantity = 0; } - $data_row = get_customer_data_row($person, $stats); + $dataRow = get_customer_data_row($person, $stats); - return $this->response->setJSON($data_row); + return $this->response->setJSON($dataRow); } - - /** - * Returns customer table data rows. This will be called with AJAX. - * - * @return void - */ public function getSearch(): ResponseInterface { $search = $this->request->getGet('search'); @@ -92,15 +77,13 @@ class Customers extends Persons $order = $this->request->getGet('order', FILTER_SANITIZE_FULL_SPECIAL_CHARS); $customers = $this->customer->search($search, $limit, $offset, $sort, $order); - $total_rows = $this->customer->get_found_rows($search); + $totalRows = $this->customer->get_found_rows($search); - $data_rows = []; + $dataRows = []; foreach ($customers->getResult() as $person) { - // Retrieve the total amount the customer spent so far together with min, max and average values - $stats = $this->customer->get_stats($person->person_id); // TODO: duplicated... see above + $stats = $this->customer->get_stats($person->person_id); if (empty($stats)) { - // Create object with empty properties. $stats = new stdClass(); $stats->total = 0; $stats->min = 0; @@ -110,16 +93,12 @@ class Customers extends Persons $stats->quantity = 0; } - $data_rows[] = get_customer_data_row($person, $stats); + $dataRows[] = get_customer_data_row($person, $stats); } - return $this->response->setJSON(['total' => $total_rows, 'rows' => $data_rows]); + return $this->response->setJSON(['total' => $totalRows, 'rows' => $dataRows]); } - /** - * Gives search suggestions based on what is being searched for - * @return ResponseInterface - */ public function getSuggest(): ResponseInterface { $search = $this->request->getGet('term'); @@ -128,10 +107,7 @@ class Customers extends Persons return $this->response->setJSON($suggestions); } - /** - * @return ResponseInterface - */ - public function suggest_search(): ResponseInterface + public function suggestSearch(): ResponseInterface { $search = $this->request->getGet('term'); $suggestions = $this->customer->get_search_suggestions($search, 25, false); @@ -139,16 +115,11 @@ class Customers extends Persons return $this->response->setJSON($suggestions); } - /** - * Loads the customer edit form - * @return string - */ - public function getView(int $customer_id = NEW_ENTRY): string + public function getView(int $customerId = NEW_ENTRY): string { - // Set default values - if ($customer_id == null) $customer_id = NEW_ENTRY; + if ($customerId == null) $customerId = NEW_ENTRY; - $info = $this->customer->get_info($customer_id); + $info = $this->customer->get_info($customerId); foreach (get_object_vars($info) as $property => $value) { $info->$property = $value; } @@ -159,28 +130,27 @@ class Customers extends Persons $data['person_info']->employee_id = $this->employee->get_logged_in_employee_info()->person_id; } - $employee_info = $this->employee->get_info($info->employee_id); - $data['employee'] = $employee_info->first_name . ' ' . $employee_info->last_name; + $employeeInfo = $this->employee->get_info($info->employee_id); + $data['employee'] = $employeeInfo->first_name . ' ' . $employeeInfo->last_name; - $tax_code_info = $this->tax_code->get_info($info->sales_tax_code_id); + $taxCodeInfo = $this->taxCode->get_info($info->sales_tax_code_id); - if ($tax_code_info->tax_code != null) { - $data['sales_tax_code_label'] = $tax_code_info->tax_code . ' ' . $tax_code_info->tax_code_name; + if ($taxCodeInfo->tax_code != null) { + $data['sales_tax_code_label'] = $taxCodeInfo->tax_code . ' ' . $taxCodeInfo->tax_code_name; } else { $data['sales_tax_code_label'] = ''; } $packages = ['' => lang('Items.none')]; - foreach ($this->customer_rewards->get_all()->getResultArray() as $row) { + foreach ($this->customerRewards->get_all()->getResultArray() as $row) { $packages[$row['package_id']] = $row['package_name']; } $data['packages'] = $packages; $data['selected_package'] = $info->package_id; - $data['use_destination_based_tax'] = $this->config['use_destination_based_tax']; + $data['use_destination_based_tax'] = $this->appConfig['use_destination_based_tax']; - // Retrieve the total amount the customer spent so far together with min, max and average values - $stats = $this->customer->get_stats($customer_id); + $stats = $this->customer->get_stats($customerId); if (!empty($stats)) { foreach (get_object_vars($stats) as $property => $value) { $info->$property = $value; @@ -188,14 +158,11 @@ class Customers extends Persons $data['stats'] = $stats; } - // Retrieve the info from Mailchimp only if there is an email address assigned if (!empty($info->email)) { - // Collect Mailchimp customer info - if (($mailchimp_info = $this->mailchimp_lib->getMemberInfo($this->_list_id, $info->email)) !== false) { - $data['mailchimp_info'] = $mailchimp_info; + if (($mailchimpInfo = $this->mailchimpLib->getMemberInfo($this->listId, $info->email)) !== false) { + $data['mailchimp_info'] = $mailchimpInfo; - // Collect customer Mailchimp emails activities (stats) - if (($activities = $this->mailchimp_lib->getMemberActivity($this->_list_id, $info->email)) !== false) { + if (($activities = $this->mailchimpLib->getMemberActivity($this->listId, $info->email)) !== false) { if (array_key_exists('activity', $activities)) { $open = 0; $unopen = 0; @@ -235,22 +202,25 @@ class Customers extends Persons } /** - * Inserts/updates a customer - * @return ResponseInterface + * Gets person attributes for a customer (AJAX) */ - public function postSave(int $customer_id = NEW_ENTRY): ResponseInterface + public function getAttributes(int $customerId = NEW_ENTRY): string { - $first_name = $this->request->getPost('first_name'); - $last_name = $this->request->getPost('last_name'); + return $this->getPersonAttributes($customerId, Attribute::SHOW_IN_CUSTOMERS); + } + + public function postSave(int $customerId = NEW_ENTRY): ResponseInterface + { + $firstName = $this->request->getPost('first_name'); + $lastName = $this->request->getPost('last_name'); $email = strtolower($this->request->getPost('email', FILTER_SANITIZE_EMAIL)); - // Format first and last name properly - $first_name = $this->nameize($first_name); - $last_name = $this->nameize($last_name); + $firstName = $this->nameize($firstName); + $lastName = $this->nameize($lastName); - $person_data = [ - 'first_name' => $first_name, - 'last_name' => $last_name, + $personData = [ + 'first_name' => $firstName, + 'last_name' => $lastName, 'gender' => $this->request->getPost('gender', FILTER_SANITIZE_NUMBER_INT), 'email' => $email, 'phone_number' => $this->request->getPost('phone_number'), @@ -263,9 +233,9 @@ class Customers extends Persons 'comments' => $this->request->getPost('comments') ]; - $date_formatter = date_create_from_format($this->config['dateformat'] . ' ' . $this->config['timeformat'], $this->request->getPost('date')); + $dateFormatter = date_create_from_format($this->appConfig['dateformat'] . ' ' . $this->appConfig['timeformat'], $this->request->getPost('date')); - $customer_data = [ + $customerData = [ 'consent' => $this->request->getPost('consent') != null, 'account_number' => $this->request->getPost('account_number') == '' ? null : $this->request->getPost('account_number'), 'tax_id' => $this->request->getPost('tax_id'), @@ -274,68 +244,57 @@ class Customers extends Persons 'discount_type' => $this->request->getPost('discount_type') == null ? PERCENT : $this->request->getPost('discount_type', FILTER_SANITIZE_NUMBER_INT), 'package_id' => $this->request->getPost('package_id') == '' ? null : $this->request->getPost('package_id'), 'taxable' => $this->request->getPost('taxable') != null, - 'date' => $date_formatter->format('Y-m-d H:i:s'), + 'date' => $dateFormatter->format('Y-m-d H:i:s'), 'employee_id' => $this->request->getPost('employee_id', FILTER_SANITIZE_NUMBER_INT), 'sales_tax_code_id' => $this->request->getPost('sales_tax_code_id') == '' ? null : $this->request->getPost('sales_tax_code_id', FILTER_SANITIZE_NUMBER_INT) ]; - if ($this->customer->save_customer($person_data, $customer_data, $customer_id)) { - // Save customer to Mailchimp selected list // TODO: addOrUpdateMember should be refactored. Potentially pass an array or object instead of 6 parameters. - $mailchimp_status = $this->request->getPost('mailchimp_status'); - $this->mailchimp_lib->addOrUpdateMember( - $this->_list_id, + if ($this->customer->save_customer($personData, $customerData, $customerId)) { + $personId = $customerId == NEW_ENTRY ? $customerData['person_id'] : $customerId; + $this->savePersonAttributes($personId, Attribute::SHOW_IN_CUSTOMERS); + + $mailchimpStatus = $this->request->getPost('mailchimp_status'); + $this->mailchimpLib->addOrUpdateMember( + $this->listId, $email, - $first_name, - $last_name, - $mailchimp_status == null ? "" : $mailchimp_status, + $firstName, + $lastName, + $mailchimpStatus == null ? "" : $mailchimpStatus, ['vip' => $this->request->getPost('mailchimp_vip') != null] ); - // New customer - if ($customer_id == NEW_ENTRY) { + if ($customerId == NEW_ENTRY) { return $this->response->setJSON([ 'success' => true, - 'message' => lang('Customers.successful_adding') . ' ' . $first_name . ' ' . $last_name, - 'id' => $customer_data['person_id'] + 'message' => lang('Customers.successful_adding') . ' ' . $firstName . ' ' . $lastName, + 'id' => $customerData['person_id'] ]); - } else { // Existing customer + } else { return $this->response->setJSON([ 'success' => true, - 'message' => lang('Customers.successful_updating') . ' ' . $first_name . ' ' . $last_name, - 'id' => $customer_id + 'message' => lang('Customers.successful_updating') . ' ' . $firstName . ' ' . $lastName, + 'id' => $customerId ]); } - } else { // Failure + } else { return $this->response->setJSON([ 'success' => false, - 'message' => lang('Customers.error_adding_updating') . ' ' . $first_name . ' ' . $last_name, + 'message' => lang('Customers.error_adding_updating') . ' ' . $firstName . ' ' . $lastName, 'id' => NEW_ENTRY ]); } } - /** - * Verifies if an email address already exists. Used in app/Views/customers/form.php - * - * @return ResponseInterface - * @noinspection PhpUnused - */ public function postCheckEmail(): ResponseInterface { $email = strtolower($this->request->getPost('email', FILTER_SANITIZE_EMAIL)); - $person_id = $this->request->getPost('person_id', FILTER_SANITIZE_NUMBER_INT); + $personId = $this->request->getPost('person_id', FILTER_SANITIZE_NUMBER_INT); - $exists = $this->customer->check_email_exists($email, $person_id); + $exists = $this->customer->check_email_exists($email, $personId); return $this->response->setJSON(!$exists ? 'true' : 'false'); } - /** - * Verifies if an account number already exists. Used in app/Views/customers/form.php - * - * @return ResponseInterface - * @noinspection PhpUnused - */ public function postCheckAccountNumber(): ResponseInterface { $exists = $this->customer->check_account_number_exists($this->request->getPost('account_number'), $this->request->getPost('person_id', FILTER_SANITIZE_NUMBER_INT)); @@ -343,27 +302,22 @@ class Customers extends Persons return $this->response->setJSON(!$exists ? 'true' : 'false'); } - /** - * This deletes customers from the customers table - * @return ResponseInterface - */ public function postDelete(): ResponseInterface { - $customers_to_delete = $this->request->getPost('ids'); - $customers_info = $this->customer->get_multiple_info($customers_to_delete); + $customersToDelete = $this->request->getPost('ids'); + $customersInfo = $this->customer->get_multiple_info($customersToDelete); $count = 0; - foreach ($customers_info->getResult() as $info) { + foreach ($customersInfo->getResult() as $info) { if ($this->customer->delete($info->person_id)) { - // remove customer from Mailchimp selected list - $this->mailchimp_lib->removeMember($this->_list_id, $info->email); + $this->mailchimpLib->removeMember($this->listId, $info->email); $count++; } } - if ($count == count($customers_to_delete)) { + if ($count == count($customersToDelete)) { return $this->response->setJSON([ 'success' => true, 'message' => lang('Customers.successful_deleted') . ' ' . $count . ' ' . lang('Customers.one_or_multiple') @@ -373,12 +327,6 @@ class Customers extends Persons } } - /** - * Customers import from csv spreadsheet - * - * @return DownloadResponse The template for Customer CSV imports is returned and download forced. - * @noinspection PhpUnused - */ public function getCsv(): DownloadResponse { $name = 'importCustomers.csv'; @@ -386,30 +334,17 @@ class Customers extends Persons return $this->response->download($name, $data); } - /** - * Displays the customer CSV import modal. Used in app/Views/people/manage.php - * - * @return string - * @noinspection PhpUnused - */ public function getCsvImport(): string { return view('customers/form_csv_import'); } - /** - * Imports a CSV file containing customers. Used in app/Views/customers/form_csv_import.php - * - * @return ResponseInterface - * @noinspection PhpUnused - */ public function postImportCsvFile(): ResponseInterface { if ($_FILES['file_path']['error'] != UPLOAD_ERR_OK) { return $this->response->setJSON(['success' => false, 'message' => lang('Customers.csv_import_failed')]); } else { if (($handle = fopen($_FILES['file_path']['tmp_name'], 'r')) !== false) { - // Skip the first row as it's the table description fgetcsv($handle); $i = 1; @@ -420,7 +355,7 @@ class Customers extends Persons if (sizeof($data) >= 16 && $consent) { $email = strtolower($data[4]); - $person_data = [ + $personData = [ 'first_name' => $data[0], 'last_name' => $data[1], 'gender' => $data[2], @@ -435,7 +370,7 @@ class Customers extends Persons 'comments' => $data[12] ]; - $customer_data = [ + $customerData = [ 'consent' => $consent, 'company_name' => $data[13], 'discount' => $data[15], @@ -444,14 +379,13 @@ class Customers extends Persons 'date' => date('Y-m-d H:i:s'), 'employee_id' => $this->employee->get_logged_in_employee_info()->person_id ]; - $account_number = $data[14]; + $accountNumber = $data[14]; - // Don't duplicate people with same email $invalidated = $this->customer->check_email_exists($email); - if ($account_number != '') { - $customer_data['account_number'] = $account_number; - $invalidated &= $this->customer->check_account_number_exists($account_number); + if ($accountNumber != '') { + $customerData['account_number'] = $accountNumber; + $invalidated &= $this->customer->check_account_number_exists($accountNumber); } } else { $invalidated = true; @@ -460,9 +394,8 @@ class Customers extends Persons if ($invalidated) { $failCodes[] = $i; log_message('error', "Row $i was not imported: Either email or account number already exist or data was invalid."); - } elseif ($this->customer->save_customer($person_data, $customer_data)) { - // Save customer to Mailchimp selected list - $this->mailchimp_lib->addOrUpdateMember($this->_list_id, $person_data['email'], $person_data['first_name'], '', $person_data['last_name']); + } elseif ($this->customer->save_customer($personData, $customerData)) { + $this->mailchimpLib->addOrUpdateMember($this->listId, $personData['email'], $personData['first_name'], '', $personData['last_name']); } else { $failCodes[] = $i; } @@ -482,4 +415,4 @@ class Customers extends Persons } } } -} +} \ No newline at end of file diff --git a/app/Controllers/Employees.php b/app/Controllers/Employees.php index 8e183d2d9..3ad473968 100644 --- a/app/Controllers/Employees.php +++ b/app/Controllers/Employees.php @@ -2,18 +2,15 @@ namespace App\Controllers; +use App\Models\Attribute; use App\Models\Module; use CodeIgniter\HTTP\ResponseInterface; use Config\Services; -/** - * - * - * @property module module - * - */ class Employees extends Persons { + protected Module $module; + public function __construct() { parent::__construct('employees'); @@ -21,35 +18,25 @@ class Employees extends Persons $this->module = model('Module'); } - /** - * Returns employee table data rows. This will be called with AJAX. - * - * @return void - */ public function getSearch(): ResponseInterface { $search = $this->request->getGet('search'); - $limit = $this->request->getGet('limit', FILTER_SANITIZE_NUMBER_INT); + $limit = $this->request->getGet('limit', FILTER_SANITIZE_NUMBER_INT); $offset = $this->request->getGet('offset', FILTER_SANITIZE_NUMBER_INT); - $sort = $this->sanitizeSortColumn(person_headers(), $this->request->getGet('sort', FILTER_SANITIZE_FULL_SPECIAL_CHARS), 'people.person_id'); - $order = $this->request->getGet('order', FILTER_SANITIZE_FULL_SPECIAL_CHARS); + $sort = $this->sanitizeSortColumn(person_headers(), $this->request->getGet('sort', FILTER_SANITIZE_FULL_SPECIAL_CHARS), 'people.person_id'); + $order = $this->request->getGet('order', FILTER_SANITIZE_FULL_SPECIAL_CHARS); $employees = $this->employee->search($search, $limit, $offset, $sort, $order); - $total_rows = $this->employee->get_found_rows($search); + $totalRows = $this->employee->get_found_rows($search); - $data_rows = []; + $dataRows = []; foreach ($employees->getResult() as $person) { - $data_rows[] = get_person_data_row($person); + $dataRows[] = get_person_data_row($person); } - return $this->response->setJSON(['total' => $total_rows, 'rows' => $data_rows]); + return $this->response->setJSON(['total' => $totalRows, 'rows' => $dataRows]); } - /** - * AJAX called function gives search suggestions based on what is being searched for. - * - * @return ResponseInterface - */ public function getSuggest(): ResponseInterface { $search = $this->request->getGet('term'); @@ -58,10 +45,7 @@ class Employees extends Persons return $this->response->setJSON($suggestions); } - /** - * @return ResponseInterface - */ - public function suggest_search(): ResponseInterface + public function suggestSearch(): ResponseInterface { $search = $this->request->getPost('term'); $suggestions = $this->employee->get_search_suggestions($search); @@ -69,39 +53,35 @@ class Employees extends Persons return $this->response->setJSON($suggestions); } - /** - * Loads the employee edit form - * @return string - */ - public function getView(int $employee_id = NEW_ENTRY): string + public function getView(int $employeeId = NEW_ENTRY): string { - $person_info = $this->employee->get_info($employee_id); - $current_user = $this->employee->get_logged_in_employee_info(); + $personInfo = $this->employee->get_info($employeeId); + $currentUser = $this->employee->get_logged_in_employee_info(); - if ($employee_id != NEW_ENTRY && !$this->employee->canModifyEmployee($person_info->person_id, $current_user->person_id)) { + if ($employeeId != NEW_ENTRY && !$this->employee->canModifyEmployee($personInfo->person_id, $currentUser->person_id)) { header('Location: ' . base_url('no_access/employees/employees')); exit(); } - foreach (get_object_vars($person_info) as $property => $value) { - $person_info->$property = $value; + foreach (get_object_vars($personInfo) as $property => $value) { + $personInfo->$property = $value; } - $data['person_info'] = $person_info; - $data['employee_id'] = $employee_id; + $data['person_info'] = $personInfo; + $data['employee_id'] = $employeeId; $modules = []; foreach ($this->module->get_all_modules()->getResult() as $module) { - $module->grant = $this->employee->has_grant($module->module_id, $person_info->person_id); - $module->menu_group = $this->employee->get_menu_group($module->module_id, $person_info->person_id); + $module->grant = $this->employee->has_grant($module->module_id, $personInfo->person_id); + $module->menu_group = $this->employee->get_menu_group($module->module_id, $personInfo->person_id); $modules[] = $module; } $data['all_modules'] = $modules; $permissions = []; - foreach ($this->module->get_all_subpermissions()->getResult() as $permission) { // TODO: subpermissions does not follow naming standards. + foreach ($this->module->get_all_subpermissions()->getResult() as $permission) { $permission->permission_id = str_replace(' ', '_', $permission->permission_id); - $permission->grant = $this->employee->has_grant($permission->permission_id, $person_info->person_id); + $permission->grant = $this->employee->has_grant($permission->permission_id, $personInfo->person_id); $permissions[] = $permission; } @@ -110,17 +90,18 @@ class Employees extends Persons return view('employees/form', $data); } - /** - * Inserts/updates an employee - * @return ResponseInterface - */ - public function postSave(int $employee_id = NEW_ENTRY): ResponseInterface + public function getAttributes(int $employeeId = NEW_ENTRY): string { - $current_user = $this->employee->get_logged_in_employee_info(); + return $this->getPersonAttributes($employeeId, Attribute::SHOW_IN_EMPLOYEES); + } - if ($employee_id != NEW_ENTRY) { - $target_employee = $this->employee->get_info($employee_id); - if (!$this->employee->canModifyEmployee($target_employee->person_id, $current_user->person_id)) { + public function postSave(int $employeeId = NEW_ENTRY): ResponseInterface + { + $currentUser = $this->employee->get_logged_in_employee_info(); + + if ($employeeId != NEW_ENTRY) { + $targetEmployee = $this->employee->get_info($employeeId); + if (!$this->employee->canModifyEmployee($targetEmployee->person_id, $currentUser->person_id)) { return $this->response->setJSON([ 'success' => false, 'message' => lang('Employees.error_updating_admin'), @@ -129,17 +110,16 @@ class Employees extends Persons } } - $first_name = $this->request->getPost('first_name', FILTER_SANITIZE_FULL_SPECIAL_CHARS); // TODO: duplicated code - $last_name = $this->request->getPost('last_name', FILTER_SANITIZE_FULL_SPECIAL_CHARS); + $firstName = $this->request->getPost('first_name', FILTER_SANITIZE_FULL_SPECIAL_CHARS); + $lastName = $this->request->getPost('last_name', FILTER_SANITIZE_FULL_SPECIAL_CHARS); $email = strtolower($this->request->getPost('email', FILTER_SANITIZE_EMAIL)); - // format first and last name properly - $first_name = $this->nameize($first_name); - $last_name = $this->nameize($last_name); + $firstName = $this->nameize($firstName); + $lastName = $this->nameize($lastName); - $person_data = [ - 'first_name' => $first_name, - 'last_name' => $last_name, + $personData = [ + 'first_name' => $firstName, + 'last_name' => $lastName, 'gender' => $this->request->getPost('gender', FILTER_SANITIZE_NUMBER_INT), 'email' => $email, 'phone_number' => $this->request->getPost('phone_number', FILTER_SANITIZE_FULL_SPECIAL_CHARS), @@ -152,108 +132,98 @@ class Employees extends Persons 'comments' => $this->request->getPost('comments', FILTER_SANITIZE_FULL_SPECIAL_CHARS) ]; - $grants_array = []; - $isAdmin = $this->employee->isAdmin($current_user->person_id); + $grantsArray = []; + $isAdmin = $this->employee->isAdmin($currentUser->person_id); foreach ($this->module->get_all_permissions()->getResult() as $permission) { $grants = []; $grant = $this->request->getPost('grant_' . $permission->permission_id) != null ? $this->request->getPost('grant_' . $permission->permission_id, FILTER_SANITIZE_FULL_SPECIAL_CHARS) : ''; if ($grant == $permission->permission_id) { - if (!$isAdmin && !$this->employee->has_grant($permission->permission_id, $current_user->person_id)) { + if (!$isAdmin && !$this->employee->has_grant($permission->permission_id, $currentUser->person_id)) { continue; } $grants['permission_id'] = $permission->permission_id; $grants['menu_group'] = $this->request->getPost('menu_group_' . $permission->permission_id) != null ? $this->request->getPost('menu_group_' . $permission->permission_id, FILTER_SANITIZE_FULL_SPECIAL_CHARS) : '--'; - $grants_array[] = $grants; + $grantsArray[] = $grants; } } - // Password has been changed OR first time password set if (!empty($this->request->getPost('password')) && ENVIRONMENT != 'testing') { $exploded = explode(":", $this->request->getPost('language', FILTER_SANITIZE_FULL_SPECIAL_CHARS)); - $employee_data = [ + $employeeData = [ 'username' => $this->request->getPost('username', FILTER_SANITIZE_FULL_SPECIAL_CHARS), 'password' => password_hash($this->request->getPost('password'), PASSWORD_DEFAULT), 'hash_version' => 2, 'language_code' => $exploded[0], 'language' => $exploded[1] ]; - } else { // Password not changed + } else { $exploded = explode(":", $this->request->getPost('language', FILTER_SANITIZE_FULL_SPECIAL_CHARS)); - $employee_data = [ + $employeeData = [ 'username' => $this->request->getPost('username', FILTER_SANITIZE_FULL_SPECIAL_CHARS), 'language_code' => $exploded[0], 'language' => $exploded[1] ]; } - if ($this->employee->save_employee($person_data, $employee_data, $grants_array, $employee_id)) { - // New employee - if ($employee_id == NEW_ENTRY) { + if ($this->employee->save_employee($personData, $employeeData, $grantsArray, $employeeId)) { + $personId = $employeeId == NEW_ENTRY ? $employeeData['person_id'] : $employeeId; + $this->savePersonAttributes($personId, Attribute::SHOW_IN_EMPLOYEES); + + if ($employeeId == NEW_ENTRY) { return $this->response->setJSON([ 'success' => true, - 'message' => lang('Employees.successful_adding') . ' ' . $first_name . ' ' . $last_name, - 'id' => $employee_data['person_id'] + 'message' => lang('Employees.successful_adding') . ' ' . $firstName . ' ' . $lastName, + 'id' => $employeeData['person_id'] ]); - } else { // Existing employee - $logged_in_employee_id = session()->get('person_id'); - if ($employee_id == $logged_in_employee_id) { - session()->set('language_code', $employee_data['language_code']); - session()->set('language', $employee_data['language']); + } else { + $loggedInEmployeeId = session()->get('person_id'); + if ($employeeId == $loggedInEmployeeId) { + session()->set('language_code', $employeeData['language_code']); + session()->set('language', $employeeData['language']); } return $this->response->setJSON([ 'success' => true, - 'message' => lang('Employees.successful_updating') . ' ' . $first_name . ' ' . $last_name, - 'id' => $employee_id + 'message' => lang('Employees.successful_updating') . ' ' . $firstName . ' ' . $lastName, + 'id' => $employeeId ]); } - } else { // Failure + } else { return $this->response->setJSON([ 'success' => false, - 'message' => lang('Employees.error_adding_updating') . ' ' . $first_name . ' ' . $last_name, + 'message' => lang('Employees.error_adding_updating') . ' ' . $firstName . ' ' . $lastName, 'id' => NEW_ENTRY ]); } } - /** - * This deletes employees from the employees table - * @return ResponseInterface - */ public function postDelete(): ResponseInterface { - $employees_to_delete = $this->request->getPost('ids', FILTER_SANITIZE_FULL_SPECIAL_CHARS); - $current_user = $this->employee->get_logged_in_employee_info(); + $employeesToDelete = $this->request->getPost('ids', FILTER_SANITIZE_FULL_SPECIAL_CHARS); + $currentUser = $this->employee->get_logged_in_employee_info(); - if (!$this->employee->isAdmin($current_user->person_id)) { - foreach ($employees_to_delete as $emp_id) { - if ($this->employee->isAdmin((int)$emp_id)) { + if (!$this->employee->isAdmin($currentUser->person_id)) { + foreach ($employeesToDelete as $empId) { + if ($this->employee->isAdmin((int)$empId)) { return $this->response->setJSON(['success' => false, 'message' => lang('Employees.error_deleting_admin')]); } } } - if ($this->employee->delete_list($employees_to_delete)) { // TODO: this is passing a string, but delete_list expects an array + if ($this->employee->delete_list($employeesToDelete)) { return $this->response->setJSON([ 'success' => true, - 'message' => lang('Employees.successful_deleted') . ' ' . count($employees_to_delete) . ' ' . lang('Employees.one_or_multiple') + 'message' => lang('Employees.successful_deleted') . ' ' . count($employeesToDelete) . ' ' . lang('Employees.one_or_multiple') ]); } else { return $this->response->setJSON(['success' => false, 'message' => lang('Employees.cannot_be_deleted')]); } } - /** - * Checks an employee username against the database. Used in app\Views\employees\form.php - * - * @param $employee_id - * @return ResponseInterface - * @noinspection PhpUnused - */ - public function getCheckUsername($employee_id): ResponseInterface + public function getCheckUsername($employeeId): ResponseInterface { - $exists = $this->employee->username_exists($employee_id, $this->request->getGet('username')); + $exists = $this->employee->username_exists($employeeId, $this->request->getGet('username')); return $this->response->setJSON(!$exists ? 'true' : 'false'); } -} +} \ No newline at end of file diff --git a/app/Controllers/Persons.php b/app/Controllers/Persons.php index afc2d4839..870d03fc0 100644 --- a/app/Controllers/Persons.php +++ b/app/Controllers/Persons.php @@ -2,28 +2,28 @@ namespace App\Controllers; +use App\Models\Attribute; use App\Models\Person; use CodeIgniter\HTTP\ResponseInterface; +use Config\OSPOS; use Config\Services; use function Tamtamchik\NameCase\str_name_case; abstract class Persons extends Secure_Controller { protected Person $person; + protected Attribute $attribute; + protected array $appConfig; - /** - * @param string|null $module_id - */ - public function __construct(?string $module_id = null) + public function __construct(?string $moduleId = null) { - parent::__construct($module_id); + parent::__construct($moduleId); $this->person = model(Person::class); + $this->attribute = model(Attribute::class); + $this->appConfig = config(OSPOS::class)->settings; } - /** - * @return string - */ public function getIndex(): string { $data['table_headers'] = get_people_manage_table_headers(); @@ -31,10 +31,6 @@ abstract class Persons extends Secure_Controller return view('people/manage', $data); } - /** - * Gives search suggestions based on what is being searched for - * @return ResponseInterface - */ public function getSuggest(): ResponseInterface { $search = $this->request->getGet('term'); @@ -43,34 +39,88 @@ abstract class Persons extends Secure_Controller return $this->response->setJSON($suggestions); } - /** - * Gets one row for a person manage table. This is called using AJAX to update one row. - * @return ResponseInterface - */ - public function getRow(int $row_id): ResponseInterface + public function getRow(int $rowId): ResponseInterface { - $data_row = get_person_data_row($this->person->get_info($row_id)); + $dataRow = get_person_data_row($this->person->get_info($rowId)); - return $this->response->setJSON($data_row); + return $this->response->setJSON($dataRow); + } + + protected function getPersonAttributes(int $personId, int $definitionFlags): string + { + $data['person_id'] = $personId; + $data['config'] = $this->appConfig; + $definitionIds = json_decode($this->request->getGet('definition_ids') ?? '', true); + $data['definition_values'] = $this->attribute->getAttributesByPerson($personId) + $this->attribute->get_values_by_definitions($definitionIds); + $data['definition_names'] = $this->attribute->getDefinitionsByType(true, $definitionFlags); + + foreach ($data['definition_values'] as $definitionId => $definitionValue) { + $attributeValue = $this->attribute->getPersonAttributeValue($personId, $definitionId); + $attributeId = (empty($attributeValue) || empty($attributeValue->attribute_id)) ? null : $attributeValue->attribute_id; + $values = &$data['definition_values'][$definitionId]; + $values['attribute_id'] = $attributeId; + $values['attribute_value'] = $attributeValue; + $values['selected_value'] = ''; + + if ($definitionValue['definition_type'] === DROPDOWN) { + $values['values'] = $this->attribute->get_definition_values($definitionId); + $linkValue = $this->getPersonLinkValue($personId, $definitionId); + $values['selected_value'] = (empty($linkValue)) ? '' : $linkValue->attribute_id; + } + + if (!empty($definitionIds[$definitionId])) { + $values['selected_value'] = $definitionIds[$definitionId]; + } + + unset($data['definition_names'][$definitionId]); + } + + return view('attributes/person', $data); + } + + private function getPersonLinkValue(int $personId, int $definitionId): ?object + { + $builder = $this->db->table('attribute_links'); + $builder->where('person_id', $personId); + $builder->where('item_id', null); + $builder->where('sale_id', null); + $builder->where('receiving_id', null); + $builder->where('definition_id', $definitionId); + + return $builder->get()->getRowObject(); + } + + protected function savePersonAttributes(int $personId, int $definitionFlags): void + { + $attributeLinks = $this->request->getPost('attribute_links') ?? []; + $attributeIds = $this->request->getPost('attribute_ids') ?? []; + + $this->attribute->deletePersonAttributeLinks($personId); + + foreach ($attributeLinks as $definitionId => $attributeId) { + $definitionInfo = $this->attribute->getAttributeInfo((int)$definitionId); + $definitionType = $definitionInfo->definition_type; + + if ($definitionType !== DROPDOWN) { + $attributeId = $this->attribute->savePersonAttributeValue( + $attributeId, + (int)$definitionId, + $personId, + $attributeIds[$definitionId] ?? false, + $definitionType + ); + } + + $this->attribute->savePersonAttributeLink($personId, (int)$definitionId, (int)$attributeId); + } } - /** - * Capitalize segments of a name, and put the rest into lower case. - * You can pass the characters you want to use as delimiters as exceptions. - * The function supports UTF-8 strings - * - * Example: - * i.e. - * - * returns John O'Grady-Smith - */ protected function nameize(string $input): string { - $adjusted_name = str_name_case($input); + $adjustedName = str_name_case($input); - // TODO: Use preg_replace to match HTML entities and convert them to lowercase. This is a workaround for https://github.com/tamtamchik/namecase/issues/20 return preg_replace_callback('/&[a-zA-Z0-9#]+;/', function ($matches) { return strtolower($matches[0]); - }, $adjusted_name); + }, $adjustedName); } -} +} \ No newline at end of file diff --git a/app/Controllers/Suppliers.php b/app/Controllers/Suppliers.php index 0d6afcd21..bafea51e1 100644 --- a/app/Controllers/Suppliers.php +++ b/app/Controllers/Suppliers.php @@ -2,6 +2,7 @@ namespace App\Controllers; +use App\Models\Attribute; use App\Models\Supplier; use CodeIgniter\HTTP\ResponseInterface; use Config\Services; @@ -17,9 +18,6 @@ class Suppliers extends Persons $this->supplier = model(Supplier::class); } - /** - * @return string - */ public function getIndex(): string { $data['table_headers'] = get_suppliers_manage_table_headers(); @@ -27,23 +25,14 @@ class Suppliers extends Persons return view('people/manage', $data); } - /** - * Gets one row for a supplier manage table. This is called using AJAX to update one row. - * @param $row_id - * @return ResponseInterface - */ - public function getRow($row_id): ResponseInterface + public function getRow($rowId): ResponseInterface { - $data_row = get_supplier_data_row($this->supplier->get_info($row_id)); - $data_row['category'] = $this->supplier->get_category_name($data_row['category']); + $dataRow = get_supplier_data_row($this->supplier->get_info($rowId)); + $dataRow['category'] = $this->supplier->get_category_name($dataRow['category']); - return $this->response->setJSON($data_row); + return $this->response->setJSON($dataRow); } - /** - * Returns Supplier table data rows. This will be called with AJAX. - * @return void - **/ public function getSearch(): ResponseInterface { $search = $this->request->getGet('search'); @@ -53,23 +42,19 @@ class Suppliers extends Persons $order = $this->request->getGet('order', FILTER_SANITIZE_FULL_SPECIAL_CHARS); $suppliers = $this->supplier->search($search, $limit, $offset, $sort, $order); - $total_rows = $this->supplier->get_found_rows($search); + $totalRows = $this->supplier->get_found_rows($search); - $data_rows = []; + $dataRows = []; foreach ($suppliers->getResult() as $supplier) { $row = get_supplier_data_row($supplier); $row['category'] = $this->supplier->get_category_name($row['category']); - $data_rows[] = $row; + $dataRows[] = $row; } - return $this->response->setJSON(['total' => $total_rows, 'rows' => $data_rows]); + return $this->response->setJSON(['total' => $totalRows, 'rows' => $dataRows]); } - /** - * Gives search suggestions based on what is being searched for - * @return ResponseInterface - **/ public function getSuggest(): ResponseInterface { $search = $this->request->getGet('term'); @@ -78,10 +63,7 @@ class Suppliers extends Persons return $this->response->setJSON($suggestions); } - /** - * @return ResponseInterface - */ - public function suggest_search(): ResponseInterface + public function suggestSearch(): ResponseInterface { $search = $this->request->getPost('term'); $suggestions = $this->supplier->get_search_suggestions($search, false); @@ -89,15 +71,9 @@ class Suppliers extends Persons return $this->response->setJSON($suggestions); } - /** - * Loads the supplier edit form - * - * @param int $supplier_id - * @return string - */ - public function getView(int $supplier_id = NEW_ENTRY): string + public function getView(int $supplierId = NEW_ENTRY): string { - $info = $this->supplier->get_info($supplier_id); + $info = $this->supplier->get_info($supplierId); foreach (get_object_vars($info) as $property => $value) { $info->$property = $value; } @@ -107,25 +83,23 @@ class Suppliers extends Persons return view("suppliers/form", $data); } - /** - * Inserts/updates a supplier - * - * @param int $supplier_id - * @return ResponseInterface - */ - public function postSave(int $supplier_id = NEW_ENTRY): ResponseInterface + public function getAttributes(int $supplierId = NEW_ENTRY): string { - $first_name = $this->request->getPost('first_name', FILTER_SANITIZE_FULL_SPECIAL_CHARS); // TODO: Duplicate code - $last_name = $this->request->getPost('last_name', FILTER_SANITIZE_FULL_SPECIAL_CHARS); + return $this->getPersonAttributes($supplierId, Attribute::SHOW_IN_SUPPLIERS); + } + + public function postSave(int $supplierId = NEW_ENTRY): ResponseInterface + { + $firstName = $this->request->getPost('first_name', FILTER_SANITIZE_FULL_SPECIAL_CHARS); + $lastName = $this->request->getPost('last_name', FILTER_SANITIZE_FULL_SPECIAL_CHARS); $email = strtolower($this->request->getPost('email', FILTER_SANITIZE_EMAIL)); - // Format first and last name properly - $first_name = $this->nameize($first_name); - $last_name = $this->nameize($last_name); + $firstName = $this->nameize($firstName); + $lastName = $this->nameize($lastName); - $person_data = [ - 'first_name' => $first_name, - 'last_name' => $last_name, + $personData = [ + 'first_name' => $firstName, + 'last_name' => $lastName, 'gender' => $this->request->getPost('gender'), 'email' => $email, 'phone_number' => $this->request->getPost('phone_number', FILTER_SANITIZE_FULL_SPECIAL_CHARS), @@ -138,7 +112,7 @@ class Suppliers extends Persons 'comments' => $this->request->getPost('comments', FILTER_SANITIZE_FULL_SPECIAL_CHARS) ]; - $supplier_data = [ + $supplierData = [ 'company_name' => $this->request->getPost('company_name', FILTER_SANITIZE_FULL_SPECIAL_CHARS), 'agency_name' => $this->request->getPost('agency_name', FILTER_SANITIZE_FULL_SPECIAL_CHARS), 'category' => $this->request->getPost('category', FILTER_SANITIZE_FULL_SPECIAL_CHARS), @@ -146,47 +120,43 @@ class Suppliers extends Persons 'tax_id' => $this->request->getPost('tax_id', FILTER_SANITIZE_NUMBER_INT) ]; - if ($this->supplier->save_supplier($person_data, $supplier_data, $supplier_id)) { - // New supplier - if ($supplier_id == NEW_ENTRY) { - return $this->response->setJSON([ - 'success' => true, - 'message' => lang('Suppliers.successful_adding') . ' ' . $supplier_data['company_name'], - 'id' => $supplier_data['person_id'] - ]); - } else { // Existing supplier + if ($this->supplier->save_supplier($personData, $supplierData, $supplierId)) { + $personId = $supplierId == NEW_ENTRY ? $supplierData['person_id'] : $supplierId; + $this->savePersonAttributes($personId, Attribute::SHOW_IN_SUPPLIERS); + if ($supplierId == NEW_ENTRY) { return $this->response->setJSON([ 'success' => true, - 'message' => lang('Suppliers.successful_updating') . ' ' . $supplier_data['company_name'], - 'id' => $supplier_id + 'message' => lang('Suppliers.successful_adding') . ' ' . $supplierData['company_name'], + 'id' => $supplierData['person_id'] + ]); + } else { + return $this->response->setJSON([ + 'success' => true, + 'message' => lang('Suppliers.successful_updating') . ' ' . $supplierData['company_name'], + 'id' => $supplierId ]); } - } else { // Failure + } else { return $this->response->setJSON([ 'success' => false, - 'message' => lang('Suppliers.error_adding_updating') . ' ' . $supplier_data['company_name'], + 'message' => lang('Suppliers.error_adding_updating') . ' ' . $supplierData['company_name'], 'id' => NEW_ENTRY ]); } } - /** - * This deletes suppliers from the suppliers table - * - * @return ResponseInterface - */ public function postDelete(): ResponseInterface { - $suppliers_to_delete = $this->request->getPost('ids', FILTER_SANITIZE_NUMBER_INT); + $suppliersToDelete = $this->request->getPost('ids', FILTER_SANITIZE_NUMBER_INT); - if ($this->supplier->delete_list($suppliers_to_delete)) { + if ($this->supplier->delete_list($suppliersToDelete)) { return $this->response->setJSON([ 'success' => true, - 'message' => lang('Suppliers.successful_deleted') . ' ' . count($suppliers_to_delete) . ' ' . lang('Suppliers.one_or_multiple') + 'message' => lang('Suppliers.successful_deleted') . ' ' . count($suppliersToDelete) . ' ' . lang('Suppliers.one_or_multiple') ]); } else { return $this->response->setJSON(['success' => false, 'message' => lang('Suppliers.cannot_be_deleted')]); } } -} +} \ No newline at end of file diff --git a/app/Database/Migrations/20260402000000_AddPersonToAttributeLinks.php b/app/Database/Migrations/20260402000000_AddPersonToAttributeLinks.php new file mode 100644 index 000000000..1b4c5b832 --- /dev/null +++ b/app/Database/Migrations/20260402000000_AddPersonToAttributeLinks.php @@ -0,0 +1,61 @@ +db->query('ALTER TABLE `ospos_attribute_links` DROP INDEX `attribute_links_uq3`'); + $this->db->query('ALTER TABLE `ospos_attribute_links` DROP COLUMN `generated_unique_column`'); + + // Add person_id column + $this->db->query('ALTER TABLE `ospos_attribute_links` ADD COLUMN `person_id` INT(10) NULL AFTER `receiving_id`'); + + // Add index for person_id + $this->db->query('ALTER TABLE `ospos_attribute_links` ADD KEY `person_id` (`person_id`)'); + + // Add foreign key constraint for person_id + $this->db->query('ALTER TABLE `ospos_attribute_links` ADD CONSTRAINT `ospos_attribute_links_ibfk_6` FOREIGN KEY (`person_id`) REFERENCES `ospos_people` (`person_id`) ON DELETE CASCADE'); + + // Recreate the generated unique column with person_id support + // This ensures uniqueness for both item attributes and person attributes + $this->db->query("ALTER TABLE `ospos_attribute_links` + ADD COLUMN `generated_unique_column` VARCHAR(255) GENERATED ALWAYS AS ( + CASE + WHEN `sale_id` IS NULL AND `receiving_id` IS NULL AND `item_id` IS NOT NULL THEN CONCAT('item-', `definition_id`, '-', `item_id`) + WHEN `sale_id` IS NULL AND `receiving_id` IS NULL AND `item_id` IS NULL AND `person_id` IS NOT NULL THEN CONCAT('person-', `definition_id`, '-', `person_id`) + ELSE NULL + END + ) STORED"); + + // Re-add unique constraint + $this->db->query('ALTER TABLE `ospos_attribute_links` ADD UNIQUE INDEX `attribute_links_uq3` (`generated_unique_column`)'); + } + + public function down(): void + { + // Drop person_id related constraints and column + $this->db->query('ALTER TABLE `ospos_attribute_links` DROP INDEX `attribute_links_uq3`'); + $this->db->query('ALTER TABLE `ospos_attribute_links` DROP COLUMN `generated_unique_column`'); + $this->db->query('ALTER TABLE `ospos_attribute_links` DROP FOREIGN KEY `ospos_attribute_links_ibfk_6`'); + $this->db->query('ALTER TABLE `ospos_attribute_links` DROP COLUMN `person_id`'); + + // Restore original generated column + $this->db->query("ALTER TABLE `ospos_attribute_links` + ADD COLUMN `generated_unique_column` VARCHAR(255) GENERATED ALWAYS AS ( + CASE + WHEN `sale_id` IS NULL AND `receiving_id` IS NULL AND `item_id` IS NOT NULL THEN CONCAT(`definition_id`, '-', `item_id`) + ELSE NULL + END + ) STORED"); + $this->db->query('ALTER TABLE `ospos_attribute_links` ADD UNIQUE INDEX `attribute_links_uq3` (`generated_unique_column`)'); + } +} \ No newline at end of file diff --git a/app/Database/Migrations/20260403000000_AddPersonAttributeFlag.php b/app/Database/Migrations/20260403000000_AddPersonAttributeFlag.php new file mode 100644 index 000000000..7b80e5f2b --- /dev/null +++ b/app/Database/Migrations/20260403000000_AddPersonAttributeFlag.php @@ -0,0 +1,18 @@ +db->query('ALTER TABLE `ospos_attribute_definitions` ADD COLUMN `person_attribute` TINYINT(1) DEFAULT 0 AFTER `definition_flags`'); + } + + public function down(): void + { + $this->db->query('ALTER TABLE `ospos_attribute_definitions` DROP COLUMN `person_attribute`'); + } +} \ No newline at end of file diff --git a/app/Language/en/Attributes.php b/app/Language/en/Attributes.php index b3ab1fde9..6ad6c84b8 100644 --- a/app/Language/en/Attributes.php +++ b/app/Language/en/Attributes.php @@ -23,6 +23,10 @@ return [ "new" => "New Attribute", "no_attributes_to_display" => "No Attributes to display", "receipt_visibility" => "Receipt", + "show_in_customers" => "Show in customers", + "show_in_customers_visibility" => "Customers", + "show_in_employees" => "Show in employees", + "show_in_employees_visibility" => "Employees", "show_in_items" => "Show in items", "show_in_items_visibility" => "Items", "show_in_receipt" => "Show in receipt", @@ -30,5 +34,7 @@ return [ "show_in_receivings_visibility" => "Receivings", "show_in_sales" => "Show in sales", "show_in_sales_visibility" => "Sales", + "show_in_suppliers" => "Show in suppliers", + "show_in_suppliers_visibility" => "Suppliers", "update" => "Update Attribute", ]; diff --git a/app/Models/Attribute.php b/app/Models/Attribute.php index d48abad2d..c8be7d435 100644 --- a/app/Models/Attribute.php +++ b/app/Models/Attribute.php @@ -27,12 +27,14 @@ class Attribute extends Model 'definition_type', 'definition_unit', 'definition_flags', + 'person_attribute', 'deleted', 'attribute_id', 'definition_id', 'item_id', 'sale_id', 'receiving_id', + 'person_id', 'attribute_value', 'attribute_date', 'attribute_decimal' @@ -41,7 +43,12 @@ class Attribute extends Model public const SHOW_IN_ITEMS = 1; // TODO: These need to be moved to constants.php public const SHOW_IN_SALES = 2; public const SHOW_IN_RECEIVINGS = 4; - public function deleteDropdownAttributeValue(string $attribute_value, int $definition_id): bool + public const SHOW_IN_SEARCH = 8; + public const SHOW_IN_CUSTOMERS = 16; + public const SHOW_IN_EMPLOYEES = 32; + public const SHOW_IN_SUPPLIERS = 64; + + public function deleteDropdownAttributeValue(string $attributeValue, int $definitionId): bool { $attribute_id = $this->getAttributeIdByValue($attribute_value); $this->deleteAttributeLinksByDefinitionIdAndAttributeId($definition_id, $attribute_id); @@ -269,7 +276,7 @@ class Attribute extends Model public function get_definitions_by_flags(int $definition_flags, bool $include_types = false): array { $builder = $this->db->table('attribute_definitions'); - $builder->where(new RawSql("definition_flags & $definition_flags")); // TODO: we need to heed CI warnings to escape properly + $builder->where(new RawSql("definition_flags & $definition_flags")); $builder->where('deleted', 0); $builder->where('definition_type <>', GROUP); $builder->orderBy('definition_id'); @@ -291,11 +298,30 @@ class Attribute extends Model } /** - * Returns an array of attribute definition names and IDs + * Gets attribute definitions filtered by type (person or item) * - * @param boolean $groups If false does not return GROUP type attributes in the array - * @return array Array containing definition IDs, attribute names and -1 index with the local language '[SELECT]' line. + * @param bool $isPersonAttribute True for person attributes, false for item attributes + * @param int $definitionFlags Optional visibility flags to further filter + * @return array */ + public function getDefinitionsByType(bool $isPersonAttribute, int $definitionFlags = 0): array + { + $builder = $this->db->table('attribute_definitions'); + $builder->where('person_attribute', $isPersonAttribute ? 1 : 0); + $builder->where('deleted', 0); + $builder->where('definition_type <>', GROUP); + + if ($definitionFlags > 0) { + $builder->where(new RawSql("definition_flags & $definitionFlags")); + } + + $builder->orderBy('definition_name', 'ASC'); + + $results = $builder->get()->getResultArray(); + + return $this->to_array($results, 'definition_id', 'definition_name'); + } + public function get_definition_names(bool $groups = true): array { $builder = $this->db->table('attribute_definitions'); @@ -1227,4 +1253,227 @@ class Attribute extends Model $itemsBuilder->update(); } } + + /** + * Gets all attributes connected to a person given the person_id + * + * @param int $personId Person to retrieve attributes for. + * @return array Attributes for the person. + */ + public function getAttributesByPerson(int $personId): array + { + $builder = $this->db->table('attribute_definitions'); + $builder->join('attribute_links', 'attribute_links.definition_id = attribute_definitions.definition_id'); + $builder->where('person_id', $personId); + $builder->where('item_id', null); + $builder->where('sale_id', null); + $builder->where('receiving_id', null); + $builder->where('deleted', 0); + $builder->orderBy('definition_name', 'ASC'); + + $results = $builder->get()->getResultArray(); + + return $this->to_array($results, 'definition_id'); + } + + /** + * Returns whether an attribute_link row exists given a person_id and optionally a definition_id + * + * @param int $personId ID of the person to check for an associated attribute. + * @param int|bool $definitionId Attribute definition ID to check. + * @return bool Returns true if at least one attribute_link exists or false if no attributes exist for that person and attribute. + */ + public function personAttributeLinkExists(int $personId, int|bool $definitionId = false): bool + { + $builder = $this->db->table('attribute_links'); + $builder->where('person_id', $personId); + $builder->where('item_id', null); + $builder->where('sale_id', null); + $builder->where('receiving_id', null); + + if ($definitionId) { + $builder->where('definition_id', $definitionId); + } else { + $builder->where('definition_id IS NOT NULL'); + $builder->where('attribute_id', null); + } + $results = $builder->countAllResults(); + return $results > 0; + } + + /** + * Inserts or updates an attribute link for a person + * + * @param int $personId + * @param int $definitionId + * @param int $attributeId + * @return bool True if the attribute link was saved successfully, false otherwise. + */ + public function savePersonAttributeLink(int $personId, int $definitionId, int $attributeId): bool + { + $this->db->transStart(); + + $builder = $this->db->table('attribute_links'); + + if ($this->personAttributeLinkExists($personId, $definitionId)) { + $builder->set(['attribute_id' => $attributeId]); + $builder->where('definition_id', $definitionId); + $builder->where('person_id', $personId); + $builder->where('item_id', null); + $builder->where('sale_id', null); + $builder->where('receiving_id', null); + $builder->update(); + } else { + $data = [ + 'attribute_id' => $attributeId, + 'person_id' => $personId, + 'definition_id' => $definitionId + ]; + $builder->insert($data); + } + + $this->db->transComplete(); + + return $this->db->transStatus(); + } + + /** + * Deletes attribute links for a person + * + * @param int $personId + * @param int|bool $definitionId + * @return bool + */ + public function deletePersonAttributeLinks(int $personId, int|bool $definitionId = false): bool + { + $deleteData = ['person_id' => $personId]; + + $builder = $this->db->table('attribute_links'); + $builder->where('item_id', null); + $builder->where('sale_id', null); + $builder->where('receiving_id', null); + + if (!empty($definitionId)) { + $deleteData['definition_id'] = $definitionId; + } + + return $builder->delete($deleteData); + } + + /** + * Gets the attribute value for a person and definition + * + * @param int $personId + * @param int $definitionId + * @return object|null + */ + public function getPersonAttributeValue(int $personId, int $definitionId): ?object + { + $builder = $this->db->table('attribute_values'); + $builder->join('attribute_links', 'attribute_links.attribute_id = attribute_values.attribute_id'); + $builder->where('person_id', $personId); + $builder->where('item_id', null); + $builder->where('sale_id', null); + $builder->where('receiving_id', null); + $builder->where('definition_id', $definitionId); + $query = $builder->get(); + + if ($query->getNumRows() == 1) { + return $query->getRow(); + } + + return $this->getEmptyObject('attribute_values'); + } + + /** + * Saves an attribute value for a person + * + * @param string $attributeValue + * @param int $definitionId + * @param int $personId + * @param int|bool $attributeId + * @param string $definitionType + * @return int + */ + public function savePersonAttributeValue(string $attributeValue, int $definitionId, int $personId, int|bool $attributeId = false, string $definitionType = DROPDOWN): int + { + $config = config(OSPOS::class)->settings; + + $this->db->transStart(); + + switch ($definitionType) { + case DATE: + $dataType = 'date'; + $attributeDateValue = DateTime::createFromFormat($config['dateformat'], $attributeValue); + $attributeValue = $attributeDateValue ? $attributeDateValue->format('Y-m-d') : $attributeValue; + break; + case DECIMAL: + $dataType = 'decimal'; + break; + default: + $dataType = 'value'; + break; + } + + // New Attribute + if (empty($attributeId) || empty($personId) || $attributeId == -1) { + $attributeId = $this->attributeValueExists($attributeValue, $definitionType); + + if (!$attributeId) { + $builder = $this->db->table('attribute_values'); + $builder->set(["attribute_$dataType" => $attributeValue]); + $builder->insert(); + + $attributeId = $this->db->insertID(); + } + + $data = [ + 'attribute_id' => empty($attributeId) ? null : $attributeId, + 'person_id' => $personId, + 'definition_id' => $definitionId + ]; + + $builder = $this->db->table('attribute_links'); + $builder->set($data); + $builder->insert(); + } + // Existing Attribute + else { + $builder = $this->db->table('attribute_values'); + $builder->set(["attribute_$dataType" => $attributeValue]); + $builder->where('attribute_id', $attributeId); + $builder->update(); + } + + $this->db->transComplete(); + + return $attributeId; + } + + /** + * Gets link values for a person given the person_id and visibility flags + * + * @param int $personId + * @param int $definitionFlags + * @return ResultInterface + */ + public function getPersonLinkValues(int $personId, int $definitionFlags): ResultInterface + { + $format = $this->db->escape(dateformat_mysql()); + + $builder = $this->db->table('attribute_links'); + $builder->select("GROUP_CONCAT(attribute_value SEPARATOR ', ') AS attribute_values"); + $builder->select("GROUP_CONCAT(DATE_FORMAT(attribute_date, $format) SEPARATOR ', ') AS attribute_dtvalues"); + $builder->join('attribute_values', 'attribute_values.attribute_id = attribute_links.attribute_id'); + $builder->join('attribute_definitions', 'attribute_definitions.definition_id = attribute_links.definition_id'); + $builder->where('definition_type <>', GROUP); + $builder->where('deleted', ACTIVE); + $builder->where('person_id', $personId); + $builder->where('item_id', null); + $builder->where('sale_id', null); + $builder->where('receiving_id', null); + $builder->where(new RawSql("definition_flags & $definitionFlags")); + + return $builder->get(); + } } diff --git a/app/Views/attributes/person.php b/app/Views/attributes/person.php new file mode 100644 index 000000000..a3bfa5a22 --- /dev/null +++ b/app/Views/attributes/person.php @@ -0,0 +1,169 @@ + + +
+ 'control-label col-xs-3']) ?> +
+ 'definition_name', + 'options' => $definition_names, + 'selected' => -1, + 'class' => 'form-control', + 'id' => 'definition_name' + ]) ?> +
+
+ + $definitionValue) { ?> + +
+ 'control-label col-xs-3']) ?> +
+
+ attribute_date)) ? NOW : strtotime($attributeValue->attribute_date); + echo form_input([ + 'name' => "attribute_links[$definitionId]", + 'value' => to_date($value), + 'class' => 'form-control input-sm datetime', + 'data-definition-id' => $definitionId, + 'readonly' => 'true' + ]); + break; + case DROPDOWN: + $selectedValue = $definitionValue['selected_value']; + echo form_dropdown([ + 'name' => "attribute_links[$definitionId]", + 'options' => $definitionValue['values'], + 'selected' => $selectedValue, + 'class' => 'form-control', + 'data-definition-id' => $definitionId + ]); + break; + case TEXT: + $value = (empty($attributeValue) || empty($attributeValue->attribute_value)) ? $definitionValue['selected_value'] : $attributeValue->attribute_value; + echo form_input([ + 'name' => "attribute_links[$definitionId]", + 'value' => esc($value), + 'class' => 'form-control valid_chars', + 'data-definition-id' => $definitionId + ]); + break; + case DECIMAL: + $value = (empty($attributeValue) || empty($attributeValue->attribute_decimal)) ? $definitionValue['selected_value'] : $attributeValue->attribute_decimal; + echo form_input([ + 'name' => "attribute_links[$definitionId]", + 'value' => to_decimals((float)$value), + 'class' => 'form-control valid_chars', + 'data-definition-id' => $definitionId + ]); + break; + case CHECKBOX: + $value = (empty($attributeValue) || empty($attributeValue->attribute_value)) ? $definitionValue['selected_value'] : $attributeValue->attribute_value; + + // Sends 0 if the box is unchecked instead of not sending anything. + echo form_input([ + 'type' => 'hidden', + 'name' => "attribute_links[$definitionId]", + 'id' => "attribute_links[$definitionId]", + 'value' => 0, + 'data-definition-id' => $definitionId + ]); + echo form_checkbox([ + 'name' => "attribute_links[$definitionId]", + 'id' => "attribute_links[$definitionId]", + 'value' => 1, + 'checked' => $value == 1, + 'class' => 'checkbox-inline', + 'data-definition-id' => $definitionId + ]); + break; + } + ?> + + + +
+
+
+ + + + \ No newline at end of file diff --git a/app/Views/customers/form.php b/app/Views/customers/form.php index c314ac6e4..4414df964 100644 --- a/app/Views/customers/form.php +++ b/app/Views/customers/form.php @@ -44,6 +44,12 @@ +
+ +
+
'control-label col-xs-3']) ?>
diff --git a/app/Views/employees/form.php b/app/Views/employees/form.php index ba12ffde5..5f6aaa574 100644 --- a/app/Views/employees/form.php +++ b/app/Views/employees/form.php @@ -29,6 +29,12 @@
+ +
+ +
diff --git a/app/Views/suppliers/form.php b/app/Views/suppliers/form.php index 441b1f03f..fedab4854 100644 --- a/app/Views/suppliers/form.php +++ b/app/Views/suppliers/form.php @@ -45,6 +45,12 @@ +
+ +
+
'control-label col-xs-3']) ?>