diff --git a/db/migrations/20260615102103_create_user_dashboard_layouts_table.php b/db/migrations/20260615102103_create_user_dashboard_layouts_table.php new file mode 100644 index 00000000..ff014312 --- /dev/null +++ b/db/migrations/20260615102103_create_user_dashboard_layouts_table.php @@ -0,0 +1,39 @@ +hasTable('user_dashboard_layouts')) { + $this->execute(" + CREATE TABLE `user_dashboard_layouts` ( + `idlayout` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, + `iduser` INT(10) UNSIGNED NOT NULL, + `page` VARCHAR(100) NOT NULL DEFAULT 'dashboard', + `layout_json` LONGTEXT NOT NULL, + `created_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`idlayout`), + UNIQUE KEY `uq_user_dashboard_page` (`iduser`, `page`), + KEY `idx_dashboard_page` (`page`), + CONSTRAINT `fk_dashboard_layout_user` + FOREIGN KEY (`iduser`) + REFERENCES `auth_users` (`id`) + ON DELETE CASCADE + ON UPDATE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + "); + } + } + + public function down(): void + { + if ($this->hasTable('user_dashboard_layouts')) { + $this->table('user_dashboard_layouts')->drop()->save(); + } + } +} diff --git a/db/migrations/20260615125715_create_sample_master_data_tables.php b/db/migrations/20260615125715_create_sample_master_data_tables.php new file mode 100644 index 00000000..c41ca4d7 --- /dev/null +++ b/db/migrations/20260615125715_create_sample_master_data_tables.php @@ -0,0 +1,427 @@ +hasTable('business_partners')) { + $this->execute(" + CREATE TABLE `business_partners` ( + `idpartner` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, + `idcompany` INT(10) UNSIGNED NOT NULL, + `partner_type` ENUM( + 'producer', + 'manufacturer', + 'supplier', + 'vendor', + 'factory', + 'agent', + 'invoice_to', + 'report_to', + 'laboratory', + 'other' + ) NOT NULL DEFAULT 'supplier', + `partner_name` VARCHAR(255) NOT NULL, + `legal_name` VARCHAR(255) DEFAULT NULL, + `external_code` VARCHAR(100) DEFAULT NULL, + `vat_number` VARCHAR(80) DEFAULT NULL, + `tax_code` VARCHAR(80) DEFAULT NULL, + `address` VARCHAR(255) DEFAULT NULL, + `city` VARCHAR(120) DEFAULT NULL, + `zip` VARCHAR(50) DEFAULT NULL, + `country_id` INT(10) UNSIGNED DEFAULT NULL, + `email` VARCHAR(191) DEFAULT NULL, + `phone` VARCHAR(80) DEFAULT NULL, + `website` VARCHAR(255) DEFAULT NULL, + `notes` TEXT DEFAULT NULL, + `status` ENUM('active','inactive','suspended') NOT NULL DEFAULT 'active', + `created_by` INT(10) UNSIGNED DEFAULT NULL, + `created_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`idpartner`), + KEY `idx_partner_company` (`idcompany`), + KEY `idx_partner_type` (`partner_type`), + KEY `idx_partner_name` (`partner_name`), + KEY `idx_partner_external_code` (`external_code`), + KEY `idx_partner_status` (`status`), + KEY `idx_partner_country` (`country_id`), + KEY `idx_partner_created_by` (`created_by`), + CONSTRAINT `fk_partner_company` + FOREIGN KEY (`idcompany`) REFERENCES `companies` (`idcompany`) + ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `fk_partner_country` + FOREIGN KEY (`country_id`) REFERENCES `auth_countries` (`id`) + ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT `fk_partner_created_by` + FOREIGN KEY (`created_by`) REFERENCES `auth_users` (`id`) + ON DELETE SET NULL ON UPDATE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + "); + } + + /* + * Contacts linked to business partners. + */ + if (!$this->hasTable('business_partner_contacts')) { + $this->execute(" + CREATE TABLE `business_partner_contacts` ( + `idcontact` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, + `idpartner` INT(10) UNSIGNED NOT NULL, + `contact_name` VARCHAR(255) NOT NULL, + `role` VARCHAR(120) DEFAULT NULL, + `email` VARCHAR(191) DEFAULT NULL, + `phone` VARCHAR(80) DEFAULT NULL, + `mobile` VARCHAR(80) DEFAULT NULL, + `is_primary` TINYINT(1) NOT NULL DEFAULT 0, + `notes` TEXT DEFAULT NULL, + `status` ENUM('active','inactive') NOT NULL DEFAULT 'active', + `created_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`idcontact`), + KEY `idx_contact_partner` (`idpartner`), + KEY `idx_contact_email` (`email`), + KEY `idx_contact_primary` (`is_primary`), + CONSTRAINT `fk_contact_partner` + FOREIGN KEY (`idpartner`) REFERENCES `business_partners` (`idpartner`) + ON DELETE CASCADE ON UPDATE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + "); + } + + /* + * Samples / products master data. + */ + if (!$this->hasTable('samples')) { + $this->execute(" + CREATE TABLE `samples` ( + `idsample` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, + `idcompany` INT(10) UNSIGNED NOT NULL, + `idbrand` INT(10) UNSIGNED DEFAULT NULL, + `iddepartment` INT(10) UNSIGNED DEFAULT NULL, + `idproducer` INT(10) UNSIGNED DEFAULT NULL, + `idsupplier` INT(10) UNSIGNED DEFAULT NULL, + `sample_code` VARCHAR(120) NOT NULL, + `external_sample_id` VARCHAR(120) DEFAULT NULL, + `article_no` VARCHAR(150) DEFAULT NULL, + `po_no` VARCHAR(255) DEFAULT NULL, + `season` VARCHAR(120) DEFAULT NULL, + `style_no` VARCHAR(150) DEFAULT NULL, + `style_name` VARCHAR(255) DEFAULT NULL, + `model` VARCHAR(255) DEFAULT NULL, + `sample_description` VARCHAR(255) NOT NULL, + `product_category` VARCHAR(150) DEFAULT NULL, + `product_type` VARCHAR(150) DEFAULT NULL, + `color` VARCHAR(120) DEFAULT NULL, + `size` VARCHAR(120) DEFAULT NULL, + `gender` VARCHAR(80) DEFAULT NULL, + `age_group` VARCHAR(80) DEFAULT NULL, + `fiber_content` TEXT DEFAULT NULL, + `material_description` TEXT DEFAULT NULL, + `claimed_weight` VARCHAR(120) DEFAULT NULL, + `product_standard` VARCHAR(255) DEFAULT NULL, + `production_stage` VARCHAR(120) DEFAULT NULL, + `country_of_origin` INT(10) UNSIGNED DEFAULT NULL, + `status` ENUM( + 'draft', + 'active', + 'archived', + 'submitted', + 'under_testing', + 'completed', + 'cancelled' + ) NOT NULL DEFAULT 'draft', + `source` ENUM('manual','xls_import','json_import','api','smarttrf') NOT NULL DEFAULT 'manual', + `raw_json` LONGTEXT DEFAULT NULL, + `created_by` INT(10) UNSIGNED DEFAULT NULL, + `created_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`idsample`), + UNIQUE KEY `uq_sample_company_code` (`idcompany`, `sample_code`), + KEY `idx_sample_company` (`idcompany`), + KEY `idx_sample_brand` (`idbrand`), + KEY `idx_sample_department` (`iddepartment`), + KEY `idx_sample_producer` (`idproducer`), + KEY `idx_sample_supplier` (`idsupplier`), + KEY `idx_sample_article` (`article_no`), + KEY `idx_sample_external` (`external_sample_id`), + KEY `idx_sample_status` (`status`), + KEY `idx_sample_created_by` (`created_by`), + CONSTRAINT `fk_sample_company` + FOREIGN KEY (`idcompany`) REFERENCES `companies` (`idcompany`) + ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `fk_sample_brand` + FOREIGN KEY (`idbrand`) REFERENCES `brands` (`idbrand`) + ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT `fk_sample_department` + FOREIGN KEY (`iddepartment`) REFERENCES `departments` (`iddepartment`) + ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT `fk_sample_producer` + FOREIGN KEY (`idproducer`) REFERENCES `business_partners` (`idpartner`) + ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT `fk_sample_supplier` + FOREIGN KEY (`idsupplier`) REFERENCES `business_partners` (`idpartner`) + ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT `fk_sample_origin_country` + FOREIGN KEY (`country_of_origin`) REFERENCES `auth_countries` (`id`) + ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT `fk_sample_created_by` + FOREIGN KEY (`created_by`) REFERENCES `auth_users` (`id`) + ON DELETE SET NULL ON UPDATE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + "); + } + + /* + * Sample status history. + */ + if (!$this->hasTable('sample_status_history')) { + $this->execute(" + CREATE TABLE `sample_status_history` ( + `idhistory` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, + `idsample` INT(10) UNSIGNED NOT NULL, + `old_status` VARCHAR(80) DEFAULT NULL, + `new_status` VARCHAR(80) NOT NULL, + `note` TEXT DEFAULT NULL, + `changed_by` INT(10) UNSIGNED DEFAULT NULL, + `created_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`idhistory`), + KEY `idx_history_sample` (`idsample`), + KEY `idx_history_changed_by` (`changed_by`), + CONSTRAINT `fk_sample_history_sample` + FOREIGN KEY (`idsample`) REFERENCES `samples` (`idsample`) + ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `fk_sample_history_user` + FOREIGN KEY (`changed_by`) REFERENCES `auth_users` (`id`) + ON DELETE SET NULL ON UPDATE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + "); + } + + /* + * Sample photos. + */ + if (!$this->hasTable('sample_photos')) { + $this->execute(" + CREATE TABLE `sample_photos` ( + `idsamplephoto` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, + `idsample` INT(10) UNSIGNED NOT NULL, + `photo_type` ENUM('main','product','label','packaging','warning','detail','other') NOT NULL DEFAULT 'product', + `filename` VARCHAR(255) NOT NULL, + `original_filename` VARCHAR(255) DEFAULT NULL, + `description` TEXT DEFAULT NULL, + `is_main` TINYINT(1) NOT NULL DEFAULT 0, + `sort_order` INT(11) NOT NULL DEFAULT 0, + `uploaded_by` INT(10) UNSIGNED DEFAULT NULL, + `created_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`idsamplephoto`), + KEY `idx_sample_photo_sample` (`idsample`), + KEY `idx_sample_photo_type` (`photo_type`), + KEY `idx_sample_photo_uploaded_by` (`uploaded_by`), + CONSTRAINT `fk_sample_photo_sample` + FOREIGN KEY (`idsample`) REFERENCES `samples` (`idsample`) + ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `fk_sample_photo_user` + FOREIGN KEY (`uploaded_by`) REFERENCES `auth_users` (`id`) + ON DELETE SET NULL ON UPDATE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + "); + } + + /* + * Sample parts / BOM. + */ + if (!$this->hasTable('sample_parts')) { + $this->execute(" + CREATE TABLE `sample_parts` ( + `idpart` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, + `idsample` INT(10) UNSIGNED NOT NULL, + `parent_idpart` INT(10) UNSIGNED DEFAULT NULL, + `part_code` VARCHAR(120) DEFAULT NULL, + `part_name` VARCHAR(255) NOT NULL, + `part_description` TEXT DEFAULT NULL, + `material` VARCHAR(255) DEFAULT NULL, + `color` VARCHAR(120) DEFAULT NULL, + `quantity` DECIMAL(12,4) DEFAULT NULL, + `unit` VARCHAR(50) DEFAULT NULL, + `supplier_id` INT(10) UNSIGNED DEFAULT NULL, + `producer_id` INT(10) UNSIGNED DEFAULT NULL, + `position` VARCHAR(120) DEFAULT NULL, + `risk_level` ENUM('low','medium','high','critical','unknown') NOT NULL DEFAULT 'unknown', + `notes` TEXT DEFAULT NULL, + `sort_order` INT(11) NOT NULL DEFAULT 0, + `created_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`idpart`), + KEY `idx_part_sample` (`idsample`), + KEY `idx_part_parent` (`parent_idpart`), + KEY `idx_part_supplier` (`supplier_id`), + KEY `idx_part_producer` (`producer_id`), + KEY `idx_part_risk` (`risk_level`), + CONSTRAINT `fk_part_sample` + FOREIGN KEY (`idsample`) REFERENCES `samples` (`idsample`) + ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `fk_part_parent` + FOREIGN KEY (`parent_idpart`) REFERENCES `sample_parts` (`idpart`) + ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT `fk_part_supplier` + FOREIGN KEY (`supplier_id`) REFERENCES `business_partners` (`idpartner`) + ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT `fk_part_producer` + FOREIGN KEY (`producer_id`) REFERENCES `business_partners` (`idpartner`) + ON DELETE SET NULL ON UPDATE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + "); + } + + /* + * Sample part photos. + */ + if (!$this->hasTable('sample_part_photos')) { + $this->execute(" + CREATE TABLE `sample_part_photos` ( + `idpartphoto` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, + `idpart` INT(10) UNSIGNED NOT NULL, + `filename` VARCHAR(255) NOT NULL, + `original_filename` VARCHAR(255) DEFAULT NULL, + `description` TEXT DEFAULT NULL, + `is_main` TINYINT(1) NOT NULL DEFAULT 0, + `sort_order` INT(11) NOT NULL DEFAULT 0, + `uploaded_by` INT(10) UNSIGNED DEFAULT NULL, + `created_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`idpartphoto`), + KEY `idx_part_photo_part` (`idpart`), + KEY `idx_part_photo_uploaded_by` (`uploaded_by`), + CONSTRAINT `fk_part_photo_part` + FOREIGN KEY (`idpart`) REFERENCES `sample_parts` (`idpart`) + ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `fk_part_photo_user` + FOREIGN KEY (`uploaded_by`) REFERENCES `auth_users` (`id`) + ON DELETE SET NULL ON UPDATE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + "); + } + + /* + * Generic documents. + */ + if (!$this->hasTable('documents')) { + $this->execute(" + CREATE TABLE `documents` ( + `iddocument` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, + `idcompany` INT(10) UNSIGNED NOT NULL, + `document_type` ENUM( + 'technical_sheet', + 'declaration', + 'bom', + 'photo', + 'certificate', + 'test_report', + 'supplier_document', + 'invoice', + 'manual', + 'other' + ) NOT NULL DEFAULT 'other', + `title` VARCHAR(255) NOT NULL, + `filename` VARCHAR(255) NOT NULL, + `original_filename` VARCHAR(255) DEFAULT NULL, + `mime_type` VARCHAR(120) DEFAULT NULL, + `file_size` BIGINT(20) UNSIGNED DEFAULT NULL, + `expiry_date` DATE DEFAULT NULL, + `status` ENUM('active','expired','archived') NOT NULL DEFAULT 'active', + `notes` TEXT DEFAULT NULL, + `uploaded_by` INT(10) UNSIGNED DEFAULT NULL, + `created_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`iddocument`), + KEY `idx_document_company` (`idcompany`), + KEY `idx_document_type` (`document_type`), + KEY `idx_document_status` (`status`), + KEY `idx_document_expiry` (`expiry_date`), + KEY `idx_document_uploaded_by` (`uploaded_by`), + CONSTRAINT `fk_document_company` + FOREIGN KEY (`idcompany`) REFERENCES `companies` (`idcompany`) + ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `fk_document_uploaded_by` + FOREIGN KEY (`uploaded_by`) REFERENCES `auth_users` (`id`) + ON DELETE SET NULL ON UPDATE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + "); + } + + /* + * Documents linked to samples. + */ + if (!$this->hasTable('sample_documents')) { + $this->execute(" + CREATE TABLE `sample_documents` ( + `idsampledocument` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, + `idsample` INT(10) UNSIGNED NOT NULL, + `iddocument` INT(10) UNSIGNED NOT NULL, + `created_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`idsampledocument`), + UNIQUE KEY `uq_sample_document` (`idsample`, `iddocument`), + KEY `idx_sample_document_document` (`iddocument`), + CONSTRAINT `fk_sample_document_sample` + FOREIGN KEY (`idsample`) REFERENCES `samples` (`idsample`) + ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `fk_sample_document_document` + FOREIGN KEY (`iddocument`) REFERENCES `documents` (`iddocument`) + ON DELETE CASCADE ON UPDATE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + "); + } + + /* + * Documents linked to sample parts. + */ + if (!$this->hasTable('sample_part_documents')) { + $this->execute(" + CREATE TABLE `sample_part_documents` ( + `idpartdocument` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, + `idpart` INT(10) UNSIGNED NOT NULL, + `iddocument` INT(10) UNSIGNED NOT NULL, + `created_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`idpartdocument`), + UNIQUE KEY `uq_part_document` (`idpart`, `iddocument`), + KEY `idx_part_document_document` (`iddocument`), + CONSTRAINT `fk_part_document_part` + FOREIGN KEY (`idpart`) REFERENCES `sample_parts` (`idpart`) + ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `fk_part_document_document` + FOREIGN KEY (`iddocument`) REFERENCES `documents` (`iddocument`) + ON DELETE CASCADE ON UPDATE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + "); + } + } + + public function down(): void + { + $tables = [ + 'sample_part_documents', + 'sample_documents', + 'documents', + 'sample_part_photos', + 'sample_parts', + 'sample_photos', + 'sample_status_history', + 'samples', + 'business_partner_contacts', + 'business_partners', + ]; + + foreach ($tables as $table) { + if ($this->hasTable($table)) { + $this->table($table)->drop()->save(); + } + } + } +} diff --git a/public/userarea/brands.php b/public/userarea/brands.php new file mode 100644 index 00000000..304c1bbf --- /dev/null +++ b/public/userarea/brands.php @@ -0,0 +1,1108 @@ + + + false, + 'message' => 'Company is required.' + ]); + } + + if ($brandName === '') { + jsonResponse([ + 'success' => false, + 'message' => 'Brand name is required.' + ]); + } + + if (!in_array($status, $allowedStatuses, true)) { + $status = 'active'; + } + + /* + * Check company exists + */ + $stmt = $db->prepare("SELECT COUNT(*) FROM companies WHERE idcompany = :idcompany"); + $stmt->execute([':idcompany' => $idcompany]); + + if ((int) $stmt->fetchColumn() === 0) { + jsonResponse([ + 'success' => false, + 'message' => 'Selected company does not exist.' + ]); + } + + if ($idbrand > 0) { + $sql = " + UPDATE brands + SET + idcompany = :idcompany, + brand_name = :brand_name, + external_brand_code = :external_brand_code, + status = :status, + updated_at = NOW() + WHERE idbrand = :idbrand + "; + + $stmt = $db->prepare($sql); + $stmt->execute([ + ':idcompany' => $idcompany, + ':brand_name' => $brandName, + ':external_brand_code' => $externalBrandCode !== '' ? $externalBrandCode : null, + ':status' => $status, + ':idbrand' => $idbrand, + ]); + + jsonResponse([ + 'success' => true, + 'message' => 'Brand updated successfully.' + ]); + } + + $sql = " + INSERT INTO brands ( + idcompany, + brand_name, + external_brand_code, + status, + created_at, + updated_at + ) VALUES ( + :idcompany, + :brand_name, + :external_brand_code, + :status, + NOW(), + NOW() + ) + "; + + $stmt = $db->prepare($sql); + $stmt->execute([ + ':idcompany' => $idcompany, + ':brand_name' => $brandName, + ':external_brand_code' => $externalBrandCode !== '' ? $externalBrandCode : null, + ':status' => $status, + ]); + + jsonResponse([ + 'success' => true, + 'message' => 'Brand created successfully.' + ]); + } + + if ($action === 'get_brand') { + $idbrand = isset($_POST['idbrand']) ? (int) $_POST['idbrand'] : 0; + + if ($idbrand <= 0) { + jsonResponse([ + 'success' => false, + 'message' => 'Invalid brand id.' + ]); + } + + $stmt = $db->prepare(" + SELECT * + FROM brands + WHERE idbrand = :idbrand + LIMIT 1 + "); + $stmt->execute([':idbrand' => $idbrand]); + $brand = $stmt->fetch(PDO::FETCH_ASSOC); + + if (!$brand) { + jsonResponse([ + 'success' => false, + 'message' => 'Brand not found.' + ]); + } + + jsonResponse([ + 'success' => true, + 'brand' => $brand + ]); + } + + if ($action === 'change_status') { + $idbrand = isset($_POST['idbrand']) ? (int) $_POST['idbrand'] : 0; + $status = $_POST['status'] ?? 'inactive'; + + $allowedStatuses = ['active', 'inactive']; + + if ($idbrand <= 0 || !in_array($status, $allowedStatuses, true)) { + jsonResponse([ + 'success' => false, + 'message' => 'Invalid request.' + ]); + } + + $stmt = $db->prepare(" + UPDATE brands + SET status = :status, updated_at = NOW() + WHERE idbrand = :idbrand + "); + $stmt->execute([ + ':status' => $status, + ':idbrand' => $idbrand, + ]); + + jsonResponse([ + 'success' => true, + 'message' => 'Brand status updated successfully.' + ]); + } + + if ($action === 'delete_brand') { + $idbrand = isset($_POST['idbrand']) ? (int) $_POST['idbrand'] : 0; + + if ($idbrand <= 0) { + jsonResponse([ + 'success' => false, + 'message' => 'Invalid brand id.' + ]); + } + + /* + * Safe delete rule: + * Do not delete a brand if it has linked departments or users. + */ + $stmt = $db->prepare(" + SELECT + (SELECT COUNT(*) FROM departments WHERE idbrand = :idbrand1) AS departments_count, + (SELECT COUNT(*) FROM company_users WHERE idbrand = :idbrand2) AS users_count + "); + $stmt->execute([ + ':idbrand1' => $idbrand, + ':idbrand2' => $idbrand, + ]); + + $usage = $stmt->fetch(PDO::FETCH_ASSOC); + + if (((int) $usage['departments_count'] > 0) || ((int) $usage['users_count'] > 0)) { + jsonResponse([ + 'success' => false, + 'message' => 'This brand has linked departments or users. Set it as inactive instead of deleting it.' + ]); + } + + $stmt = $db->prepare(" + DELETE FROM brands + WHERE idbrand = :idbrand + "); + $stmt->execute([':idbrand' => $idbrand]); + + jsonResponse([ + 'success' => true, + 'message' => 'Brand deleted successfully.' + ]); + } + + jsonResponse([ + 'success' => false, + 'message' => 'Unknown action.' + ]); + } catch (Throwable $e) { + jsonResponse([ + 'success' => false, + 'message' => $e->getMessage() + ]); + } +} + +/* + * Page data + */ +$companies = []; + +try { + $stmt = $db->query(" + SELECT idcompany, company_name, status + FROM companies + ORDER BY company_name ASC + "); + $companies = $stmt->fetchAll(PDO::FETCH_ASSOC); +} catch (Throwable $e) { + $companies = []; +} + +$brands = []; + +try { + $stmt = $db->query(" + SELECT + b.idbrand, + b.idcompany, + b.brand_name, + b.external_brand_code, + b.status, + b.created_at, + c.company_name, + c.status AS company_status, + COUNT(DISTINCT d.iddepartment) AS department_count, + COUNT(DISTINCT cu.idcompanyuser) AS user_count + FROM brands b + INNER JOIN companies c ON c.idcompany = b.idcompany + LEFT JOIN departments d ON d.idbrand = b.idbrand + LEFT JOIN company_users cu ON cu.idbrand = b.idbrand + GROUP BY + b.idbrand, + b.idcompany, + b.brand_name, + b.external_brand_code, + b.status, + b.created_at, + c.company_name, + c.status + ORDER BY c.company_name ASC, b.brand_name ASC + "); + $brands = $stmt->fetchAll(PDO::FETCH_ASSOC); +} catch (Throwable $e) { + $brands = []; +} + +$pageTitle = 'Brands'; +?> + + + + + + + + + + + + <?= e($pageTitle); ?> - <?= isset($titlewebsite) ? e($titlewebsite) : 'TRFgo'; ?> + + + + + +
+ + + +
+
+ +
+
+
+
+
+ + TRFgo Registry +
+

Brands

+

+ Manage brands and divisions linked to customer companies. + Brands help organize samples, TRF requests, departments and user access. +

+
+ +
+ +
+
+
+
+ + $row['status'] === 'active')); + $inactiveBrands = count(array_filter($brands, fn($row) => $row['status'] === 'inactive')); + ?> + +
+
+
+
+
+
+
Total Brands
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
Active
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
Inactive
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+
Brand List
+

Brands and divisions configured for customer companies

+
+ + +
+
+ +
+ +
+ No companies available. + Create at least one company before adding brands. +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
BrandCompanyExternal CodeDepartmentsUsersStatusCreatedActions
+
+
ID:
+
+
+ +
Company status:
+ +
+ -'; ?> + + + + + + + + + + + + + Active + + Inactive + + + + + + + + + + + + + +
+
+
+
+ +
+
+ +
+ + + + + + +
+ + + + + + + + + + \ No newline at end of file diff --git a/public/userarea/business-partners.php b/public/userarea/business-partners.php new file mode 100644 index 00000000..abb14f76 --- /dev/null +++ b/public/userarea/business-partners.php @@ -0,0 +1,1988 @@ + + + 'Producer', + 'manufacturer' => 'Manufacturer', + 'supplier' => 'Supplier', + 'vendor' => 'Vendor', + 'factory' => 'Factory', + 'agent' => 'Agent', + 'invoice_to' => 'Invoice To', + 'report_to' => 'Report To', + 'laboratory' => 'Laboratory', + 'other' => 'Other', +]; + +$partnerStatuses = [ + 'active' => 'Active', + 'inactive' => 'Inactive', + 'suspended' => 'Suspended', +]; + +$contactStatuses = [ + 'active' => 'Active', + 'inactive' => 'Inactive', +]; + +/* + * AJAX actions + */ +if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) { + $action = $_POST['action']; + + try { + if ($action === 'save_partner') { + $idpartner = isset($_POST['idpartner']) ? (int) $_POST['idpartner'] : 0; + $idcompany = isset($_POST['idcompany']) ? (int) $_POST['idcompany'] : 0; + + $partnerType = $_POST['partner_type'] ?? 'supplier'; + $partnerName = trim($_POST['partner_name'] ?? ''); + $legalName = trim($_POST['legal_name'] ?? ''); + $externalCode = trim($_POST['external_code'] ?? ''); + $vatNumber = trim($_POST['vat_number'] ?? ''); + $taxCode = trim($_POST['tax_code'] ?? ''); + $address = trim($_POST['address'] ?? ''); + $city = trim($_POST['city'] ?? ''); + $zip = trim($_POST['zip'] ?? ''); + $countryId = !empty($_POST['country_id']) ? (int) $_POST['country_id'] : null; + $email = trim($_POST['email'] ?? ''); + $phone = trim($_POST['phone'] ?? ''); + $website = trim($_POST['website'] ?? ''); + $notes = trim($_POST['notes'] ?? ''); + $status = $_POST['status'] ?? 'active'; + + if ($idcompany <= 0) { + jsonResponse([ + 'success' => false, + 'message' => 'Company is required.' + ]); + } + + if ($partnerName === '') { + jsonResponse([ + 'success' => false, + 'message' => 'Partner name is required.' + ]); + } + + if (!array_key_exists($partnerType, $partnerTypes)) { + $partnerType = 'supplier'; + } + + if (!array_key_exists($status, $partnerStatuses)) { + $status = 'active'; + } + + /* + * Check company exists. + */ + $stmt = $db->prepare(" + SELECT COUNT(*) + FROM companies + WHERE idcompany = :idcompany + "); + $stmt->execute([':idcompany' => $idcompany]); + + if ((int) $stmt->fetchColumn() === 0) { + jsonResponse([ + 'success' => false, + 'message' => 'Selected company does not exist.' + ]); + } + + if ($idpartner > 0) { + $sql = " + UPDATE business_partners + SET + idcompany = :idcompany, + partner_type = :partner_type, + partner_name = :partner_name, + legal_name = :legal_name, + external_code = :external_code, + vat_number = :vat_number, + tax_code = :tax_code, + address = :address, + city = :city, + zip = :zip, + country_id = :country_id, + email = :email, + phone = :phone, + website = :website, + notes = :notes, + status = :status, + updated_at = NOW() + WHERE idpartner = :idpartner + "; + + $stmt = $db->prepare($sql); + $stmt->execute([ + ':idcompany' => $idcompany, + ':partner_type' => $partnerType, + ':partner_name' => $partnerName, + ':legal_name' => $legalName !== '' ? $legalName : null, + ':external_code' => $externalCode !== '' ? $externalCode : null, + ':vat_number' => $vatNumber !== '' ? $vatNumber : null, + ':tax_code' => $taxCode !== '' ? $taxCode : null, + ':address' => $address !== '' ? $address : null, + ':city' => $city !== '' ? $city : null, + ':zip' => $zip !== '' ? $zip : null, + ':country_id' => $countryId, + ':email' => $email !== '' ? $email : null, + ':phone' => $phone !== '' ? $phone : null, + ':website' => $website !== '' ? $website : null, + ':notes' => $notes !== '' ? $notes : null, + ':status' => $status, + ':idpartner' => $idpartner, + ]); + + jsonResponse([ + 'success' => true, + 'message' => 'Business partner updated successfully.' + ]); + } + + $sql = " + INSERT INTO business_partners ( + idcompany, + partner_type, + partner_name, + legal_name, + external_code, + vat_number, + tax_code, + address, + city, + zip, + country_id, + email, + phone, + website, + notes, + status, + created_by, + created_at, + updated_at + ) VALUES ( + :idcompany, + :partner_type, + :partner_name, + :legal_name, + :external_code, + :vat_number, + :tax_code, + :address, + :city, + :zip, + :country_id, + :email, + :phone, + :website, + :notes, + :status, + :created_by, + NOW(), + NOW() + ) + "; + + $stmt = $db->prepare($sql); + $stmt->execute([ + ':idcompany' => $idcompany, + ':partner_type' => $partnerType, + ':partner_name' => $partnerName, + ':legal_name' => $legalName !== '' ? $legalName : null, + ':external_code' => $externalCode !== '' ? $externalCode : null, + ':vat_number' => $vatNumber !== '' ? $vatNumber : null, + ':tax_code' => $taxCode !== '' ? $taxCode : null, + ':address' => $address !== '' ? $address : null, + ':city' => $city !== '' ? $city : null, + ':zip' => $zip !== '' ? $zip : null, + ':country_id' => $countryId, + ':email' => $email !== '' ? $email : null, + ':phone' => $phone !== '' ? $phone : null, + ':website' => $website !== '' ? $website : null, + ':notes' => $notes !== '' ? $notes : null, + ':status' => $status, + ':created_by' => $iduserlogin, + ]); + + jsonResponse([ + 'success' => true, + 'message' => 'Business partner created successfully.' + ]); + } + + if ($action === 'get_partner') { + $idpartner = isset($_POST['idpartner']) ? (int) $_POST['idpartner'] : 0; + + if ($idpartner <= 0) { + jsonResponse([ + 'success' => false, + 'message' => 'Invalid partner id.' + ]); + } + + $stmt = $db->prepare(" + SELECT * + FROM business_partners + WHERE idpartner = :idpartner + LIMIT 1 + "); + $stmt->execute([':idpartner' => $idpartner]); + $partner = $stmt->fetch(PDO::FETCH_ASSOC); + + if (!$partner) { + jsonResponse([ + 'success' => false, + 'message' => 'Business partner not found.' + ]); + } + + jsonResponse([ + 'success' => true, + 'partner' => $partner + ]); + } + + if ($action === 'change_partner_status') { + $idpartner = isset($_POST['idpartner']) ? (int) $_POST['idpartner'] : 0; + $status = $_POST['status'] ?? 'inactive'; + + if ($idpartner <= 0 || !array_key_exists($status, $partnerStatuses)) { + jsonResponse([ + 'success' => false, + 'message' => 'Invalid request.' + ]); + } + + $stmt = $db->prepare(" + UPDATE business_partners + SET status = :status, updated_at = NOW() + WHERE idpartner = :idpartner + "); + $stmt->execute([ + ':status' => $status, + ':idpartner' => $idpartner, + ]); + + jsonResponse([ + 'success' => true, + 'message' => 'Business partner status updated successfully.' + ]); + } + + if ($action === 'delete_partner') { + $idpartner = isset($_POST['idpartner']) ? (int) $_POST['idpartner'] : 0; + + if ($idpartner <= 0) { + jsonResponse([ + 'success' => false, + 'message' => 'Invalid partner id.' + ]); + } + + /* + * Safe delete: + * Do not delete a partner if already used by samples or parts. + */ + $stmt = $db->prepare(" + SELECT + (SELECT COUNT(*) FROM samples WHERE idproducer = :idpartner1 OR idsupplier = :idpartner2) AS samples_count, + (SELECT COUNT(*) FROM sample_parts WHERE supplier_id = :idpartner3 OR producer_id = :idpartner4) AS parts_count + "); + $stmt->execute([ + ':idpartner1' => $idpartner, + ':idpartner2' => $idpartner, + ':idpartner3' => $idpartner, + ':idpartner4' => $idpartner, + ]); + + $usage = $stmt->fetch(PDO::FETCH_ASSOC); + + if (((int) $usage['samples_count'] > 0) || ((int) $usage['parts_count'] > 0)) { + jsonResponse([ + 'success' => false, + 'message' => 'This partner is linked to samples or BOM parts. Set it as inactive instead of deleting it.' + ]); + } + + $stmt = $db->prepare(" + DELETE FROM business_partners + WHERE idpartner = :idpartner + "); + $stmt->execute([':idpartner' => $idpartner]); + + jsonResponse([ + 'success' => true, + 'message' => 'Business partner deleted successfully.' + ]); + } + + if ($action === 'get_partner_contacts') { + $idpartner = isset($_POST['idpartner']) ? (int) $_POST['idpartner'] : 0; + + if ($idpartner <= 0) { + jsonResponse([ + 'success' => false, + 'message' => 'Invalid partner id.' + ]); + } + + $stmt = $db->prepare(" + SELECT * + FROM business_partner_contacts + WHERE idpartner = :idpartner + ORDER BY is_primary DESC, contact_name ASC + "); + $stmt->execute([':idpartner' => $idpartner]); + + jsonResponse([ + 'success' => true, + 'contacts' => $stmt->fetchAll(PDO::FETCH_ASSOC) + ]); + } + + if ($action === 'save_contact') { + $idcontact = isset($_POST['idcontact']) ? (int) $_POST['idcontact'] : 0; + $idpartner = isset($_POST['idpartner']) ? (int) $_POST['idpartner'] : 0; + $contactName = trim($_POST['contact_name'] ?? ''); + $role = trim($_POST['role'] ?? ''); + $email = trim($_POST['email'] ?? ''); + $phone = trim($_POST['phone'] ?? ''); + $mobile = trim($_POST['mobile'] ?? ''); + $isPrimary = isset($_POST['is_primary']) ? (int) $_POST['is_primary'] : 0; + $notes = trim($_POST['notes'] ?? ''); + $status = $_POST['status'] ?? 'active'; + + if ($idpartner <= 0) { + jsonResponse([ + 'success' => false, + 'message' => 'Partner is required.' + ]); + } + + if ($contactName === '') { + jsonResponse([ + 'success' => false, + 'message' => 'Contact name is required.' + ]); + } + + if (!array_key_exists($status, $contactStatuses)) { + $status = 'active'; + } + + /* + * If this contact is primary, unset other primary contacts for the same partner. + */ + if ($isPrimary === 1) { + $stmt = $db->prepare(" + UPDATE business_partner_contacts + SET is_primary = 0 + WHERE idpartner = :idpartner + "); + $stmt->execute([':idpartner' => $idpartner]); + } + + if ($idcontact > 0) { + $stmt = $db->prepare(" + UPDATE business_partner_contacts + SET + contact_name = :contact_name, + role = :role, + email = :email, + phone = :phone, + mobile = :mobile, + is_primary = :is_primary, + notes = :notes, + status = :status, + updated_at = NOW() + WHERE idcontact = :idcontact + AND idpartner = :idpartner + "); + + $stmt->execute([ + ':contact_name' => $contactName, + ':role' => $role !== '' ? $role : null, + ':email' => $email !== '' ? $email : null, + ':phone' => $phone !== '' ? $phone : null, + ':mobile' => $mobile !== '' ? $mobile : null, + ':is_primary' => $isPrimary === 1 ? 1 : 0, + ':notes' => $notes !== '' ? $notes : null, + ':status' => $status, + ':idcontact' => $idcontact, + ':idpartner' => $idpartner, + ]); + + jsonResponse([ + 'success' => true, + 'message' => 'Contact updated successfully.' + ]); + } + + $stmt = $db->prepare(" + INSERT INTO business_partner_contacts ( + idpartner, + contact_name, + role, + email, + phone, + mobile, + is_primary, + notes, + status, + created_at, + updated_at + ) VALUES ( + :idpartner, + :contact_name, + :role, + :email, + :phone, + :mobile, + :is_primary, + :notes, + :status, + NOW(), + NOW() + ) + "); + + $stmt->execute([ + ':idpartner' => $idpartner, + ':contact_name' => $contactName, + ':role' => $role !== '' ? $role : null, + ':email' => $email !== '' ? $email : null, + ':phone' => $phone !== '' ? $phone : null, + ':mobile' => $mobile !== '' ? $mobile : null, + ':is_primary' => $isPrimary === 1 ? 1 : 0, + ':notes' => $notes !== '' ? $notes : null, + ':status' => $status, + ]); + + jsonResponse([ + 'success' => true, + 'message' => 'Contact created successfully.' + ]); + } + + if ($action === 'get_contact') { + $idcontact = isset($_POST['idcontact']) ? (int) $_POST['idcontact'] : 0; + + if ($idcontact <= 0) { + jsonResponse([ + 'success' => false, + 'message' => 'Invalid contact id.' + ]); + } + + $stmt = $db->prepare(" + SELECT * + FROM business_partner_contacts + WHERE idcontact = :idcontact + LIMIT 1 + "); + $stmt->execute([':idcontact' => $idcontact]); + $contact = $stmt->fetch(PDO::FETCH_ASSOC); + + if (!$contact) { + jsonResponse([ + 'success' => false, + 'message' => 'Contact not found.' + ]); + } + + jsonResponse([ + 'success' => true, + 'contact' => $contact + ]); + } + + if ($action === 'delete_contact') { + $idcontact = isset($_POST['idcontact']) ? (int) $_POST['idcontact'] : 0; + + if ($idcontact <= 0) { + jsonResponse([ + 'success' => false, + 'message' => 'Invalid contact id.' + ]); + } + + $stmt = $db->prepare(" + DELETE FROM business_partner_contacts + WHERE idcontact = :idcontact + "); + $stmt->execute([':idcontact' => $idcontact]); + + jsonResponse([ + 'success' => true, + 'message' => 'Contact deleted successfully.' + ]); + } + + jsonResponse([ + 'success' => false, + 'message' => 'Unknown action.' + ]); + } catch (Throwable $e) { + jsonResponse([ + 'success' => false, + 'message' => $e->getMessage() + ]); + } +} + +/* + * Page data. + */ +$companies = []; +$countries = []; +$partners = []; + +try { + $stmt = $db->query(" + SELECT idcompany, company_name, status + FROM companies + ORDER BY company_name ASC + "); + $companies = $stmt->fetchAll(PDO::FETCH_ASSOC); +} catch (Throwable $e) { + $companies = []; +} + +try { + $stmt = $db->query(" + SELECT id, name, iso_3166_2 + FROM auth_countries + ORDER BY name ASC + "); + $countries = $stmt->fetchAll(PDO::FETCH_ASSOC); +} catch (Throwable $e) { + $countries = []; +} + +try { + $stmt = $db->query(" + SELECT + bp.idpartner, + bp.idcompany, + bp.partner_type, + bp.partner_name, + bp.legal_name, + bp.external_code, + bp.vat_number, + bp.city, + bp.email, + bp.phone, + bp.status, + bp.created_at, + c.company_name, + ac.name AS country_name, + COUNT(DISTINCT bpc.idcontact) AS contacts_count, + SUM(CASE WHEN bpc.is_primary = 1 THEN 1 ELSE 0 END) AS primary_contacts_count, + COUNT(DISTINCT s1.idsample) AS producer_samples_count, + COUNT(DISTINCT s2.idsample) AS supplier_samples_count, + COUNT(DISTINCT sp1.idpart) AS producer_parts_count, + COUNT(DISTINCT sp2.idpart) AS supplier_parts_count + FROM business_partners bp + INNER JOIN companies c ON c.idcompany = bp.idcompany + LEFT JOIN auth_countries ac ON ac.id = bp.country_id + LEFT JOIN business_partner_contacts bpc ON bpc.idpartner = bp.idpartner + LEFT JOIN samples s1 ON s1.idproducer = bp.idpartner + LEFT JOIN samples s2 ON s2.idsupplier = bp.idpartner + LEFT JOIN sample_parts sp1 ON sp1.producer_id = bp.idpartner + LEFT JOIN sample_parts sp2 ON sp2.supplier_id = bp.idpartner + GROUP BY + bp.idpartner, + bp.idcompany, + bp.partner_type, + bp.partner_name, + bp.legal_name, + bp.external_code, + bp.vat_number, + bp.city, + bp.email, + bp.phone, + bp.status, + bp.created_at, + c.company_name, + ac.name + ORDER BY c.company_name ASC, bp.partner_name ASC + "); + $partners = $stmt->fetchAll(PDO::FETCH_ASSOC); +} catch (Throwable $e) { + $partners = []; +} + +$pageTitle = 'Business Partners'; +$totalPartners = count($partners); +$activePartners = count(array_filter($partners, fn($row) => $row['status'] === 'active')); +$totalContacts = array_sum(array_map(fn($row) => (int) $row['contacts_count'], $partners)); +?> + + + + + + + + + + + + <?= e($pageTitle); ?> - <?= isset($titlewebsite) ? e($titlewebsite) : 'TRFgo'; ?> + + + + + +
+ + + +
+
+ +
+
+
+
+
+ + TRFgo Master Data +
+

Business Partners

+

+ Manage producers, suppliers, manufacturers, vendors, factories, laboratories and contacts. + These records will be used in sample identity cards, BOM parts, TRF requests and document flows. +

+
+ +
+ +
+
+
+
+ +
+
+
+
+
+
+
Total Partners
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
Active Partners
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
Contacts
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+
Partner List
+

Suppliers, producers, vendors, laboratories and related entities

+
+ + +
+
+ +
+ +
+ No companies available. + Create at least one company before adding business partners. +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PartnerCompanyTypeExternal CodeCountry / CityEmailContactsUsageStatusActions
+
+ +
+ + +
VAT:
+ +
+
+
+ + + -'; ?> + +
-'; ?>
+ +
+ +
+ + + + - + + + +
+ +
+ + + + + + + + + + + + Active + + Suspended + + Inactive + + + + + + + + + + + + + +
+
+ +
+
+ +
+
+ +
+ + + + + + +
+ + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/userarea/companies.php b/public/userarea/companies.php new file mode 100644 index 00000000..5c2823dd --- /dev/null +++ b/public/userarea/companies.php @@ -0,0 +1,1223 @@ + + + false, + 'message' => 'Company name is required.' + ]); + } + + if (!in_array($status, $allowedStatuses, true)) { + $status = 'active'; + } + + if ($idcompany > 0) { + $sql = " + UPDATE companies + SET + company_name = :company_name, + legal_name = :legal_name, + vat_number = :vat_number, + external_code = :external_code, + address = :address, + city = :city, + zip = :zip, + country_id = :country_id, + email = :email, + phone = :phone, + status = :status, + updated_at = NOW() + WHERE idcompany = :idcompany + "; + + $stmt = $db->prepare($sql); + $stmt->execute([ + ':company_name' => $companyName, + ':legal_name' => $legalName !== '' ? $legalName : null, + ':vat_number' => $vatNumber !== '' ? $vatNumber : null, + ':external_code' => $externalCode !== '' ? $externalCode : null, + ':address' => $address !== '' ? $address : null, + ':city' => $city !== '' ? $city : null, + ':zip' => $zip !== '' ? $zip : null, + ':country_id' => $countryId, + ':email' => $email !== '' ? $email : null, + ':phone' => $phone !== '' ? $phone : null, + ':status' => $status, + ':idcompany' => $idcompany, + ]); + + jsonResponse([ + 'success' => true, + 'message' => 'Company updated successfully.' + ]); + } + + $sql = " + INSERT INTO companies ( + company_name, + legal_name, + vat_number, + external_code, + address, + city, + zip, + country_id, + email, + phone, + status, + created_at, + updated_at + ) VALUES ( + :company_name, + :legal_name, + :vat_number, + :external_code, + :address, + :city, + :zip, + :country_id, + :email, + :phone, + :status, + NOW(), + NOW() + ) + "; + + $stmt = $db->prepare($sql); + $stmt->execute([ + ':company_name' => $companyName, + ':legal_name' => $legalName !== '' ? $legalName : null, + ':vat_number' => $vatNumber !== '' ? $vatNumber : null, + ':external_code' => $externalCode !== '' ? $externalCode : null, + ':address' => $address !== '' ? $address : null, + ':city' => $city !== '' ? $city : null, + ':zip' => $zip !== '' ? $zip : null, + ':country_id' => $countryId, + ':email' => $email !== '' ? $email : null, + ':phone' => $phone !== '' ? $phone : null, + ':status' => $status, + ]); + + jsonResponse([ + 'success' => true, + 'message' => 'Company created successfully.' + ]); + } + + if ($action === 'get_company') { + $idcompany = isset($_POST['idcompany']) ? (int) $_POST['idcompany'] : 0; + + if ($idcompany <= 0) { + jsonResponse([ + 'success' => false, + 'message' => 'Invalid company id.' + ]); + } + + $stmt = $db->prepare(" + SELECT * + FROM companies + WHERE idcompany = :idcompany + LIMIT 1 + "); + $stmt->execute([':idcompany' => $idcompany]); + $company = $stmt->fetch(PDO::FETCH_ASSOC); + + if (!$company) { + jsonResponse([ + 'success' => false, + 'message' => 'Company not found.' + ]); + } + + jsonResponse([ + 'success' => true, + 'company' => $company + ]); + } + + if ($action === 'change_status') { + $idcompany = isset($_POST['idcompany']) ? (int) $_POST['idcompany'] : 0; + $status = $_POST['status'] ?? 'inactive'; + + $allowedStatuses = ['active', 'inactive', 'suspended']; + + if ($idcompany <= 0 || !in_array($status, $allowedStatuses, true)) { + jsonResponse([ + 'success' => false, + 'message' => 'Invalid request.' + ]); + } + + $stmt = $db->prepare(" + UPDATE companies + SET status = :status, updated_at = NOW() + WHERE idcompany = :idcompany + "); + $stmt->execute([ + ':status' => $status, + ':idcompany' => $idcompany, + ]); + + jsonResponse([ + 'success' => true, + 'message' => 'Company status updated successfully.' + ]); + } + + if ($action === 'delete_company') { + $idcompany = isset($_POST['idcompany']) ? (int) $_POST['idcompany'] : 0; + + if ($idcompany <= 0) { + jsonResponse([ + 'success' => false, + 'message' => 'Invalid company id.' + ]); + } + + /* + * Safe delete rule: + * Do not delete a company if it already has linked brands, departments or users. + */ + $stmt = $db->prepare(" + SELECT + (SELECT COUNT(*) FROM brands WHERE idcompany = :idcompany1) AS brands_count, + (SELECT COUNT(*) FROM departments WHERE idcompany = :idcompany2) AS departments_count, + (SELECT COUNT(*) FROM company_users WHERE idcompany = :idcompany3) AS users_count + "); + $stmt->execute([ + ':idcompany1' => $idcompany, + ':idcompany2' => $idcompany, + ':idcompany3' => $idcompany, + ]); + + $usage = $stmt->fetch(PDO::FETCH_ASSOC); + + if ( + ((int) $usage['brands_count'] > 0) || + ((int) $usage['departments_count'] > 0) || + ((int) $usage['users_count'] > 0) + ) { + jsonResponse([ + 'success' => false, + 'message' => 'This company has linked brands, departments or users. Set it as inactive instead of deleting it.' + ]); + } + + $stmt = $db->prepare(" + DELETE FROM companies + WHERE idcompany = :idcompany + "); + $stmt->execute([':idcompany' => $idcompany]); + + jsonResponse([ + 'success' => true, + 'message' => 'Company deleted successfully.' + ]); + } + + jsonResponse([ + 'success' => false, + 'message' => 'Unknown action.' + ]); + } catch (Throwable $e) { + jsonResponse([ + 'success' => false, + 'message' => $e->getMessage() + ]); + } +} + +/* + * Page data + */ +$countries = []; + +try { + $stmt = $db->query(" + SELECT id, name, iso_3166_2 + FROM auth_countries + ORDER BY name ASC + "); + $countries = $stmt->fetchAll(PDO::FETCH_ASSOC); +} catch (Throwable $e) { + $countries = []; +} + +$companies = []; + +try { + $stmt = $db->query(" + SELECT + c.idcompany, + c.company_name, + c.legal_name, + c.vat_number, + c.external_code, + c.city, + c.email, + c.phone, + c.status, + c.created_at, + ac.name AS country_name, + COUNT(DISTINCT b.idbrand) AS brand_count, + COUNT(DISTINCT d.iddepartment) AS department_count, + COUNT(DISTINCT cu.idcompanyuser) AS user_count + FROM companies c + LEFT JOIN auth_countries ac ON ac.id = c.country_id + LEFT JOIN brands b ON b.idcompany = c.idcompany + LEFT JOIN departments d ON d.idcompany = c.idcompany + LEFT JOIN company_users cu ON cu.idcompany = c.idcompany + GROUP BY + c.idcompany, + c.company_name, + c.legal_name, + c.vat_number, + c.external_code, + c.city, + c.email, + c.phone, + c.status, + c.created_at, + ac.name + ORDER BY c.company_name ASC + "); + $companies = $stmt->fetchAll(PDO::FETCH_ASSOC); +} catch (Throwable $e) { + $companies = []; +} + +$pageTitle = 'Companies'; +?> + + + + + + + + + + + + <?= e($pageTitle); ?> - <?= isset($titlewebsite) ? e($titlewebsite) : 'TRFgo'; ?> + + + + + +
+ + + +
+
+ +
+
+
+
+
+ + TRFgo Registry +
+

Companies

+

+ Manage customer companies, laboratories and organizations connected to TRFgo. + Companies are the base layer for brands, departments, users and future TRF requests. +

+
+ +
+ +
+
+
+
+ + $row['status'] === 'active')); + $suspendedCompanies = count(array_filter($companies, fn($row) => $row['status'] === 'suspended')); + ?> + +
+
+
+
+
+
+
Total Companies
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
Active
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
Suspended
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+
Company List
+

Customer and laboratory organizations configured in TRFgo

+
+ + +
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CompanyExternal CodeCountry / CityEmailBrandsDepartmentsUsersStatusCreatedActions
+
+ +
+ + +
VAT:
+ +
+ -'; ?> + +
-'; ?>
+ +
+ +
+ + + + - + + + +
+ +
+ + + + + + + + + + + + + + + + + Active + + Suspended + + Inactive + + + + + + + + + + + + + +
+
+
+
+ +
+
+ +
+ + + + + + +
+ + + + + + + + + + \ No newline at end of file diff --git a/public/userarea/company-users.php b/public/userarea/company-users.php new file mode 100644 index 00000000..30e1da08 --- /dev/null +++ b/public/userarea/company-users.php @@ -0,0 +1,1651 @@ + + + true, + 'brands' => [] + ]); + } + + $stmt = $db->prepare(" + SELECT idbrand, brand_name, status + FROM brands + WHERE idcompany = :idcompany + ORDER BY brand_name ASC + "); + $stmt->execute([':idcompany' => $idcompany]); + + jsonResponse([ + 'success' => true, + 'brands' => $stmt->fetchAll(PDO::FETCH_ASSOC) + ]); + } + + if ($action === 'get_departments_by_company_brand') { + $idcompany = isset($_POST['idcompany']) ? (int) $_POST['idcompany'] : 0; + $idbrand = !empty($_POST['idbrand']) ? (int) $_POST['idbrand'] : null; + + if ($idcompany <= 0) { + jsonResponse([ + 'success' => true, + 'departments' => [] + ]); + } + + if ($idbrand !== null) { + $stmt = $db->prepare(" + SELECT iddepartment, department_name, status + FROM departments + WHERE idcompany = :idcompany + AND (idbrand = :idbrand OR idbrand IS NULL) + ORDER BY department_name ASC + "); + $stmt->execute([ + ':idcompany' => $idcompany, + ':idbrand' => $idbrand, + ]); + } else { + $stmt = $db->prepare(" + SELECT iddepartment, department_name, status + FROM departments + WHERE idcompany = :idcompany + ORDER BY department_name ASC + "); + $stmt->execute([':idcompany' => $idcompany]); + } + + jsonResponse([ + 'success' => true, + 'departments' => $stmt->fetchAll(PDO::FETCH_ASSOC) + ]); + } + + if ($action === 'save_company_user') { + $idcompanyuser = isset($_POST['idcompanyuser']) ? (int) $_POST['idcompanyuser'] : 0; + $iduser = isset($_POST['iduser']) ? (int) $_POST['iduser'] : 0; + $idcompany = isset($_POST['idcompany']) ? (int) $_POST['idcompany'] : 0; + $idbrand = !empty($_POST['idbrand']) ? (int) $_POST['idbrand'] : null; + $iddepartment = !empty($_POST['iddepartment']) ? (int) $_POST['iddepartment'] : null; + $userScope = $_POST['user_scope'] ?? 'company'; + $companyRole = $_POST['company_role'] ?? 'viewer'; + $status = $_POST['status'] ?? 'active'; + + $allowedScopes = ['company', 'brand', 'department']; + $allowedRoles = ['owner', 'admin', 'manager', 'operator', 'viewer', 'api_user', 'lab_user']; + $allowedStatuses = ['active', 'inactive']; + + if ($iduser <= 0) { + jsonResponse([ + 'success' => false, + 'message' => 'User is required.' + ]); + } + + if ($idcompany <= 0) { + jsonResponse([ + 'success' => false, + 'message' => 'Company is required.' + ]); + } + + if (!in_array($userScope, $allowedScopes, true)) { + $userScope = 'company'; + } + + if (!in_array($companyRole, $allowedRoles, true)) { + $companyRole = 'viewer'; + } + + if (!in_array($status, $allowedStatuses, true)) { + $status = 'active'; + } + + /* + * Scope consistency. + */ + if ($userScope === 'company') { + $idbrand = null; + $iddepartment = null; + } + + if ($userScope === 'brand') { + if ($idbrand === null) { + jsonResponse([ + 'success' => false, + 'message' => 'Brand is required for brand scope.' + ]); + } + + $iddepartment = null; + } + + if ($userScope === 'department') { + if ($iddepartment === null) { + jsonResponse([ + 'success' => false, + 'message' => 'Department is required for department scope.' + ]); + } + } + + /* + * Check Vanguard user exists. + */ + $stmt = $db->prepare(" + SELECT COUNT(*) + FROM auth_users + WHERE id = :iduser + "); + $stmt->execute([':iduser' => $iduser]); + + if ((int) $stmt->fetchColumn() === 0) { + jsonResponse([ + 'success' => false, + 'message' => 'Selected Vanguard user does not exist.' + ]); + } + + /* + * Check company exists. + */ + $stmt = $db->prepare(" + SELECT COUNT(*) + FROM companies + WHERE idcompany = :idcompany + "); + $stmt->execute([':idcompany' => $idcompany]); + + if ((int) $stmt->fetchColumn() === 0) { + jsonResponse([ + 'success' => false, + 'message' => 'Selected company does not exist.' + ]); + } + + /* + * Check brand belongs to company. + */ + if ($idbrand !== null) { + $stmt = $db->prepare(" + SELECT COUNT(*) + FROM brands + WHERE idbrand = :idbrand + AND idcompany = :idcompany + "); + $stmt->execute([ + ':idbrand' => $idbrand, + ':idcompany' => $idcompany, + ]); + + if ((int) $stmt->fetchColumn() === 0) { + jsonResponse([ + 'success' => false, + 'message' => 'Selected brand does not belong to the selected company.' + ]); + } + } + + /* + * Check department belongs to company and, if brand is selected, is compatible. + */ + if ($iddepartment !== null) { + if ($idbrand !== null) { + $stmt = $db->prepare(" + SELECT COUNT(*) + FROM departments + WHERE iddepartment = :iddepartment + AND idcompany = :idcompany + AND (idbrand = :idbrand OR idbrand IS NULL) + "); + $stmt->execute([ + ':iddepartment' => $iddepartment, + ':idcompany' => $idcompany, + ':idbrand' => $idbrand, + ]); + } else { + $stmt = $db->prepare(" + SELECT COUNT(*) + FROM departments + WHERE iddepartment = :iddepartment + AND idcompany = :idcompany + "); + $stmt->execute([ + ':iddepartment' => $iddepartment, + ':idcompany' => $idcompany, + ]); + } + + if ((int) $stmt->fetchColumn() === 0) { + jsonResponse([ + 'success' => false, + 'message' => 'Selected department is not compatible with the selected company/brand.' + ]); + } + } + + /* + * Check duplicate assignment. + */ + if ($idcompanyuser > 0) { + $stmt = $db->prepare(" + SELECT COUNT(*) + FROM company_users + WHERE iduser = :iduser + AND idcompany = :idcompany + AND ( + (idbrand IS NULL AND :idbrand_null = 1) + OR idbrand = :idbrand + ) + AND ( + (iddepartment IS NULL AND :iddepartment_null = 1) + OR iddepartment = :iddepartment + ) + AND idcompanyuser <> :idcompanyuser + "); + $stmt->execute([ + ':iduser' => $iduser, + ':idcompany' => $idcompany, + ':idbrand' => $idbrand, + ':idbrand_null' => $idbrand === null ? 1 : 0, + ':iddepartment' => $iddepartment, + ':iddepartment_null' => $iddepartment === null ? 1 : 0, + ':idcompanyuser' => $idcompanyuser, + ]); + } else { + $stmt = $db->prepare(" + SELECT COUNT(*) + FROM company_users + WHERE iduser = :iduser + AND idcompany = :idcompany + AND ( + (idbrand IS NULL AND :idbrand_null = 1) + OR idbrand = :idbrand + ) + AND ( + (iddepartment IS NULL AND :iddepartment_null = 1) + OR iddepartment = :iddepartment + ) + "); + $stmt->execute([ + ':iduser' => $iduser, + ':idcompany' => $idcompany, + ':idbrand' => $idbrand, + ':idbrand_null' => $idbrand === null ? 1 : 0, + ':iddepartment' => $iddepartment, + ':iddepartment_null' => $iddepartment === null ? 1 : 0, + ]); + } + + if ((int) $stmt->fetchColumn() > 0) { + jsonResponse([ + 'success' => false, + 'message' => 'This user already has the same assignment.' + ]); + } + + if ($idcompanyuser > 0) { + $sql = " + UPDATE company_users + SET + iduser = :iduser, + idcompany = :idcompany, + idbrand = :idbrand, + iddepartment = :iddepartment, + user_scope = :user_scope, + company_role = :company_role, + status = :status, + updated_at = NOW() + WHERE idcompanyuser = :idcompanyuser + "; + + $stmt = $db->prepare($sql); + $stmt->execute([ + ':iduser' => $iduser, + ':idcompany' => $idcompany, + ':idbrand' => $idbrand, + ':iddepartment' => $iddepartment, + ':user_scope' => $userScope, + ':company_role' => $companyRole, + ':status' => $status, + ':idcompanyuser' => $idcompanyuser, + ]); + + jsonResponse([ + 'success' => true, + 'message' => 'User assignment updated successfully.' + ]); + } + + $sql = " + INSERT INTO company_users ( + iduser, + idcompany, + idbrand, + iddepartment, + user_scope, + company_role, + status, + created_at, + updated_at + ) VALUES ( + :iduser, + :idcompany, + :idbrand, + :iddepartment, + :user_scope, + :company_role, + :status, + NOW(), + NOW() + ) + "; + + $stmt = $db->prepare($sql); + $stmt->execute([ + ':iduser' => $iduser, + ':idcompany' => $idcompany, + ':idbrand' => $idbrand, + ':iddepartment' => $iddepartment, + ':user_scope' => $userScope, + ':company_role' => $companyRole, + ':status' => $status, + ]); + + jsonResponse([ + 'success' => true, + 'message' => 'User assigned successfully.' + ]); + } + + if ($action === 'get_company_user') { + $idcompanyuser = isset($_POST['idcompanyuser']) ? (int) $_POST['idcompanyuser'] : 0; + + if ($idcompanyuser <= 0) { + jsonResponse([ + 'success' => false, + 'message' => 'Invalid assignment id.' + ]); + } + + $stmt = $db->prepare(" + SELECT * + FROM company_users + WHERE idcompanyuser = :idcompanyuser + LIMIT 1 + "); + $stmt->execute([':idcompanyuser' => $idcompanyuser]); + $companyUser = $stmt->fetch(PDO::FETCH_ASSOC); + + if (!$companyUser) { + jsonResponse([ + 'success' => false, + 'message' => 'Assignment not found.' + ]); + } + + jsonResponse([ + 'success' => true, + 'company_user' => $companyUser + ]); + } + + if ($action === 'change_status') { + $idcompanyuser = isset($_POST['idcompanyuser']) ? (int) $_POST['idcompanyuser'] : 0; + $status = $_POST['status'] ?? 'inactive'; + + $allowedStatuses = ['active', 'inactive']; + + if ($idcompanyuser <= 0 || !in_array($status, $allowedStatuses, true)) { + jsonResponse([ + 'success' => false, + 'message' => 'Invalid request.' + ]); + } + + $stmt = $db->prepare(" + UPDATE company_users + SET status = :status, updated_at = NOW() + WHERE idcompanyuser = :idcompanyuser + "); + $stmt->execute([ + ':status' => $status, + ':idcompanyuser' => $idcompanyuser, + ]); + + jsonResponse([ + 'success' => true, + 'message' => 'Assignment status updated successfully.' + ]); + } + + if ($action === 'delete_company_user') { + $idcompanyuser = isset($_POST['idcompanyuser']) ? (int) $_POST['idcompanyuser'] : 0; + + if ($idcompanyuser <= 0) { + jsonResponse([ + 'success' => false, + 'message' => 'Invalid assignment id.' + ]); + } + + $stmt = $db->prepare(" + DELETE FROM company_users + WHERE idcompanyuser = :idcompanyuser + "); + $stmt->execute([':idcompanyuser' => $idcompanyuser]); + + jsonResponse([ + 'success' => true, + 'message' => 'User assignment deleted successfully.' + ]); + } + + jsonResponse([ + 'success' => false, + 'message' => 'Unknown action.' + ]); + } catch (Throwable $e) { + jsonResponse([ + 'success' => false, + 'message' => $e->getMessage() + ]); + } +} + +/* + * Page data + */ +$users = []; + +try { + $stmt = $db->query(" + SELECT + u.id, + u.email, + u.first_name, + u.last_name, + u.username, + u.status, + r.display_name AS role_display_name, + r.name AS role_name + FROM auth_users u + LEFT JOIN auth_roles r ON r.id = u.role_id + ORDER BY u.first_name ASC, u.last_name ASC, u.email ASC + "); + $users = $stmt->fetchAll(PDO::FETCH_ASSOC); +} catch (Throwable $e) { + $users = []; +} + +$companies = []; + +try { + $stmt = $db->query(" + SELECT idcompany, company_name, status + FROM companies + ORDER BY company_name ASC + "); + $companies = $stmt->fetchAll(PDO::FETCH_ASSOC); +} catch (Throwable $e) { + $companies = []; +} + +$brands = []; + +try { + $stmt = $db->query(" + SELECT idbrand, idcompany, brand_name, status + FROM brands + ORDER BY brand_name ASC + "); + $brands = $stmt->fetchAll(PDO::FETCH_ASSOC); +} catch (Throwable $e) { + $brands = []; +} + +$departments = []; + +try { + $stmt = $db->query(" + SELECT iddepartment, idcompany, idbrand, department_name, status + FROM departments + ORDER BY department_name ASC + "); + $departments = $stmt->fetchAll(PDO::FETCH_ASSOC); +} catch (Throwable $e) { + $departments = []; +} + +$assignments = []; + +try { + $stmt = $db->query(" + SELECT + cu.idcompanyuser, + cu.iduser, + cu.idcompany, + cu.idbrand, + cu.iddepartment, + cu.user_scope, + cu.company_role, + cu.status, + cu.created_at, + u.email, + u.first_name, + u.last_name, + u.username, + u.status AS user_status, + r.display_name AS vanguard_role, + c.company_name, + c.status AS company_status, + b.brand_name, + b.status AS brand_status, + d.department_name, + d.status AS department_status + FROM company_users cu + INNER JOIN auth_users u ON u.id = cu.iduser + LEFT JOIN auth_roles r ON r.id = u.role_id + INNER JOIN companies c ON c.idcompany = cu.idcompany + LEFT JOIN brands b ON b.idbrand = cu.idbrand + LEFT JOIN departments d ON d.iddepartment = cu.iddepartment + ORDER BY c.company_name ASC, u.first_name ASC, u.last_name ASC, u.email ASC + "); + $assignments = $stmt->fetchAll(PDO::FETCH_ASSOC); +} catch (Throwable $e) { + $assignments = []; +} + +$pageTitle = 'Company Users'; +?> + + + + + + + + + + + + <?= e($pageTitle); ?> - <?= isset($titlewebsite) ? e($titlewebsite) : 'TRFgo'; ?> + + + + + +
+ + + +
+
+ +
+
+
+
+
+ + TRFgo Access Control +
+

Company Users

+

+ Link Vanguard users to TRFgo companies, brands and departments. + Vanguard remains responsible for authentication; this page defines operational access and data visibility. +

+
+ +
+ +
+
+
+
+ + $row['status'] === 'active')); + $companyScopeCount = count(array_filter($assignments, fn($row) => $row['user_scope'] === 'company')); + $departmentScopeCount = count(array_filter($assignments, fn($row) => $row['user_scope'] === 'department')); + ?> + +
+
+
+
+
+
+
Assignments
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
Active
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
Company Scope
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
Department Scope
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+
User Assignments
+

Vanguard users linked to TRFgo operating scopes

+
+ + +
+
+ +
+ +
+ No companies available. + Create at least one company before assigning users. +
+ + + +
+ No Vanguard users available. + Create users in Vanguard before assigning TRFgo access. +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
UserCompanyScopeRoleVanguard RoleStatusCreatedActions
+
+
+
+
+ +
Company status:
+ +
+
+ + Company +
Full company visibility
+ + Brand +
+ + Department +
+ + + / + +
+ +
+
+ + + -'; ?> + + + Active + + Inactive + + + + + + + + + + + + + +
+
+ +
+
+ +
+
+ +
+ + + + + + +
+ + + + + + + + + + \ No newline at end of file diff --git a/public/userarea/dashboard-admin.php b/public/userarea/dashboard-admin.php new file mode 100644 index 00000000..c056130d --- /dev/null +++ b/public/userarea/dashboard-admin.php @@ -0,0 +1,1419 @@ + + +prepare($sql); + $stmt->execute($params); + return $stmt->fetchColumn(); + } catch (Throwable $e) { + return 0; + } +} + +function getRows(PDO $db, string $sql, array $params = []) +{ + try { + $stmt = $db->prepare($sql); + $stmt->execute($params); + return $stmt->fetchAll(PDO::FETCH_ASSOC); + } catch (Throwable $e) { + return []; + } +} + +$dashboardPage = 'dashboard'; + +$availableWidgets = [ + 'kpi_companies', + 'kpi_brands', + 'kpi_departments', + 'kpi_users', + 'chart_structure', + 'setup_progress', + 'quick_actions', + 'recent_companies', +]; + +$defaultLayout = [ + 'kpi_companies', + 'kpi_brands', + 'kpi_departments', + 'kpi_users', + 'chart_structure', + 'setup_progress', + 'quick_actions', + 'recent_companies', +]; + +/* + * AJAX: save/reset dashboard layout. + */ +if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) { + try { + if ($_POST['action'] === 'save_dashboard_layout') { + $layoutRaw = $_POST['layout'] ?? '[]'; + $layout = json_decode($layoutRaw, true); + + if (!is_array($layout)) { + jsonResponse([ + 'success' => false, + 'message' => 'Invalid dashboard layout.' + ]); + } + + $cleanLayout = []; + + foreach ($layout as $widgetKey) { + if (in_array($widgetKey, $availableWidgets, true) && !in_array($widgetKey, $cleanLayout, true)) { + $cleanLayout[] = $widgetKey; + } + } + + foreach ($defaultLayout as $widgetKey) { + if (!in_array($widgetKey, $cleanLayout, true)) { + $cleanLayout[] = $widgetKey; + } + } + + $layoutJson = json_encode($cleanLayout); + + $stmt = $db->prepare(" + INSERT INTO user_dashboard_layouts ( + iduser, + page, + layout_json, + created_at, + updated_at + ) VALUES ( + :iduser, + :page, + :layout_json, + NOW(), + NOW() + ) + ON DUPLICATE KEY UPDATE + layout_json = VALUES(layout_json), + updated_at = NOW() + "); + + $stmt->execute([ + ':iduser' => $iduserlogin, + ':page' => $dashboardPage, + ':layout_json' => $layoutJson, + ]); + + jsonResponse([ + 'success' => true, + 'message' => 'Dashboard layout saved.' + ]); + } + + if ($_POST['action'] === 'reset_dashboard_layout') { + $stmt = $db->prepare(" + DELETE FROM user_dashboard_layouts + WHERE iduser = :iduser + AND page = :page + "); + + $stmt->execute([ + ':iduser' => $iduserlogin, + ':page' => $dashboardPage, + ]); + + jsonResponse([ + 'success' => true, + 'message' => 'Dashboard layout reset.' + ]); + } + + jsonResponse([ + 'success' => false, + 'message' => 'Unknown action.' + ]); + } catch (Throwable $e) { + jsonResponse([ + 'success' => false, + 'message' => $e->getMessage() + ]); + } +} + +/* + * Dashboard counters. + */ +$totalCompanies = (int) getScalar($db, "SELECT COUNT(*) FROM companies"); +$activeCompanies = (int) getScalar($db, "SELECT COUNT(*) FROM companies WHERE status = 'active'"); +$totalBrands = (int) getScalar($db, "SELECT COUNT(*) FROM brands"); +$activeBrands = (int) getScalar($db, "SELECT COUNT(*) FROM brands WHERE status = 'active'"); +$totalDepartments = (int) getScalar($db, "SELECT COUNT(*) FROM departments"); +$activeDepartments = (int) getScalar($db, "SELECT COUNT(*) FROM departments WHERE status = 'active'"); +$totalCompanyUsers = (int) getScalar($db, "SELECT COUNT(*) FROM company_users"); +$activeCompanyUsers = (int) getScalar($db, "SELECT COUNT(*) FROM company_users WHERE status = 'active'"); + +/* + * Recent companies. + */ +$recentCompanies = getRows($db, " + SELECT + c.idcompany, + c.company_name, + c.legal_name, + c.status, + c.created_at, + COUNT(DISTINCT b.idbrand) AS brand_count, + COUNT(DISTINCT d.iddepartment) AS department_count, + COUNT(DISTINCT cu.idcompanyuser) AS user_count + FROM companies c + LEFT JOIN brands b ON b.idcompany = c.idcompany + LEFT JOIN departments d ON d.idcompany = c.idcompany + LEFT JOIN company_users cu ON cu.idcompany = c.idcompany + GROUP BY c.idcompany, c.company_name, c.legal_name, c.status, c.created_at + ORDER BY c.created_at DESC, c.idcompany DESC + LIMIT 8 +"); + +/* + * Chart data. + */ +$companyDistribution = getRows($db, " + SELECT + c.company_name, + COUNT(DISTINCT b.idbrand) AS brands, + COUNT(DISTINCT d.iddepartment) AS departments, + COUNT(DISTINCT cu.idcompanyuser) AS users + FROM companies c + LEFT JOIN brands b ON b.idcompany = c.idcompany + LEFT JOIN departments d ON d.idcompany = c.idcompany + LEFT JOIN company_users cu ON cu.idcompany = c.idcompany + GROUP BY c.idcompany, c.company_name + ORDER BY c.company_name ASC + LIMIT 10 +"); + +$chartCompanyLabels = []; +$chartBrands = []; +$chartDepartments = []; +$chartUsers = []; + +foreach ($companyDistribution as $row) { + $chartCompanyLabels[] = $row['company_name']; + $chartBrands[] = (int) $row['brands']; + $chartDepartments[] = (int) $row['departments']; + $chartUsers[] = (int) $row['users']; +} + +/* + * Setup progress. + */ +$setupItems = [ + [ + 'label' => 'Companies', + 'completed' => $totalCompanies > 0, + 'icon' => 'bx bx-buildings', + ], + [ + 'label' => 'Brands', + 'completed' => $totalBrands > 0, + 'icon' => 'bx bx-purchase-tag-alt', + ], + [ + 'label' => 'Departments', + 'completed' => $totalDepartments > 0, + 'icon' => 'bx bx-sitemap', + ], + [ + 'label' => 'User access', + 'completed' => $totalCompanyUsers > 0, + 'icon' => 'bx bx-user-check', + ], +]; + +$completedSetupItems = count(array_filter($setupItems, function ($item) { + return $item['completed']; +})); + +$setupProgress = count($setupItems) > 0 + ? round(($completedSetupItems / count($setupItems)) * 100) + : 0; + +/* + * Load user dashboard layout. + */ +$userLayout = $defaultLayout; + +try { + $stmt = $db->prepare(" + SELECT layout_json + FROM user_dashboard_layouts + WHERE iduser = :iduser + AND page = :page + LIMIT 1 + "); + $stmt->execute([ + ':iduser' => $iduserlogin, + ':page' => $dashboardPage, + ]); + + $savedLayoutJson = $stmt->fetchColumn(); + + if ($savedLayoutJson) { + $savedLayout = json_decode($savedLayoutJson, true); + + if (is_array($savedLayout)) { + $cleanLayout = []; + + foreach ($savedLayout as $widgetKey) { + if (in_array($widgetKey, $availableWidgets, true) && !in_array($widgetKey, $cleanLayout, true)) { + $cleanLayout[] = $widgetKey; + } + } + + foreach ($defaultLayout as $widgetKey) { + if (!in_array($widgetKey, $cleanLayout, true)) { + $cleanLayout[] = $widgetKey; + } + } + + $userLayout = $cleanLayout; + } + } +} catch (Throwable $e) { + $userLayout = $defaultLayout; +} + +$pageTitle = 'TRFgo Dashboard'; +?> + + + + + + + + + + + + <?= e($pageTitle); ?> - <?= isset($titlewebsite) ? e($titlewebsite) : 'TRFgo'; ?> + + + + + +
+ + + +
+
+ +
+
+
+ + TRFgo Platform +
+ +

+ Welcome, +

+ +

+ Manage companies, brands, departments and user access from one central workspace. + TRFgo is the customer-side platform for digital test request forms, sample tracking + and laboratory result exchange. +

+ + +
+
+ +
+
+
Your Dashboard
+

+ Drag widgets using the handle. The layout is saved for your user account. +

+
+ +
+ + + +
+
+ +
+ + + + +
+
+
+
+
+
+ +
+
+
Companies
+
+
active companies
+
+
+ +
+
+
+
+
+ + + +
+
+
+
+
+
+ +
+
+
Brands
+
+
active brands
+
+
+ +
+
+
+
+
+ + + +
+
+
+
+
+
+ +
+
+
Departments
+
+
active departments
+
+
+ +
+
+
+
+
+ + + +
+
+
+
+
+
+ +
+
+
Company Users
+
+
active assignments
+
+
+ +
+
+
+
+
+ + + +
+
+
+
+
+
Company Structure Overview
+

Brands, departments and user assignments by company

+
+
+
+
+ +
+ 0): ?> +
+ +
+ +
No chart data available
+

Create your first company, brand or department to populate this chart.

+
+ +
+
+
+ + + +
+
+
+
+
+
TRFgo Setup
+

Initial configuration progress

+
+
+
+
+ +
+
+ % completed + / +
+ +
+
+
+ + +
+
+
+ +
+
+
+ + + Ready + + Missing + +
+ +
+
+
+ + + + + + + +
+
+
+
+
+
Recent Companies
+

Latest configured organizations in TRFgo

+
+ +
+
+ +
+ 0): ?> +
+ + + + + + + + + + + + + + + + + + + + + + + +
CompanyStatusBrandsDepartmentsUsersCreated
+
+ + + +
+ + Active + + Suspended + + Inactive + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
No companies configured yet
+

Create your first company to start using TRFgo.

+ + + Add first company + +
+ +
+
+
+ + + + +
+ +
+
+ +
+ + + + + + +
+ + + + + + + + + + + \ No newline at end of file diff --git a/public/userarea/dashboard-customer.php b/public/userarea/dashboard-customer.php new file mode 100644 index 00000000..5f5ad6e2 --- /dev/null +++ b/public/userarea/dashboard-customer.php @@ -0,0 +1,2002 @@ + + +prepare(" + SELECT COUNT(*) + FROM information_schema.tables + WHERE table_schema = DATABASE() + AND table_name = :table_name + "); + $stmt->execute([':table_name' => $tableName]); + + return (int) $stmt->fetchColumn() > 0; + } catch (Throwable $e) { + return false; + } +} + +function getScalar(PDO $db, string $sql, array $params = []) +{ + try { + $stmt = $db->prepare($sql); + $stmt->execute($params); + + return $stmt->fetchColumn(); + } catch (Throwable $e) { + return 0; + } +} + +function getRows(PDO $db, string $sql, array $params = []) +{ + try { + $stmt = $db->prepare($sql); + $stmt->execute($params); + + return $stmt->fetchAll(PDO::FETCH_ASSOC); + } catch (Throwable $e) { + return []; + } +} + +$dashboardPage = 'dashboard_customer'; + +/* + * Widget registry. + */ +$availableWidgets = [ + 'customer_company_profile', + 'kpi_business_partners', + 'kpi_samples', + 'kpi_sample_parts', + 'kpi_documents', + 'kpi_trf_requests', + 'kpi_pending_requests', + 'kpi_reports_received', + 'master_data_readiness', + 'chart_trf_status', + 'recent_samples', + 'recent_trf_requests', + 'document_repository', + 'quick_actions', +]; + +$defaultLayout = [ + 'customer_company_profile', + 'kpi_business_partners', + 'kpi_samples', + 'kpi_sample_parts', + 'kpi_documents', + 'master_data_readiness', + 'recent_samples', + 'quick_actions', + 'kpi_trf_requests', + 'kpi_pending_requests', + 'kpi_reports_received', + 'chart_trf_status', + 'recent_trf_requests', + 'document_repository', +]; + +/* + * Find customer company scope for current user. + * + * SaaS: + * - user can be linked to one or more companies through company_users. + * + * On-premise: + * - if no assignment exists, fallback to TRFGO_DEFAULT_COMPANY_ID or first company. + */ +$userCompanies = getRows($db, " + SELECT DISTINCT + c.idcompany, + c.company_name, + c.legal_name, + c.status, + c.email, + c.phone, + c.city, + c.external_code, + cu.company_role, + cu.user_scope + FROM company_users cu + INNER JOIN companies c ON c.idcompany = cu.idcompany + WHERE cu.iduser = :iduser + AND cu.status = 'active' + ORDER BY c.company_name ASC +", [ + ':iduser' => $iduserlogin, +]); + +$selectedCompanyId = 0; + +if (!empty($_GET['idcompany'])) { + $selectedCompanyId = (int) $_GET['idcompany']; +} + +if ($selectedCompanyId <= 0 && count($userCompanies) > 0) { + $selectedCompanyId = (int) $userCompanies[0]['idcompany']; +} + +/* + * On-premise fallback. + */ +if ($selectedCompanyId <= 0) { + $defaultCompanyId = isset($_ENV['TRFGO_DEFAULT_COMPANY_ID']) ? (int) $_ENV['TRFGO_DEFAULT_COMPANY_ID'] : 0; + + if ($defaultCompanyId > 0) { + $selectedCompanyId = $defaultCompanyId; + } else { + $selectedCompanyId = (int) getScalar($db, " + SELECT idcompany + FROM companies + ORDER BY idcompany ASC + LIMIT 1 + "); + } +} + +$selectedCompany = null; + +if ($selectedCompanyId > 0) { + $rows = getRows($db, " + SELECT * + FROM companies + WHERE idcompany = :idcompany + LIMIT 1 + ", [ + ':idcompany' => $selectedCompanyId, + ]); + + $selectedCompany = $rows[0] ?? null; +} + +$companyParams = [ + ':idcompany' => $selectedCompanyId, +]; + +/* + * Existing setup data. + */ +$totalBrands = $selectedCompanyId > 0 + ? (int) getScalar($db, "SELECT COUNT(*) FROM brands WHERE idcompany = :idcompany", $companyParams) + : 0; + +$totalDepartments = $selectedCompanyId > 0 + ? (int) getScalar($db, "SELECT COUNT(*) FROM departments WHERE idcompany = :idcompany", $companyParams) + : 0; + +$totalCompanyUsers = $selectedCompanyId > 0 + ? (int) getScalar($db, "SELECT COUNT(*) FROM company_users WHERE idcompany = :idcompany AND status = 'active'", $companyParams) + : 0; + +/* + * Future operational tables. + * These names are provisional and will be created later with Phinx. + */ +$hasSamplesTable = tableExists($db, 'samples'); +$hasTrfRequestsTable = tableExists($db, 'trf_requests'); +$hasLabReportsTable = tableExists($db, 'lab_reports'); +$hasDocumentsTable = tableExists($db, 'documents') || tableExists($db, 'trf_documents'); +$hasBusinessPartnersTable = tableExists($db, 'business_partners'); +$hasSamplePartsTable = tableExists($db, 'sample_parts'); +$hasSamplePhotosTable = tableExists($db, 'sample_photos'); +/* + * KPI counters. + */ +$totalSamples = 0; +$totalTrfRequests = 0; +$pendingTrfRequests = 0; +$reportsReceived = 0; +$totalDocuments = 0; +$totalBusinessPartners = 0; +$activeBusinessPartners = 0; +$totalSampleParts = 0; +$totalSamplePhotos = 0; +$recentSamples = []; + +if ($selectedCompanyId > 0 && $hasSamplesTable) { + $totalSamples = (int) getScalar($db, " + SELECT COUNT(*) + FROM samples + WHERE idcompany = :idcompany + ", $companyParams); +} +if ($selectedCompanyId > 0 && $hasBusinessPartnersTable) { + $totalBusinessPartners = (int) getScalar($db, " + SELECT COUNT(*) + FROM business_partners + WHERE idcompany = :idcompany + ", $companyParams); + + $activeBusinessPartners = (int) getScalar($db, " + SELECT COUNT(*) + FROM business_partners + WHERE idcompany = :idcompany + AND status = 'active' + ", $companyParams); +} + +if ($selectedCompanyId > 0 && $hasSamplePartsTable && $hasSamplesTable) { + $totalSampleParts = (int) getScalar($db, " + SELECT COUNT(sp.idpart) + FROM sample_parts sp + INNER JOIN samples s ON s.idsample = sp.idsample + WHERE s.idcompany = :idcompany + ", $companyParams); +} + +if ($selectedCompanyId > 0 && $hasSamplePhotosTable && $hasSamplesTable) { + $totalSamplePhotos = (int) getScalar($db, " + SELECT COUNT(sph.idsamplephoto) + FROM sample_photos sph + INNER JOIN samples s ON s.idsample = sph.idsample + WHERE s.idcompany = :idcompany + ", $companyParams); +} + +if ($selectedCompanyId > 0 && $hasSamplesTable) { + $recentSamples = getRows($db, " + SELECT + s.idsample, + s.sample_code, + s.external_sample_id, + s.article_no, + s.po_no, + s.season, + s.sample_description, + s.color, + s.production_stage, + s.status, + s.created_at, + b.brand_name, + d.department_name, + bp1.partner_name AS producer_name, + bp2.partner_name AS supplier_name, + COUNT(DISTINCT sp.idpart) AS parts_count, + COUNT(DISTINCT sph.idsamplephoto) AS photos_count, + COUNT(DISTINCT sd.idsampledocument) AS documents_count + FROM samples s + LEFT JOIN brands b ON b.idbrand = s.idbrand + LEFT JOIN departments d ON d.iddepartment = s.iddepartment + LEFT JOIN business_partners bp1 ON bp1.idpartner = s.idproducer + LEFT JOIN business_partners bp2 ON bp2.idpartner = s.idsupplier + LEFT JOIN sample_parts sp ON sp.idsample = s.idsample + LEFT JOIN sample_photos sph ON sph.idsample = s.idsample + LEFT JOIN sample_documents sd ON sd.idsample = s.idsample + WHERE s.idcompany = :idcompany + GROUP BY + s.idsample, + s.sample_code, + s.external_sample_id, + s.article_no, + s.po_no, + s.season, + s.sample_description, + s.color, + s.production_stage, + s.status, + s.created_at, + b.brand_name, + d.department_name, + bp1.partner_name, + bp2.partner_name + ORDER BY s.created_at DESC, s.idsample DESC + LIMIT 8 + ", $companyParams); +} + +if ($selectedCompanyId > 0 && $hasTrfRequestsTable) { + $totalTrfRequests = (int) getScalar($db, " + SELECT COUNT(*) + FROM trf_requests + WHERE idcompany = :idcompany + ", $companyParams); + + $pendingTrfRequests = (int) getScalar($db, " + SELECT COUNT(*) + FROM trf_requests + WHERE idcompany = :idcompany + AND status IN ('draft', 'submitted', 'available_for_lab', 'pulled_by_lab', 'in_lims', 'testing') + ", $companyParams); +} + +if ($selectedCompanyId > 0 && $hasLabReportsTable) { + $reportsReceived = (int) getScalar($db, " + SELECT COUNT(lr.idlabreport) + FROM lab_reports lr + INNER JOIN trf_requests trf ON trf.idtrf = lr.idtrf + WHERE trf.idcompany = :idcompany + ", $companyParams); +} + +if ($selectedCompanyId > 0 && tableExists($db, 'documents')) { + $totalDocuments = (int) getScalar($db, " + SELECT COUNT(*) + FROM documents + WHERE idcompany = :idcompany + ", $companyParams); +} + +if ($selectedCompanyId > 0 && !$totalDocuments && tableExists($db, 'trf_documents')) { + $totalDocuments = (int) getScalar($db, " + SELECT COUNT(td.iddocument) + FROM trf_documents td + INNER JOIN trf_requests trf ON trf.idtrf = td.idtrf + WHERE trf.idcompany = :idcompany + ", $companyParams); +} + +/* + * TRF status chart data. + */ +$trfStatusRows = []; + +if ($selectedCompanyId > 0 && $hasTrfRequestsTable) { + $trfStatusRows = getRows($db, " + SELECT status, COUNT(*) AS total + FROM trf_requests + WHERE idcompany = :idcompany + GROUP BY status + ORDER BY status ASC + ", $companyParams); +} + +$trfStatusLabels = []; +$trfStatusValues = []; + +foreach ($trfStatusRows as $row) { + $trfStatusLabels[] = ucfirst(str_replace('_', ' ', $row['status'])); + $trfStatusValues[] = (int) $row['total']; +} + +/* + * Recent TRF requests. + */ +$recentTrfRequests = []; + +if ($selectedCompanyId > 0 && $hasTrfRequestsTable) { + $recentTrfRequests = getRows($db, " + SELECT + idtrf, + trf_code, + external_trf_id, + trf_type, + service_required, + status, + created_at + FROM trf_requests + WHERE idcompany = :idcompany + ORDER BY created_at DESC, idtrf DESC + LIMIT 8 + ", $companyParams); +} + +/* + * Pending actions. + */ +$pendingActions = [ + [ + 'title' => 'Create business partners', + 'text' => $hasBusinessPartnersTable ? 'Add producers, suppliers, vendors and factories.' : 'Business partner module is not active yet.', + 'icon' => 'bx bx-network-chart', + 'completed' => $hasBusinessPartnersTable && $totalBusinessPartners > 0, + 'link' => $hasBusinessPartnersTable ? 'business-partners.php' : '#', + ], + [ + 'title' => 'Create or import samples', + 'text' => $hasSamplesTable ? 'Start building product identity cards.' : 'Sample module is not active yet.', + 'icon' => 'bx bx-package', + 'completed' => $hasSamplesTable && $totalSamples > 0, + 'link' => $hasSamplesTable ? 'samples.php' : '#', + ], + [ + 'title' => 'Add BOM / parts', + 'text' => $hasSamplePartsTable ? 'Complete sample identity with materials and components.' : 'BOM module is not active yet.', + 'icon' => 'bx bx-git-branch', + 'completed' => $hasSamplePartsTable && $totalSampleParts > 0, + 'link' => $hasSamplesTable ? 'samples.php' : '#', + ], + [ + 'title' => 'Attach documents', + 'text' => $hasDocumentsTable ? 'Attach technical sheets, certificates and declarations.' : 'Document repository is not active yet.', + 'icon' => 'bx bx-folder', + 'completed' => $hasDocumentsTable && $totalDocuments > 0, + 'link' => $hasDocumentsTable ? 'documents.php' : '#', + ], + [ + 'title' => 'Prepare TRF requests', + 'text' => $hasTrfRequestsTable ? 'Create test requests from one or more samples.' : 'TRF request module is not active yet.', + 'icon' => 'bx bx-file', + 'completed' => $hasTrfRequestsTable && $totalTrfRequests > 0, + 'link' => $hasTrfRequestsTable ? 'trf-requests.php' : '#', + ], +]; + +$completedPendingActions = count(array_filter($pendingActions, function ($item) { + return $item['completed']; +})); + +$operationalProgress = count($pendingActions) > 0 + ? round(($completedPendingActions / count($pendingActions)) * 100) + : 0; + +/* + * AJAX: save/reset dashboard layout. + */ +if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) { + try { + if ($_POST['action'] === 'save_dashboard_layout') { + $layoutRaw = $_POST['layout'] ?? '[]'; + $layout = json_decode($layoutRaw, true); + + if (!is_array($layout)) { + jsonResponse([ + 'success' => false, + 'message' => 'Invalid dashboard layout.' + ]); + } + + $cleanLayout = []; + + foreach ($layout as $widgetKey) { + if (in_array($widgetKey, $availableWidgets, true) && !in_array($widgetKey, $cleanLayout, true)) { + $cleanLayout[] = $widgetKey; + } + } + + foreach ($defaultLayout as $widgetKey) { + if (!in_array($widgetKey, $cleanLayout, true)) { + $cleanLayout[] = $widgetKey; + } + } + + $layoutJson = json_encode($cleanLayout); + + $stmt = $db->prepare(" + INSERT INTO user_dashboard_layouts ( + iduser, + page, + layout_json, + created_at, + updated_at + ) VALUES ( + :iduser, + :page, + :layout_json, + NOW(), + NOW() + ) + ON DUPLICATE KEY UPDATE + layout_json = VALUES(layout_json), + updated_at = NOW() + "); + + $stmt->execute([ + ':iduser' => $iduserlogin, + ':page' => $dashboardPage, + ':layout_json' => $layoutJson, + ]); + + jsonResponse([ + 'success' => true, + 'message' => 'Dashboard layout saved.' + ]); + } + + if ($_POST['action'] === 'reset_dashboard_layout') { + $stmt = $db->prepare(" + DELETE FROM user_dashboard_layouts + WHERE iduser = :iduser + AND page = :page + "); + + $stmt->execute([ + ':iduser' => $iduserlogin, + ':page' => $dashboardPage, + ]); + + jsonResponse([ + 'success' => true, + 'message' => 'Dashboard layout reset.' + ]); + } + + jsonResponse([ + 'success' => false, + 'message' => 'Unknown action.' + ]); + } catch (Throwable $e) { + jsonResponse([ + 'success' => false, + 'message' => $e->getMessage() + ]); + } +} + +/* + * Load user layout. + */ +$userLayout = $defaultLayout; + +try { + $stmt = $db->prepare(" + SELECT layout_json + FROM user_dashboard_layouts + WHERE iduser = :iduser + AND page = :page + LIMIT 1 + "); + + $stmt->execute([ + ':iduser' => $iduserlogin, + ':page' => $dashboardPage, + ]); + + $savedLayoutJson = $stmt->fetchColumn(); + + if ($savedLayoutJson) { + $savedLayout = json_decode($savedLayoutJson, true); + + if (is_array($savedLayout)) { + $cleanLayout = []; + + foreach ($savedLayout as $widgetKey) { + if (in_array($widgetKey, $availableWidgets, true) && !in_array($widgetKey, $cleanLayout, true)) { + $cleanLayout[] = $widgetKey; + } + } + + foreach ($defaultLayout as $widgetKey) { + if (!in_array($widgetKey, $cleanLayout, true)) { + $cleanLayout[] = $widgetKey; + } + } + + $userLayout = $cleanLayout; + } + } +} catch (Throwable $e) { + $userLayout = $defaultLayout; +} + +$pageTitle = 'Customer Dashboard'; +$companyName = $selectedCompany['company_name'] ?? 'Your Company'; +$companyLegalName = $selectedCompany['legal_name'] ?? ''; +?> + + + + + + + + + + + + <?= e($pageTitle); ?> - <?= isset($titlewebsite) ? e($titlewebsite) : 'TRFgo'; ?> + + + + + +
+ + + +
+
+ +
+
+
+
+
+ + Customer Workspace +
+ +

+ +

+ +

+ Manage your samples, digital test request forms, laboratory reports and technical documents from one operational dashboard. + This workspace is designed for customer users and on-premise installations. +

+ + +
+ + 1): ?> +
+ +
+ +
+
+
+ + +
+ No company available. + This user is not linked to any TRFgo company yet. +
+ + +
+
+
Operational Dashboard
+

+ Drag widgets using the handle. The layout is saved for your user account. +

+
+ +
+ + + +
+
+ +
+ + + + +
+
+
+
+
+
Company Profile
+

Current customer workspace

+
+
+
+
+ +
+
+
Company
+
+
+ +
+
Legal Name
+
+
+ +
+
Brands
+
+
+ +
+
Departments
+
+
+ +
+
Active Users
+
+
+
+
+
+ + + +
+
+
+
+
+ + Coming soon + +
+ +
+
+
Business Partners
+
+
active partners
+
+
+ +
+
+
+
+
+ + + +
+
+
+
+
+ + Coming soon + +
+ +
+
+
Samples
+
+
Products or samples in archive
+
+
+ +
+
+
+
+
+ + + +
+
+
+
+
+ + Coming soon + +
+ +
+
+
BOM / Parts
+
+
sample photos uploaded
+
+
+ +
+
+
+
+
+ + + +
+
+
+
+
+ + Coming soon + +
+ +
+
+
TRF Requests
+
+
Digital test request forms
+
+
+ +
+
+
+
+
+ + + +
+
+
+
+
+ + Coming soon + +
+ +
+
+
Documents
+
+
Technical files and certificates
+
+
+ +
+
+
+
+
+ + + +
+
+
+
+
+ + Coming soon + +
+ +
+
+
Pending
+
+
Requests not completed yet
+
+
+ +
+
+
+
+
+ + + +
+
+
+
+
+ + Coming soon + +
+ +
+
+
Reports
+
+
Laboratory reports received
+
+
+ +
+
+
+
+
+ + + +
+
+
+
+
+
TRF Status Overview
+

Distribution of digital test request forms by status

+
+
+
+
+ +
+ 0): ?> +
+ +
+ +
TRF chart not available yet
+

The chart will be populated when TRF requests are created.

+
+ +
+
+
+ + + +
+
+
+
+
+
Master Data Readiness
+

Product identity card setup progress

+
+
+
+
+ +
+
+ % ready + / +
+ +
+
+
+ + +
+
+
+ +
+
+
+
+
+
+ + + Ready + + Todo + +
+ +
+
+
+ + + +
+
+
+
+
+
Recent Samples
+

Latest product identity cards created by the customer

+
+ +
+
+ +
+ 0): ?> +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SampleBrand / Dept.Producer / SupplierBOMFilesStatus
+
+
+ + + | Article: + +
+
+
+
+
+
+
+
+ parts + + + files + + + + + +
+
+ +
+ +
No samples yet
+

The latest product identity cards will appear here.

+ + + Add sample + +
+ +
+
+
+ + + +
+
+
+
+
+
Recent TRF Requests
+

Latest customer test request forms

+
+ +
+
+ +
+ 0): ?> +
+ + + + + + + + + + + + + + + + + + + + + +
TRF CodeTypeServiceStatusCreated
+
+ +
+ +
+ + + + + +
+
+ +
+ +
No TRF requests yet
+

The latest digital test request forms will appear here.

+ + + New TRF request + +
+ +
+
+
+ + + +
+
+
+
+
+
Document Repository
+

Technical files and report archive

+
+
+
+
+ +
+
+
+
Documents
+
+
Linked technical and laboratory files
+
+
+ +
+
+ + +
+ Coming soon.
+ The document repository will be activated with the sample/TRF modules. +
+ + + + Open Repository + + +
+
+
+ + + + + + + + +
+ +
+
+ +
+ + + + + + +
+ + + + + + + + + + + \ No newline at end of file diff --git a/public/userarea/departments.php b/public/userarea/departments.php new file mode 100644 index 00000000..303465cf --- /dev/null +++ b/public/userarea/departments.php @@ -0,0 +1,1230 @@ + + + true, + 'brands' => [] + ]); + } + + $stmt = $db->prepare(" + SELECT idbrand, brand_name, status + FROM brands + WHERE idcompany = :idcompany + ORDER BY brand_name ASC + "); + $stmt->execute([':idcompany' => $idcompany]); + + jsonResponse([ + 'success' => true, + 'brands' => $stmt->fetchAll(PDO::FETCH_ASSOC) + ]); + } + + if ($action === 'save_department') { + $iddepartment = isset($_POST['iddepartment']) ? (int) $_POST['iddepartment'] : 0; + $idcompany = isset($_POST['idcompany']) ? (int) $_POST['idcompany'] : 0; + $idbrand = !empty($_POST['idbrand']) ? (int) $_POST['idbrand'] : null; + $departmentName = trim($_POST['department_name'] ?? ''); + $externalDepartmentCode = trim($_POST['external_department_code'] ?? ''); + $status = $_POST['status'] ?? 'active'; + + $allowedStatuses = ['active', 'inactive']; + + if ($idcompany <= 0) { + jsonResponse([ + 'success' => false, + 'message' => 'Company is required.' + ]); + } + + if ($departmentName === '') { + jsonResponse([ + 'success' => false, + 'message' => 'Department name is required.' + ]); + } + + if (!in_array($status, $allowedStatuses, true)) { + $status = 'active'; + } + + /* + * Check company exists. + */ + $stmt = $db->prepare("SELECT COUNT(*) FROM companies WHERE idcompany = :idcompany"); + $stmt->execute([':idcompany' => $idcompany]); + + if ((int) $stmt->fetchColumn() === 0) { + jsonResponse([ + 'success' => false, + 'message' => 'Selected company does not exist.' + ]); + } + + /* + * If a brand is selected, check that it belongs to the selected company. + */ + if ($idbrand !== null) { + $stmt = $db->prepare(" + SELECT COUNT(*) + FROM brands + WHERE idbrand = :idbrand + AND idcompany = :idcompany + "); + $stmt->execute([ + ':idbrand' => $idbrand, + ':idcompany' => $idcompany, + ]); + + if ((int) $stmt->fetchColumn() === 0) { + jsonResponse([ + 'success' => false, + 'message' => 'Selected brand does not belong to the selected company.' + ]); + } + } + + if ($iddepartment > 0) { + $sql = " + UPDATE departments + SET + idcompany = :idcompany, + idbrand = :idbrand, + department_name = :department_name, + external_department_code = :external_department_code, + status = :status, + updated_at = NOW() + WHERE iddepartment = :iddepartment + "; + + $stmt = $db->prepare($sql); + $stmt->execute([ + ':idcompany' => $idcompany, + ':idbrand' => $idbrand, + ':department_name' => $departmentName, + ':external_department_code' => $externalDepartmentCode !== '' ? $externalDepartmentCode : null, + ':status' => $status, + ':iddepartment' => $iddepartment, + ]); + + jsonResponse([ + 'success' => true, + 'message' => 'Department updated successfully.' + ]); + } + + $sql = " + INSERT INTO departments ( + idcompany, + idbrand, + department_name, + external_department_code, + status, + created_at, + updated_at + ) VALUES ( + :idcompany, + :idbrand, + :department_name, + :external_department_code, + :status, + NOW(), + NOW() + ) + "; + + $stmt = $db->prepare($sql); + $stmt->execute([ + ':idcompany' => $idcompany, + ':idbrand' => $idbrand, + ':department_name' => $departmentName, + ':external_department_code' => $externalDepartmentCode !== '' ? $externalDepartmentCode : null, + ':status' => $status, + ]); + + jsonResponse([ + 'success' => true, + 'message' => 'Department created successfully.' + ]); + } + + if ($action === 'get_department') { + $iddepartment = isset($_POST['iddepartment']) ? (int) $_POST['iddepartment'] : 0; + + if ($iddepartment <= 0) { + jsonResponse([ + 'success' => false, + 'message' => 'Invalid department id.' + ]); + } + + $stmt = $db->prepare(" + SELECT * + FROM departments + WHERE iddepartment = :iddepartment + LIMIT 1 + "); + $stmt->execute([':iddepartment' => $iddepartment]); + $department = $stmt->fetch(PDO::FETCH_ASSOC); + + if (!$department) { + jsonResponse([ + 'success' => false, + 'message' => 'Department not found.' + ]); + } + + jsonResponse([ + 'success' => true, + 'department' => $department + ]); + } + + if ($action === 'change_status') { + $iddepartment = isset($_POST['iddepartment']) ? (int) $_POST['iddepartment'] : 0; + $status = $_POST['status'] ?? 'inactive'; + + $allowedStatuses = ['active', 'inactive']; + + if ($iddepartment <= 0 || !in_array($status, $allowedStatuses, true)) { + jsonResponse([ + 'success' => false, + 'message' => 'Invalid request.' + ]); + } + + $stmt = $db->prepare(" + UPDATE departments + SET status = :status, updated_at = NOW() + WHERE iddepartment = :iddepartment + "); + $stmt->execute([ + ':status' => $status, + ':iddepartment' => $iddepartment, + ]); + + jsonResponse([ + 'success' => true, + 'message' => 'Department status updated successfully.' + ]); + } + + if ($action === 'delete_department') { + $iddepartment = isset($_POST['iddepartment']) ? (int) $_POST['iddepartment'] : 0; + + if ($iddepartment <= 0) { + jsonResponse([ + 'success' => false, + 'message' => 'Invalid department id.' + ]); + } + + /* + * Safe delete rule: + * Do not delete a department if it has linked users. + */ + $stmt = $db->prepare(" + SELECT COUNT(*) + FROM company_users + WHERE iddepartment = :iddepartment + "); + $stmt->execute([':iddepartment' => $iddepartment]); + + if ((int) $stmt->fetchColumn() > 0) { + jsonResponse([ + 'success' => false, + 'message' => 'This department has linked users. Set it as inactive instead of deleting it.' + ]); + } + + $stmt = $db->prepare(" + DELETE FROM departments + WHERE iddepartment = :iddepartment + "); + $stmt->execute([':iddepartment' => $iddepartment]); + + jsonResponse([ + 'success' => true, + 'message' => 'Department deleted successfully.' + ]); + } + + jsonResponse([ + 'success' => false, + 'message' => 'Unknown action.' + ]); + } catch (Throwable $e) { + jsonResponse([ + 'success' => false, + 'message' => $e->getMessage() + ]); + } +} + +/* + * Page data + */ +$companies = []; + +try { + $stmt = $db->query(" + SELECT idcompany, company_name, status + FROM companies + ORDER BY company_name ASC + "); + $companies = $stmt->fetchAll(PDO::FETCH_ASSOC); +} catch (Throwable $e) { + $companies = []; +} + +$brands = []; + +try { + $stmt = $db->query(" + SELECT idbrand, idcompany, brand_name, status + FROM brands + ORDER BY brand_name ASC + "); + $brands = $stmt->fetchAll(PDO::FETCH_ASSOC); +} catch (Throwable $e) { + $brands = []; +} + +$departments = []; + +try { + $stmt = $db->query(" + SELECT + d.iddepartment, + d.idcompany, + d.idbrand, + d.department_name, + d.external_department_code, + d.status, + d.created_at, + c.company_name, + c.status AS company_status, + b.brand_name, + b.status AS brand_status, + COUNT(DISTINCT cu.idcompanyuser) AS user_count + FROM departments d + INNER JOIN companies c ON c.idcompany = d.idcompany + LEFT JOIN brands b ON b.idbrand = d.idbrand + LEFT JOIN company_users cu ON cu.iddepartment = d.iddepartment + GROUP BY + d.iddepartment, + d.idcompany, + d.idbrand, + d.department_name, + d.external_department_code, + d.status, + d.created_at, + c.company_name, + c.status, + b.brand_name, + b.status + ORDER BY c.company_name ASC, b.brand_name ASC, d.department_name ASC + "); + $departments = $stmt->fetchAll(PDO::FETCH_ASSOC); +} catch (Throwable $e) { + $departments = []; +} + +$pageTitle = 'Departments'; +?> + + + + + + + + + + + + <?= e($pageTitle); ?> - <?= isset($titlewebsite) ? e($titlewebsite) : 'TRFgo'; ?> + + + + + +
+ + + +
+
+ +
+
+
+
+
+ + TRFgo Registry +
+

Departments

+

+ Manage departments and operational business units for each company. + Departments can optionally be linked to a brand and will later drive TRF visibility and user permissions. +

+
+ +
+ +
+
+
+
+ + $row['status'] === 'active')); + $inactiveDepartments = count(array_filter($departments, fn($row) => $row['status'] === 'inactive')); + ?> + +
+
+
+
+
+
+
Total Departments
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
Active
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
Inactive
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+
Department List
+

Departments configured by company and brand

+
+ + +
+
+ +
+ +
+ No companies available. + Create at least one company before adding departments. +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
DepartmentCompanyBrandExternal CodeUsersStatusCreatedActions
+
+
ID:
+
+
+ +
Company status:
+ +
+ +
+ +
Brand status:
+ + + Not linked + +
+ -'; ?> + + + + + + + + Active + + Inactive + + + + + + + + + + + + + +
+
+
+
+ +
+
+ +
+ + + + + + +
+ + + + + + + + + + \ No newline at end of file diff --git a/public/userarea/sample-detail.php b/public/userarea/sample-detail.php new file mode 100644 index 00000000..a191254c --- /dev/null +++ b/public/userarea/sample-detail.php @@ -0,0 +1,2560 @@ + + +format('Ymd_His') + . '_EuropeRome_' + . $cleanOriginalName + . '.' . $extension; +} + +function resizeAndConvertImageToJpeg(string $sourcePath, string $targetPath, int $maxSize = 1600, int $quality = 82): bool +{ + $imageInfo = getimagesize($sourcePath); + + if ($imageInfo === false) { + return false; + } + + $mimeType = $imageInfo['mime']; + $sourceImage = null; + + switch ($mimeType) { + case 'image/jpeg': + $sourceImage = imagecreatefromjpeg($sourcePath); + break; + + case 'image/png': + $sourceImage = imagecreatefrompng($sourcePath); + break; + + case 'image/webp': + if (!function_exists('imagecreatefromwebp')) { + return false; + } + $sourceImage = imagecreatefromwebp($sourcePath); + break; + + default: + return false; + } + + if (!$sourceImage) { + return false; + } + + $originalWidth = imagesx($sourceImage); + $originalHeight = imagesy($sourceImage); + + if ($originalWidth <= 0 || $originalHeight <= 0) { + imagedestroy($sourceImage); + return false; + } + + $ratio = min($maxSize / $originalWidth, $maxSize / $originalHeight, 1); + + $newWidth = (int) round($originalWidth * $ratio); + $newHeight = (int) round($originalHeight * $ratio); + + $targetImage = imagecreatetruecolor($newWidth, $newHeight); + + // White background avoids black areas when converting transparent PNG/WEBP to JPG. + $white = imagecolorallocate($targetImage, 255, 255, 255); + imagefill($targetImage, 0, 0, $white); + + imagecopyresampled( + $targetImage, + $sourceImage, + 0, + 0, + 0, + 0, + $newWidth, + $newHeight, + $originalWidth, + $originalHeight + ); + + $result = imagejpeg($targetImage, $targetPath, $quality); + + imagedestroy($sourceImage); + imagedestroy($targetImage); + + return $result; +} + +$idsample = isset($_GET['idsample']) ? (int) $_GET['idsample'] : 0; + +if ($idsample <= 0) { + die('Invalid sample id.'); +} + + +$partRiskLevels = [ + 'unknown' => 'Unknown', + 'low' => 'Low', + 'medium' => 'Medium', + 'high' => 'High', + 'critical' => 'Critical', +]; + +$photoTypes = [ + 'main' => 'Main', + 'product' => 'Product', + 'label' => 'Label', + 'packaging' => 'Packaging', + 'warning' => 'Warning', + 'detail' => 'Detail', + 'other' => 'Other', +]; + +$documentTypes = [ + 'technical_sheet' => 'Technical Sheet', + 'declaration' => 'Declaration', + 'bom' => 'BOM', + 'photo' => 'Photo', + 'certificate' => 'Certificate', + 'test_report' => 'Test Report', + 'supplier_document' => 'Supplier Document', + 'invoice' => 'Invoice', + 'manual' => 'Manual', + 'other' => 'Other', +]; + +/* + * Load sample. + */ +$stmt = $db->prepare(" + SELECT + s.*, + c.company_name, + c.legal_name AS company_legal_name, + b.brand_name, + d.department_name, + bp1.partner_name AS producer_name, + bp2.partner_name AS supplier_name, + ac.name AS country_of_origin_name, + u.email AS created_by_email, + u.first_name AS created_by_first_name, + u.last_name AS created_by_last_name + FROM samples s + INNER JOIN companies c ON c.idcompany = s.idcompany + LEFT JOIN brands b ON b.idbrand = s.idbrand + LEFT JOIN departments d ON d.iddepartment = s.iddepartment + LEFT JOIN business_partners bp1 ON bp1.idpartner = s.idproducer + LEFT JOIN business_partners bp2 ON bp2.idpartner = s.idsupplier + LEFT JOIN auth_countries ac ON ac.id = s.country_of_origin + LEFT JOIN auth_users u ON u.id = s.created_by + WHERE s.idsample = :idsample + LIMIT 1 +"); +$stmt->execute([':idsample' => $idsample]); +$sample = $stmt->fetch(PDO::FETCH_ASSOC); + +if (!$sample) { + die('Sample not found.'); +} + +$idcompany = (int) $sample['idcompany']; + +/* + * Upload folders. + * Current file is inside public/userarea. + * Files will be stored inside public/userarea/uploads/trfgo. + */ +$uploadBaseDir = __DIR__ . '/uploads/trfgo'; +$uploadBaseWebPath = 'uploads/trfgo/'; + +$samplePhotoRelativeDir = 'company_' . $idcompany . '/samples/' . $idsample . '/photos'; +$documentRelativeDir = 'company_' . $idcompany . '/samples/' . $idsample . '/documents'; + +$samplePhotoDir = $uploadBaseDir . '/' . $samplePhotoRelativeDir; +$documentDir = $uploadBaseDir . '/' . $documentRelativeDir; + +ensureDirectory($samplePhotoDir); +ensureDirectory($documentDir); + +$photoWebPath = $uploadBaseWebPath; +$documentWebPath = $uploadBaseWebPath; + +/* + * AJAX actions. + */ +if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) { + $action = $_POST['action']; + + try { + if ($action === 'save_part') { + $idpart = isset($_POST['idpart']) ? (int) $_POST['idpart'] : 0; + $parentIdpart = !empty($_POST['parent_idpart']) ? (int) $_POST['parent_idpart'] : null; + $partCode = trim($_POST['part_code'] ?? ''); + $partName = trim($_POST['part_name'] ?? ''); + $partDescription = trim($_POST['part_description'] ?? ''); + $material = trim($_POST['material'] ?? ''); + $color = trim($_POST['color'] ?? ''); + $quantity = trim($_POST['quantity'] ?? ''); + $unit = trim($_POST['unit'] ?? ''); + $supplierId = !empty($_POST['supplier_id']) ? (int) $_POST['supplier_id'] : null; + $producerId = !empty($_POST['producer_id']) ? (int) $_POST['producer_id'] : null; + $position = trim($_POST['position'] ?? ''); + $riskLevel = $_POST['risk_level'] ?? 'unknown'; + $notes = trim($_POST['notes'] ?? ''); + $sortOrder = isset($_POST['sort_order']) ? (int) $_POST['sort_order'] : 0; + + if ($partName === '') { + jsonResponse([ + 'success' => false, + 'message' => 'Part name is required.' + ]); + } + + if (!array_key_exists($riskLevel, $partRiskLevels)) { + $riskLevel = 'unknown'; + } + + /* + * Validate parent part belongs to the same sample. + */ + if ($parentIdpart !== null) { + $stmt = $db->prepare(" + SELECT COUNT(*) + FROM sample_parts + WHERE idpart = :idpart + AND idsample = :idsample + "); + $stmt->execute([ + ':idpart' => $parentIdpart, + ':idsample' => $idsample, + ]); + + if ((int) $stmt->fetchColumn() === 0) { + jsonResponse([ + 'success' => false, + 'message' => 'Selected parent part does not belong to this sample.' + ]); + } + } + + /* + * Validate supplier/producer belong to the sample company. + */ + foreach ( + [ + 'supplier' => $supplierId, + 'producer' => $producerId, + ] as $label => $partnerId + ) { + if ($partnerId !== null) { + $stmt = $db->prepare(" + SELECT COUNT(*) + FROM business_partners + WHERE idpartner = :idpartner + AND idcompany = :idcompany + "); + $stmt->execute([ + ':idpartner' => $partnerId, + ':idcompany' => $idcompany, + ]); + + if ((int) $stmt->fetchColumn() === 0) { + jsonResponse([ + 'success' => false, + 'message' => 'Selected ' . $label . ' does not belong to the sample company.' + ]); + } + } + } + + if ($idpart > 0) { + $stmt = $db->prepare(" + UPDATE sample_parts + SET + parent_idpart = :parent_idpart, + part_code = :part_code, + part_name = :part_name, + part_description = :part_description, + material = :material, + color = :color, + quantity = :quantity, + unit = :unit, + supplier_id = :supplier_id, + producer_id = :producer_id, + position = :position, + risk_level = :risk_level, + notes = :notes, + sort_order = :sort_order, + updated_at = NOW() + WHERE idpart = :idpart + AND idsample = :idsample + "); + + $stmt->execute([ + ':parent_idpart' => $parentIdpart, + ':part_code' => $partCode !== '' ? $partCode : null, + ':part_name' => $partName, + ':part_description' => $partDescription !== '' ? $partDescription : null, + ':material' => $material !== '' ? $material : null, + ':color' => $color !== '' ? $color : null, + ':quantity' => $quantity !== '' ? $quantity : null, + ':unit' => $unit !== '' ? $unit : null, + ':supplier_id' => $supplierId, + ':producer_id' => $producerId, + ':position' => $position !== '' ? $position : null, + ':risk_level' => $riskLevel, + ':notes' => $notes !== '' ? $notes : null, + ':sort_order' => $sortOrder, + ':idpart' => $idpart, + ':idsample' => $idsample, + ]); + + jsonResponse([ + 'success' => true, + 'message' => 'BOM part updated successfully.' + ]); + } + + $stmt = $db->prepare(" + INSERT INTO sample_parts ( + idsample, + parent_idpart, + part_code, + part_name, + part_description, + material, + color, + quantity, + unit, + supplier_id, + producer_id, + position, + risk_level, + notes, + sort_order, + created_at, + updated_at + ) VALUES ( + :idsample, + :parent_idpart, + :part_code, + :part_name, + :part_description, + :material, + :color, + :quantity, + :unit, + :supplier_id, + :producer_id, + :position, + :risk_level, + :notes, + :sort_order, + NOW(), + NOW() + ) + "); + + $stmt->execute([ + ':idsample' => $idsample, + ':parent_idpart' => $parentIdpart, + ':part_code' => $partCode !== '' ? $partCode : null, + ':part_name' => $partName, + ':part_description' => $partDescription !== '' ? $partDescription : null, + ':material' => $material !== '' ? $material : null, + ':color' => $color !== '' ? $color : null, + ':quantity' => $quantity !== '' ? $quantity : null, + ':unit' => $unit !== '' ? $unit : null, + ':supplier_id' => $supplierId, + ':producer_id' => $producerId, + ':position' => $position !== '' ? $position : null, + ':risk_level' => $riskLevel, + ':notes' => $notes !== '' ? $notes : null, + ':sort_order' => $sortOrder, + ]); + + jsonResponse([ + 'success' => true, + 'message' => 'BOM part created successfully.' + ]); + } + + if ($action === 'get_part') { + $idpart = isset($_POST['idpart']) ? (int) $_POST['idpart'] : 0; + + if ($idpart <= 0) { + jsonResponse([ + 'success' => false, + 'message' => 'Invalid part id.' + ]); + } + + $stmt = $db->prepare(" + SELECT * + FROM sample_parts + WHERE idpart = :idpart + AND idsample = :idsample + LIMIT 1 + "); + $stmt->execute([ + ':idpart' => $idpart, + ':idsample' => $idsample, + ]); + + $part = $stmt->fetch(PDO::FETCH_ASSOC); + + if (!$part) { + jsonResponse([ + 'success' => false, + 'message' => 'Part not found.' + ]); + } + + jsonResponse([ + 'success' => true, + 'part' => $part + ]); + } + + if ($action === 'delete_part') { + $idpart = isset($_POST['idpart']) ? (int) $_POST['idpart'] : 0; + + if ($idpart <= 0) { + jsonResponse([ + 'success' => false, + 'message' => 'Invalid part id.' + ]); + } + + /* + * Prevent delete if child parts or documents/photos exist. + */ + $stmt = $db->prepare(" + SELECT + (SELECT COUNT(*) FROM sample_parts WHERE parent_idpart = :idpart1) AS child_count, + (SELECT COUNT(*) FROM sample_part_photos WHERE idpart = :idpart2) AS photos_count, + (SELECT COUNT(*) FROM sample_part_documents WHERE idpart = :idpart3) AS documents_count + "); + $stmt->execute([ + ':idpart1' => $idpart, + ':idpart2' => $idpart, + ':idpart3' => $idpart, + ]); + + $usage = $stmt->fetch(PDO::FETCH_ASSOC); + + if ( + (int) $usage['child_count'] > 0 || + (int) $usage['photos_count'] > 0 || + (int) $usage['documents_count'] > 0 + ) { + jsonResponse([ + 'success' => false, + 'message' => 'This part has child parts, photos or documents. Remove them before deleting the part.' + ]); + } + + $stmt = $db->prepare(" + DELETE FROM sample_parts + WHERE idpart = :idpart + AND idsample = :idsample + "); + $stmt->execute([ + ':idpart' => $idpart, + ':idsample' => $idsample, + ]); + + jsonResponse([ + 'success' => true, + 'message' => 'BOM part deleted successfully.' + ]); + } + + if ($action === 'upload_sample_photo') { + if (empty($_FILES['photo_file']['name'])) { + jsonResponse([ + 'success' => false, + 'message' => 'No photo uploaded.' + ]); + } + + if (!extension_loaded('gd')) { + jsonResponse([ + 'success' => false, + 'message' => 'PHP GD extension is required to resize and convert images.' + ]); + } + + $photoType = $_POST['photo_type'] ?? 'product'; + $description = trim($_POST['description'] ?? ''); + $isMain = isset($_POST['is_main']) ? (int) $_POST['is_main'] : 0; + + if (!array_key_exists($photoType, $photoTypes)) { + $photoType = 'product'; + } + + $originalFilename = $_FILES['photo_file']['name']; + $tmpFile = $_FILES['photo_file']['tmp_name']; + + $imageInfo = getimagesize($tmpFile); + + if ($imageInfo === false) { + jsonResponse([ + 'success' => false, + 'message' => 'Uploaded file is not a valid image.' + ]); + } + + $allowedMimeTypes = [ + 'image/jpeg', + 'image/png', + 'image/webp', + ]; + + if (!in_array($imageInfo['mime'], $allowedMimeTypes, true)) { + jsonResponse([ + 'success' => false, + 'message' => 'Invalid photo format. Allowed: JPG, PNG, WEBP.' + ]); + } + + // All photos are converted to JPG for web consistency. + $newFilename = buildDatedFilename('photo', $idcompany, $idsample, $originalFilename, 'jpg'); + $relativeFilename = $samplePhotoRelativeDir . '/' . $newFilename; + $targetPath = $samplePhotoDir . '/' . $newFilename; + + $converted = resizeAndConvertImageToJpeg($tmpFile, $targetPath, 1600, 82); + + if (!$converted) { + jsonResponse([ + 'success' => false, + 'message' => 'Unable to resize and convert photo.' + ]); + } + + if ($isMain === 1) { + $stmt = $db->prepare(" + UPDATE sample_photos + SET is_main = 0 + WHERE idsample = :idsample + "); + $stmt->execute([':idsample' => $idsample]); + } + + $stmt = $db->prepare(" + INSERT INTO sample_photos ( + idsample, + photo_type, + filename, + original_filename, + description, + is_main, + sort_order, + uploaded_by, + created_at + ) VALUES ( + :idsample, + :photo_type, + :filename, + :original_filename, + :description, + :is_main, + 0, + :uploaded_by, + NOW() + ) + "); + + $stmt->execute([ + ':idsample' => $idsample, + ':photo_type' => $photoType, + ':filename' => $relativeFilename, + ':original_filename' => $originalFilename, + ':description' => $description !== '' ? $description : null, + ':is_main' => $isMain === 1 ? 1 : 0, + ':uploaded_by' => $iduserlogin, + ]); + + jsonResponse([ + 'success' => true, + 'message' => 'Photo uploaded, resized and converted successfully.' + ]); + } + + if ($action === 'delete_sample_photo') { + $idsamplephoto = isset($_POST['idsamplephoto']) ? (int) $_POST['idsamplephoto'] : 0; + + if ($idsamplephoto <= 0) { + jsonResponse([ + 'success' => false, + 'message' => 'Invalid photo id.' + ]); + } + + $stmt = $db->prepare(" + SELECT filename + FROM sample_photos + WHERE idsamplephoto = :idsamplephoto + AND idsample = :idsample + LIMIT 1 + "); + $stmt->execute([ + ':idsamplephoto' => $idsamplephoto, + ':idsample' => $idsample, + ]); + + $photo = $stmt->fetch(PDO::FETCH_ASSOC); + + if (!$photo) { + jsonResponse([ + 'success' => false, + 'message' => 'Photo not found.' + ]); + } + + $filePath = $uploadBaseDir . '/' . $photo['filename']; + + if (is_file($filePath)) { + unlink($filePath); + } + + $stmt = $db->prepare(" + DELETE FROM sample_photos + WHERE idsamplephoto = :idsamplephoto + AND idsample = :idsample + "); + $stmt->execute([ + ':idsamplephoto' => $idsamplephoto, + ':idsample' => $idsample, + ]); + + jsonResponse([ + 'success' => true, + 'message' => 'Photo deleted successfully.' + ]); + } + + if ($action === 'upload_document') { + if (empty($_FILES['document_file']['name'])) { + jsonResponse([ + 'success' => false, + 'message' => 'No document uploaded.' + ]); + } + + $documentType = $_POST['document_type'] ?? 'other'; + $title = trim($_POST['title'] ?? ''); + $expiryDate = !empty($_POST['expiry_date']) ? $_POST['expiry_date'] : null; + $notes = trim($_POST['notes'] ?? ''); + + if (!array_key_exists($documentType, $documentTypes)) { + $documentType = 'other'; + } + + $originalFilename = $_FILES['document_file']['name']; + $extension = strtolower(pathinfo($originalFilename, PATHINFO_EXTENSION)); + + $allowedExtensions = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'csv', 'jpg', 'jpeg', 'png', 'webp', 'txt']; + + if (!in_array($extension, $allowedExtensions, true)) { + jsonResponse([ + 'success' => false, + 'message' => 'Invalid document format.' + ]); + } + + if ($title === '') { + $title = pathinfo($originalFilename, PATHINFO_FILENAME); + } + + $newFilename = buildDatedFilename('document', $idcompany, $idsample, $originalFilename, $extension); + $relativeFilename = $documentRelativeDir . '/' . $newFilename; + $targetPath = $documentDir . '/' . $newFilename; + + if (!move_uploaded_file($_FILES['document_file']['tmp_name'], $targetPath)) { + jsonResponse([ + 'success' => false, + 'message' => 'Unable to upload document.' + ]); + } + + $stmt = $db->prepare(" + INSERT INTO documents ( + idcompany, + document_type, + title, + filename, + original_filename, + mime_type, + file_size, + expiry_date, + status, + notes, + uploaded_by, + created_at, + updated_at + ) VALUES ( + :idcompany, + :document_type, + :title, + :filename, + :original_filename, + :mime_type, + :file_size, + :expiry_date, + 'active', + :notes, + :uploaded_by, + NOW(), + NOW() + ) + "); + $stmt->execute([ + ':idcompany' => $idcompany, + ':document_type' => $documentType, + ':title' => $title, + ':filename' => $relativeFilename, + ':original_filename' => $originalFilename, + ':mime_type' => $_FILES['document_file']['type'] ?? null, + ':file_size' => $_FILES['document_file']['size'] ?? null, + ':expiry_date' => $expiryDate, + ':notes' => $notes !== '' ? $notes : null, + ':uploaded_by' => $iduserlogin, + ]); + + $iddocument = (int) $db->lastInsertId(); + + $stmt = $db->prepare(" + INSERT INTO sample_documents ( + idsample, + iddocument, + created_at + ) VALUES ( + :idsample, + :iddocument, + NOW() + ) + "); + $stmt->execute([ + ':idsample' => $idsample, + ':iddocument' => $iddocument, + ]); + + jsonResponse([ + 'success' => true, + 'message' => 'Document uploaded successfully.' + ]); + } + + if ($action === 'delete_document') { + $iddocument = isset($_POST['iddocument']) ? (int) $_POST['iddocument'] : 0; + + if ($iddocument <= 0) { + jsonResponse([ + 'success' => false, + 'message' => 'Invalid document id.' + ]); + } + + $stmt = $db->prepare(" + SELECT d.filename + FROM documents d + INNER JOIN sample_documents sd ON sd.iddocument = d.iddocument + WHERE d.iddocument = :iddocument + AND sd.idsample = :idsample + LIMIT 1 + "); + $stmt->execute([ + ':iddocument' => $iddocument, + ':idsample' => $idsample, + ]); + + $document = $stmt->fetch(PDO::FETCH_ASSOC); + + if (!$document) { + jsonResponse([ + 'success' => false, + 'message' => 'Document not found.' + ]); + } + + $filePath = $uploadBaseDir . '/' . $document['filename']; + + if (is_file($filePath)) { + unlink($filePath); + } + + $stmt = $db->prepare(" + DELETE FROM documents + WHERE iddocument = :iddocument + "); + $stmt->execute([':iddocument' => $iddocument]); + + jsonResponse([ + 'success' => true, + 'message' => 'Document deleted successfully.' + ]); + } + + jsonResponse([ + 'success' => false, + 'message' => 'Unknown action.' + ]); + } catch (Throwable $e) { + jsonResponse([ + 'success' => false, + 'message' => $e->getMessage() + ]); + } +} + +/* + * Page data. + */ +$parts = []; +$photos = []; +$documents = []; +$history = []; +$partners = []; + +try { + $stmt = $db->prepare(" + SELECT + sp.*, + parent.part_name AS parent_part_name, + bp1.partner_name AS supplier_name, + bp2.partner_name AS producer_name + FROM sample_parts sp + LEFT JOIN sample_parts parent ON parent.idpart = sp.parent_idpart + LEFT JOIN business_partners bp1 ON bp1.idpartner = sp.supplier_id + LEFT JOIN business_partners bp2 ON bp2.idpartner = sp.producer_id + WHERE sp.idsample = :idsample + ORDER BY sp.sort_order ASC, sp.idpart ASC + "); + $stmt->execute([':idsample' => $idsample]); + $parts = $stmt->fetchAll(PDO::FETCH_ASSOC); +} catch (Throwable $e) { + $parts = []; +} + +try { + $stmt = $db->prepare(" + SELECT * + FROM sample_photos + WHERE idsample = :idsample + ORDER BY is_main DESC, sort_order ASC, idsamplephoto DESC + "); + $stmt->execute([':idsample' => $idsample]); + $photos = $stmt->fetchAll(PDO::FETCH_ASSOC); +} catch (Throwable $e) { + $photos = []; +} + +try { + $stmt = $db->prepare(" + SELECT + d.*, + sd.idsampledocument, + u.email AS uploaded_by_email + FROM sample_documents sd + INNER JOIN documents d ON d.iddocument = sd.iddocument + LEFT JOIN auth_users u ON u.id = d.uploaded_by + WHERE sd.idsample = :idsample + ORDER BY d.created_at DESC, d.iddocument DESC + "); + $stmt->execute([':idsample' => $idsample]); + $documents = $stmt->fetchAll(PDO::FETCH_ASSOC); +} catch (Throwable $e) { + $documents = []; +} + +try { + $stmt = $db->prepare(" + SELECT + h.*, + u.email, + u.first_name, + u.last_name + FROM sample_status_history h + LEFT JOIN auth_users u ON u.id = h.changed_by + WHERE h.idsample = :idsample + ORDER BY h.created_at DESC + "); + $stmt->execute([':idsample' => $idsample]); + $history = $stmt->fetchAll(PDO::FETCH_ASSOC); +} catch (Throwable $e) { + $history = []; +} + +try { + $stmt = $db->prepare(" + SELECT idpartner, partner_type, partner_name, status + FROM business_partners + WHERE idcompany = :idcompany + ORDER BY partner_name ASC + "); + $stmt->execute([':idcompany' => $idcompany]); + $partners = $stmt->fetchAll(PDO::FETCH_ASSOC); +} catch (Throwable $e) { + $partners = []; +} + +$pageTitle = 'Sample Detail'; +$statusLabel = ucfirst(str_replace('_', ' ', $sample['status'])); +$createdBy = trim(($sample['created_by_first_name'] ?? '') . ' ' . ($sample['created_by_last_name'] ?? '')); +if ($createdBy === '') { + $createdBy = $sample['created_by_email'] ?? '-'; +} +?> + + + + + + + + + + + + <?= e($pageTitle); ?> - <?= isset($titlewebsite) ? e($titlewebsite) : 'TRFgo'; ?> + + + + + +
+ + + +
+
+ +
+
+
+
+
+ + Sample Identity Card +
+ +

+ — +

+ +

+ Product/sample master record with overview, photos, BOM parts, documents and lifecycle history. +

+
+ + +
+
+
+ +
+
+
+
+
+
+
Status
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
BOM Parts
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
Photos
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
Documents
+
+
+
+ +
+
+
+
+
+
+ +
+
+ + +
+ + +
+
+
+
Product Information
+

Main product/sample identity data

+ +
+
Company
+
+
+ +
+
Brand
+
+
+ +
+
Department
+
+
+ +
+
Article No.
+
+
+ +
+
PO No.
+
+
+ +
+
Season
+
+
+ +
+
Color
+
+
+ +
+
Production Stage
+
+
+
+ +
+
Technical Information
+

Materials, partners and source data

+ +
+
Producer
+
+
+ +
+
Supplier
+
+
+ +
+
Product Category
+
+
+ +
+
Product Type
+
+
+ +
+
Product Standard
+
+
+ +
+
Country of Origin
+
+
+ +
+
Created By
+
+
+ +
+
Created At
+
+
+
+ +
+
Material Details
+ +
+
+
+
Fiber Content
+
+
+
+ +
+
+
Material Description
+
+
+
+
+
+
+
+ + +
+
+
+
Sample Photos
+

Product, label, packaging and detail images

+
+ + +
+ + 0): ?> +
+ +
+
+ <?= e($photo['original_filename']); ?> + +
+
+
+
+
+
+ + + Main + +
+ + +
+ + +
+ +
+
+
+
+ +
+ +
+ +
No photos uploaded yet
+

Upload product, label, packaging or detail photos.

+
+ +
+ + +
+
+
+
Parts / BOM
+

Bill of materials and product components

+
+ + +
+ + 0): ?> +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PartParentMaterial / ColorProducer / SupplierQtyRiskActions
+
+ +
Code:
+ + +
Position:
+ +
+
+
+
+
+
+
+ + + + + + + + + + +
+
+ +
+ +
No BOM parts yet
+

Add parts such as upper, lining, sole, zip, label, packaging or other components.

+
+ +
+ + +
+
+
+
Documents
+

Technical sheets, certificates, declarations and related files

+
+ + +
+ + 0): ?> +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
DocumentTypeExpiryUploadedActions
+
+
+
+ + + + + + +
+
+
+ + + + + +
+
+ +
+ +
No documents uploaded yet
+

Upload technical sheets, certificates, declarations or product documentation.

+
+ +
+ + +
+
Status History
+

Sample lifecycle changes

+ + 0): ?> +
+ + +
+
+ +
+
+ + | + +
+ + +
+ + + + → + + + +
+ + + +
+ +
+ +
+ +
+ +
No history yet
+

Status changes will appear here.

+
+ +
+ +
+
+
+ +
+
+ +
+ + + + + + +
+ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/userarea/samples.php b/public/userarea/samples.php new file mode 100644 index 00000000..b4f083a4 --- /dev/null +++ b/public/userarea/samples.php @@ -0,0 +1,1970 @@ + + +prepare(" + SELECT COUNT(*) + FROM samples + WHERE idcompany = :idcompany + AND YEAR(created_at) = :year + "); + $stmt->execute([ + ':idcompany' => $idcompany, + ':year' => $year, + ]); + + $nextNumber = ((int) $stmt->fetchColumn()) + 1; + + return 'SMP-' . $year . '-' . str_pad((string) $nextNumber, 5, '0', STR_PAD_LEFT); +} + +$sampleStatuses = [ + 'draft' => 'Draft', + 'active' => 'Active', + 'archived' => 'Archived', + 'submitted' => 'Submitted', + 'under_testing' => 'Under Testing', + 'completed' => 'Completed', + 'cancelled' => 'Cancelled', +]; + +$sampleSources = [ + 'manual' => 'Manual', + 'xls_import' => 'XLS Import', + 'json_import' => 'JSON Import', + 'api' => 'API', + 'smarttrf' => 'SmartTRF', +]; + +/* + * AJAX actions. + */ +if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) { + $action = $_POST['action']; + + try { + if ($action === 'save_sample') { + $idsample = isset($_POST['idsample']) ? (int) $_POST['idsample'] : 0; + $idcompany = isset($_POST['idcompany']) ? (int) $_POST['idcompany'] : 0; + $idbrand = !empty($_POST['idbrand']) ? (int) $_POST['idbrand'] : null; + $iddepartment = !empty($_POST['iddepartment']) ? (int) $_POST['iddepartment'] : null; + $idproducer = !empty($_POST['idproducer']) ? (int) $_POST['idproducer'] : null; + $idsupplier = !empty($_POST['idsupplier']) ? (int) $_POST['idsupplier'] : null; + + $sampleCode = trim($_POST['sample_code'] ?? ''); + $externalSampleId = trim($_POST['external_sample_id'] ?? ''); + $articleNo = trim($_POST['article_no'] ?? ''); + $poNo = trim($_POST['po_no'] ?? ''); + $season = trim($_POST['season'] ?? ''); + $styleNo = trim($_POST['style_no'] ?? ''); + $styleName = trim($_POST['style_name'] ?? ''); + $model = trim($_POST['model'] ?? ''); + $sampleDescription = trim($_POST['sample_description'] ?? ''); + $productCategory = trim($_POST['product_category'] ?? ''); + $productType = trim($_POST['product_type'] ?? ''); + $color = trim($_POST['color'] ?? ''); + $size = trim($_POST['size'] ?? ''); + $gender = trim($_POST['gender'] ?? ''); + $ageGroup = trim($_POST['age_group'] ?? ''); + $fiberContent = trim($_POST['fiber_content'] ?? ''); + $materialDescription = trim($_POST['material_description'] ?? ''); + $claimedWeight = trim($_POST['claimed_weight'] ?? ''); + $productStandard = trim($_POST['product_standard'] ?? ''); + $productionStage = trim($_POST['production_stage'] ?? ''); + $countryOfOrigin = !empty($_POST['country_of_origin']) ? (int) $_POST['country_of_origin'] : null; + $status = $_POST['status'] ?? 'draft'; + + if ($idcompany <= 0) { + jsonResponse([ + 'success' => false, + 'message' => 'Company is required.' + ]); + } + + if ($sampleDescription === '') { + jsonResponse([ + 'success' => false, + 'message' => 'Sample description is required.' + ]); + } + + if (!array_key_exists($status, $sampleStatuses)) { + $status = 'draft'; + } + + /* + * Check company exists. + */ + $stmt = $db->prepare(" + SELECT COUNT(*) + FROM companies + WHERE idcompany = :idcompany + "); + $stmt->execute([':idcompany' => $idcompany]); + + if ((int) $stmt->fetchColumn() === 0) { + jsonResponse([ + 'success' => false, + 'message' => 'Selected company does not exist.' + ]); + } + + /* + * Generate sample code if empty. + */ + if ($sampleCode === '') { + $sampleCode = generateSampleCode($db, $idcompany); + } + + /* + * Check brand belongs to company. + */ + if ($idbrand !== null) { + $stmt = $db->prepare(" + SELECT COUNT(*) + FROM brands + WHERE idbrand = :idbrand + AND idcompany = :idcompany + "); + $stmt->execute([ + ':idbrand' => $idbrand, + ':idcompany' => $idcompany, + ]); + + if ((int) $stmt->fetchColumn() === 0) { + jsonResponse([ + 'success' => false, + 'message' => 'Selected brand does not belong to the selected company.' + ]); + } + } + + /* + * Check department belongs to company and is compatible with selected brand. + */ + if ($iddepartment !== null) { + if ($idbrand !== null) { + $stmt = $db->prepare(" + SELECT COUNT(*) + FROM departments + WHERE iddepartment = :iddepartment + AND idcompany = :idcompany + AND (idbrand = :idbrand OR idbrand IS NULL) + "); + $stmt->execute([ + ':iddepartment' => $iddepartment, + ':idcompany' => $idcompany, + ':idbrand' => $idbrand, + ]); + } else { + $stmt = $db->prepare(" + SELECT COUNT(*) + FROM departments + WHERE iddepartment = :iddepartment + AND idcompany = :idcompany + "); + $stmt->execute([ + ':iddepartment' => $iddepartment, + ':idcompany' => $idcompany, + ]); + } + + if ((int) $stmt->fetchColumn() === 0) { + jsonResponse([ + 'success' => false, + 'message' => 'Selected department is not compatible with the selected company/brand.' + ]); + } + } + + /* + * Check producer/supplier belong to the same company. + */ + foreach ( + [ + 'producer' => $idproducer, + 'supplier' => $idsupplier, + ] as $partnerLabel => $partnerId + ) { + if ($partnerId !== null) { + $stmt = $db->prepare(" + SELECT COUNT(*) + FROM business_partners + WHERE idpartner = :idpartner + AND idcompany = :idcompany + "); + $stmt->execute([ + ':idpartner' => $partnerId, + ':idcompany' => $idcompany, + ]); + + if ((int) $stmt->fetchColumn() === 0) { + jsonResponse([ + 'success' => false, + 'message' => 'Selected ' . $partnerLabel . ' does not belong to the selected company.' + ]); + } + } + } + + /* + * Check duplicate sample code inside the same company. + */ + if ($idsample > 0) { + $stmt = $db->prepare(" + SELECT COUNT(*) + FROM samples + WHERE idcompany = :idcompany + AND sample_code = :sample_code + AND idsample <> :idsample + "); + $stmt->execute([ + ':idcompany' => $idcompany, + ':sample_code' => $sampleCode, + ':idsample' => $idsample, + ]); + } else { + $stmt = $db->prepare(" + SELECT COUNT(*) + FROM samples + WHERE idcompany = :idcompany + AND sample_code = :sample_code + "); + $stmt->execute([ + ':idcompany' => $idcompany, + ':sample_code' => $sampleCode, + ]); + } + + if ((int) $stmt->fetchColumn() > 0) { + jsonResponse([ + 'success' => false, + 'message' => 'Sample code already exists for this company.' + ]); + } + + $oldStatus = null; + + if ($idsample > 0) { + $stmt = $db->prepare(" + SELECT status + FROM samples + WHERE idsample = :idsample + LIMIT 1 + "); + $stmt->execute([':idsample' => $idsample]); + $oldStatus = $stmt->fetchColumn(); + + $sql = " + UPDATE samples + SET + idcompany = :idcompany, + idbrand = :idbrand, + iddepartment = :iddepartment, + idproducer = :idproducer, + idsupplier = :idsupplier, + sample_code = :sample_code, + external_sample_id = :external_sample_id, + article_no = :article_no, + po_no = :po_no, + season = :season, + style_no = :style_no, + style_name = :style_name, + model = :model, + sample_description = :sample_description, + product_category = :product_category, + product_type = :product_type, + color = :color, + size = :size, + gender = :gender, + age_group = :age_group, + fiber_content = :fiber_content, + material_description = :material_description, + claimed_weight = :claimed_weight, + product_standard = :product_standard, + production_stage = :production_stage, + country_of_origin = :country_of_origin, + status = :status, + updated_at = NOW() + WHERE idsample = :idsample + "; + + $stmt = $db->prepare($sql); + $stmt->execute([ + ':idcompany' => $idcompany, + ':idbrand' => $idbrand, + ':iddepartment' => $iddepartment, + ':idproducer' => $idproducer, + ':idsupplier' => $idsupplier, + ':sample_code' => $sampleCode, + ':external_sample_id' => $externalSampleId !== '' ? $externalSampleId : null, + ':article_no' => $articleNo !== '' ? $articleNo : null, + ':po_no' => $poNo !== '' ? $poNo : null, + ':season' => $season !== '' ? $season : null, + ':style_no' => $styleNo !== '' ? $styleNo : null, + ':style_name' => $styleName !== '' ? $styleName : null, + ':model' => $model !== '' ? $model : null, + ':sample_description' => $sampleDescription, + ':product_category' => $productCategory !== '' ? $productCategory : null, + ':product_type' => $productType !== '' ? $productType : null, + ':color' => $color !== '' ? $color : null, + ':size' => $size !== '' ? $size : null, + ':gender' => $gender !== '' ? $gender : null, + ':age_group' => $ageGroup !== '' ? $ageGroup : null, + ':fiber_content' => $fiberContent !== '' ? $fiberContent : null, + ':material_description' => $materialDescription !== '' ? $materialDescription : null, + ':claimed_weight' => $claimedWeight !== '' ? $claimedWeight : null, + ':product_standard' => $productStandard !== '' ? $productStandard : null, + ':production_stage' => $productionStage !== '' ? $productionStage : null, + ':country_of_origin' => $countryOfOrigin, + ':status' => $status, + ':idsample' => $idsample, + ]); + + if ($oldStatus !== $status) { + $stmt = $db->prepare(" + INSERT INTO sample_status_history ( + idsample, + old_status, + new_status, + note, + changed_by, + created_at + ) VALUES ( + :idsample, + :old_status, + :new_status, + :note, + :changed_by, + NOW() + ) + "); + $stmt->execute([ + ':idsample' => $idsample, + ':old_status' => $oldStatus, + ':new_status' => $status, + ':note' => 'Status changed from sample form.', + ':changed_by' => $iduserlogin, + ]); + } + + jsonResponse([ + 'success' => true, + 'message' => 'Sample updated successfully.' + ]); + } + + $sql = " + INSERT INTO samples ( + idcompany, + idbrand, + iddepartment, + idproducer, + idsupplier, + sample_code, + external_sample_id, + article_no, + po_no, + season, + style_no, + style_name, + model, + sample_description, + product_category, + product_type, + color, + size, + gender, + age_group, + fiber_content, + material_description, + claimed_weight, + product_standard, + production_stage, + country_of_origin, + status, + source, + created_by, + created_at, + updated_at + ) VALUES ( + :idcompany, + :idbrand, + :iddepartment, + :idproducer, + :idsupplier, + :sample_code, + :external_sample_id, + :article_no, + :po_no, + :season, + :style_no, + :style_name, + :model, + :sample_description, + :product_category, + :product_type, + :color, + :size, + :gender, + :age_group, + :fiber_content, + :material_description, + :claimed_weight, + :product_standard, + :production_stage, + :country_of_origin, + :status, + 'manual', + :created_by, + NOW(), + NOW() + ) + "; + + $stmt = $db->prepare($sql); + $stmt->execute([ + ':idcompany' => $idcompany, + ':idbrand' => $idbrand, + ':iddepartment' => $iddepartment, + ':idproducer' => $idproducer, + ':idsupplier' => $idsupplier, + ':sample_code' => $sampleCode, + ':external_sample_id' => $externalSampleId !== '' ? $externalSampleId : null, + ':article_no' => $articleNo !== '' ? $articleNo : null, + ':po_no' => $poNo !== '' ? $poNo : null, + ':season' => $season !== '' ? $season : null, + ':style_no' => $styleNo !== '' ? $styleNo : null, + ':style_name' => $styleName !== '' ? $styleName : null, + ':model' => $model !== '' ? $model : null, + ':sample_description' => $sampleDescription, + ':product_category' => $productCategory !== '' ? $productCategory : null, + ':product_type' => $productType !== '' ? $productType : null, + ':color' => $color !== '' ? $color : null, + ':size' => $size !== '' ? $size : null, + ':gender' => $gender !== '' ? $gender : null, + ':age_group' => $ageGroup !== '' ? $ageGroup : null, + ':fiber_content' => $fiberContent !== '' ? $fiberContent : null, + ':material_description' => $materialDescription !== '' ? $materialDescription : null, + ':claimed_weight' => $claimedWeight !== '' ? $claimedWeight : null, + ':product_standard' => $productStandard !== '' ? $productStandard : null, + ':production_stage' => $productionStage !== '' ? $productionStage : null, + ':country_of_origin' => $countryOfOrigin, + ':status' => $status, + ':created_by' => $iduserlogin, + ]); + + $newSampleId = (int) $db->lastInsertId(); + + $stmt = $db->prepare(" + INSERT INTO sample_status_history ( + idsample, + old_status, + new_status, + note, + changed_by, + created_at + ) VALUES ( + :idsample, + NULL, + :new_status, + :note, + :changed_by, + NOW() + ) + "); + $stmt->execute([ + ':idsample' => $newSampleId, + ':new_status' => $status, + ':note' => 'Sample created.', + ':changed_by' => $iduserlogin, + ]); + + jsonResponse([ + 'success' => true, + 'message' => 'Sample created successfully.' + ]); + } + + if ($action === 'get_sample') { + $idsample = isset($_POST['idsample']) ? (int) $_POST['idsample'] : 0; + + if ($idsample <= 0) { + jsonResponse([ + 'success' => false, + 'message' => 'Invalid sample id.' + ]); + } + + $stmt = $db->prepare(" + SELECT * + FROM samples + WHERE idsample = :idsample + LIMIT 1 + "); + $stmt->execute([':idsample' => $idsample]); + $sample = $stmt->fetch(PDO::FETCH_ASSOC); + + if (!$sample) { + jsonResponse([ + 'success' => false, + 'message' => 'Sample not found.' + ]); + } + + jsonResponse([ + 'success' => true, + 'sample' => $sample + ]); + } + + if ($action === 'change_sample_status') { + $idsample = isset($_POST['idsample']) ? (int) $_POST['idsample'] : 0; + $status = $_POST['status'] ?? 'archived'; + + if ($idsample <= 0 || !array_key_exists($status, $sampleStatuses)) { + jsonResponse([ + 'success' => false, + 'message' => 'Invalid request.' + ]); + } + + $stmt = $db->prepare(" + SELECT status + FROM samples + WHERE idsample = :idsample + LIMIT 1 + "); + $stmt->execute([':idsample' => $idsample]); + $oldStatus = $stmt->fetchColumn(); + + if (!$oldStatus) { + jsonResponse([ + 'success' => false, + 'message' => 'Sample not found.' + ]); + } + + $stmt = $db->prepare(" + UPDATE samples + SET status = :status, updated_at = NOW() + WHERE idsample = :idsample + "); + $stmt->execute([ + ':status' => $status, + ':idsample' => $idsample, + ]); + + if ($oldStatus !== $status) { + $stmt = $db->prepare(" + INSERT INTO sample_status_history ( + idsample, + old_status, + new_status, + note, + changed_by, + created_at + ) VALUES ( + :idsample, + :old_status, + :new_status, + :note, + :changed_by, + NOW() + ) + "); + $stmt->execute([ + ':idsample' => $idsample, + ':old_status' => $oldStatus, + ':new_status' => $status, + ':note' => 'Status changed from sample list.', + ':changed_by' => $iduserlogin, + ]); + } + + jsonResponse([ + 'success' => true, + 'message' => 'Sample status updated successfully.' + ]); + } + + if ($action === 'delete_sample') { + $idsample = isset($_POST['idsample']) ? (int) $_POST['idsample'] : 0; + + if ($idsample <= 0) { + jsonResponse([ + 'success' => false, + 'message' => 'Invalid sample id.' + ]); + } + + /* + * Safe delete: + * Do not delete samples with BOM parts, photos or documents. + */ + $stmt = $db->prepare(" + SELECT + (SELECT COUNT(*) FROM sample_parts WHERE idsample = :idsample1) AS parts_count, + (SELECT COUNT(*) FROM sample_photos WHERE idsample = :idsample2) AS photos_count, + (SELECT COUNT(*) FROM sample_documents WHERE idsample = :idsample3) AS documents_count + "); + $stmt->execute([ + ':idsample1' => $idsample, + ':idsample2' => $idsample, + ':idsample3' => $idsample, + ]); + + $usage = $stmt->fetch(PDO::FETCH_ASSOC); + + if ( + (int) $usage['parts_count'] > 0 || + (int) $usage['photos_count'] > 0 || + (int) $usage['documents_count'] > 0 + ) { + jsonResponse([ + 'success' => false, + 'message' => 'This sample has BOM parts, photos or documents. Set it as archived instead of deleting it.' + ]); + } + + $stmt = $db->prepare(" + DELETE FROM samples + WHERE idsample = :idsample + "); + $stmt->execute([':idsample' => $idsample]); + + jsonResponse([ + 'success' => true, + 'message' => 'Sample deleted successfully.' + ]); + } + + jsonResponse([ + 'success' => false, + 'message' => 'Unknown action.' + ]); + } catch (Throwable $e) { + jsonResponse([ + 'success' => false, + 'message' => $e->getMessage() + ]); + } +} + +/* + * Page data. + */ +$companies = []; +$brands = []; +$departments = []; +$partners = []; +$countries = []; +$samples = []; + +try { + $stmt = $db->query(" + SELECT idcompany, company_name, status + FROM companies + ORDER BY company_name ASC + "); + $companies = $stmt->fetchAll(PDO::FETCH_ASSOC); +} catch (Throwable $e) { + $companies = []; +} + +try { + $stmt = $db->query(" + SELECT idbrand, idcompany, brand_name, status + FROM brands + ORDER BY brand_name ASC + "); + $brands = $stmt->fetchAll(PDO::FETCH_ASSOC); +} catch (Throwable $e) { + $brands = []; +} + +try { + $stmt = $db->query(" + SELECT iddepartment, idcompany, idbrand, department_name, status + FROM departments + ORDER BY department_name ASC + "); + $departments = $stmt->fetchAll(PDO::FETCH_ASSOC); +} catch (Throwable $e) { + $departments = []; +} + +try { + $stmt = $db->query(" + SELECT idpartner, idcompany, partner_type, partner_name, status + FROM business_partners + ORDER BY partner_name ASC + "); + $partners = $stmt->fetchAll(PDO::FETCH_ASSOC); +} catch (Throwable $e) { + $partners = []; +} + +try { + $stmt = $db->query(" + SELECT id, name, iso_3166_2 + FROM auth_countries + ORDER BY name ASC + "); + $countries = $stmt->fetchAll(PDO::FETCH_ASSOC); +} catch (Throwable $e) { + $countries = []; +} + +try { + $stmt = $db->query(" + SELECT + s.idsample, + s.idcompany, + s.idbrand, + s.iddepartment, + s.sample_code, + s.external_sample_id, + s.article_no, + s.po_no, + s.season, + s.sample_description, + s.product_category, + s.product_type, + s.color, + s.production_stage, + s.status, + s.source, + s.created_at, + c.company_name, + b.brand_name, + d.department_name, + bp1.partner_name AS producer_name, + bp2.partner_name AS supplier_name, + COUNT(DISTINCT sp.idpart) AS parts_count, + COUNT(DISTINCT sph.idsamplephoto) AS photos_count, + COUNT(DISTINCT sd.idsampledocument) AS documents_count + FROM samples s + INNER JOIN companies c ON c.idcompany = s.idcompany + LEFT JOIN brands b ON b.idbrand = s.idbrand + LEFT JOIN departments d ON d.iddepartment = s.iddepartment + LEFT JOIN business_partners bp1 ON bp1.idpartner = s.idproducer + LEFT JOIN business_partners bp2 ON bp2.idpartner = s.idsupplier + LEFT JOIN sample_parts sp ON sp.idsample = s.idsample + LEFT JOIN sample_photos sph ON sph.idsample = s.idsample + LEFT JOIN sample_documents sd ON sd.idsample = s.idsample + GROUP BY + s.idsample, + s.idcompany, + s.idbrand, + s.iddepartment, + s.sample_code, + s.external_sample_id, + s.article_no, + s.po_no, + s.season, + s.sample_description, + s.product_category, + s.product_type, + s.color, + s.production_stage, + s.status, + s.source, + s.created_at, + c.company_name, + b.brand_name, + d.department_name, + bp1.partner_name, + bp2.partner_name + ORDER BY s.created_at DESC, s.idsample DESC + "); + $samples = $stmt->fetchAll(PDO::FETCH_ASSOC); +} catch (Throwable $e) { + $samples = []; +} + +$pageTitle = 'Samples'; + +$totalSamples = count($samples); +$activeSamples = count(array_filter($samples, fn($row) => in_array($row['status'], ['active', 'submitted', 'under_testing'], true))); +$totalParts = array_sum(array_map(fn($row) => (int) $row['parts_count'], $samples)); +$totalFiles = array_sum(array_map(fn($row) => (int) $row['photos_count'] + (int) $row['documents_count'], $samples)); +?> + + + + + + + + + + + + <?= e($pageTitle); ?> - <?= isset($titlewebsite) ? e($titlewebsite) : 'TRFgo'; ?> + + + + + +
+ + + +
+
+ +
+
+
+
+
+ + TRFgo Product Identity +
+

Samples

+

+ Create and manage product/sample identity cards with company, brand, department, + producer, supplier, article data, material information and testing lifecycle status. +

+
+ +
+ +
+
+
+
+ +
+
+
+
+
+
+
Total Samples
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
Active / Testing
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
BOM Parts
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
Files
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+
Sample List
+

Product identity cards ready for BOM, documents and TRF requests

+
+ + +
+
+ +
+ +
+ No companies available. + Create at least one company before adding samples. +
+ + + +
+ No business partners yet. + You can create samples without partners, but producer and supplier dropdowns will be empty. +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SampleCompanyBrand / DepartmentProducer / SupplierArticle / POBOMFilesStatusActions
+
+
+ +
External ID:
+ + +
Color:
+ +
+
+
+
+
-'; ?>
+
+
+
-'; ?>
+
+
+
-'; ?>
+
+ +
Season:
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+
+ +
+
+ +
+ + + + + + +
+ + + + + + + + + + + \ No newline at end of file diff --git a/public/userarea/uploads/trfgo/company_1/samples/1/documents/document_c1_s1_20260615_155635_EuropeRome_Invoice-B8DDF571-0034.pdf b/public/userarea/uploads/trfgo/company_1/samples/1/documents/document_c1_s1_20260615_155635_EuropeRome_Invoice-B8DDF571-0034.pdf new file mode 100644 index 00000000..e1cba08f Binary files /dev/null and b/public/userarea/uploads/trfgo/company_1/samples/1/documents/document_c1_s1_20260615_155635_EuropeRome_Invoice-B8DDF571-0034.pdf differ diff --git a/public/userarea/uploads/trfgo/company_1/samples/1/photos/photo_c1_s1_20260615_153850_EuropeRome_1e672dd9-5420-4432-b422-02d8d271c178.jpg b/public/userarea/uploads/trfgo/company_1/samples/1/photos/photo_c1_s1_20260615_153850_EuropeRome_1e672dd9-5420-4432-b422-02d8d271c178.jpg new file mode 100644 index 00000000..767b4b25 Binary files /dev/null and b/public/userarea/uploads/trfgo/company_1/samples/1/photos/photo_c1_s1_20260615_153850_EuropeRome_1e672dd9-5420-4432-b422-02d8d271c178.jpg differ