chore: update document approval workflow and bug fixes
This commit is contained in:
98
Modules/MasterData/app/Exports/BoqTemplateExport.php
Normal file
98
Modules/MasterData/app/Exports/BoqTemplateExport.php
Normal file
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\MasterData\Exports;
|
||||
|
||||
use Maatwebsite\Excel\Concerns\FromArray;
|
||||
use Maatwebsite\Excel\Concerns\WithStyles;
|
||||
use Maatwebsite\Excel\Concerns\WithColumnWidths;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Fill;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Alignment;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Border;
|
||||
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||
|
||||
class BoqTemplateExport implements FromArray, WithStyles, WithColumnWidths
|
||||
{
|
||||
public function array(): array
|
||||
{
|
||||
return [
|
||||
// Header section (A=label, B=value)
|
||||
['PROJECT NAME', ''],
|
||||
['OWNER NAME', ''],
|
||||
['LOCATION', ''],
|
||||
['DATE', ''],
|
||||
[],
|
||||
// Detail table header
|
||||
['BILL OF QUANTITIES'],
|
||||
['Item no.', 'Description of Materials', 'UNIT', 'APPROVED QUANTITY', 'REQUEST QUANTITY', 'SYSTEM /WORK AREA'],
|
||||
// Sample rows
|
||||
['1', '200mm thk aggregate sub-base course', 'cu.m', '2970.00', '505.00', 'Access Road'],
|
||||
['2', '300mm thk portland cement concrete pavement', 'cu.m', '4240.00', '721.00', 'Access Road'],
|
||||
['3', '6mm thk bituminous joint sealant', 'lm', '1460.00', '90', 'Access Road'],
|
||||
];
|
||||
}
|
||||
|
||||
public function columnWidths(): array
|
||||
{
|
||||
return [
|
||||
'A' => 12,
|
||||
'B' => 50,
|
||||
'C' => 12,
|
||||
'D' => 20,
|
||||
'E' => 20,
|
||||
'F' => 25,
|
||||
];
|
||||
}
|
||||
|
||||
public function styles(Worksheet $sheet): array
|
||||
{
|
||||
// Merge header label cells for the title section
|
||||
$sheet->mergeCells('B1:F1');
|
||||
$sheet->mergeCells('B2:F2');
|
||||
$sheet->mergeCells('B3:F3');
|
||||
$sheet->mergeCells('B4:F4');
|
||||
|
||||
// BOQ title row
|
||||
$sheet->mergeCells('A6:F6');
|
||||
$sheet->getStyle('A6')->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
|
||||
|
||||
return [
|
||||
// Header labels (A1:A4)
|
||||
'A1:A4' => [
|
||||
'font' => ['bold' => true, 'size' => 11],
|
||||
'fill' => [
|
||||
'fillType' => Fill::FILL_SOLID,
|
||||
'startColor' => ['argb' => 'FFD9E1F2'],
|
||||
],
|
||||
],
|
||||
// BOQ title row
|
||||
6 => [
|
||||
'font' => ['bold' => true, 'size' => 14, 'color' => ['argb' => 'FFFFFFFF']],
|
||||
'fill' => [
|
||||
'fillType' => Fill::FILL_SOLID,
|
||||
'startColor' => ['argb' => 'FF2F5496'],
|
||||
],
|
||||
],
|
||||
// Detail table header (row 7)
|
||||
7 => [
|
||||
'font' => ['bold' => true, 'size' => 10],
|
||||
'fill' => [
|
||||
'fillType' => Fill::FILL_SOLID,
|
||||
'startColor' => ['argb' => 'FFFFFF00'],
|
||||
],
|
||||
'borders' => [
|
||||
'allBorders' => ['borderStyle' => Border::BORDER_THIN],
|
||||
],
|
||||
],
|
||||
// Sample data rows
|
||||
'8:10' => [
|
||||
'fill' => [
|
||||
'fillType' => Fill::FILL_SOLID,
|
||||
'startColor' => ['argb' => 'FFFFFFCC'],
|
||||
],
|
||||
'borders' => [
|
||||
'allBorders' => ['borderStyle' => Border::BORDER_THIN],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
37
Modules/MasterData/app/Exports/MaterialTemplateExport.php
Normal file
37
Modules/MasterData/app/Exports/MaterialTemplateExport.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\MasterData\Exports;
|
||||
|
||||
use Maatwebsite\Excel\Concerns\FromArray;
|
||||
use Maatwebsite\Excel\Concerns\WithHeadings;
|
||||
use Maatwebsite\Excel\Concerns\WithStyles;
|
||||
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||
|
||||
class MaterialTemplateExport implements FromArray, WithHeadings, WithStyles
|
||||
{
|
||||
public function headings(): array
|
||||
{
|
||||
return ['Name', 'SKU', 'Category', 'Unit', 'Unit Cost', 'Description', 'Group'];
|
||||
}
|
||||
|
||||
public function array(): array
|
||||
{
|
||||
return [
|
||||
['Portland Cement Type I', 'CEM-001', 'cement', 'bag', '280.00', '50kg bag', 'Foundation Materials'],
|
||||
['Deformed Steel Bar 16mm', 'STL-016', 'steel', 'pcs', '450.00', '6m length', 'Structural Materials'],
|
||||
];
|
||||
}
|
||||
|
||||
public function styles(Worksheet $sheet): array
|
||||
{
|
||||
return [
|
||||
1 => [
|
||||
'font' => ['bold' => true],
|
||||
'fill' => [
|
||||
'fillType' => \PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID,
|
||||
'startColor' => ['argb' => 'FFE8E8E8'],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
0
Modules/MasterData/app/Http/Controllers/.gitkeep
Normal file
0
Modules/MasterData/app/Http/Controllers/.gitkeep
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\MasterData\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class MasterDataController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display a listing of the resource.
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
return view('masterdata::index');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for creating a new resource.
|
||||
*/
|
||||
public function create()
|
||||
{
|
||||
return view('masterdata::create');
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created resource in storage.
|
||||
*/
|
||||
public function store(Request $request) {}
|
||||
|
||||
/**
|
||||
* Show the specified resource.
|
||||
*/
|
||||
public function show($id)
|
||||
{
|
||||
return view('masterdata::show');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for editing the specified resource.
|
||||
*/
|
||||
public function edit($id)
|
||||
{
|
||||
return view('masterdata::edit');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified resource in storage.
|
||||
*/
|
||||
public function update(Request $request, $id) {}
|
||||
|
||||
/**
|
||||
* Remove the specified resource from storage.
|
||||
*/
|
||||
public function destroy($id) {}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\MasterData\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Modules\MasterData\Models\Material;
|
||||
use Modules\MasterData\Models\MaterialGroup;
|
||||
|
||||
class MaterialGroupController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$query = MaterialGroup::withCount('materials');
|
||||
|
||||
if ($search = $request->search) {
|
||||
$query->where('name', 'like', "%{$search}%");
|
||||
}
|
||||
|
||||
if ($status = $request->status) {
|
||||
$query->where('status', $status);
|
||||
}
|
||||
|
||||
$groups = $query->latest()->paginate(15)->withQueryString();
|
||||
|
||||
$allMaterials = Material::where('status', 'active')
|
||||
->select('id', 'ulid', 'name', 'sku', 'unit', 'unit_cost', 'category')
|
||||
->get();
|
||||
|
||||
return Inertia::render('MaterialLogistics::Groups/Index', [
|
||||
'groups' => $groups,
|
||||
'allMaterials' => $allMaterials,
|
||||
'filters' => $request->only(['search', 'status']),
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(MaterialGroup $materialGroup)
|
||||
{
|
||||
$materialGroup->load('materials:id,ulid,name,sku,unit,unit_cost,category');
|
||||
|
||||
return response()->json($materialGroup);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$group = MaterialGroup::create($validated);
|
||||
|
||||
if ($request->has('material_ids') && is_array($request->material_ids)) {
|
||||
$materialIds = Material::whereIn('ulid', $request->material_ids)->pluck('id');
|
||||
$group->materials()->attach($materialIds);
|
||||
}
|
||||
|
||||
return back()->with('success', "Material group \"{$group->name}\" created.");
|
||||
}
|
||||
|
||||
public function update(Request $request, MaterialGroup $materialGroup)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'status' => 'nullable|in:active,archived',
|
||||
]);
|
||||
|
||||
$materialGroup->update($validated);
|
||||
|
||||
return back()->with('success', 'Group updated.');
|
||||
}
|
||||
|
||||
public function destroy(MaterialGroup $materialGroup)
|
||||
{
|
||||
if ($materialGroup->projects()->exists()) {
|
||||
return back()->with('error', 'Cannot delete a group that is assigned to projects. Remove it from all projects first.');
|
||||
}
|
||||
|
||||
$name = $materialGroup->name;
|
||||
$materialGroup->delete();
|
||||
|
||||
return back()->with('success', "Group \"{$name}\" deleted.");
|
||||
}
|
||||
|
||||
public function addMaterial(Request $request, MaterialGroup $materialGroup)
|
||||
{
|
||||
$request->validate([
|
||||
'material_id' => 'required|string',
|
||||
]);
|
||||
|
||||
$material = Material::where('ulid', $request->material_id)->firstOrFail();
|
||||
|
||||
if ($materialGroup->materials()->where('material_id', $material->id)->exists()) {
|
||||
return back()->with('error', 'Material is already in this group.');
|
||||
}
|
||||
|
||||
$materialGroup->materials()->attach($material->id);
|
||||
|
||||
return back()->with('success', "{$material->name} added to group.");
|
||||
}
|
||||
|
||||
public function removeMaterial(MaterialGroup $materialGroup, Material $material)
|
||||
{
|
||||
$materialGroup->materials()->detach($material->id);
|
||||
|
||||
return back()->with('success', "{$material->name} removed from group.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\MasterData\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
use Modules\MasterData\Exports\BoqTemplateExport;
|
||||
use Modules\MasterData\Exports\MaterialTemplateExport;
|
||||
use Modules\MasterData\Imports\BoqImport;
|
||||
use Modules\MasterData\Imports\MaterialImport;
|
||||
use Modules\MasterData\Models\Material;
|
||||
use Modules\ProjectManagement\Models\Project;
|
||||
use Modules\ProjectManagement\Models\Task;
|
||||
|
||||
class MaterialImportController extends Controller
|
||||
{
|
||||
public function template()
|
||||
{
|
||||
return Excel::download(new MaterialTemplateExport(), 'material_import_template.xlsx');
|
||||
}
|
||||
|
||||
public function import(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'file' => 'required|file|mimes:xlsx,csv,xls|max:10240',
|
||||
]);
|
||||
|
||||
$import = new MaterialImport();
|
||||
|
||||
try {
|
||||
Excel::import($import, $request->file('file'));
|
||||
} catch (\Exception $e) {
|
||||
return back()->with('error', 'Import failed: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
$failures = $import->failures();
|
||||
$failureCount = $failures->count();
|
||||
|
||||
$message = "{$import->getImportedCount()} materials imported.";
|
||||
|
||||
if ($import->getSkippedCount() > 0) {
|
||||
$message .= " {$import->getSkippedCount()} skipped (duplicate SKU).";
|
||||
}
|
||||
|
||||
if (count($import->getGroupsCreated()) > 0) {
|
||||
$message .= " Groups: " . implode(', ', $import->getGroupsCreated()) . ".";
|
||||
}
|
||||
|
||||
if ($failureCount > 0) {
|
||||
$message .= " {$failureCount} rows had validation errors.";
|
||||
}
|
||||
|
||||
return back()->with('success', $message);
|
||||
}
|
||||
|
||||
// --- BOQ Import ---
|
||||
|
||||
public function boqTemplate()
|
||||
{
|
||||
return Excel::download(new BoqTemplateExport(), 'boq_template.xlsx');
|
||||
}
|
||||
|
||||
public function boqPreview(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'file' => 'required|file|mimes:xlsx,csv,xls|max:10240',
|
||||
]);
|
||||
|
||||
$import = new BoqImport();
|
||||
|
||||
try {
|
||||
Excel::import($import, $request->file('file'));
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Failed to parse file: ' . $e->getMessage(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
if (!empty($import->getErrors())) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => implode('; ', $import->getErrors()),
|
||||
], 422);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'header' => $import->getHeader(),
|
||||
'items' => $import->getItems(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function boqConfirm(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'project_ulid' => 'required|string',
|
||||
'task_ulid' => 'required|string',
|
||||
'header' => 'required|array',
|
||||
'header.project_name' => 'nullable|string',
|
||||
'header.owner_name' => 'nullable|string',
|
||||
'header.location' => 'nullable|string',
|
||||
'header.date' => 'nullable|string',
|
||||
'items' => 'required|array|min:1',
|
||||
'items.*.material' => 'required|string|max:255',
|
||||
'items.*.unit' => 'required|string|max:20',
|
||||
'items.*.approved_qty' => 'nullable|numeric|min:0',
|
||||
'items.*.request_qty' => 'nullable|numeric|min:0',
|
||||
'items.*.work_area' => 'nullable|string|max:255',
|
||||
]);
|
||||
|
||||
$project = Project::where('ulid', $validated['project_ulid'])->firstOrFail();
|
||||
$task = Task::where('ulid', $validated['task_ulid'])
|
||||
->where('project_id', $project->id)
|
||||
->firstOrFail();
|
||||
|
||||
$created = 0;
|
||||
|
||||
DB::transaction(function () use ($validated, $project, $task, &$created) {
|
||||
// Update project fields from header
|
||||
$updates = [];
|
||||
if (!empty($validated['header']['location'])) {
|
||||
$updates['location'] = $validated['header']['location'];
|
||||
}
|
||||
if (!empty($validated['header']['date'])) {
|
||||
try {
|
||||
$updates['start_date'] = Carbon::parse($validated['header']['date'])->format('Y-m-d');
|
||||
} catch (\Exception) {
|
||||
// Skip invalid date
|
||||
}
|
||||
}
|
||||
if (!empty($updates)) {
|
||||
$project->update($updates);
|
||||
}
|
||||
|
||||
// Create materials and assign to task
|
||||
foreach ($validated['items'] as $item) {
|
||||
$material = Material::create([
|
||||
'name' => $item['material'],
|
||||
'unit' => $item['unit'],
|
||||
'unit_cost' => 0,
|
||||
'category' => null,
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
$task->taskMaterials()->create([
|
||||
'material_id' => $material->id,
|
||||
'planned_qty' => $item['request_qty'] ?? 0,
|
||||
'actual_qty' => $item['approved_qty'] ?? 0,
|
||||
'unit_cost' => 0,
|
||||
'notes' => $item['work_area'] ?? null,
|
||||
]);
|
||||
|
||||
$created++;
|
||||
}
|
||||
|
||||
// Recalculate project capitalization
|
||||
$project->load('tasks.taskMaterials');
|
||||
$project->recalculateCapitalization();
|
||||
});
|
||||
|
||||
return back()->with('success', "{$created} materials imported and assigned to task \"{$task->name}\".");
|
||||
}
|
||||
}
|
||||
144
Modules/MasterData/app/Imports/BoqImport.php
Normal file
144
Modules/MasterData/app/Imports/BoqImport.php
Normal file
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\MasterData\Imports;
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
use Maatwebsite\Excel\Concerns\ToCollection;
|
||||
|
||||
class BoqImport implements ToCollection
|
||||
{
|
||||
private array $header = [];
|
||||
private array $items = [];
|
||||
private array $errors = [];
|
||||
|
||||
/**
|
||||
* Real file layout:
|
||||
* Rows 0-3: Header (A=label, B=value) → PROJECT NAME, OWNER NAME, LOCATION, DATE
|
||||
* Row 4: Empty
|
||||
* Row 5: "BILL OF QUANTITIES" title
|
||||
* Row 6: Column headers (Item no. | Description of Materials | UNIT | APPROVED QUANTITY | REQUEST QUANTITY | SYSTEM /WORK AREA)
|
||||
* Row 7+: Data rows
|
||||
*
|
||||
* Mapping:
|
||||
* B = material name (filter out "Description of Materials")
|
||||
* C = unit
|
||||
* D = APPROVED QUANTITY → actual_qty
|
||||
* E = REQUEST QUANTITY → planned_qty
|
||||
* F = SYSTEM /WORK AREA → notes
|
||||
*/
|
||||
public function collection(Collection $rows): void
|
||||
{
|
||||
// Parse header section (rows 0-3 → A=label, B=value)
|
||||
foreach ($rows->slice(0, 4) as $row) {
|
||||
$label = strtolower(trim($row[0] ?? ''));
|
||||
$value = trim($row[1] ?? '');
|
||||
|
||||
match (true) {
|
||||
str_contains($label, 'project') => $this->header['project_name'] = $value,
|
||||
str_contains($label, 'owner') => $this->header['owner_name'] = $value,
|
||||
str_contains($label, 'location') => $this->header['location'] = $value,
|
||||
str_contains($label, 'date') => $this->header['date'] = $value,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
// Find the detail table header row by scanning for "Description of Materials" in column B
|
||||
$dataStartIndex = null;
|
||||
foreach ($rows as $index => $row) {
|
||||
$colB = strtolower(trim($row[1] ?? ''));
|
||||
if (str_contains($colB, 'description') && str_contains($colB, 'material')) {
|
||||
$dataStartIndex = $index + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: look for "Item" in column A
|
||||
if ($dataStartIndex === null) {
|
||||
foreach ($rows as $index => $row) {
|
||||
$colA = strtolower(trim($row[0] ?? ''));
|
||||
if (str_contains($colA, 'item') && str_contains($colA, 'no')) {
|
||||
$dataStartIndex = $index + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($dataStartIndex === null) {
|
||||
$this->errors[] = 'Could not find the detail table header row. Expected "Description of Materials" in column B.';
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse detail rows
|
||||
$itemNumber = 0;
|
||||
foreach ($rows->slice($dataStartIndex) as $row) {
|
||||
$materialName = trim($row[1] ?? '');
|
||||
|
||||
// Skip empty rows and the header text itself
|
||||
if (empty($materialName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Filter out rows that contain the header text
|
||||
if (str_contains(strtolower($materialName), 'description of material')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$itemNumber++;
|
||||
$unit = strtolower(trim($row[2] ?? 'pcs'));
|
||||
$approvedQty = $this->parseNumber($row[3] ?? 0);
|
||||
$requestQty = $this->parseNumber($row[4] ?? 0);
|
||||
$workArea = trim($row[5] ?? '');
|
||||
|
||||
$errors = [];
|
||||
if ($requestQty <= 0 && $approvedQty <= 0) {
|
||||
$errors[] = 'No quantity specified';
|
||||
}
|
||||
|
||||
$this->items[] = [
|
||||
'item_no' => $row[0] ?? $itemNumber,
|
||||
'material' => $materialName,
|
||||
'unit' => $unit,
|
||||
'approved_qty' => $approvedQty,
|
||||
'request_qty' => $requestQty,
|
||||
'work_area' => $workArea,
|
||||
'errors' => $errors,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
private function parseNumber(mixed $value): float
|
||||
{
|
||||
if (is_numeric($value)) {
|
||||
return (float) $value;
|
||||
}
|
||||
// Handle comma-formatted numbers like "2,970.00"
|
||||
$cleaned = str_replace(',', '', (string) $value);
|
||||
return is_numeric($cleaned) ? (float) $cleaned : 0;
|
||||
}
|
||||
|
||||
public function getHeader(): array
|
||||
{
|
||||
return $this->header;
|
||||
}
|
||||
|
||||
public function getItems(): array
|
||||
{
|
||||
return $this->items;
|
||||
}
|
||||
|
||||
public function getErrors(): array
|
||||
{
|
||||
return $this->errors;
|
||||
}
|
||||
|
||||
public function hasErrors(): bool
|
||||
{
|
||||
if (!empty($this->errors)) return true;
|
||||
|
||||
foreach ($this->items as $item) {
|
||||
if (!empty($item['errors'])) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
99
Modules/MasterData/app/Imports/MaterialImport.php
Normal file
99
Modules/MasterData/app/Imports/MaterialImport.php
Normal file
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\MasterData\Imports;
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
use Maatwebsite\Excel\Concerns\ToCollection;
|
||||
use Maatwebsite\Excel\Concerns\WithHeadingRow;
|
||||
use Maatwebsite\Excel\Concerns\WithValidation;
|
||||
use Maatwebsite\Excel\Concerns\SkipsOnFailure;
|
||||
use Maatwebsite\Excel\Concerns\SkipsFailures;
|
||||
use Modules\MasterData\Models\Material;
|
||||
use Modules\MasterData\Models\MaterialGroup;
|
||||
|
||||
class MaterialImport implements ToCollection, WithHeadingRow, WithValidation, SkipsOnFailure
|
||||
{
|
||||
use SkipsFailures;
|
||||
|
||||
private int $importedCount = 0;
|
||||
private int $skippedCount = 0;
|
||||
private array $groupsCreated = [];
|
||||
|
||||
public function collection(Collection $rows): void
|
||||
{
|
||||
foreach ($rows as $row) {
|
||||
// Skip completely empty rows
|
||||
if (empty($row['name'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$sku = $row['sku'] ?? null;
|
||||
$name = $row['name'];
|
||||
|
||||
$matchAttributes = [];
|
||||
if (!empty($sku)) {
|
||||
$matchAttributes['sku'] = $sku;
|
||||
} else {
|
||||
$matchAttributes['name'] = $name;
|
||||
}
|
||||
|
||||
$material = Material::updateOrCreate(
|
||||
$matchAttributes,
|
||||
[
|
||||
'name' => $name,
|
||||
'sku' => $sku,
|
||||
'category' => $row['category'] ?? null,
|
||||
'unit' => $row['unit'] ?? 'pcs',
|
||||
'unit_cost' => $row['unit_cost'] ?? 0,
|
||||
'description' => $row['description'] ?? null,
|
||||
]
|
||||
);
|
||||
|
||||
// Handle group assignment
|
||||
$groupName = trim($row['group'] ?? '');
|
||||
if ($groupName) {
|
||||
$group = MaterialGroup::firstOrCreate(
|
||||
['name' => $groupName],
|
||||
['description' => "Auto-created from import"]
|
||||
);
|
||||
|
||||
$group->materials()->syncWithoutDetaching([$material->id]);
|
||||
|
||||
if (!in_array($groupName, $this->groupsCreated)) {
|
||||
$this->groupsCreated[] = $groupName;
|
||||
}
|
||||
}
|
||||
|
||||
$this->importedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => 'required|string|max:255',
|
||||
'sku' => 'nullable|string|max:50',
|
||||
'category' => 'nullable|string|max:50',
|
||||
'unit' => 'nullable|string|max:20',
|
||||
'unit_cost' => 'nullable|numeric|min:0',
|
||||
'description' => 'nullable|string',
|
||||
'group' => 'nullable|string|max:255',
|
||||
];
|
||||
}
|
||||
|
||||
public function getImportedCount(): int
|
||||
{
|
||||
return $this->importedCount;
|
||||
}
|
||||
|
||||
public function getSkippedCount(): int
|
||||
{
|
||||
return $this->skippedCount;
|
||||
}
|
||||
|
||||
public function getGroupsCreated(): array
|
||||
{
|
||||
return $this->groupsCreated;
|
||||
}
|
||||
}
|
||||
72
Modules/MasterData/app/Models/Material.php
Normal file
72
Modules/MasterData/app/Models/Material.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\MasterData\Models;
|
||||
|
||||
use App\Traits\BelongsToTenant;
|
||||
use App\Traits\HasPublicIdentifier;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
use Modules\MaterialLogistics\Models\WarehouseStock;
|
||||
use Modules\MaterialLogistics\Models\ProjectInventory;
|
||||
use Modules\MaterialLogistics\Models\MaterialRequirement;
|
||||
use Modules\MaterialLogistics\Models\MaterialDeployment;
|
||||
|
||||
class Material extends Model
|
||||
{
|
||||
use HasPublicIdentifier, BelongsToTenant;
|
||||
|
||||
protected $fillable = [
|
||||
'name', 'sku', 'category', 'unit',
|
||||
'unit_cost', 'description', 'status', 'type', 'contractor_id',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'unit_cost' => 'decimal:2',
|
||||
];
|
||||
}
|
||||
|
||||
public function components(): HasMany
|
||||
{
|
||||
return $this->hasMany(MaterialComponent::class, 'parent_id');
|
||||
}
|
||||
|
||||
public function parentKits(): HasMany
|
||||
{
|
||||
return $this->hasMany(MaterialComponent::class, 'component_id');
|
||||
}
|
||||
|
||||
public function warehouseStocks(): HasMany
|
||||
{
|
||||
return $this->hasMany(WarehouseStock::class);
|
||||
}
|
||||
|
||||
public function projectInventories(): HasMany
|
||||
{
|
||||
return $this->hasMany(ProjectInventory::class);
|
||||
}
|
||||
|
||||
public function inventories(): HasMany
|
||||
{
|
||||
return $this->hasMany(ProjectInventory::class);
|
||||
}
|
||||
|
||||
public function requirements(): HasMany
|
||||
{
|
||||
return $this->hasMany(MaterialRequirement::class);
|
||||
}
|
||||
|
||||
public function deployments(): HasMany
|
||||
{
|
||||
return $this->hasMany(MaterialDeployment::class);
|
||||
}
|
||||
|
||||
public function groups(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(MaterialGroup::class, 'material_group_items')
|
||||
->withTimestamps();
|
||||
}
|
||||
}
|
||||
27
Modules/MasterData/app/Models/MaterialComponent.php
Normal file
27
Modules/MasterData/app/Models/MaterialComponent.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\MasterData\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class MaterialComponent extends Model
|
||||
{
|
||||
protected $table = 'material_components';
|
||||
|
||||
protected $fillable = [
|
||||
'parent_id',
|
||||
'component_id',
|
||||
'quantity',
|
||||
];
|
||||
|
||||
public function parent(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Material::class, 'parent_id');
|
||||
}
|
||||
|
||||
public function component(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Material::class, 'component_id');
|
||||
}
|
||||
}
|
||||
32
Modules/MasterData/app/Models/MaterialGroup.php
Normal file
32
Modules/MasterData/app/Models/MaterialGroup.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\MasterData\Models;
|
||||
|
||||
use App\Traits\HasPublicIdentifier;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Modules\ProjectManagement\Models\Project;
|
||||
|
||||
class MaterialGroup extends Model
|
||||
{
|
||||
use HasPublicIdentifier;
|
||||
|
||||
protected $fillable = ['name', 'description', 'status'];
|
||||
|
||||
public function materials(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Material::class, 'material_group_items')
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
public function projects(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Project::class, 'project_material_groups')
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('status', 'active');
|
||||
}
|
||||
}
|
||||
23
Modules/MasterData/app/Models/MaterialGroupItem.php
Normal file
23
Modules/MasterData/app/Models/MaterialGroupItem.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\MasterData\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\Pivot;
|
||||
|
||||
class MaterialGroupItem extends Pivot
|
||||
{
|
||||
protected $table = 'material_group_items';
|
||||
|
||||
public $incrementing = true;
|
||||
|
||||
public function group(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(MaterialGroup::class, 'material_group_id');
|
||||
}
|
||||
|
||||
public function material(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Material::class);
|
||||
}
|
||||
}
|
||||
0
Modules/MasterData/app/Providers/.gitkeep
Normal file
0
Modules/MasterData/app/Providers/.gitkeep
Normal file
27
Modules/MasterData/app/Providers/EventServiceProvider.php
Normal file
27
Modules/MasterData/app/Providers/EventServiceProvider.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\MasterData\Providers;
|
||||
|
||||
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
|
||||
|
||||
class EventServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* The event handler mappings for the application.
|
||||
*
|
||||
* @var array<string, array<int, string>>
|
||||
*/
|
||||
protected $listen = [];
|
||||
|
||||
/**
|
||||
* Indicates if events should be discovered.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
protected static $shouldDiscoverEvents = true;
|
||||
|
||||
/**
|
||||
* Configure the proper event listeners for email verification.
|
||||
*/
|
||||
protected function configureEmailVerification(): void {}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\MasterData\Providers;
|
||||
|
||||
use Nwidart\Modules\Support\ModuleServiceProvider;
|
||||
use Illuminate\Console\Scheduling\Schedule;
|
||||
|
||||
class MasterDataServiceProvider extends ModuleServiceProvider
|
||||
{
|
||||
/**
|
||||
* The name of the module.
|
||||
*/
|
||||
protected string $name = 'MasterData';
|
||||
|
||||
/**
|
||||
* The lowercase version of the module name.
|
||||
*/
|
||||
protected string $nameLower = 'masterdata';
|
||||
|
||||
/**
|
||||
* Command classes to register.
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
// protected array $commands = [];
|
||||
|
||||
/**
|
||||
* Provider classes to register.
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
protected array $providers = [
|
||||
EventServiceProvider::class,
|
||||
RouteServiceProvider::class,
|
||||
];
|
||||
|
||||
/**
|
||||
* Define module schedules.
|
||||
*
|
||||
* @param $schedule
|
||||
*/
|
||||
// protected function configureSchedules(Schedule $schedule): void
|
||||
// {
|
||||
// $schedule->command('inspire')->hourly();
|
||||
// }
|
||||
}
|
||||
50
Modules/MasterData/app/Providers/RouteServiceProvider.php
Normal file
50
Modules/MasterData/app/Providers/RouteServiceProvider.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\MasterData\Providers;
|
||||
|
||||
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
class RouteServiceProvider extends ServiceProvider
|
||||
{
|
||||
protected string $name = 'MasterData';
|
||||
|
||||
/**
|
||||
* Called before routes are registered.
|
||||
*
|
||||
* Register any model bindings or pattern based filters.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
parent::boot();
|
||||
}
|
||||
|
||||
/**
|
||||
* Define the routes for the application.
|
||||
*/
|
||||
public function map(): void
|
||||
{
|
||||
$this->mapApiRoutes();
|
||||
$this->mapWebRoutes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Define the "web" routes for the application.
|
||||
*
|
||||
* These routes all receive session state, CSRF protection, etc.
|
||||
*/
|
||||
protected function mapWebRoutes(): void
|
||||
{
|
||||
Route::middleware('web')->group(module_path($this->name, '/routes/web.php'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Define the "api" routes for the application.
|
||||
*
|
||||
* These routes are typically stateless.
|
||||
*/
|
||||
protected function mapApiRoutes(): void
|
||||
{
|
||||
Route::middleware('api')->prefix('api')->name('api.')->group(module_path($this->name, '/routes/api.php'));
|
||||
}
|
||||
}
|
||||
30
Modules/MasterData/composer.json
Normal file
30
Modules/MasterData/composer.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "nwidart/masterdata",
|
||||
"description": "",
|
||||
"authors": [
|
||||
{
|
||||
"name": "Nicolas Widart",
|
||||
"email": "n.widart@gmail.com"
|
||||
}
|
||||
],
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"providers": [],
|
||||
"aliases": {
|
||||
|
||||
}
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Modules\\MasterData\\": "app/",
|
||||
"Modules\\MasterData\\Database\\Factories\\": "database/factories/",
|
||||
"Modules\\MasterData\\Database\\Seeders\\": "database/seeders/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"Modules\\MasterData\\Tests\\": "tests/"
|
||||
}
|
||||
}
|
||||
}
|
||||
0
Modules/MasterData/config/.gitkeep
Normal file
0
Modules/MasterData/config/.gitkeep
Normal file
5
Modules/MasterData/config/config.php
Normal file
5
Modules/MasterData/config/config.php
Normal file
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'name' => 'MasterData',
|
||||
];
|
||||
0
Modules/MasterData/database/factories/.gitkeep
Normal file
0
Modules/MasterData/database/factories/.gitkeep
Normal file
0
Modules/MasterData/database/migrations/.gitkeep
Normal file
0
Modules/MasterData/database/migrations/.gitkeep
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('materials', function (Blueprint $table) {
|
||||
$table->enum('type', ['single', 'kit', 'assembly'])->default('single')->after('description');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('materials', function (Blueprint $table) {
|
||||
$table->dropColumn('type');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('material_components', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('parent_id')->constrained('materials')->cascadeOnDelete();
|
||||
$table->foreignId('component_id')->constrained('materials')->cascadeOnDelete();
|
||||
$table->decimal('quantity', 12, 4);
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['parent_id', 'component_id']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('material_components');
|
||||
}
|
||||
};
|
||||
0
Modules/MasterData/database/seeders/.gitkeep
Normal file
0
Modules/MasterData/database/seeders/.gitkeep
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\MasterData\Database\Seeders;
|
||||
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class MasterDataDatabaseSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Run the database seeds.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
// $this->call([]);
|
||||
}
|
||||
}
|
||||
11
Modules/MasterData/module.json
Normal file
11
Modules/MasterData/module.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "MasterData",
|
||||
"alias": "masterdata",
|
||||
"description": "",
|
||||
"keywords": [],
|
||||
"priority": 0,
|
||||
"providers": [
|
||||
"Modules\\MasterData\\Providers\\MasterDataServiceProvider"
|
||||
],
|
||||
"files": []
|
||||
}
|
||||
15
Modules/MasterData/package.json
Normal file
15
Modules/MasterData/package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"axios": "^1.1.2",
|
||||
"laravel-vite-plugin": "^0.7.5",
|
||||
"sass": "^1.69.5",
|
||||
"postcss": "^8.3.7",
|
||||
"vite": "^4.0.0"
|
||||
}
|
||||
}
|
||||
0
Modules/MasterData/resources/assets/.gitkeep
Normal file
0
Modules/MasterData/resources/assets/.gitkeep
Normal file
0
Modules/MasterData/resources/assets/js/app.js
Normal file
0
Modules/MasterData/resources/assets/js/app.js
Normal file
0
Modules/MasterData/resources/assets/sass/app.scss
Normal file
0
Modules/MasterData/resources/assets/sass/app.scss
Normal file
0
Modules/MasterData/resources/views/.gitkeep
Normal file
0
Modules/MasterData/resources/views/.gitkeep
Normal file
@@ -0,0 +1,30 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
|
||||
<title>MasterData Module - {{ config('app.name', 'Laravel') }}</title>
|
||||
|
||||
<meta name="description" content="{{ $description ?? '' }}">
|
||||
<meta name="keywords" content="{{ $keywords ?? '' }}">
|
||||
<meta name="author" content="{{ $author ?? '' }}">
|
||||
|
||||
<!-- Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet" />
|
||||
|
||||
{{-- Vite CSS --}}
|
||||
{{-- {{ module_vite('build-masterdata', 'resources/assets/sass/app.scss') }} --}}
|
||||
</head>
|
||||
|
||||
<body>
|
||||
{{ $slot }}
|
||||
|
||||
{{-- Vite JS --}}
|
||||
{{-- {{ module_vite('build-masterdata', 'resources/assets/js/app.js') }} --}}
|
||||
</body>
|
||||
</html>
|
||||
5
Modules/MasterData/resources/views/index.blade.php
Normal file
5
Modules/MasterData/resources/views/index.blade.php
Normal file
@@ -0,0 +1,5 @@
|
||||
<x-masterdata::layouts.master>
|
||||
<h1>Hello World</h1>
|
||||
|
||||
<p>Module: {!! config('masterdata.name') !!}</p>
|
||||
</x-masterdata::layouts.master>
|
||||
0
Modules/MasterData/routes/.gitkeep
Normal file
0
Modules/MasterData/routes/.gitkeep
Normal file
8
Modules/MasterData/routes/api.php
Normal file
8
Modules/MasterData/routes/api.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Modules\MasterData\Http\Controllers\MasterDataController;
|
||||
|
||||
Route::middleware(['auth:sanctum'])->prefix('v1')->group(function () {
|
||||
Route::apiResource('masterdatas', MasterDataController::class)->names('masterdata');
|
||||
});
|
||||
8
Modules/MasterData/routes/web.php
Normal file
8
Modules/MasterData/routes/web.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Modules\MasterData\Http\Controllers\MasterDataController;
|
||||
|
||||
Route::middleware(['auth', 'verified'])->group(function () {
|
||||
Route::resource('masterdatas', MasterDataController::class)->names('masterdata');
|
||||
});
|
||||
0
Modules/MasterData/tests/Feature/.gitkeep
Normal file
0
Modules/MasterData/tests/Feature/.gitkeep
Normal file
0
Modules/MasterData/tests/Unit/.gitkeep
Normal file
0
Modules/MasterData/tests/Unit/.gitkeep
Normal file
41
Modules/MasterData/vite.config.js
Normal file
41
Modules/MasterData/vite.config.js
Normal file
@@ -0,0 +1,41 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import laravel from 'laravel-vite-plugin';
|
||||
// Uncomment the import for your frontend framework:
|
||||
// import vue from '@vitejs/plugin-vue';
|
||||
// import react from '@vitejs/plugin-react';
|
||||
// import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
outDir: '../../public/build-masterdata',
|
||||
emptyOutDir: true,
|
||||
manifest: true,
|
||||
},
|
||||
plugins: [
|
||||
laravel({
|
||||
publicDirectory: '../../public',
|
||||
buildDirectory: 'build-masterdata',
|
||||
input: [
|
||||
__dirname + '/resources/assets/sass/app.scss',
|
||||
__dirname + '/resources/assets/js/app.js'
|
||||
],
|
||||
refresh: true,
|
||||
}),
|
||||
// Uncomment the plugin for your frontend framework:
|
||||
// vue({
|
||||
// template: {
|
||||
// transformAssetUrls: {
|
||||
// base: null,
|
||||
// includeAbsolute: false,
|
||||
// },
|
||||
// },
|
||||
// }),
|
||||
// react(),
|
||||
// svelte(),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': __dirname + '/resources/js',
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user