Files
trfgo/public/userarea/sample-detail.php
T
2026-06-15 16:10:44 +02:00

2560 lines
100 KiB
PHP

<?php include('include/headscript.php'); ?>
<?php
/*
* TRFgo - Sample detail page
* Product/sample identity card with overview, photos, BOM parts, documents and history.
*/
function e($value)
{
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
}
function jsonResponse(array $payload): void
{
header('Content-Type: application/json');
echo json_encode($payload);
exit;
}
function ensureDirectory(string $path): void
{
if (!is_dir($path)) {
mkdir($path, 0775, true);
}
}
function safeFilename(string $filename): string
{
$filename = preg_replace('/[^a-zA-Z0-9_\.\-]/', '_', $filename);
return trim($filename, '_');
}
function buildDatedFilename(string $prefix, int $idcompany, int $idsample, string $originalFilename, string $extension): string
{
$timezone = new DateTimeZone('Europe/Rome');
$now = new DateTime('now', $timezone);
$cleanOriginalName = pathinfo($originalFilename, PATHINFO_FILENAME);
$cleanOriginalName = safeFilename($cleanOriginalName);
if ($cleanOriginalName === '') {
$cleanOriginalName = 'file';
}
return $prefix
. '_c' . $idcompany
. '_s' . $idsample
. '_' . $now->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'] ?? '-';
}
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" href="assets/images/favicon-32x32.png" type="image/png" />
<?php include('cssinclude.php'); ?>
<title><?= e($pageTitle); ?> - <?= isset($titlewebsite) ? e($titlewebsite) : 'TRFgo'; ?></title>
<style>
:root {
--trfgo-primary: #2563eb;
--trfgo-muted: #64748b;
--trfgo-border: #e5e7eb;
--trfgo-soft-blue: #eff6ff;
--trfgo-soft-green: #ecfdf5;
--trfgo-soft-orange: #fff7ed;
--trfgo-soft-purple: #f5f3ff;
}
.sample-hero {
border: 0;
border-radius: 20px;
overflow: hidden;
background:
radial-gradient(circle at top right, rgba(59, 130, 246, 0.22), transparent 30%),
linear-gradient(135deg, #0f172a 0%, #1d4ed8 60%, #38bdf8 100%);
color: #fff;
box-shadow: 0 18px 45px rgba(15, 23, 42, 0.18);
}
.sample-hero .card-body {
padding: 26px;
}
.sample-hero h1,
.sample-hero h2,
.sample-hero h3,
.sample-hero h4,
.sample-hero h5,
.sample-hero h6 {
color: #ffffff;
}
.hero-eyebrow {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.16);
color: rgba(255, 255, 255, 0.94);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
margin-bottom: 12px;
}
.hero-title {
font-size: 28px;
font-weight: 800;
letter-spacing: -0.03em;
margin-bottom: 8px;
color: #ffffff;
}
.hero-text {
max-width: 850px;
color: rgba(255, 255, 255, 0.82);
margin-bottom: 0;
line-height: 1.6;
}
.btn-hero {
border-radius: 12px;
padding: 10px 16px;
font-weight: 700;
display: inline-flex;
align-items: center;
gap: 8px;
border: 0;
background: #fff;
color: #1d4ed8;
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.18);
text-decoration: none;
}
.summary-card,
.content-card {
border: 0;
border-radius: 18px;
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.06);
}
.summary-icon {
width: 44px;
height: 44px;
border-radius: 14px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 24px;
}
.summary-icon.blue {
background: var(--trfgo-soft-blue);
color: #2563eb;
}
.summary-icon.green {
background: var(--trfgo-soft-green);
color: #059669;
}
.summary-icon.orange {
background: var(--trfgo-soft-orange);
color: #ea580c;
}
.summary-icon.purple {
background: var(--trfgo-soft-purple);
color: #7c3aed;
}
.summary-label {
color: var(--trfgo-muted);
font-size: 13px;
font-weight: 700;
margin-bottom: 3px;
}
.summary-value {
color: #0f172a;
font-size: 26px;
font-weight: 800;
line-height: 1.1;
}
.nav-tabs .nav-link {
border: 0;
color: #64748b;
font-weight: 800;
border-radius: 12px;
margin-right: 6px;
}
.nav-tabs .nav-link.active {
color: #1d4ed8;
background: #eff6ff;
}
.info-row {
display: flex;
justify-content: space-between;
gap: 14px;
padding: 12px 0;
border-bottom: 1px solid #eef2f7;
}
.info-row:last-child {
border-bottom: 0;
}
.info-label {
color: var(--trfgo-muted);
font-size: 13px;
font-weight: 700;
}
.info-value {
color: #0f172a;
font-size: 13px;
font-weight: 800;
text-align: right;
}
.section-title {
color: #0f172a;
font-size: 18px;
font-weight: 800;
margin-bottom: 3px;
}
.section-subtitle {
color: var(--trfgo-muted);
font-size: 13px;
margin-bottom: 0;
}
.badge-soft-success {
background: #dcfce7;
color: #166534;
border-radius: 999px;
font-weight: 700;
padding: 6px 10px;
white-space: nowrap;
}
.badge-soft-warning {
background: #ffedd5;
color: #9a3412;
border-radius: 999px;
font-weight: 700;
padding: 6px 10px;
white-space: nowrap;
}
.badge-soft-muted {
background: #f1f5f9;
color: #475569;
border-radius: 999px;
font-weight: 700;
padding: 6px 10px;
white-space: nowrap;
}
.badge-soft-primary {
background: #dbeafe;
color: #1d4ed8;
border-radius: 999px;
font-weight: 700;
padding: 6px 10px;
white-space: nowrap;
}
.badge-risk-low {
background: #dcfce7;
color: #166534;
}
.badge-risk-medium {
background: #fef3c7;
color: #92400e;
}
.badge-risk-high {
background: #ffedd5;
color: #9a3412;
}
.badge-risk-critical {
background: #fee2e2;
color: #991b1b;
}
.badge-risk-unknown {
background: #f1f5f9;
color: #475569;
}
.metric-pill {
display: inline-flex;
align-items: center;
gap: 6px;
background: #f8fafc;
border: 1px solid #e5e7eb;
color: #475569;
border-radius: 999px;
padding: 5px 9px;
font-size: 12px;
font-weight: 700;
white-space: nowrap;
}
.detail-table th {
color: #64748b;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.04em;
border-bottom: 1px solid var(--trfgo-border) !important;
white-space: nowrap;
}
.detail-table td {
vertical-align: middle;
border-color: #eef2f7;
}
.item-title {
font-weight: 800;
color: #0f172a;
}
.item-sub {
color: var(--trfgo-muted);
font-size: 12px;
}
.btn-action {
width: 34px;
height: 34px;
border-radius: 11px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid #e5e7eb;
background: #fff;
color: #475569;
transition: all 0.16s ease;
text-decoration: none;
}
.btn-action:hover {
color: #1d4ed8;
border-color: rgba(37, 99, 235, 0.35);
background: #eff6ff;
}
.btn-action.danger:hover {
color: #dc2626;
border-color: rgba(220, 38, 38, 0.30);
background: #fef2f2;
}
.photo-card {
border: 1px solid #eef2f7;
border-radius: 18px;
overflow: hidden;
height: 100%;
background: #fff;
}
.photo-card img {
width: 100%;
height: 210px;
object-fit: cover;
background: #f8fafc;
}
.photo-card-body {
padding: 14px;
}
.empty-state {
text-align: center;
padding: 38px 20px;
color: var(--trfgo-muted);
}
.empty-state i {
font-size: 46px;
color: #cbd5e1;
margin-bottom: 10px;
}
.modal-content {
border: 0;
border-radius: 20px;
box-shadow: 0 24px 80px rgba(15, 23, 42, 0.22);
}
.modal-header {
border-bottom: 1px solid #eef2f7;
}
.modal-title {
font-weight: 800;
color: #0f172a;
}
.form-label {
font-size: 13px;
font-weight: 700;
color: #334155;
}
.form-control,
.form-select {
border-radius: 12px;
border-color: #dbe3ef;
}
.timeline {
position: relative;
padding-left: 24px;
}
.timeline::before {
content: "";
position: absolute;
left: 7px;
top: 4px;
bottom: 4px;
width: 2px;
background: #e5e7eb;
}
.timeline-item {
position: relative;
padding-bottom: 18px;
}
.timeline-item::before {
content: "";
position: absolute;
left: -22px;
top: 3px;
width: 12px;
height: 12px;
border-radius: 50%;
background: #2563eb;
border: 3px solid #dbeafe;
}
.timeline-title {
font-weight: 800;
color: #0f172a;
}
.timeline-sub {
color: var(--trfgo-muted);
font-size: 12px;
}
@media (max-width: 767px) {
.hero-title {
font-size: 24px;
}
.btn-hero {
width: 100%;
justify-content: center;
margin-top: 15px;
}
.info-row {
flex-direction: column;
gap: 4px;
}
.info-value {
text-align: left;
}
}
</style>
</head>
<body>
<div class="wrapper">
<?php include('include/navbar.php'); ?>
<?php include('include/topbar.php'); ?>
<div class="page-wrapper">
<div class="page-content">
<div class="card sample-hero mb-4">
<div class="card-body">
<div class="d-flex align-items-start justify-content-between flex-wrap gap-3">
<div>
<div class="hero-eyebrow">
<i class="bx bx-package"></i>
Sample Identity Card
</div>
<h1 class="hero-title">
<?= e($sample['sample_code']); ?> — <?= e($sample['sample_description']); ?>
</h1>
<p class="hero-text">
Product/sample master record with overview, photos, BOM parts, documents and lifecycle history.
</p>
</div>
<div>
<a href="samples.php" class="btn-hero">
<i class="bx bx-arrow-back"></i>
Back to Samples
</a>
</div>
</div>
</div>
</div>
<div class="row row-cols-1 row-cols-md-4 mb-3">
<div class="col">
<div class="card summary-card">
<div class="card-body">
<div class="d-flex align-items-center justify-content-between">
<div>
<div class="summary-label">Status</div>
<div class="summary-value" style="font-size:20px;"><?= e($statusLabel); ?></div>
</div>
<div class="summary-icon blue">
<i class="bx bx-check-shield"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col">
<div class="card summary-card">
<div class="card-body">
<div class="d-flex align-items-center justify-content-between">
<div>
<div class="summary-label">BOM Parts</div>
<div class="summary-value"><?= e(count($parts)); ?></div>
</div>
<div class="summary-icon orange">
<i class="bx bx-git-branch"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col">
<div class="card summary-card">
<div class="card-body">
<div class="d-flex align-items-center justify-content-between">
<div>
<div class="summary-label">Photos</div>
<div class="summary-value"><?= e(count($photos)); ?></div>
</div>
<div class="summary-icon green">
<i class="bx bx-image"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col">
<div class="card summary-card">
<div class="card-body">
<div class="d-flex align-items-center justify-content-between">
<div>
<div class="summary-label">Documents</div>
<div class="summary-value"><?= e(count($documents)); ?></div>
</div>
<div class="summary-icon purple">
<i class="bx bx-folder"></i>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="card content-card">
<div class="card-body">
<ul class="nav nav-tabs mb-4" id="sampleTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="overview-tab" data-bs-toggle="tab" data-bs-target="#overviewPane" type="button" role="tab">
<i class="bx bx-info-circle"></i> Overview
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="photos-tab" data-bs-toggle="tab" data-bs-target="#photosPane" type="button" role="tab">
<i class="bx bx-image"></i> Photos
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="parts-tab" data-bs-toggle="tab" data-bs-target="#partsPane" type="button" role="tab">
<i class="bx bx-git-branch"></i> Parts / BOM
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="documents-tab" data-bs-toggle="tab" data-bs-target="#documentsPane" type="button" role="tab">
<i class="bx bx-folder"></i> Documents
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="history-tab" data-bs-toggle="tab" data-bs-target="#historyPane" type="button" role="tab">
<i class="bx bx-history"></i> History
</button>
</li>
</ul>
<div class="tab-content" id="sampleTabsContent">
<!-- Overview -->
<div class="tab-pane fade show active" id="overviewPane" role="tabpanel">
<div class="row g-4">
<div class="col-12 col-xl-6">
<h6 class="section-title">Product Information</h6>
<p class="section-subtitle mb-3">Main product/sample identity data</p>
<div class="info-row">
<div class="info-label">Company</div>
<div class="info-value"><?= e($sample['company_name']); ?></div>
</div>
<div class="info-row">
<div class="info-label">Brand</div>
<div class="info-value"><?= e($sample['brand_name'] ?: '-'); ?></div>
</div>
<div class="info-row">
<div class="info-label">Department</div>
<div class="info-value"><?= e($sample['department_name'] ?: '-'); ?></div>
</div>
<div class="info-row">
<div class="info-label">Article No.</div>
<div class="info-value"><?= e($sample['article_no'] ?: '-'); ?></div>
</div>
<div class="info-row">
<div class="info-label">PO No.</div>
<div class="info-value"><?= e($sample['po_no'] ?: '-'); ?></div>
</div>
<div class="info-row">
<div class="info-label">Season</div>
<div class="info-value"><?= e($sample['season'] ?: '-'); ?></div>
</div>
<div class="info-row">
<div class="info-label">Color</div>
<div class="info-value"><?= e($sample['color'] ?: '-'); ?></div>
</div>
<div class="info-row">
<div class="info-label">Production Stage</div>
<div class="info-value"><?= e($sample['production_stage'] ?: '-'); ?></div>
</div>
</div>
<div class="col-12 col-xl-6">
<h6 class="section-title">Technical Information</h6>
<p class="section-subtitle mb-3">Materials, partners and source data</p>
<div class="info-row">
<div class="info-label">Producer</div>
<div class="info-value"><?= e($sample['producer_name'] ?: '-'); ?></div>
</div>
<div class="info-row">
<div class="info-label">Supplier</div>
<div class="info-value"><?= e($sample['supplier_name'] ?: '-'); ?></div>
</div>
<div class="info-row">
<div class="info-label">Product Category</div>
<div class="info-value"><?= e($sample['product_category'] ?: '-'); ?></div>
</div>
<div class="info-row">
<div class="info-label">Product Type</div>
<div class="info-value"><?= e($sample['product_type'] ?: '-'); ?></div>
</div>
<div class="info-row">
<div class="info-label">Product Standard</div>
<div class="info-value"><?= e($sample['product_standard'] ?: '-'); ?></div>
</div>
<div class="info-row">
<div class="info-label">Country of Origin</div>
<div class="info-value"><?= e($sample['country_of_origin_name'] ?: '-'); ?></div>
</div>
<div class="info-row">
<div class="info-label">Created By</div>
<div class="info-value"><?= e($createdBy); ?></div>
</div>
<div class="info-row">
<div class="info-label">Created At</div>
<div class="info-value"><?= !empty($sample['created_at']) ? e(date('d/m/Y H:i', strtotime($sample['created_at']))) : '-'; ?></div>
</div>
</div>
<div class="col-12">
<h6 class="section-title">Material Details</h6>
<div class="row g-3 mt-1">
<div class="col-12 col-xl-6">
<div class="border rounded-4 p-3 h-100">
<div class="info-label mb-2">Fiber Content</div>
<div><?= nl2br(e($sample['fiber_content'] ?: '-')); ?></div>
</div>
</div>
<div class="col-12 col-xl-6">
<div class="border rounded-4 p-3 h-100">
<div class="info-label mb-2">Material Description</div>
<div><?= nl2br(e($sample['material_description'] ?: '-')); ?></div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Photos -->
<div class="tab-pane fade" id="photosPane" role="tabpanel">
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
<div>
<h6 class="section-title mb-0">Sample Photos</h6>
<p class="section-subtitle">Product, label, packaging and detail images</p>
</div>
<button type="button" class="btn btn-primary btn-sm" id="btnUploadPhoto">
<i class="bx bx-image-add"></i>
Upload Photo
</button>
</div>
<?php if (count($photos) > 0): ?>
<div class="row g-3">
<?php foreach ($photos as $photo): ?>
<div class="col-12 col-md-6 col-xl-3">
<div class="photo-card">
<img src="<?= e($photoWebPath . $photo['filename']); ?>" alt="<?= e($photo['original_filename']); ?>">
<div class="photo-card-body">
<div class="d-flex align-items-center justify-content-between gap-2">
<div>
<div class="item-title"><?= e($photoTypes[$photo['photo_type']] ?? $photo['photo_type']); ?></div>
<div class="item-sub"><?= e($photo['original_filename'] ?: $photo['filename']); ?></div>
</div>
<?php if ((int) $photo['is_main'] === 1): ?>
<span class="badge-soft-success">Main</span>
<?php endif; ?>
</div>
<?php if (!empty($photo['description'])): ?>
<div class="item-sub mt-2"><?= e($photo['description']); ?></div>
<?php endif; ?>
<div class="d-flex justify-content-end mt-3">
<button type="button"
class="btn-action danger btnDeletePhoto"
data-id="<?= e($photo['idsamplephoto']); ?>"
title="Delete photo">
<i class="bx bx-trash"></i>
</button>
</div>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php else: ?>
<div class="empty-state">
<i class="bx bx-image"></i>
<h6>No photos uploaded yet</h6>
<p>Upload product, label, packaging or detail photos.</p>
</div>
<?php endif; ?>
</div>
<!-- Parts -->
<div class="tab-pane fade" id="partsPane" role="tabpanel">
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
<div>
<h6 class="section-title mb-0">Parts / BOM</h6>
<p class="section-subtitle">Bill of materials and product components</p>
</div>
<button type="button" class="btn btn-primary btn-sm" id="btnAddPart">
<i class="bx bx-plus-circle"></i>
Add Part
</button>
</div>
<?php if (count($parts) > 0): ?>
<div class="table-responsive">
<table id="partsTable" class="table table-striped table-hover align-middle detail-table" style="width:100%">
<thead>
<tr>
<th>Part</th>
<th>Parent</th>
<th>Material / Color</th>
<th>Producer / Supplier</th>
<th>Qty</th>
<th>Risk</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
<?php foreach ($parts as $part): ?>
<?php
$riskClass = 'badge-risk-' . $part['risk_level'];
$riskLabel = $partRiskLevels[$part['risk_level']] ?? ucfirst($part['risk_level']);
?>
<tr>
<td>
<div class="item-title"><?= e($part['part_name']); ?></div>
<?php if (!empty($part['part_code'])): ?>
<div class="item-sub">Code: <?= e($part['part_code']); ?></div>
<?php endif; ?>
<?php if (!empty($part['position'])): ?>
<div class="item-sub">Position: <?= e($part['position']); ?></div>
<?php endif; ?>
</td>
<td><?= e($part['parent_part_name'] ?: '-'); ?></td>
<td>
<div><?= e($part['material'] ?: '-'); ?></div>
<div class="item-sub"><?= e($part['color'] ?: '-'); ?></div>
</td>
<td>
<div><?= e($part['producer_name'] ?: '-'); ?></div>
<div class="item-sub"><?= e($part['supplier_name'] ?: '-'); ?></div>
</td>
<td>
<?= e($part['quantity'] ?: '-'); ?>
<?= e($part['unit'] ?: ''); ?>
</td>
<td>
<span class="badge-soft-muted <?= e($riskClass); ?>">
<?= e($riskLabel); ?>
</span>
</td>
<td class="text-end">
<button type="button"
class="btn-action btnEditPart"
data-id="<?= e($part['idpart']); ?>"
title="Edit part">
<i class="bx bx-edit-alt"></i>
</button>
<button type="button"
class="btn-action danger btnDeletePart"
data-id="<?= e($part['idpart']); ?>"
title="Delete part">
<i class="bx bx-trash"></i>
</button>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php else: ?>
<div class="empty-state">
<i class="bx bx-git-branch"></i>
<h6>No BOM parts yet</h6>
<p>Add parts such as upper, lining, sole, zip, label, packaging or other components.</p>
</div>
<?php endif; ?>
</div>
<!-- Documents -->
<div class="tab-pane fade" id="documentsPane" role="tabpanel">
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
<div>
<h6 class="section-title mb-0">Documents</h6>
<p class="section-subtitle">Technical sheets, certificates, declarations and related files</p>
</div>
<button type="button" class="btn btn-primary btn-sm" id="btnUploadDocument">
<i class="bx bx-upload"></i>
Upload Document
</button>
</div>
<?php if (count($documents) > 0): ?>
<div class="table-responsive">
<table id="documentsTable" class="table table-striped table-hover align-middle detail-table" style="width:100%">
<thead>
<tr>
<th>Document</th>
<th>Type</th>
<th>Expiry</th>
<th>Uploaded</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
<?php foreach ($documents as $document): ?>
<tr>
<td>
<div class="item-title"><?= e($document['title']); ?></div>
<div class="item-sub"><?= e($document['original_filename'] ?: $document['filename']); ?></div>
</td>
<td>
<span class="badge-soft-primary">
<?= e($documentTypes[$document['document_type']] ?? $document['document_type']); ?>
</span>
</td>
<td>
<?= !empty($document['expiry_date']) ? e(date('d/m/Y', strtotime($document['expiry_date']))) : '-'; ?>
</td>
<td>
<div><?= !empty($document['created_at']) ? e(date('d/m/Y H:i', strtotime($document['created_at']))) : '-'; ?></div>
<div class="item-sub"><?= e($document['uploaded_by_email'] ?: '-'); ?></div>
</td>
<td class="text-end">
<a href="<?= e($documentWebPath . $document['filename']); ?>"
target="_blank"
class="btn-action"
title="Open document">
<i class="bx bx-show"></i>
</a>
<button type="button"
class="btn-action danger btnDeleteDocument"
data-id="<?= e($document['iddocument']); ?>"
title="Delete document">
<i class="bx bx-trash"></i>
</button>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php else: ?>
<div class="empty-state">
<i class="bx bx-folder"></i>
<h6>No documents uploaded yet</h6>
<p>Upload technical sheets, certificates, declarations or product documentation.</p>
</div>
<?php endif; ?>
</div>
<!-- History -->
<div class="tab-pane fade" id="historyPane" role="tabpanel">
<h6 class="section-title mb-0">Status History</h6>
<p class="section-subtitle mb-4">Sample lifecycle changes</p>
<?php if (count($history) > 0): ?>
<div class="timeline">
<?php foreach ($history as $item): ?>
<?php
$userName = trim(($item['first_name'] ?? '') . ' ' . ($item['last_name'] ?? ''));
if ($userName === '') {
$userName = $item['email'] ?: '-';
}
?>
<div class="timeline-item">
<div class="timeline-title">
<?= e(ucfirst(str_replace('_', ' ', $item['new_status']))); ?>
</div>
<div class="timeline-sub">
<?= !empty($item['created_at']) ? e(date('d/m/Y H:i', strtotime($item['created_at']))) : '-'; ?>
|
<?= e($userName); ?>
</div>
<?php if (!empty($item['old_status'])): ?>
<div class="mt-1">
<span class="badge-soft-muted">
<?= e(ucfirst(str_replace('_', ' ', $item['old_status']))); ?>
</span>
<span class="badge-soft-primary">
<?= e(ucfirst(str_replace('_', ' ', $item['new_status']))); ?>
</span>
</div>
<?php endif; ?>
<?php if (!empty($item['note'])): ?>
<div class="timeline-sub mt-1"><?= e($item['note']); ?></div>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<?php else: ?>
<div class="empty-state">
<i class="bx bx-history"></i>
<h6>No history yet</h6>
<p>Status changes will appear here.</p>
</div>
<?php endif; ?>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="overlay toggle-icon"></div>
<a href="javaScript:;" class="back-to-top">
<i class='bx bxs-up-arrow-alt'></i>
</a>
<?php include('include/footer.php'); ?>
</div>
<!-- Part Modal -->
<div class="modal fade" id="partModal" tabindex="-1" aria-labelledby="partModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-centered">
<form id="partForm" class="modal-content">
<input type="hidden" name="action" value="save_part">
<input type="hidden" name="idpart" id="idpart" value="0">
<div class="modal-header">
<div>
<h5 class="modal-title" id="partModalLabel">Add BOM Part</h5>
<small class="text-muted">Create or update a component/part of the sample.</small>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="row g-3">
<div class="col-12 col-md-4">
<label class="form-label">Parent Part</label>
<select class="form-select" name="parent_idpart" id="parent_idpart">
<option value="">No parent</option>
<?php foreach ($parts as $partOption): ?>
<option value="<?= e($partOption['idpart']); ?>">
<?= e($partOption['part_name']); ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="col-12 col-md-4">
<label class="form-label">Part Code</label>
<input type="text" class="form-control" name="part_code" id="part_code">
</div>
<div class="col-12 col-md-4">
<label class="form-label">Sort Order</label>
<input type="number" class="form-control" name="sort_order" id="sort_order" value="0">
</div>
<div class="col-12">
<label class="form-label">Part Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" name="part_name" id="part_name" required>
</div>
<div class="col-12">
<label class="form-label">Part Description</label>
<textarea class="form-control" name="part_description" id="part_description" rows="2"></textarea>
</div>
<div class="col-12 col-md-4">
<label class="form-label">Material</label>
<input type="text" class="form-control" name="material" id="material">
</div>
<div class="col-12 col-md-4">
<label class="form-label">Color</label>
<input type="text" class="form-control" name="color" id="color">
</div>
<div class="col-12 col-md-2">
<label class="form-label">Quantity</label>
<input type="number" step="0.0001" class="form-control" name="quantity" id="quantity">
</div>
<div class="col-12 col-md-2">
<label class="form-label">Unit</label>
<input type="text" class="form-control" name="unit" id="unit" placeholder="pcs, m, kg">
</div>
<div class="col-12 col-md-4">
<label class="form-label">Producer</label>
<select class="form-select" name="producer_id" id="producer_id">
<option value="">No producer</option>
<?php foreach ($partners as $partner): ?>
<option value="<?= e($partner['idpartner']); ?>">
<?= e($partner['partner_name']); ?> - <?= e($partner['partner_type']); ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="col-12 col-md-4">
<label class="form-label">Supplier</label>
<select class="form-select" name="supplier_id" id="supplier_id">
<option value="">No supplier</option>
<?php foreach ($partners as $partner): ?>
<option value="<?= e($partner['idpartner']); ?>">
<?= e($partner['partner_name']); ?> - <?= e($partner['partner_type']); ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="col-12 col-md-4">
<label class="form-label">Risk Level</label>
<select class="form-select" name="risk_level" id="risk_level">
<?php foreach ($partRiskLevels as $value => $label): ?>
<option value="<?= e($value); ?>"><?= e($label); ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-12 col-md-4">
<label class="form-label">Position</label>
<input type="text" class="form-control" name="position" id="position">
</div>
<div class="col-12 col-md-8">
<label class="form-label">Notes</label>
<input type="text" class="form-control" name="notes" id="notes">
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary" id="btnSavePart">
<i class="bx bx-save"></i>
Save Part
</button>
</div>
</form>
</div>
</div>
<!-- Photo Modal -->
<div class="modal fade" id="photoModal" tabindex="-1" aria-labelledby="photoModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered">
<form id="photoForm" class="modal-content" enctype="multipart/form-data">
<input type="hidden" name="action" value="upload_sample_photo">
<div class="modal-header">
<div>
<h5 class="modal-title" id="photoModalLabel">Upload Photo</h5>
<small class="text-muted">Upload product, label, packaging or detail image.</small>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="row g-3">
<div class="col-12">
<label class="form-label">Photo File <span class="text-danger">*</span></label>
<input type="file" class="form-control" name="photo_file" id="photo_file" accept=".jpg,.jpeg,.png,.webp" required>
</div>
<div class="col-12 col-md-6">
<label class="form-label">Photo Type</label>
<select class="form-select" name="photo_type" id="photo_type">
<?php foreach ($photoTypes as $value => $label): ?>
<option value="<?= e($value); ?>"><?= e($label); ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-12 col-md-6">
<label class="form-label">Main Photo</label>
<select class="form-select" name="is_main" id="is_main">
<option value="0">No</option>
<option value="1">Yes</option>
</select>
</div>
<div class="col-12">
<label class="form-label">Description</label>
<input type="text" class="form-control" name="description" id="photo_description">
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary" id="btnSavePhoto">
<i class="bx bx-upload"></i>
Upload Photo
</button>
</div>
</form>
</div>
</div>
<!-- Document Modal -->
<div class="modal fade" id="documentModal" tabindex="-1" aria-labelledby="documentModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered">
<form id="documentForm" class="modal-content" enctype="multipart/form-data">
<input type="hidden" name="action" value="upload_document">
<div class="modal-header">
<div>
<h5 class="modal-title" id="documentModalLabel">Upload Document</h5>
<small class="text-muted">Upload a technical sheet, certificate, declaration or other file.</small>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="row g-3">
<div class="col-12">
<label class="form-label">Document File <span class="text-danger">*</span></label>
<input type="file" class="form-control" name="document_file" id="document_file" required>
</div>
<div class="col-12 col-md-6">
<label class="form-label">Document Type</label>
<select class="form-select" name="document_type" id="document_type">
<?php foreach ($documentTypes as $value => $label): ?>
<option value="<?= e($value); ?>"><?= e($label); ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-12 col-md-6">
<label class="form-label">Expiry Date</label>
<input type="date" class="form-control" name="expiry_date" id="expiry_date">
</div>
<div class="col-12">
<label class="form-label">Title</label>
<input type="text" class="form-control" name="title" id="document_title" placeholder="Auto from filename if empty">
</div>
<div class="col-12">
<label class="form-label">Notes</label>
<input type="text" class="form-control" name="notes" id="document_notes">
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary" id="btnSaveDocument">
<i class="bx bx-upload"></i>
Upload Document
</button>
</div>
</form>
</div>
</div>
<?php include('jsinclude.php'); ?>
<script>
$(document).ready(function() {
if ($.fn.DataTable) {
$('#partsTable').DataTable({
pageLength: 25,
order: [
[0, 'asc']
],
responsive: true,
language: {
search: "",
searchPlaceholder: "Search BOM parts..."
}
});
$('#documentsTable').DataTable({
pageLength: 25,
order: [
[3, 'desc']
],
responsive: true,
language: {
search: "",
searchPlaceholder: "Search documents..."
}
});
}
const partModal = new bootstrap.Modal(document.getElementById('partModal'));
const photoModal = new bootstrap.Modal(document.getElementById('photoModal'));
const documentModal = new bootstrap.Modal(document.getElementById('documentModal'));
function showAlert(type, message) {
if (typeof Swal !== 'undefined') {
Swal.fire({
icon: type,
title: type === 'success' ? 'Done' : 'Attention',
text: message,
confirmButtonColor: '#2563eb'
});
return;
}
alert(message);
}
function reloadAfterSuccess(message) {
if (typeof Swal !== 'undefined') {
Swal.fire({
icon: 'success',
title: 'Done',
text: message,
confirmButtonColor: '#2563eb'
}).then(function() {
window.location.reload();
});
return;
}
alert(message);
window.location.reload();
}
function resetPartForm() {
$('#partForm')[0].reset();
$('#idpart').val('0');
$('#risk_level').val('unknown');
$('#sort_order').val('0');
$('#partModalLabel').text('Add BOM Part');
}
$('#btnAddPart').on('click', function() {
resetPartForm();
partModal.show();
});
$('.btnEditPart').on('click', function() {
const idpart = $(this).data('id');
$.ajax({
url: 'sample-detail.php?idsample=<?= e($idsample); ?>',
type: 'POST',
dataType: 'json',
data: {
action: 'get_part',
idpart: idpart
},
success: function(response) {
if (!response.success) {
showAlert('error', response.message || 'Unable to load part.');
return;
}
const part = response.part;
$('#partModalLabel').text('Edit BOM Part');
$('#idpart').val(part.idpart);
$('#parent_idpart').val(part.parent_idpart);
$('#part_code').val(part.part_code);
$('#part_name').val(part.part_name);
$('#part_description').val(part.part_description);
$('#material').val(part.material);
$('#color').val(part.color);
$('#quantity').val(part.quantity);
$('#unit').val(part.unit);
$('#supplier_id').val(part.supplier_id);
$('#producer_id').val(part.producer_id);
$('#position').val(part.position);
$('#risk_level').val(part.risk_level);
$('#notes').val(part.notes);
$('#sort_order').val(part.sort_order);
partModal.show();
},
error: function() {
showAlert('error', 'Server error while loading part.');
}
});
});
$('#partForm').on('submit', function(e) {
e.preventDefault();
$('#btnSavePart').prop('disabled', true).html('<i class="bx bx-loader-alt bx-spin"></i> Saving...');
$.ajax({
url: 'sample-detail.php?idsample=<?= e($idsample); ?>',
type: 'POST',
dataType: 'json',
data: $(this).serialize(),
success: function(response) {
$('#btnSavePart').prop('disabled', false).html('<i class="bx bx-save"></i> Save Part');
if (!response.success) {
showAlert('error', response.message || 'Unable to save part.');
return;
}
partModal.hide();
reloadAfterSuccess(response.message || 'BOM part saved successfully.');
},
error: function() {
$('#btnSavePart').prop('disabled', false).html('<i class="bx bx-save"></i> Save Part');
showAlert('error', 'Server error while saving part.');
}
});
});
$('.btnDeletePart').on('click', function() {
const idpart = $(this).data('id');
const doDelete = function() {
$.ajax({
url: 'sample-detail.php?idsample=<?= e($idsample); ?>',
type: 'POST',
dataType: 'json',
data: {
action: 'delete_part',
idpart: idpart
},
success: function(response) {
if (!response.success) {
showAlert('error', response.message || 'Unable to delete part.');
return;
}
reloadAfterSuccess(response.message || 'BOM part deleted successfully.');
},
error: function() {
showAlert('error', 'Server error while deleting part.');
}
});
};
if (typeof Swal !== 'undefined') {
Swal.fire({
icon: 'warning',
title: 'Delete BOM part?',
text: 'The part will be deleted only if it has no child parts, photos or documents.',
showCancelButton: true,
confirmButtonColor: '#dc2626',
cancelButtonColor: '#64748b',
confirmButtonText: 'Yes, delete'
}).then(function(result) {
if (result.isConfirmed) {
doDelete();
}
});
return;
}
if (confirm('Delete BOM part?')) {
doDelete();
}
});
$('#btnUploadPhoto').on('click', function() {
$('#photoForm')[0].reset();
photoModal.show();
});
$('#photoForm').on('submit', function(e) {
e.preventDefault();
const formData = new FormData(this);
$('#btnSavePhoto').prop('disabled', true).html('<i class="bx bx-loader-alt bx-spin"></i> Uploading...');
$.ajax({
url: 'sample-detail.php?idsample=<?= e($idsample); ?>',
type: 'POST',
dataType: 'json',
data: formData,
processData: false,
contentType: false,
success: function(response) {
$('#btnSavePhoto').prop('disabled', false).html('<i class="bx bx-upload"></i> Upload Photo');
if (!response.success) {
showAlert('error', response.message || 'Unable to upload photo.');
return;
}
photoModal.hide();
reloadAfterSuccess(response.message || 'Photo uploaded successfully.');
},
error: function() {
$('#btnSavePhoto').prop('disabled', false).html('<i class="bx bx-upload"></i> Upload Photo');
showAlert('error', 'Server error while uploading photo.');
}
});
});
$('.btnDeletePhoto').on('click', function() {
const idsamplephoto = $(this).data('id');
const doDelete = function() {
$.ajax({
url: 'sample-detail.php?idsample=<?= e($idsample); ?>',
type: 'POST',
dataType: 'json',
data: {
action: 'delete_sample_photo',
idsamplephoto: idsamplephoto
},
success: function(response) {
if (!response.success) {
showAlert('error', response.message || 'Unable to delete photo.');
return;
}
reloadAfterSuccess(response.message || 'Photo deleted successfully.');
},
error: function() {
showAlert('error', 'Server error while deleting photo.');
}
});
};
if (typeof Swal !== 'undefined') {
Swal.fire({
icon: 'warning',
title: 'Delete photo?',
text: 'The photo file will also be removed from the server.',
showCancelButton: true,
confirmButtonColor: '#dc2626',
cancelButtonColor: '#64748b',
confirmButtonText: 'Yes, delete'
}).then(function(result) {
if (result.isConfirmed) {
doDelete();
}
});
return;
}
if (confirm('Delete photo?')) {
doDelete();
}
});
$('#btnUploadDocument').on('click', function() {
$('#documentForm')[0].reset();
documentModal.show();
});
$('#documentForm').on('submit', function(e) {
e.preventDefault();
const formData = new FormData(this);
$('#btnSaveDocument').prop('disabled', true).html('<i class="bx bx-loader-alt bx-spin"></i> Uploading...');
$.ajax({
url: 'sample-detail.php?idsample=<?= e($idsample); ?>',
type: 'POST',
dataType: 'json',
data: formData,
processData: false,
contentType: false,
success: function(response) {
$('#btnSaveDocument').prop('disabled', false).html('<i class="bx bx-upload"></i> Upload Document');
if (!response.success) {
showAlert('error', response.message || 'Unable to upload document.');
return;
}
documentModal.hide();
reloadAfterSuccess(response.message || 'Document uploaded successfully.');
},
error: function() {
$('#btnSaveDocument').prop('disabled', false).html('<i class="bx bx-upload"></i> Upload Document');
showAlert('error', 'Server error while uploading document.');
}
});
});
$('.btnDeleteDocument').on('click', function() {
const iddocument = $(this).data('id');
const doDelete = function() {
$.ajax({
url: 'sample-detail.php?idsample=<?= e($idsample); ?>',
type: 'POST',
dataType: 'json',
data: {
action: 'delete_document',
iddocument: iddocument
},
success: function(response) {
if (!response.success) {
showAlert('error', response.message || 'Unable to delete document.');
return;
}
reloadAfterSuccess(response.message || 'Document deleted successfully.');
},
error: function() {
showAlert('error', 'Server error while deleting document.');
}
});
};
if (typeof Swal !== 'undefined') {
Swal.fire({
icon: 'warning',
title: 'Delete document?',
text: 'The document file will also be removed from the server.',
showCancelButton: true,
confirmButtonColor: '#dc2626',
cancelButtonColor: '#64748b',
confirmButtonText: 'Yes, delete'
}).then(function(result) {
if (result.isConfirmed) {
doDelete();
}
});
return;
}
if (confirm('Delete document?')) {
doDelete();
}
});
});
</script>
</body>
</html>