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.
+
+
+
+
+
+
+ Add Brand
+
+
+
+
+
+
+ $row['status'] === 'active'));
+ $inactiveBrands = count(array_filter($brands, fn($row) => $row['status'] === 'inactive'));
+ ?>
+
+
+
+
+
+
+
+
Total Brands
+
= e($totalBrands); ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Active
+
= e($activeBrands); ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Inactive
+
= e($inactiveBrands); ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ No companies available.
+ Create at least one company before adding brands.
+
+
+
+
+
+
+
+ Brand
+ Company
+ External Code
+ Departments
+ Users
+ Status
+ Created
+ Actions
+
+
+
+
+
+
+
+ = e($brand['brand_name']); ?>
+ ID: = e($brand['idbrand']); ?>
+
+
+
+ = e($brand['company_name']); ?>
+
+ Company status: = e($brand['company_status']); ?>
+
+
+
+
+ = !empty($brand['external_brand_code']) ? e($brand['external_brand_code']) : '- '; ?>
+
+
+
+
+
+ = e($brand['department_count']); ?>
+
+
+
+
+
+
+ = e($brand['user_count']); ?>
+
+
+
+
+
+ Active
+
+ Inactive
+
+
+
+
+ = !empty($brand['created_at']) ? e(date('d/m/Y', strtotime($brand['created_at']))) : '-'; ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ 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.
+
+
+
+
+
+
+ Add Partner
+
+
+
+
+
+
+
+
+
+
+
+
+
Total Partners
+
= e($totalPartners); ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Active Partners
+
= e($activePartners); ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Contacts
+
= e($totalContacts); ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ No companies available.
+ Create at least one company before adding business partners.
+
+
+
+
+
+
+
+ Partner
+ Company
+ Type
+ External Code
+ Country / City
+ Email
+ Contacts
+ Usage
+ Status
+ Actions
+
+
+
+
+
+
+
+
+ = e($partner['partner_name']); ?>
+
+ = e($partner['legal_name']); ?>
+
+
+ VAT: = e($partner['vat_number']); ?>
+
+
+
+
+ = e($partner['company_name']); ?>
+
+
+
+ = e($partnerTypeLabel); ?>
+
+
+
+ = !empty($partner['external_code']) ? e($partner['external_code']) : '- '; ?>
+
+
+
+ = !empty($partner['country_name']) ? e($partner['country_name']) : '- '; ?>
+
+ = e($partner['city']); ?>
+
+
+
+
+
+ = e($partner['email']); ?>
+
+ -
+
+
+
+ = e($partner['phone']); ?>
+
+
+
+
+
+
+ = e($partner['contacts_count']); ?>
+
+
+
+
+
+
+ = e($usageCount); ?>
+
+
+
+
+
+ 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.
+
+
+
+
+
+
+ Add Company
+
+
+
+
+
+
+ $row['status'] === 'active'));
+ $suspendedCompanies = count(array_filter($companies, fn($row) => $row['status'] === 'suspended'));
+ ?>
+
+
+
+
+
+
+
+
Total Companies
+
= e($totalCompanies); ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Active
+
= e($activeCompanies); ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Suspended
+
= e($suspendedCompanies); ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Company
+ External Code
+ Country / City
+ Email
+ Brands
+ Departments
+ Users
+ Status
+ Created
+ Actions
+
+
+
+
+
+
+
+ = e($company['company_name']); ?>
+
+ = e($company['legal_name']); ?>
+
+
+ VAT: = e($company['vat_number']); ?>
+
+
+
+
+ = !empty($company['external_code']) ? e($company['external_code']) : '- '; ?>
+
+
+
+ = !empty($company['country_name']) ? e($company['country_name']) : '- '; ?>
+
+ = e($company['city']); ?>
+
+
+
+
+
+ = e($company['email']); ?>
+
+ -
+
+
+
+ = e($company['phone']); ?>
+
+
+
+
+
+
+ = e($company['brand_count']); ?>
+
+
+
+
+
+
+ = e($company['department_count']); ?>
+
+
+
+
+
+
+ = e($company['user_count']); ?>
+
+
+
+
+
+ Active
+
+ Suspended
+
+ Inactive
+
+
+
+
+ = !empty($company['created_at']) ? e(date('d/m/Y', strtotime($company['created_at']))) : '-'; ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ 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.
+
+
+
+
+
+
+ Assign User
+
+
+
+
+
+
+ $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
+
= e($totalAssignments); ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Active
+
= e($activeAssignments); ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Company Scope
+
= e($companyScopeCount); ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Department Scope
+
= e($departmentScopeCount); ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ No companies available.
+ Create at least one company before assigning users.
+
+
+
+
+
+ No Vanguard users available.
+ Create users in Vanguard before assigning TRFgo access.
+
+
+
+
+
+
+
+ User
+ Company
+ Scope
+ Role
+ Vanguard Role
+ Status
+ Created
+ Actions
+
+
+
+
+
+
+
+
+ = e($fullName); ?>
+ = e($assignment['email']); ?>
+
+
+
+ = e($assignment['company_name']); ?>
+
+ Company status: = e($assignment['company_status']); ?>
+
+
+
+
+
+
+
Company
+
Full company visibility
+
+
Brand
+
= e($assignment['brand_name'] ?: '-'); ?>
+
+
Department
+
+ = e($assignment['department_name'] ?: '-'); ?>
+
+ / = e($assignment['brand_name']); ?>
+
+
+
+
+
+
+
+ = e($roleLabel); ?>
+
+
+
+ = !empty($assignment['vanguard_role']) ? e($assignment['vanguard_role']) : '- '; ?>
+
+
+
+
+ Active
+
+ Inactive
+
+
+
+
+ = !empty($assignment['created_at']) ? e(date('d/m/Y', strtotime($assignment['created_at']))) : '-'; ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Important: Vanguard manages login, password and main role.
+ TRFgo assignment defines which company data the user can access.
+
+
+
+
+ Vanguard User *
+
+ Select user
+
+
+
+ = e($displayName); ?> - = e($userRow['email']); ?>
+
+
+
+
+
+
+ Company *
+
+ Select company
+
+
+ = e($company['company_name']); ?>
+ = $company['status'] !== 'active' ? ' - ' . e($company['status']) : ''; ?>
+
+
+
+
+
+
+ Scope *
+
+ Company
+ Brand
+ Department
+
+
+
+
+ Brand
+
+ No brand
+
+
+ = e($brand['brand_name']); ?>
+ = $brand['status'] !== 'active' ? ' - ' . e($brand['status']) : ''; ?>
+
+
+
+
+
+
+ Department
+
+ No department
+
+
+ = e($department['department_name']); ?>
+ = $department['status'] !== 'active' ? ' - ' . e($department['status']) : ''; ?>
+
+
+
+
+
+
+ TRFgo Role *
+
+ Owner
+ Admin
+ Manager
+ Operator
+ Viewer
+ API User
+ Lab User
+
+
+
+
+ Status
+
+ 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, = e(trim($nameuser . ' ' . $surnameuser)); ?>
+
+
+
+ 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.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ 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
+
+
+
+ = e($companyName); ?>
+
+
+
+ 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): ?>
+
+
+
+ >
+ = e($companyOption['company_name']); ?>
+
+
+
+
+
+
+
+
+
+
+
+ No company available.
+ This user is not linked to any TRFgo company yet.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ 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.
+
+
+
+
+
+
+ Add Department
+
+
+
+
+
+
+ $row['status'] === 'active'));
+ $inactiveDepartments = count(array_filter($departments, fn($row) => $row['status'] === 'inactive'));
+ ?>
+
+
+
+
+
+
+
+
Total Departments
+
= e($totalDepartments); ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Active
+
= e($activeDepartments); ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Inactive
+
= e($inactiveDepartments); ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ No companies available.
+ Create at least one company before adding departments.
+
+
+
+
+
+
+
+ Department
+ Company
+ Brand
+ External Code
+ Users
+ Status
+ Created
+ Actions
+
+
+
+
+
+
+
+ = e($department['department_name']); ?>
+ ID: = e($department['iddepartment']); ?>
+
+
+
+ = e($department['company_name']); ?>
+
+ Company status: = e($department['company_status']); ?>
+
+
+
+
+
+ = e($department['brand_name']); ?>
+
+ Brand status: = e($department['brand_status']); ?>
+
+
+ Not linked
+
+
+
+
+ = !empty($department['external_department_code']) ? e($department['external_department_code']) : '- '; ?>
+
+
+
+
+
+ = e($department['user_count']); ?>
+
+
+
+
+
+ Active
+
+ Inactive
+
+
+
+
+ = !empty($department['created_at']) ? e(date('d/m/Y', strtotime($department['created_at']))) : '-'; ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Company *
+
+ Select company
+
+
+ = e($company['company_name']); ?>
+ = $company['status'] !== 'active' ? ' - ' . e($company['status']) : ''; ?>
+
+
+
+
+
+
+ Brand
+
+ No brand / company level department
+
+
+ = e($brand['brand_name']); ?>
+ = $brand['status'] !== 'active' ? ' - ' . e($brand['status']) : ''; ?>
+
+
+
+ Only brands belonging to the selected company will be available.
+
+
+
+ Department Name *
+
+
+
+
+ External Department Code
+
+
+
+
+ Status
+
+ 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
+
+
+
+ = e($sample['sample_code']); ?> — = e($sample['sample_description']); ?>
+
+
+
+ Product/sample master record with overview, photos, BOM parts, documents and lifecycle history.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Status
+
= e($statusLabel); ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
BOM Parts
+
= e(count($parts)); ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Photos
+
= e(count($photos)); ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Documents
+
= e(count($documents)); ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Overview
+
+
+
+
+
+ Photos
+
+
+
+
+
+ Parts / BOM
+
+
+
+
+
+ Documents
+
+
+
+
+
+ History
+
+
+
+
+
+
+
+
+
+
+
Product Information
+
Main product/sample identity data
+
+
+
Company
+
= e($sample['company_name']); ?>
+
+
+
+
Brand
+
= e($sample['brand_name'] ?: '-'); ?>
+
+
+
+
Department
+
= e($sample['department_name'] ?: '-'); ?>
+
+
+
+
Article No.
+
= e($sample['article_no'] ?: '-'); ?>
+
+
+
+
PO No.
+
= e($sample['po_no'] ?: '-'); ?>
+
+
+
+
Season
+
= e($sample['season'] ?: '-'); ?>
+
+
+
+
Color
+
= e($sample['color'] ?: '-'); ?>
+
+
+
+
Production Stage
+
= e($sample['production_stage'] ?: '-'); ?>
+
+
+
+
+
Technical Information
+
Materials, partners and source data
+
+
+
Producer
+
= e($sample['producer_name'] ?: '-'); ?>
+
+
+
+
Supplier
+
= e($sample['supplier_name'] ?: '-'); ?>
+
+
+
+
Product Category
+
= e($sample['product_category'] ?: '-'); ?>
+
+
+
+
Product Type
+
= e($sample['product_type'] ?: '-'); ?>
+
+
+
+
Product Standard
+
= e($sample['product_standard'] ?: '-'); ?>
+
+
+
+
Country of Origin
+
= e($sample['country_of_origin_name'] ?: '-'); ?>
+
+
+
+
Created By
+
= e($createdBy); ?>
+
+
+
+
Created At
+
= !empty($sample['created_at']) ? e(date('d/m/Y H:i', strtotime($sample['created_at']))) : '-'; ?>
+
+
+
+
+
Material Details
+
+
+
+
+
Fiber Content
+
= nl2br(e($sample['fiber_content'] ?: '-')); ?>
+
+
+
+
+
+
Material Description
+
= nl2br(e($sample['material_description'] ?: '-')); ?>
+
+
+
+
+
+
+
+
+
+
+
+
Sample Photos
+
Product, label, packaging and detail images
+
+
+
+
+ Upload Photo
+
+
+
+ 0): ?>
+
+
+
+
+
+
+
+
+
+
= e($photoTypes[$photo['photo_type']] ?? $photo['photo_type']); ?>
+
= e($photo['original_filename'] ?: $photo['filename']); ?>
+
+
+
+
Main
+
+
+
+
+
= e($photo['description']); ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
No photos uploaded yet
+
Upload product, label, packaging or detail photos.
+
+
+
+
+
+
+
+
+
Parts / BOM
+
Bill of materials and product components
+
+
+
+
+ Add Part
+
+
+
+ 0): ?>
+
+
+
+
+ Part
+ Parent
+ Material / Color
+ Producer / Supplier
+ Qty
+ Risk
+ Actions
+
+
+
+
+
+
+
+
+ = e($part['part_name']); ?>
+
+ Code: = e($part['part_code']); ?>
+
+
+ Position: = e($part['position']); ?>
+
+
+
+ = e($part['parent_part_name'] ?: '-'); ?>
+
+
+ = e($part['material'] ?: '-'); ?>
+ = e($part['color'] ?: '-'); ?>
+
+
+
+ = e($part['producer_name'] ?: '-'); ?>
+ = e($part['supplier_name'] ?: '-'); ?>
+
+
+
+ = e($part['quantity'] ?: '-'); ?>
+ = e($part['unit'] ?: ''); ?>
+
+
+
+
+ = e($riskLabel); ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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
+
+
+
+
+ Upload Document
+
+
+
+ 0): ?>
+
+
+
+
+ Document
+ Type
+ Expiry
+ Uploaded
+ Actions
+
+
+
+
+
+
+
+ = e($document['title']); ?>
+ = e($document['original_filename'] ?: $document['filename']); ?>
+
+
+
+
+ = e($documentTypes[$document['document_type']] ?? $document['document_type']); ?>
+
+
+
+
+ = !empty($document['expiry_date']) ? e(date('d/m/Y', strtotime($document['expiry_date']))) : '-'; ?>
+
+
+
+ = !empty($document['created_at']) ? e(date('d/m/Y H:i', strtotime($document['created_at']))) : '-'; ?>
+ = e($document['uploaded_by_email'] ?: '-'); ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
No documents uploaded yet
+
Upload technical sheets, certificates, declarations or product documentation.
+
+
+
+
+
+
+
Status History
+
Sample lifecycle changes
+
+ 0): ?>
+
+
+
+
+
+ = e(ucfirst(str_replace('_', ' ', $item['new_status']))); ?>
+
+
+ = !empty($item['created_at']) ? e(date('d/m/Y H:i', strtotime($item['created_at']))) : '-'; ?>
+ |
+ = e($userName); ?>
+
+
+
+
+
+ = e(ucfirst(str_replace('_', ' ', $item['old_status']))); ?>
+
+ →
+
+ = e(ucfirst(str_replace('_', ' ', $item['new_status']))); ?>
+
+
+
+
+
+
= e($item['note']); ?>
+
+
+
+
+
+
+
+
No history yet
+
Status changes will appear here.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Parent Part
+
+ No parent
+
+
+ = e($partOption['part_name']); ?>
+
+
+
+
+
+
+ Part Code
+
+
+
+
+ Sort Order
+
+
+
+
+ Part Name *
+
+
+
+
+ Part Description
+
+
+
+
+ Material
+
+
+
+
+ Color
+
+
+
+
+ Quantity
+
+
+
+
+ Unit
+
+
+
+
+ Producer
+
+ No producer
+
+
+ = e($partner['partner_name']); ?> - = e($partner['partner_type']); ?>
+
+
+
+
+
+
+ Supplier
+
+ No supplier
+
+
+ = e($partner['partner_name']); ?> - = e($partner['partner_type']); ?>
+
+
+
+
+
+
+ Risk Level
+
+ $label): ?>
+ = e($label); ?>
+
+
+
+
+
+ Position
+
+
+
+
+ Notes
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Photo File *
+
+
+
+
+ Photo Type
+
+ $label): ?>
+ = e($label); ?>
+
+
+
+
+
+ Main Photo
+
+ No
+ Yes
+
+
+
+
+ Description
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ 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.
+
+
+
+
+
+
+ Add Sample
+
+
+
+
+
+
+
+
+
+
+
+
+
Total Samples
+
= e($totalSamples); ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Active / Testing
+
= e($activeSamples); ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
BOM Parts
+
= e($totalParts); ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Files
+
= e($totalFiles); ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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.
+
+
+
+
+
+
+
+ Sample
+ Company
+ Brand / Department
+ Producer / Supplier
+ Article / PO
+ BOM
+ Files
+ Status
+ Actions
+
+
+
+
+
+
+
+
+ = e($sample['sample_code']); ?>
+ = e($sample['sample_description']); ?>
+
+ External ID: = e($sample['external_sample_id']); ?>
+
+
+ Color: = e($sample['color']); ?>
+
+
+
+
+ = e($sample['company_name']); ?>
+ = e($sampleSources[$sample['source']] ?? $sample['source']); ?>
+
+
+
+ = !empty($sample['brand_name']) ? e($sample['brand_name']) : '- '; ?>
+ = !empty($sample['department_name']) ? e($sample['department_name']) : '-'; ?>
+
+
+
+ = !empty($sample['producer_name']) ? e($sample['producer_name']) : '- '; ?>
+ = !empty($sample['supplier_name']) ? e($sample['supplier_name']) : '-'; ?>
+
+
+
+ = !empty($sample['article_no']) ? e($sample['article_no']) : '- '; ?>
+ = !empty($sample['po_no']) ? e($sample['po_no']) : '-'; ?>
+
+ Season: = e($sample['season']); ?>
+
+
+
+
+
+
+ = e($sample['parts_count']); ?>
+
+
+
+
+
+
+ = e($filesCount); ?>
+
+
+
+
+
+ = e($statusLabel); ?>
+
+ = e($statusLabel); ?>
+
+ = e($statusLabel); ?>
+
+ = e($statusLabel); ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Company *
+
+ Select company
+
+
+ = e($company['company_name']); ?>
+ = $company['status'] !== 'active' ? ' - ' . e($company['status']) : ''; ?>
+
+
+
+
+
+
+ Brand
+
+ No brand
+
+
+ = e($brand['brand_name']); ?>
+ = $brand['status'] !== 'active' ? ' - ' . e($brand['status']) : ''; ?>
+
+
+
+
+
+
+ Department
+
+ No department
+
+
+ = e($department['department_name']); ?>
+ = $department['status'] !== 'active' ? ' - ' . e($department['status']) : ''; ?>
+
+
+
+
+
+
+ Producer
+
+ No producer
+
+
+ = e($partner['partner_name']); ?> - = e($partner['partner_type']); ?>
+
+
+
+
+
+
+ Supplier
+
+ No supplier
+
+
+ = e($partner['partner_name']); ?> - = e($partner['partner_type']); ?>
+
+
+
+
+
+
+ Status
+
+ $label): ?>
+ = e($label); ?>
+
+
+
+
+
+ Sample Code
+
+
+
+
+ External Sample ID
+
+
+
+
+ Article No.
+
+
+
+
+ PO No.
+
+
+
+
+ Season
+
+
+
+
+ Production Stage
+
+
+
+
+ Sample Description *
+
+
+
+
+ Style No.
+
+
+
+
+ Style Name
+
+
+
+
+ Model
+
+
+
+
+ Product Category
+
+
+
+
+ Product Type
+
+
+
+
+ Color
+
+
+
+
+ Size
+
+
+
+
+ Gender
+
+
+
+
+ Age Group
+
+
+
+
+ Claimed Weight
+
+
+
+
+ Fiber Content
+
+
+
+
+ Material Description
+
+
+
+
+ Product Standard
+
+
+
+
+ Country of Origin
+
+ Select country
+
+
+ = e($country['name']); ?>
+ = !empty($country['iso_3166_2']) ? ' (' . e($country['iso_3166_2']) . ')' : ''; ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ 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