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

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

Status
BOM Parts
Photos
Documents
Product Information

Main product/sample identity data

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

Materials, partners and source data

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

Product, label, packaging and detail images

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

Upload product, label, packaging or detail photos.

Parts / BOM

Bill of materials and product components

0): ?>
Part Parent Material / Color Producer / Supplier Qty Risk Actions
Code:
Position:
No BOM parts yet

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

Documents

Technical sheets, certificates, declarations and related files

0): ?>
Document Type Expiry Uploaded Actions
No documents uploaded yet

Upload technical sheets, certificates, declarations or product documentation.

Status History

Sample lifecycle changes

0): ?>
|
No history yet

Status changes will appear here.