2560 lines
100 KiB
PHP
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>
|