Files
admin/app/Http/Controllers/CustomFieldController.php
Husanjonazamov e0f1989655 classify admin
2026-02-24 12:52:01 +05:00

1639 lines
70 KiB
PHP

<?php
namespace App\Http\Controllers;
use App\Models\Category;
use App\Models\CustomField;
use App\Models\CustomFieldCategory;
use App\Models\CustomFieldsTranslation;
use App\Services\BootstrapTableService;
use App\Services\CachingService;
use App\Services\FileService;
use App\Services\HelperService;
use App\Services\ResponseService;
use Illuminate\Database\QueryException;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator;
use PhpOffice\PhpSpreadsheet\Reader\Csv;
use PhpOffice\PhpSpreadsheet\Reader\Exception as ReaderException;
use PhpOffice\PhpSpreadsheet\Reader\Xls;
use PhpOffice\PhpSpreadsheet\Reader\Xlsx;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use Throwable;
class CustomFieldController extends Controller
{
private string $uploadFolder;
public function __construct()
{
$this->uploadFolder = 'custom-fields';
}
public function index()
{
ResponseService::noAnyPermissionThenRedirect(['custom-field-list', 'custom-field-create', 'custom-field-update', 'custom-field-delete']);
$categories = Category::get();
return view('custom-fields.index', compact('categories'));
}
public function create(Request $request)
{
$languages = CachingService::getLanguages()->values();
ResponseService::noPermissionThenRedirect('custom-field-create');
$cat_id = $request->id ?? 0;
$categories = Category::without('translations')
->get()
->each->setAppends([]);
$categories = HelperService::buildNestedChildSubcategoryObject($categories);
$selected_categories = [];
$selected_all_categories = [];
return view(
'custom-fields.create',
compact('categories', 'cat_id', 'languages', 'selected_categories', 'selected_all_categories')
);
}
public function store(Request $request)
{
ResponseService::noPermissionThenSendJson('custom-field-create');
$languages = CachingService::getLanguages();
$defaultLangId = 1;
$otherLanguages = $languages->where('id', '!=', $defaultLangId);
$baseRules = [
"name.$defaultLangId" => 'required|string|max:255',
'type' => 'required|in:number,textbox,fileinput,radio,dropdown,checkbox',
'image' => 'required|file|mimes:jpg,jpeg,png,svg',
'required' => 'required|in:0,1',
'status' => 'required|in:0,1',
'selected_categories' => 'required|array|min:1',
];
if (in_array($request->type, ['radio', 'dropdown', 'checkbox'])) {
$baseRules["values.$defaultLangId"] = 'required|array|min:1';
}
if (in_array($request->type, ['number', 'textbox'])) {
$baseRules['min_length'] = 'nullable|integer|min:0';
$baseRules['max_length'] = 'nullable|integer|min:0|gt:min_length';
}
foreach ($otherLanguages as $lang) {
$langId = $lang->id;
$baseRules["name.$langId"] = 'nullable|string|max:255';
if (in_array($request->type, ['radio', 'dropdown', 'checkbox'])) {
$defaultValues = $request->input("values.$defaultLangId", []);
$baseRules["values.$langId"] = 'nullable|array|size:'.count($defaultValues);
}
}
// $messages = [];
// foreach ($otherLanguages as $lang) {
// $langId = $lang->id;
// $langName = $lang->name;
// $messages["name.$langId.required"] = "Please enter the field name for $langName.";
// if (in_array($request->type, ['radio', 'dropdown', 'checkbox'])) {
// $messages["values.$langId.required"] = "Please enter values for $langName.";
// $messages["values.$langId.size"] = "Number of values for $langName must match the English values.";
// }
// }
$validator = Validator::make($request->all(), $baseRules);
if ($validator->fails()) {
return ResponseService::validationError($validator->errors()->first());
}
try {
DB::beginTransaction();
$customFieldData = [
'name' => $request->input("name.$defaultLangId"),
'type' => $request->type,
'min_length' => $request->min_length ?? null,
'max_length' => $request->max_length ?? null,
'required' => $request->required,
'status' => $request->status,
'image' => $request->hasFile('image')
? FileService::compressAndUpload($request->file('image'), $this->uploadFolder)
: null,
];
if (in_array($request->type, ['radio', 'dropdown', 'checkbox'])) {
$customFieldData['values'] = json_encode($request->input("values.$defaultLangId"), JSON_THROW_ON_ERROR);
}
$customField = CustomField::create($customFieldData);
$categoryMappings = collect($request->selected_categories)->map(function ($categoryId) use ($customField) {
return [
'category_id' => $categoryId,
'custom_field_id' => $customField->id,
];
})->toArray();
CustomFieldCategory::upsert($categoryMappings, ['custom_field_id', 'category_id']);
foreach ($otherLanguages as $lang) {
$langId = $lang->id;
$translatedName = $request->input("name.$langId");
$translatedValues = $request->input("values.$langId", null);
CustomFieldsTranslation::create([
'custom_field_id' => $customField->id,
'language_id' => $langId,
'name' => $translatedName,
'value' => $translatedValues ? json_encode($translatedValues, JSON_THROW_ON_ERROR) : null,
]);
}
DB::commit();
return ResponseService::successResponse('Custom Field Added Successfully');
} catch (\Throwable $th) {
DB::rollBack();
ResponseService::logErrorResponse($th);
return ResponseService::errorResponse('Something went wrong while saving the custom field.');
}
}
public function show(Request $request)
{
try {
ResponseService::noPermissionThenSendJson('custom-field-list');
$offset = $request->input('offset', 0);
$limit = $request->input('limit', 15);
$sort = $request->input('sort', 'id');
$order = $request->input('order', 'DESC');
$sql = CustomField::orderBy($sort, $order);
$sql->with(['categories:id,name,parent_category_id']);
if (! empty($request->filter)) {
// Fix escaped JSON if middleware or frontend sent &quot; instead of "
$filterString = html_entity_decode($request->filter, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
try {
$filterData = json_decode($filterString, false, 512, JSON_THROW_ON_ERROR);
$sql = $sql->filter($filterData);
} catch (\JsonException $e) {
return response()->json(['error' => 'Invalid JSON format in filter parameter'], 400);
}
}
if (! empty($request->search)) {
$sql = $sql->search($request->search);
}
$total = $sql->count();
$result = $sql->skip($offset)->take($limit)->get();
$result->each(function ($field) {
$field->categories->each(function ($cat) {
$cat->full_path = $cat->full_path; // accessor already generates it
});
});
$bulkData = [];
$bulkData['total'] = $total;
$rows = [];
foreach ($result as $row) {
$operate = '';
if (Auth::user()->can('custom-field-update')) {
$operate .= BootstrapTableService::editButton(route('custom-fields.edit', $row->id));
}
if (Auth::user()->can('custom-field-delete')) {
$operate .= BootstrapTableService::deleteButton(route('custom-fields.destroy', $row->id));
}
$tempRow = $row->toArray();
$tempRow['operate'] = $operate;
$tempRow['category_names'] = array_column($row->categories->toArray(), 'full_path');
$rows[] = $tempRow;
}
$bulkData['rows'] = $rows;
return response()->json($bulkData);
} catch (Throwable $e) {
ResponseService::logErrorResponse($e, 'CustomFieldController -> show');
ResponseService::errorResponse('Something Went Wrong');
}
}
public function edit($id)
{
ResponseService::noPermissionThenRedirect('custom-field-update');
$custom_field = CustomField::with('custom_field_category', 'translations')->findOrFail($id);
$translations = [];
// Add English (default) name and values
$translations[1] = [
'name' => $custom_field->name,
'value' => $custom_field->values,
];
// Add other language translations
foreach ($custom_field->translations as $translation) {
$translations[$translation->language_id] = [
'name' => $translation->name,
'value' => $translation->value,
];
}
$selected_categories = $custom_field->custom_field_category->pluck('category_id')->toArray();
$selected_all_categories = $selected_categories;
foreach ($selected_categories as $catId) {
$categoryId = $catId;
while ($categoryId) {
$parent = Category::without('translations')->where('id', $categoryId)->value('parent_category_id');
if ($parent) {
$selected_all_categories[] = $parent;
$categoryId = $parent;
} else {
$categoryId = null;
}
}
}
$selected_all_categories = array_unique($selected_all_categories);
$categories = Category::without('translations')
->get()
->each->setAppends([]);
$categories = HelperService::buildNestedChildSubcategoryObject($categories);
// Get all languages including English
$languages = CachingService::getLanguages()->values();
return view('custom-fields.edit', compact('custom_field', 'categories', 'selected_categories', 'selected_all_categories', 'languages', 'translations'));
}
public function update(Request $request, $id)
{
ResponseService::noPermissionThenSendJson('custom-field-update');
$languages = CachingService::getLanguages();
$defaultLangId = 1;
$otherLanguages = $languages->where('id', '!=', $defaultLangId);
$optionTypes = ['radio', 'dropdown', 'checkbox'];
$lengthTypes = ['number', 'textbox'];
$isOptionType = in_array($request->type, $optionTypes);
$isLengthType = in_array($request->type, $lengthTypes);
$rules = [
"name.$defaultLangId" => 'required|string|max:255',
'type' => 'required|in:number,textbox,fileinput,radio,dropdown,checkbox',
'image' => 'nullable|file|mimes:jpg,jpeg,png,svg',
'required' => 'required|in:0,1',
'status' => 'required|in:0,1',
'selected_categories' => 'required|array|min:1',
];
if ($isOptionType) {
$rules["values.$defaultLangId"] = 'required|array|min:1';
}
if ($isLengthType) {
$rules['min_length'] = 'nullable|integer|min:0';
$rules['max_length'] = 'nullable|integer|min:0|gt:min_length';
}
foreach ($otherLanguages as $lang) {
$rules["values.$lang->id"] = 'nullable|array';
$rules["values.$lang->id.*"] = 'nullable|string|max:255';
}
$normalizedValues = [];
foreach ($request->input('values', []) as $langId => $json) {
if (empty($json)) {
$normalizedValues[$langId] = [];
continue;
}
$decoded = json_decode($json, true);
if (! is_array($decoded)) {
$normalizedValues[$langId] = [];
continue;
}
$normalizedValues[$langId] = collect($decoded)
->pluck('value')
->filter()
->values()
->all();
}
$request->merge(['values' => $normalizedValues]);
if ($isOptionType) {
$baseCount = count($request->input("values.$defaultLangId", []));
if ($baseCount === 0) {
return ResponseService::validationError(
'Please enter at least one value for default language.'
);
}
foreach ($otherLanguages as $language) {
$langId = $language->id;
$langName = $language->name;
$values = $request->values[$langId] ?? [];
$name = $request->name[$langId] ?? null;
if (! empty($values) && count($values) !== $baseCount) {
return ResponseService::validationError(
"Number of values for {$langName} must be exactly {$baseCount}."
);
}
if (! empty($values) && $name === null) {
return ResponseService::validationError(
"Please enter field name for {$langName}."
);
}
if (empty($values) && $name !== null) {
return ResponseService::validationError(
"Please enter values for {$langName}."
);
}
}
}
$validator = Validator::make($request->all(), $rules);
if ($validator->fails()) {
return ResponseService::validationError($validator->errors()->first());
}
try {
DB::beginTransaction();
$customField = CustomField::with('custom_field_category')->findOrFail($id);
$updateData = [
'name' => $request->name[$defaultLangId],
'type' => $request->type,
'required' => $request->required,
'status' => $request->status,
'min_length' => $isLengthType ? $request->min_length : null,
'max_length' => $isLengthType ? $request->max_length : null,
'values' => $isOptionType
? json_encode($request->values[$defaultLangId], JSON_THROW_ON_ERROR)
: null,
];
if ($request->hasFile('image')) {
$updateData['image'] = FileService::compressAndReplace(
$request->file('image'),
$this->uploadFolder,
$customField->getRawOriginal('image')
);
}
$customField->update($updateData);
$oldCategories = $customField->custom_field_category->pluck('category_id')->toArray();
$newCategories = $request->selected_categories;
foreach (array_diff($oldCategories, $newCategories) as $categoryId) {
$customField->custom_field_category
->firstWhere('category_id', $categoryId)
?->delete();
}
$insertData = [];
foreach (array_diff($newCategories, $oldCategories) as $categoryId) {
$insertData[] = [
'category_id' => $categoryId,
'custom_field_id' => $customField->id,
'created_at' => now(),
'updated_at' => now(),
];
}
if (! empty($insertData)) {
CustomFieldCategory::insert($insertData);
}
foreach ($otherLanguages as $language) {
CustomFieldsTranslation::updateOrCreate(
[
'custom_field_id' => $customField->id,
'language_id' => $language->id,
],
[
'name' => $request->name[$language->id] ?? null,
'value' => $isOptionType
? json_encode($request->values[$language->id] ?? [], JSON_THROW_ON_ERROR)
: null,
]
);
}
DB::commit();
ResponseService::successResponse('Custom Fields Updated Successfully');
} catch (Throwable $th) {
DB::rollBack();
ResponseService::logErrorResponse($th, 'CustomField Controller -> update');
ResponseService::errorResponse('Something Went Wrong');
}
}
public function destroy($id)
{
try {
ResponseService::noPermissionThenSendJson('custom-field-delete');
CustomField::find($id)->delete();
ResponseService::successResponse('Custom Field delete successfully');
} catch (QueryException $th) {
ResponseService::logErrorResponse($th, 'Custom Field Controller -> destroy');
ResponseService::errorResponse('Cannot delete custom field! Remove associated subcategories first');
} catch (Throwable $th) {
ResponseService::logErrorResponse($th, 'Custom Field Controller -> destroy');
ResponseService::errorResponse('Something Went Wrong');
}
}
public function getCustomFieldValues(Request $request, $id)
{
ResponseService::noPermissionThenSendJson('custom-field-update');
$values = CustomField::findOrFail($id)->values;
if (! empty($request->search)) {
$matchingElements = [];
foreach ($values as $element) {
// Convert the element to a string for easy searching
$stringElement = (string) $element;
// Check if the search term is present in the element
if (str_contains($stringElement, $request->search)) {
// If found, add it to the matching elements array
$matchingElements[] = $element;
}
}
$values = $matchingElements;
}
$bulkData = [];
$bulkData['total'] = count($values);
$rows = [];
foreach ($values as $key => $row) {
$tempRow['id'] = $key;
$tempRow['value'] = $row;
// $tempRow['operate'] = BootstrapTableService::editButton(route('custom-fields.value.update', $id), true);
$tempRow['operate'] = BootstrapTableService::button('fa fa-edit', route('custom-fields.value.update', $id), ['edit_btn'], ['title' => 'Edit', 'data-bs-target' => '#editModal', 'data-bs-toggle' => 'modal']);
$tempRow['operate'] .= BootstrapTableService::deleteButton(route('custom-fields.value.delete', [$id, $row]), true);
$rows[] = $tempRow;
}
$bulkData['rows'] = $rows;
return response()->json($bulkData);
}
public function addCustomFieldValue(Request $request, $id)
{
ResponseService::noPermissionThenSendJson('custom-field-create');
$validator = Validator::make($request->all(), [
'values' => 'required',
]);
if ($validator->fails()) {
ResponseService::errorResponse($validator->errors()->first());
}
try {
$customField = CustomField::findOrFail($id);
$newValues = explode(',', $request->values);
$values = [
...$customField->values,
...$newValues,
];
$customField->values = json_encode($values, JSON_THROW_ON_ERROR);
$customField->save();
ResponseService::successResponse('Custom Field Value added Successfully');
} catch (Throwable) {
ResponseService::errorResponse('Something Went Wrong ');
}
}
public function updateCustomFieldValue(Request $request, $id)
{
ResponseService::noPermissionThenSendJson('custom-field-update');
$validator = Validator::make($request->all(), [
'old_custom_field_value' => 'required',
'new_custom_field_value' => 'required',
]);
if ($validator->fails()) {
ResponseService::errorResponse($validator->errors()->first());
}
try {
$customField = CustomField::findOrFail($id);
$values = $customField->values;
if (is_array($values)) {
$values[array_search($request->old_custom_field_value, $values, true)] = $request->new_custom_field_value;
} else {
$values = $request->new_custom_field_value;
}
$customField->values = $values;
$customField->save();
ResponseService::successResponse('Custom Field Value Updated Successfully');
} catch (Throwable) {
ResponseService::errorResponse('Something Went Wrong ');
}
}
public function deleteCustomFieldValue($id, $deletedValue)
{
try {
ResponseService::noPermissionThenSendJson('custom-field-delete');
$customField = CustomField::findOrFail($id);
$values = $customField->values;
unset($values[array_search($deletedValue, $values, true)]);
$customField->values = json_encode($values, JSON_THROW_ON_ERROR);
$customField->save();
ResponseService::successResponse('Custom Field Value Deleted Successfully');
} catch (Throwable $th) {
ResponseService::logErrorResponse($th);
ResponseService::errorResponse('Something Went Wrong');
}
}
public function bulkUpload()
{
ResponseService::noPermissionThenRedirect('custom-field-create');
return view('custom-fields.bulk-upload');
}
public function downloadExample()
{
ResponseService::noPermissionThenRedirect('custom-field-create');
try {
$filename = 'custom-fields-bulk-upload-example.csv';
// Clear any previous output
if (ob_get_level()) {
ob_end_clean();
}
// Set headers for CSV download
header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment;filename="'.$filename.'"');
header('Cache-Control: max-age=0');
header('Pragma: public');
// Open output stream
$output = fopen('php://output', 'w');
// Add BOM for UTF-8 Excel compatibility (helps Excel recognize UTF-8)
fprintf($output, chr(0xEF).chr(0xBB).chr(0xBF));
// Headers
$headers = ['Type', 'Name', 'Image', 'Required', 'Status', 'Min Length', 'Max Length', 'Values', 'Categories'];
fputcsv($output, $headers);
// Example rows
$examples = [
['number', 'Price', 'custom-fields/example.jpg', '1', '1', '0', '1000000', '', '1,2'],
['textbox', 'Description', 'custom-fields/example.jpg', '1', '1', '10', '500', '', '1,2'],
['fileinput', 'Document Upload', 'custom-fields/example.jpg', '0', '1', '', '', '', '1,2'],
['radio', 'Condition', 'custom-fields/example.jpg', '1', '1', '', '', 'New|Used|Refurbished', '1,2'],
['dropdown', 'Size', 'custom-fields/example.jpg', '1', '1', '', '', 'Small|Medium|Large|XLarge', '1,2'],
['checkbox', 'Features', 'custom-fields/example.jpg', '0', '1', '', '', 'WiFi|Bluetooth|GPS', '1,2'],
];
foreach ($examples as $example) {
fputcsv($output, $example);
}
fclose($output);
exit;
} catch (\Throwable $th) {
ResponseService::logErrorResponse($th, 'CustomFieldController -> downloadExample');
return ResponseService::errorResponse('Error generating CSV file: '.$th->getMessage());
}
}
public function processBulkUpload(Request $request)
{
ResponseService::noPermissionThenSendJson('custom-field-create');
// Validation - Accept CSV and Excel files
$validator = Validator::make($request->all(), [
'excel_file' => [
'required',
'file',
'max:10240',
function ($attribute, $value, $fail) {
$extension = strtolower($value->getClientOriginalExtension());
$mimeType = $value->getMimeType();
$allowedExtensions = ['xlsx', 'xls', 'csv'];
$allowedMimeTypes = [
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', // .xlsx
'application/vnd.ms-excel', // .xls
'text/csv',
'text/plain',
'application/csv',
];
if (! in_array($extension, $allowedExtensions) && ! in_array($mimeType, $allowedMimeTypes)) {
$fail('The '.$attribute.' must be a CSV or Excel file (CSV, XLSX or XLS format).');
}
},
],
]);
if ($validator->fails()) {
return ResponseService::validationError($validator->errors()->first());
}
try {
if (! class_exists(Spreadsheet::class)) {
return ResponseService::errorResponse('PhpSpreadsheet library is not installed. Please run: composer require phpoffice/phpspreadsheet');
}
$file = $request->file('excel_file');
$extension = strtolower($file->getClientOriginalExtension());
$filePath = $file->getRealPath();
// Validate file exists and is readable
if (! file_exists($filePath) || ! is_readable($filePath)) {
return ResponseService::errorResponse('File is not readable. Please try uploading again.');
}
$rows = [];
try {
// Explicitly set the reader based on file extension
if ($extension === 'csv') {
$reader = new Csv;
// Set CSV options
$reader->setInputEncoding('UTF-8');
$reader->setDelimiter(',');
$reader->setEnclosure('"');
$reader->setSheetIndex(0);
} elseif ($extension === 'xlsx') {
$reader = new Xlsx;
} elseif ($extension === 'xls') {
$reader = new Xls;
} else {
return ResponseService::errorResponse('Unsupported file format. Only CSV, XLSX and XLS files are supported.');
}
// Set reader options
$reader->setReadDataOnly(false);
$reader->setReadEmptyCells(true);
// Load the spreadsheet
$spreadsheet = $reader->load($filePath);
$worksheet = $spreadsheet->getActiveSheet();
// Get all rows as array
$rows = $worksheet->toArray(null, true, true, true);
// Convert associative array to indexed array
$rows = array_values($rows);
// Clean up
$spreadsheet->disconnectWorksheets();
unset($spreadsheet);
} catch (ReaderException $ex) {
return ResponseService::errorResponse('Error reading file: '.$ex->getMessage().'. Please ensure the file is a valid CSV or Excel file.');
} catch (\Throwable $ex) {
return ResponseService::errorResponse('Error processing file: '.$ex->getMessage());
}
// Remove header row - better detection
$headerRemoved = false;
if (count($rows) > 0) {
$firstRow = $rows[0];
// Convert associative array to indexed if needed
if (isset($firstRow['A'])) {
$firstRow = [
$firstRow['A'] ?? '',
$firstRow['B'] ?? '',
$firstRow['C'] ?? '',
$firstRow['D'] ?? '',
$firstRow['E'] ?? '',
$firstRow['F'] ?? '',
$firstRow['G'] ?? '',
$firstRow['H'] ?? '',
$firstRow['I'] ?? '',
];
}
// Check if first row looks like headers
// If first cell is "Type" (case-insensitive), it's likely a header
$firstCell = isset($firstRow[0]) ? strtolower(trim((string) $firstRow[0])) : '';
if ($firstCell === 'type') {
array_shift($rows);
$headerRemoved = true;
}
}
if (count($rows) < 1) {
return ResponseService::errorResponse('File must contain at least one data row (excluding header)');
}
$languages = CachingService::getLanguages();
$defaultLangId = 1;
$otherLanguages = $languages->where('id', '!=', $defaultLangId);
$successCount = 0;
$errorCount = 0;
$errors = [];
DB::beginTransaction();
foreach ($rows as $rowIndex => $row) {
// Calculate row number: +1 for 1-based, +1 if header was removed
$rowNumber = $rowIndex + 1 + ($headerRemoved ? 1 : 0);
try {
// Convert associative array to indexed if needed
if (isset($row['A'])) {
$row = [
$row['A'] ?? '',
$row['B'] ?? '',
$row['C'] ?? '',
$row['D'] ?? '',
$row['E'] ?? '',
$row['F'] ?? '',
$row['G'] ?? '',
$row['H'] ?? '',
$row['I'] ?? '',
];
}
// Ensure row is an array and has at least 9 elements
if (! is_array($row)) {
$row = [];
}
while (count($row) < 9) {
$row[] = '';
}
// Skip completely empty rows
if (empty(array_filter($row, function ($val) {
return $val !== null && trim((string) $val) !== '';
}))) {
continue;
}
$type = trim((string) ($row[0] ?? ''));
$name = trim((string) ($row[1] ?? ''));
$image = trim((string) ($row[2] ?? ''));
$required = trim((string) ($row[3] ?? '0'));
$status = trim((string) ($row[4] ?? '1'));
// Handle min_length - allow 0 as valid value (empty() treats 0 as empty, so we check explicitly)
$minLengthRaw = isset($row[5]) ? trim((string) $row[5]) : '';
$minLength = $minLengthRaw !== '' ? $minLengthRaw : null;
// Handle max_length - allow 0 as valid value
$maxLengthRaw = isset($row[6]) ? trim((string) $row[6]) : '';
$maxLength = $maxLengthRaw !== '' ? $maxLengthRaw : null;
$values = trim((string) ($row[7] ?? ''));
$categories = trim((string) ($row[8] ?? ''));
// Skip header rows that might have been missed
// If type is exactly "Type" (case-insensitive), it's a header row
$firstCellLower = strtolower($type);
if ($firstCellLower === 'type') {
continue; // Skip this row, it's a header
}
// Validation - Check required fields first with specific error messages
$missingFields = [];
if (empty($type)) {
$missingFields[] = 'Type';
}
if (empty($name)) {
$missingFields[] = 'Name';
}
if (empty($image)) {
$missingFields[] = 'Image';
}
if (empty($categories)) {
$missingFields[] = 'Categories';
}
if (! empty($missingFields)) {
$errors[] = "Row $rowNumber: Missing required field(s): ".implode(', ', $missingFields);
$errorCount++;
continue;
}
// Validate type only if it's not empty
$validTypes = ['number', 'textbox', 'fileinput', 'radio', 'dropdown', 'checkbox'];
if (! in_array($type, $validTypes)) {
$errors[] = "Row $rowNumber: Invalid type '$type'. Must be one of: ".implode(', ', $validTypes);
$errorCount++;
continue;
}
// Validate required field
if (! in_array($required, ['0', '1'])) {
$errors[] = "Row $rowNumber: Invalid required value '$required'. Must be 0 (Optional) or 1 (Required)";
$errorCount++;
continue;
}
// Validate status field
if (! in_array($status, ['0', '1'])) {
$errors[] = "Row $rowNumber: Invalid status value '$status'. Must be 0 (Inactive) or 1 (Active)";
$errorCount++;
continue;
}
$valuesTypes = ['radio', 'dropdown', 'checkbox'];
if (in_array($type, $valuesTypes)) {
if (empty($values)) {
$errors[] = "Row $rowNumber: Values are required for type '$type'";
$errorCount++;
continue;
}
} else {
if (! empty($values)) {
$errors[] = "Row $rowNumber: Values should be empty for type '$type'";
$errorCount++;
continue;
}
}
if (in_array($type, ['number', 'textbox'])) {
if ($minLength !== null && $minLength !== '' && (! is_numeric($minLength) || $minLength < 0)) {
$errors[] = "Row $rowNumber: Min length must be a non-negative number";
$errorCount++;
continue;
}
if ($maxLength !== null && $maxLength !== '' && (! is_numeric($maxLength) || $maxLength < 0)) {
$errors[] = "Row $rowNumber: Max length must be a non-negative number";
$errorCount++;
continue;
}
if ($minLength !== null && $minLength !== '' && $maxLength !== null && $maxLength !== '' && $maxLength <= $minLength) {
$errors[] = "Row $rowNumber: Max length must be greater than min length";
$errorCount++;
continue;
}
} else {
if (($minLength !== null && $minLength !== '') || ($maxLength !== null && $maxLength !== '')) {
$errors[] = "Row $rowNumber: Min/Max length should be empty for type '$type'";
$errorCount++;
continue;
}
}
$categoryIds = array_filter(array_map('trim', explode(',', $categories)));
if (empty($categoryIds)) {
$errors[] = "Row $rowNumber: At least one category ID is required";
$errorCount++;
continue;
}
foreach ($categoryIds as $catId) {
if (! is_numeric($catId) || ! Category::where('id', $catId)->exists()) {
$errors[] = "Row $rowNumber: Invalid category ID: $catId";
$errorCount++;
continue 2; // Continue outer loop
}
}
// Validate image path exists
if (! Storage::disk(config('filesystems.default'))->exists($image)) {
$errors[] = "Row $rowNumber: Image path does not exist: $image";
$errorCount++;
continue;
}
// Create custom field
$customFieldData = [
'name' => $name,
'type' => $type,
'image' => $image,
'required' => $required,
'status' => $status,
'min_length' => ($minLength !== null && $minLength !== '') ? (int)$minLength : (($minLength === '0' || $minLength === 0) ? 0 : null),
'max_length' => ($maxLength !== null && $maxLength !== '') ? (int)$maxLength : (($maxLength === '0' || $maxLength === 0) ? 0 : null),
];
if (in_array($type, $valuesTypes) && ! empty($values)) {
$valuesArray = array_filter(array_map('trim', explode('|', $values)));
$customFieldData['values'] = json_encode($valuesArray, JSON_THROW_ON_ERROR);
}
$customField = CustomField::create($customFieldData);
// Create category mappings
$categoryMappings = collect($categoryIds)->map(function ($categoryId) use ($customField) {
return [
'category_id' => $categoryId,
'custom_field_id' => $customField->id,
];
})->toArray();
CustomFieldCategory::upsert($categoryMappings, ['custom_field_id', 'category_id']);
foreach ($otherLanguages as $lang) {
CustomFieldsTranslation::create([
'custom_field_id' => $customField->id,
'language_id' => $lang->id,
'name' => $name,
'value' => in_array($type, $valuesTypes) && ! empty($values)
? json_encode(array_filter(array_map('trim', explode('|', $values))), JSON_THROW_ON_ERROR)
: null,
]);
}
$successCount++;
} catch (\Throwable $th) {
$errors[] = "Row $rowNumber: ".$th->getMessage();
$errorCount++;
ResponseService::logErrorResponse($th, "CustomFieldController -> processBulkUpload Row $rowNumber");
}
}
if ($errorCount > 0 && $successCount === 0) {
DB::rollBack();
$errorMessage = 'All rows failed to process. ';
if (count($errors) <= 10) {
$errorMessage .= 'Errors: '.implode('; ', $errors);
} else {
$errorMessage .= 'First 10 errors: '.implode('; ', array_slice($errors, 0, 10)).' (and '.(count($errors) - 10).' more)';
}
return ResponseService::errorResponse($errorMessage);
}
DB::commit();
// Build appropriate success/error message
if ($successCount > 0 && $errorCount > 0) {
$message = 'Bulk upload partially completed. ';
$message .= "$successCount row(s) processed successfully. ";
$message .= "$errorCount row(s) failed. ";
if (count($errors) <= 5) {
$message .= 'Errors: '.implode('; ', $errors);
} else {
$message .= 'First 5 errors: '.implode('; ', array_slice($errors, 0, 5)).' (and '.(count($errors) - 5).' more)';
}
return ResponseService::successResponse($message);
} elseif ($successCount > 0) {
$message = "Bulk upload completed successfully. $successCount custom field(s) created.";
return ResponseService::successResponse($message);
} else {
return ResponseService::errorResponse('No rows were processed. Please check your file format.');
}
} catch (\Throwable $th) {
DB::rollBack();
ResponseService::logErrorResponse($th, 'CustomFieldController -> processBulkUpload');
return ResponseService::errorResponse('Error processing file: '.$th->getMessage());
}
}
public function uploadGalleryImage(Request $request)
{
ResponseService::noPermissionThenSendJson('custom-field-create');
$validator = Validator::make($request->all(), [
'images.*' => 'required|file|mimes:jpg,jpeg,png,svg|max:5120',
]);
if ($validator->fails()) {
return ResponseService::validationError($validator->errors()->first());
}
try {
$uploadedImages = [];
$files = $request->file('images');
foreach ($files as $file) {
$path = FileService::compressAndUpload($file, $this->uploadFolder);
$uploadedImages[] = [
'path' => $path,
'url' => url(Storage::url($path)),
];
}
return response()->json([
'status' => 'success',
'message' => count($uploadedImages).' image(s) uploaded successfully',
'images' => $uploadedImages,
]);
} catch (\Throwable $th) {
ResponseService::logErrorResponse($th, 'CustomFieldController -> uploadGalleryImage');
return ResponseService::errorResponse('Error uploading images: '.$th->getMessage());
}
}
public function getGalleryImages()
{
ResponseService::noPermissionThenSendJson('custom-field-list');
try {
$files = Storage::disk(config('filesystems.default'))->files($this->uploadFolder);
$images = [];
foreach ($files as $file) {
$extension = strtolower(pathinfo($file, PATHINFO_EXTENSION));
if (in_array($extension, ['jpg', 'jpeg', 'png', 'svg', 'gif'])) {
$images[] = [
'path' => $file,
'url' => url(Storage::url($file)),
];
}
}
// Sort by modification time, newest first
usort($images, function ($a, $b) {
$timeA = Storage::disk(config('filesystems.default'))->lastModified($a['path']);
$timeB = Storage::disk(config('filesystems.default'))->lastModified($b['path']);
return $timeB <=> $timeA;
});
return response()->json([
'status' => 'success',
'images' => $images,
]);
} catch (\Throwable $th) {
ResponseService::logErrorResponse($th, 'CustomFieldController -> getGalleryImages');
return ResponseService::errorResponse('Error loading images: '.$th->getMessage());
}
}
public function downloadInstructionsPdf()
{
ResponseService::noPermissionThenRedirect('custom-field-create');
$pdfPath = public_path('custom-fields-bulk-upload-instructions.pdf');
if (! file_exists($pdfPath)) {
return redirect()->back()->with('error', 'Instructions PDF not found.');
}
return response()->download($pdfPath, 'custom-fields-bulk-upload-instructions.pdf');
}
public function bulkUpdate()
{
ResponseService::noPermissionThenRedirect('custom-field-update');
return view('custom-fields.bulk-update');
}
public function downloadCurrentCustomFields()
{
ResponseService::noPermissionThenRedirect('custom-field-update');
try {
$filename = 'custom-fields-export-'.date('Y-m-d-His').'.csv';
// Clear any previous output
if (ob_get_level()) {
ob_end_clean();
}
// Set headers for CSV download
header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment;filename="'.$filename.'"');
header('Cache-Control: max-age=0');
header('Pragma: public');
// Open output stream
$output = fopen('php://output', 'w');
// Add BOM for UTF-8 Excel compatibility
fprintf($output, chr(0xEF).chr(0xBB).chr(0xBF));
// Headers - Added ID column for updates
$headers = ['ID', 'Type', 'Name', 'Image', 'Required', 'Status', 'Min Length', 'Max Length', 'Values', 'Categories'];
fputcsv($output, $headers);
// Process in chunks to handle large datasets efficiently
$chunkSize = 100; // Process 100 records at a time
CustomField::with('custom_field_category')
->chunk($chunkSize, function ($customFields) use ($output) {
foreach ($customFields as $field) {
$values = '';
if ($field->values && is_array($field->values)) {
$values = implode('|', $field->values);
}
$categoryIds = $field->custom_field_category->pluck('category_id')->toArray();
$categories = implode(',', $categoryIds);
// Get raw image path (not URL)
$imagePath = $field->getRawOriginal('image') ?? '';
$row = [
$field->id,
$field->type,
$field->name,
$imagePath,
$field->required,
$field->status,
$field->min_length ?? '',
$field->max_length ?? '',
$values,
$categories,
];
fputcsv($output, $row);
}
});
fclose($output);
exit;
} catch (\Throwable $th) {
ResponseService::logErrorResponse($th, 'CustomFieldController -> downloadCurrentCustomFields');
return ResponseService::errorResponse('Error generating CSV file: '.$th->getMessage());
}
}
public function processBulkUpdate(Request $request)
{
ResponseService::noPermissionThenSendJson('custom-field-update');
// Validation - Accept CSV and Excel files
$validator = Validator::make($request->all(), [
'excel_file' => [
'required',
'file',
'max:10240',
function ($attribute, $value, $fail) {
$extension = strtolower($value->getClientOriginalExtension());
$mimeType = $value->getMimeType();
$allowedExtensions = ['xlsx', 'xls', 'csv'];
$allowedMimeTypes = [
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', // .xlsx
'application/vnd.ms-excel', // .xls
'text/csv',
'text/plain',
'application/csv',
];
if (! in_array($extension, $allowedExtensions) && ! in_array($mimeType, $allowedMimeTypes)) {
$fail('The '.$attribute.' must be a CSV or Excel file (CSV, XLSX or XLS format).');
}
},
],
]);
if ($validator->fails()) {
return ResponseService::validationError($validator->errors()->first());
}
try {
if (! class_exists(Spreadsheet::class)) {
return ResponseService::errorResponse('PhpSpreadsheet library is not installed. Please run: composer require phpoffice/phpspreadsheet');
}
$file = $request->file('excel_file');
$extension = strtolower($file->getClientOriginalExtension());
$filePath = $file->getRealPath();
// Validate file exists and is readable
if (! file_exists($filePath) || ! is_readable($filePath)) {
return ResponseService::errorResponse('File is not readable. Please try uploading again.');
}
$rows = [];
try {
// Explicitly set the reader based on file extension
if ($extension === 'csv') {
$reader = new Csv;
$reader->setInputEncoding('UTF-8');
$reader->setDelimiter(',');
$reader->setEnclosure('"');
$reader->setSheetIndex(0);
} elseif ($extension === 'xlsx') {
$reader = new Xlsx;
} elseif ($extension === 'xls') {
$reader = new Xls;
} else {
return ResponseService::errorResponse('Unsupported file format. Only CSV, XLSX and XLS files are supported.');
}
$reader->setReadDataOnly(false);
$reader->setReadEmptyCells(true);
$spreadsheet = $reader->load($filePath);
$worksheet = $spreadsheet->getActiveSheet();
$rows = $worksheet->toArray(null, true, true, true);
$rows = array_values($rows);
$spreadsheet->disconnectWorksheets();
unset($spreadsheet);
} catch (ReaderException $ex) {
return ResponseService::errorResponse('Error reading file: '.$ex->getMessage().'. Please ensure the file is a valid CSV or Excel file.');
} catch (\Throwable $ex) {
return ResponseService::errorResponse('Error processing file: '.$ex->getMessage());
}
// Remove header row
$headerRemoved = false;
if (count($rows) > 0) {
$firstRow = $rows[0];
if (isset($firstRow['A'])) {
$firstRow = [
$firstRow['A'] ?? '',
$firstRow['B'] ?? '',
$firstRow['C'] ?? '',
$firstRow['D'] ?? '',
$firstRow['E'] ?? '',
$firstRow['F'] ?? '',
$firstRow['G'] ?? '',
$firstRow['H'] ?? '',
$firstRow['I'] ?? '',
$firstRow['J'] ?? '',
];
}
$firstCell = isset($firstRow[0]) ? strtolower(trim((string) $firstRow[0])) : '';
if ($firstCell === 'id') {
array_shift($rows);
$headerRemoved = true;
}
}
if (count($rows) < 1) {
return ResponseService::errorResponse('File must contain at least one data row (excluding header)');
}
$successCount = 0;
$errorCount = 0;
$errors = [];
$maxErrorsToReturn = 100; // Limit errors to prevent memory issues
$batchSize = 50; // Process 50 rows per transaction batch
// Process in batches to avoid long-running transactions
$batches = array_chunk($rows, $batchSize, true);
foreach ($batches as $batchIndex => $batch) {
DB::beginTransaction();
try {
foreach ($batch as $rowIndex => $row) {
$rowNumber = $rowIndex + 1 + ($headerRemoved ? 1 : 0);
try {
// Convert associative array to indexed if needed
if (isset($row['A'])) {
$row = [
$row['A'] ?? '',
$row['B'] ?? '',
$row['C'] ?? '',
$row['D'] ?? '',
$row['E'] ?? '',
$row['F'] ?? '',
$row['G'] ?? '',
$row['H'] ?? '',
$row['I'] ?? '',
$row['J'] ?? '',
];
}
// Ensure row has at least 10 elements
if (! is_array($row)) {
$row = [];
}
while (count($row) < 10) {
$row[] = '';
}
// Skip completely empty rows
if (empty(array_filter($row, function ($val) {
return $val !== null && trim((string) $val) !== '';
}))) {
continue;
}
$id = trim((string) ($row[0] ?? ''));
$type = trim((string) ($row[1] ?? ''));
$name = trim((string) ($row[2] ?? ''));
$image = trim((string) ($row[3] ?? ''));
$required = trim((string) ($row[4] ?? '0'));
$status = trim((string) ($row[5] ?? '1'));
// Handle min_length - allow 0 as valid value (empty() treats 0 as empty, so we check explicitly)
$minLengthRaw = isset($row[6]) ? trim((string) $row[6]) : '';
$minLength = $minLengthRaw !== '' ? $minLengthRaw : null;
// Handle max_length - allow 0 as valid value
$maxLengthRaw = isset($row[7]) ? trim((string) $row[7]) : '';
$maxLength = $maxLengthRaw !== '' ? $maxLengthRaw : null;
$values = trim((string) ($row[8] ?? ''));
$categories = trim((string) ($row[9] ?? ''));
// Skip header rows
$firstCellLower = strtolower($id);
if ($firstCellLower === 'id') {
continue;
}
// Validation
if (empty($id)) {
if (count($errors) < $maxErrorsToReturn) {
$errors[] = ['row' => $rowNumber, 'message' => 'ID is required for updates'];
}
$errorCount++;
continue;
}
if (! is_numeric($id)) {
if (count($errors) < $maxErrorsToReturn) {
$errors[] = ['row' => $rowNumber, 'message' => 'Invalid ID format'];
}
$errorCount++;
continue;
}
$customField = CustomField::find($id);
if (! $customField) {
if (count($errors) < $maxErrorsToReturn) {
$errors[] = ['row' => $rowNumber, 'message' => "Custom field with ID $id not found"];
}
$errorCount++;
continue;
}
$missingFields = [];
if (empty($type)) {
$missingFields[] = 'Type';
}
if (empty($name)) {
$missingFields[] = 'Name';
}
if (empty($categories)) {
$missingFields[] = 'Categories';
}
if (! empty($missingFields)) {
if (count($errors) < $maxErrorsToReturn) {
$errors[] = ['row' => $rowNumber, 'message' => 'Missing required field(s): '.implode(', ', $missingFields)];
}
$errorCount++;
continue;
}
$validTypes = ['number', 'textbox', 'fileinput', 'radio', 'dropdown', 'checkbox'];
if (! in_array($type, $validTypes)) {
if (count($errors) < $maxErrorsToReturn) {
$errors[] = ['row' => $rowNumber, 'message' => "Invalid type '$type'. Must be one of: ".implode(', ', $validTypes)];
}
$errorCount++;
continue;
}
if (! in_array($required, ['0', '1'])) {
if (count($errors) < $maxErrorsToReturn) {
$errors[] = ['row' => $rowNumber, 'message' => "Invalid required value '$required'. Must be 0 (Optional) or 1 (Required)"];
}
$errorCount++;
continue;
}
if (! in_array($status, ['0', '1'])) {
if (count($errors) < $maxErrorsToReturn) {
$errors[] = ['row' => $rowNumber, 'message' => "Invalid status value '$status'. Must be 0 (Inactive) or 1 (Active)"];
}
$errorCount++;
continue;
}
$valuesTypes = ['radio', 'dropdown', 'checkbox'];
if (in_array($type, $valuesTypes)) {
if (empty($values)) {
if (count($errors) < $maxErrorsToReturn) {
$errors[] = ['row' => $rowNumber, 'message' => "Values are required for type '$type'"];
}
$errorCount++;
continue;
}
}
if (in_array($type, ['number', 'textbox'])) {
if ($minLength !== null && $minLength !== '' && (! is_numeric($minLength) || $minLength < 0)) {
if (count($errors) < $maxErrorsToReturn) {
$errors[] = ['row' => $rowNumber, 'message' => 'Min length must be a non-negative number'];
}
$errorCount++;
continue;
}
if ($maxLength !== null && $maxLength !== '' && (! is_numeric($maxLength) || $maxLength < 0)) {
if (count($errors) < $maxErrorsToReturn) {
$errors[] = ['row' => $rowNumber, 'message' => 'Max length must be a non-negative number'];
}
$errorCount++;
continue;
}
if ($minLength !== null && $minLength !== '' && $maxLength !== null && $maxLength !== '' && $maxLength <= $minLength) {
if (count($errors) < $maxErrorsToReturn) {
$errors[] = ['row' => $rowNumber, 'message' => 'Max length must be greater than min length'];
}
$errorCount++;
continue;
}
}
$categoryIds = array_filter(array_map('trim', explode(',', $categories)));
if (empty($categoryIds)) {
if (count($errors) < $maxErrorsToReturn) {
$errors[] = ['row' => $rowNumber, 'message' => 'At least one category ID is required'];
}
$errorCount++;
continue;
}
foreach ($categoryIds as $catId) {
if (! is_numeric($catId) || ! Category::where('id', $catId)->exists()) {
if (count($errors) < $maxErrorsToReturn) {
$errors[] = ['row' => $rowNumber, 'message' => "Invalid category ID: $catId"];
}
$errorCount++;
continue 2;
}
}
// Validate image path exists (if provided and not empty)
if (! empty($image) && ! Storage::disk(config('filesystems.default'))->exists($image)) {
if (count($errors) < $maxErrorsToReturn) {
$errors[] = ['row' => $rowNumber, 'message' => "Image path does not exist: $image"];
}
$errorCount++;
continue;
}
// Update custom field
$updateData = [
'name' => $name,
'type' => $type,
'required' => $required,
'status' => $status,
'min_length' => in_array($type, ['number', 'textbox']) ? (($minLength !== null && $minLength !== '') ? (int)$minLength : (($minLength === '0' || $minLength === 0) ? 0 : null)) : null,
'max_length' => in_array($type, ['number', 'textbox']) ? (($maxLength !== null && $maxLength !== '') ? (int)$maxLength : (($maxLength === '0' || $maxLength === 0) ? 0 : null)) : null,
];
// Update image only if provided
if (! empty($image)) {
$updateData['image'] = $image;
}
if (in_array($type, $valuesTypes) && ! empty($values)) {
$valuesArray = array_filter(array_map('trim', explode('|', $values)));
$updateData['values'] = json_encode($valuesArray, JSON_THROW_ON_ERROR);
} else {
$updateData['values'] = null;
}
$customField->update($updateData);
// Update category mappings
$oldCategoryIds = $customField->custom_field_category->pluck('category_id')->toArray();
// Delete removed categories
foreach (array_diff($oldCategoryIds, $categoryIds) as $categoryId) {
$customField->custom_field_category->first(function ($data) use ($categoryId) {
return $data->category_id == $categoryId;
})->delete();
}
// Add new categories
$newSelectedCategory = [];
foreach (array_diff($categoryIds, $oldCategoryIds) as $categoryId) {
$newSelectedCategory[] = [
'category_id' => $categoryId,
'custom_field_id' => $customField->id,
'created_at' => time(),
'updated_at' => time(),
];
}
if (count($newSelectedCategory) > 0) {
CustomFieldCategory::insert($newSelectedCategory);
}
$successCount++;
} catch (\Throwable $th) {
if (count($errors) < $maxErrorsToReturn) {
$errors[] = [
'row' => $rowNumber,
'message' => $th->getMessage(),
];
}
$errorCount++;
ResponseService::logErrorResponse($th, "CustomFieldController -> processBulkUpdate Row $rowNumber");
}
}
// Commit batch transaction
DB::commit();
} catch (\Throwable $batchException) {
// Rollback batch on critical error
DB::rollBack();
ResponseService::logErrorResponse($batchException, "CustomFieldController -> processBulkUpdate Batch $batchIndex");
// Continue with next batch
}
}
// Prepare response data
$responseData = [
'success_count' => $successCount,
'error_count' => $errorCount,
'total_processed' => $successCount + $errorCount,
'errors' => $errors,
'has_more_errors' => $errorCount > count($errors),
];
if ($errorCount > 0 && $successCount === 0) {
$message = "All rows failed to process. $errorCount error(s) found.";
return ResponseService::errorResponse($message, $responseData);
}
// Build appropriate success/error message
if ($successCount > 0 && $errorCount > 0) {
$message = "Bulk update partially completed. $successCount row(s) updated successfully. $errorCount row(s) failed.";
return ResponseService::warningResponse($message, $responseData);
} elseif ($successCount > 0) {
$message = "Bulk update completed successfully. $successCount custom field(s) updated.";
return ResponseService::successResponse($message, $responseData);
} else {
return ResponseService::errorResponse('No rows were processed. Please check your file format.', $responseData);
}
} catch (\Throwable $th) {
DB::rollBack();
ResponseService::logErrorResponse($th, 'CustomFieldController -> processBulkUpdate');
return ResponseService::errorResponse('Error processing file: '.$th->getMessage());
}
}
}