feat: add Excel importer and template download for projects/departments

- ProjectImportService: reads Projects + Departments sheets, creates
  companies automatically if missing, skips duplicates (case-insensitive)
- Controller: import() POST (JSON response) + downloadTemplate() GET
- Template: 2-tab .xlsx (Projects | Departments) with headers, sample rows, note
- View: Import Excel button (AJAX upload with drag-and-drop modal) +
  Template download button in page header; page reloads after successful import

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
ghassanandClaude Sonnet 4.6 committed 2026-05-26 18:42:25 +03:00
1 parent 12e07480a0
commit f2c0f22156
4 files changed
+434 -2

No files matched your search

@@ -7,7 +7,14 @@ use App\Models\Settings\Company;
use App\Models\Settings\Department;
use App\Models\Settings\Location;
use App\Models\Settings\ProjectSetting;
use App\Services\ProjectImportService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
class ProjectSettingController extends Controller
{
@@ -167,4 +174,100 @@ class ProjectSettingController extends Controller
$department->delete();
return response()->json(['ok' => true]);
}
// ── Import ────────────────────────────────────────────────────────────────
public function import(Request $request): JsonResponse
{
$request->validate(['file' => 'required|file|mimes:xlsx,xls|max:10240']);
try {
$stats = app(ProjectImportService::class)->import(
$request->file('file')->getPathname()
);
$parts = [];
if ($stats['projects_created']) $parts[] = "{$stats['projects_created']} project(s)";
if ($stats['departments_created']) $parts[] = "{$stats['departments_created']} department(s)";
if ($stats['companies_created']) $parts[] = "{$stats['companies_created']} new company(s)";
$message = $parts
? 'Imported: ' . implode(', ', $parts) . ($stats['skipped'] ? " — {$stats['skipped']} row(s) skipped" : '')
: 'Nothing new to import' . ($stats['skipped'] ? " ({$stats['skipped']} rows already exist)" : '');
return response()->json(['success' => true, 'message' => $message, 'stats' => $stats]);
} catch (\Exception $e) {
return response()->json(['success' => false, 'message' => $e->getMessage()], 422);
}
}
public function downloadTemplate(): \Symfony\Component\HttpFoundation\BinaryFileResponse
{
$path = storage_path('app/projects_template.xlsx');
$this->buildTemplate($path);
return response()->download($path, 'projects_template.xlsx');
}
private function buildTemplate(string $path): void
{
$spreadsheet = new Spreadsheet();
$headerStyle = [
'font' => ['bold' => true, 'color' => ['rgb' => '1e293b']],
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => 'DBEAFE']],
'alignment' => ['horizontal' => Alignment::HORIZONTAL_LEFT],
];
$noteStyle = [
'font' => ['italic' => true, 'color' => ['rgb' => '64748b'], 'size' => 10],
];
// ── Sheet 1: Projects ──────────────────────────────────────────────
$s1 = $spreadsheet->getActiveSheet()->setTitle('Projects');
$s1->setCellValue('A1', 'Company Name')
->setCellValue('B1', 'Project Name');
$s1->getStyle('A1:B1')->applyFromArray($headerStyle);
// Sample rows
$samples = [
['Miknas Industrial', 'New Warehouse'],
['Steel tech', 'Factory Extension'],
['Steel tech', 'New Office Block'],
];
foreach ($samples as $i => $row) {
$s1->setCellValue('A' . ($i + 2), $row[0]);
$s1->setCellValue('B' . ($i + 2), $row[1]);
}
$s1->setCellValue('A6', '* Delete sample rows before importing. Company will be created if it does not exist.');
$s1->getStyle('A6')->applyFromArray($noteStyle);
$s1->mergeCells('A6:B6');
$s1->getColumnDimension('A')->setWidth(32);
$s1->getColumnDimension('B')->setWidth(32);
// ── Sheet 2: Departments ───────────────────────────────────────────
$s2 = new Worksheet($spreadsheet, 'Departments');
$spreadsheet->addSheet($s2);
$s2->setCellValue('A1', 'Company Name')
->setCellValue('B1', 'Department Name');
$s2->getStyle('A1:B1')->applyFromArray($headerStyle);
$deptSamples = [
['Miknas Industrial', 'Finance'],
['Miknas Industrial', 'Operations'],
['Steel tech', 'Production'],
];
foreach ($deptSamples as $i => $row) {
$s2->setCellValue('A' . ($i + 2), $row[0]);
$s2->setCellValue('B' . ($i + 2), $row[1]);
}
$s2->setCellValue('A6', '* Delete sample rows before importing. Company will be created if it does not exist.');
$s2->getStyle('A6')->applyFromArray($noteStyle);
$s2->mergeCells('A6:B6');
$s2->getColumnDimension('A')->setWidth(32);
$s2->getColumnDimension('B')->setWidth(32);
(new Xlsx($spreadsheet))->save($path);
}
}
+164
View File
@@ -0,0 +1,164 @@
<?php
namespace App\Services;
use App\Models\Settings\Company;
use App\Models\Settings\Department;
use App\Models\Settings\ProjectSetting;
use PhpOffice\PhpSpreadsheet\IOFactory;
class ProjectImportService
{
private array $stats = [
'companies_created' => 0,
'projects_created' => 0,
'departments_created' => 0,
'skipped' => 0,
];
/** @var array<string,Company> */
private array $companyCache = [];
public function import(string $filePath): array
{
$spreadsheet = IOFactory::load($filePath);
$projectSheet = $this->findSheet($spreadsheet, ['projects', 'project']);
$deptSheet = $this->findSheet($spreadsheet, ['departments', 'department', 'depts', 'dept']);
if ($projectSheet) {
$this->importProjects($projectSheet);
}
if ($deptSheet) {
$this->importDepartments($deptSheet);
}
// Single-sheet file with no named tabs → treat as projects
if (!$projectSheet && !$deptSheet) {
$this->importProjects($spreadsheet->getActiveSheet());
}
return $this->stats;
}
private function findSheet($spreadsheet, array $names): ?\PhpOffice\PhpSpreadsheet\Worksheet\Worksheet
{
foreach ($spreadsheet->getSheetNames() as $i => $sheetName) {
if (in_array(strtolower(trim($sheetName)), $names)) {
return $spreadsheet->getSheet($i);
}
}
return null;
}
private function importProjects(\PhpOffice\PhpSpreadsheet\Worksheet\Worksheet $sheet): void
{
$rows = $sheet->toArray(null, true, true, false);
$headers = $this->normalizeHeaders((array) array_shift($rows));
$coIdx = $this->findCol($headers, ['company', 'company name', 'company name', 'companyname']);
$projIdx = $this->findCol($headers, ['project', 'project name', 'project name', 'projectname']);
if ($coIdx === null || $projIdx === null) {
return;
}
foreach ($rows as $row) {
$coName = $this->str($row[$coIdx] ?? null);
$projName = $this->str($row[$projIdx] ?? null);
if (!$coName || !$projName) {
$this->stats['skipped']++;
continue;
}
$company = $this->findOrCreateCompany($coName);
$existing = ProjectSetting::whereRaw('LOWER(name) = ?', [strtolower($projName)])
->where('company_id', $company->id)->first();
if ($existing) {
$this->stats['skipped']++;
continue;
}
ProjectSetting::create(['name' => $projName, 'company_id' => $company->id, 'is_active' => true]);
$this->stats['projects_created']++;
}
}
private function importDepartments(\PhpOffice\PhpSpreadsheet\Worksheet\Worksheet $sheet): void
{
$rows = $sheet->toArray(null, true, true, false);
$headers = $this->normalizeHeaders((array) array_shift($rows));
$coIdx = $this->findCol($headers, ['company', 'company name', 'companyname']);
$deptIdx = $this->findCol($headers, ['department', 'department name', 'departmentname', 'dept', 'dept name']);
if ($coIdx === null || $deptIdx === null) {
return;
}
foreach ($rows as $row) {
$coName = $this->str($row[$coIdx] ?? null);
$deptName = $this->str($row[$deptIdx] ?? null);
if (!$coName || !$deptName) {
$this->stats['skipped']++;
continue;
}
$company = $this->findOrCreateCompany($coName);
$existing = Department::whereRaw('LOWER(name) = ?', [strtolower($deptName)])
->where('company_id', $company->id)->first();
if ($existing) {
$this->stats['skipped']++;
continue;
}
Department::create(['name' => $deptName, 'company_id' => $company->id, 'is_active' => true]);
$this->stats['departments_created']++;
}
}
private function findOrCreateCompany(string $name): Company
{
$key = strtolower($name);
if (isset($this->companyCache[$key])) {
return $this->companyCache[$key];
}
$company = Company::whereRaw('LOWER(name) = ?', [$key])->first();
if (!$company) {
$company = Company::create(['name' => $name, 'is_active' => true]);
$this->stats['companies_created']++;
}
$this->companyCache[$key] = $company;
return $company;
}
private function normalizeHeaders(array $row): array
{
return array_map(
fn ($h) => strtolower(trim(str_replace(['*', '_', '-'], ' ', (string) ($h ?? '')))),
$row
);
}
private function findCol(array $headers, array $aliases): ?int
{
foreach ($headers as $i => $h) {
if (in_array($h, $aliases)) {
return $i;
}
}
return null;
}
private function str(mixed $value): ?string
{
$v = trim((string) ($value ?? ''));
return $v === '' ? null : $v;
}
}