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 <noreply@anthropic.com>
This commit is contained in:
parent
12e07480a0
commit
f2c0f22156
@ -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
app/Services/ProjectImportService.php
Normal file
164
app/Services/ProjectImportService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -36,6 +36,16 @@
|
||||
<h1 class="page-title">Projects, Locations & Departments</h1>
|
||||
<p class="page-subtitle">Manage projects with their sub-locations and departments.</p>
|
||||
</div>
|
||||
<div style="display:flex; gap:8px; align-items:center; flex-shrink:0;">
|
||||
<a href="{{ route('settings.projects.template') }}" class="btn-secondary" style="display:inline-flex;align-items:center;gap:6px;text-decoration:none;">
|
||||
<svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg>
|
||||
Template
|
||||
</a>
|
||||
<button type="button" onclick="openImportModal()" class="btn-primary" style="display:inline-flex;align-items:center;gap:6px;">
|
||||
<svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l4-4m0 0l4 4m-4-4v12"/></svg>
|
||||
Import Excel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Stat boxes --}}
|
||||
@ -326,6 +336,68 @@ $allDeptsJson = json_encode($allDeptsData);
|
||||
@endforelse
|
||||
</div>{{-- end companies-list --}}
|
||||
|
||||
{{-- ═══════════════ Import Modal ═══════════════ --}}
|
||||
<div id="import-modal-overlay" onclick="if(event.target===this)closeImportModal()"
|
||||
style="display:none;position:fixed;inset:0;background:rgba(15,23,42,0.55);z-index:9998;align-items:center;justify-content:center;">
|
||||
<div style="background:white;border-radius:1rem;width:520px;max-width:96vw;box-shadow:0 25px 60px rgba(0,0,0,0.22);overflow:hidden;">
|
||||
|
||||
{{-- Header --}}
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:1rem 1.5rem;border-bottom:1px solid #e2e8f0;">
|
||||
<div>
|
||||
<h2 style="font-size:16px;font-weight:700;color:#0f172a;margin:0;">Import from Excel</h2>
|
||||
<p style="font-size:12px;color:#64748b;margin:3px 0 0;">Companies, projects and departments from .xlsx / .xls</p>
|
||||
</div>
|
||||
<button type="button" onclick="closeImportModal()" style="background:none;border:none;cursor:pointer;color:#94a3b8;padding:4px;line-height:0;">
|
||||
<svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- Drop zone --}}
|
||||
<div style="padding:1.25rem 1.5rem;">
|
||||
<div id="import-dz"
|
||||
ondragover="event.preventDefault();this.classList.add('dz-active')"
|
||||
ondragleave="this.classList.remove('dz-active')"
|
||||
ondrop="importHandleDrop(event)"
|
||||
onclick="document.getElementById('import-file-input').click()"
|
||||
style="border:2px dashed #cbd5e1;border-radius:0.75rem;padding:2rem;text-align:center;cursor:pointer;transition:border-color .15s,background .15s;background:#f8fafc;">
|
||||
<svg id="import-dz-icon" width="32" height="32" fill="none" stroke="#94a3b8" viewBox="0 0 24 24" style="margin:0 auto 10px;display:block;"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l4-4m0 0l4 4m-4-4v12"/></svg>
|
||||
<p id="import-dz-text" style="font-size:14px;color:#475569;margin:0;font-weight:500;">Drop your Excel file here, or click to browse</p>
|
||||
<p id="import-dz-sub" style="font-size:12px;color:#94a3b8;margin:4px 0 0;">.xlsx or .xls — max 10 MB</p>
|
||||
</div>
|
||||
<input type="file" id="import-file-input" accept=".xlsx,.xls" style="display:none;" onchange="importFileSelected(this)">
|
||||
|
||||
{{-- Info box --}}
|
||||
<div style="margin-top:1rem;background:#f0f9ff;border:1px solid #bae6fd;border-radius:0.5rem;padding:0.75rem 1rem;">
|
||||
<p style="font-size:12px;color:#0369a1;margin:0;line-height:1.7;">
|
||||
<strong>Expected format (2 tabs):</strong><br>
|
||||
<strong>Projects</strong> tab — columns: <em>Company Name</em> | <em>Project Name</em><br>
|
||||
<strong>Departments</strong> tab — columns: <em>Company Name</em> | <em>Department Name</em><br>
|
||||
Companies are created automatically if they don't exist. Duplicates are skipped.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Footer --}}
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:0.875rem 1.5rem;border-top:1px solid #e2e8f0;background:#f8fafc;">
|
||||
<a href="{{ route('settings.projects.template') }}" style="font-size:13px;color:#3b82f6;text-decoration:none;display:flex;align-items:center;gap:5px;">
|
||||
<svg width="13" height="13" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg>
|
||||
Download template
|
||||
</a>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<button type="button" onclick="closeImportModal()" class="btn-secondary">Cancel</button>
|
||||
<button type="button" id="import-submit-btn" onclick="submitImport()" class="btn-primary" disabled
|
||||
style="min-width:100px;opacity:.5;cursor:not-allowed;">
|
||||
Import
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<style>
|
||||
#import-dz.dz-active { border-color:#6366f1; background:#eef2ff; }
|
||||
#import-dz.dz-has-file { border-color:#22c55e; background:#f0fdf4; border-style:solid; }
|
||||
</style>
|
||||
|
||||
{{-- ═══════════════ Location Map Modal ═══════════════ --}}
|
||||
<div id="loc-modal-overlay" onclick="if(event.target===this)closeLocModal()">
|
||||
<div id="loc-modal-box">
|
||||
@ -416,10 +488,101 @@ $allDeptsJson = json_encode($allDeptsData);
|
||||
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV/XN/WLc=" crossorigin=""></script>
|
||||
<script>
|
||||
var CSRF = document.querySelector('meta[name="csrf-token"]').content;
|
||||
var BASE = '{{ url("settings/projects") }}';
|
||||
var CSRF = document.querySelector('meta[name="csrf-token"]').content;
|
||||
var BASE = '{{ url("settings/projects") }}';
|
||||
var IMPORT_URL = '{{ route("settings.projects.import") }}';
|
||||
var COMPANIES = {!! json_encode($companies->map(fn($c) => ['id' => $c->id, 'name' => $c->name])->values()) !!};
|
||||
|
||||
// ── Import Modal ──────────────────────────────────────────────────────────────
|
||||
var importFile = null;
|
||||
|
||||
function openImportModal() {
|
||||
importFile = null;
|
||||
document.getElementById('import-file-input').value = '';
|
||||
resetImportDz();
|
||||
var overlay = document.getElementById('import-modal-overlay');
|
||||
overlay.style.display = 'flex';
|
||||
}
|
||||
|
||||
function closeImportModal() {
|
||||
document.getElementById('import-modal-overlay').style.display = 'none';
|
||||
}
|
||||
|
||||
function resetImportDz() {
|
||||
var dz = document.getElementById('import-dz');
|
||||
var btn = document.getElementById('import-submit-btn');
|
||||
dz.classList.remove('dz-has-file', 'dz-active');
|
||||
document.getElementById('import-dz-text').textContent = 'Drop your Excel file here, or click to browse';
|
||||
document.getElementById('import-dz-sub').textContent = '.xlsx or .xls — max 10 MB';
|
||||
document.getElementById('import-dz-icon').setAttribute('stroke', '#94a3b8');
|
||||
btn.disabled = true;
|
||||
btn.style.opacity = '.5';
|
||||
btn.style.cursor = 'not-allowed';
|
||||
}
|
||||
|
||||
function applyImportFile(file) {
|
||||
if (!file) return;
|
||||
importFile = file;
|
||||
var dz = document.getElementById('import-dz');
|
||||
var btn = document.getElementById('import-submit-btn');
|
||||
dz.classList.remove('dz-active');
|
||||
dz.classList.add('dz-has-file');
|
||||
document.getElementById('import-dz-text').textContent = file.name;
|
||||
document.getElementById('import-dz-sub').textContent = (file.size / 1024).toFixed(1) + ' KB — ready to import';
|
||||
document.getElementById('import-dz-icon').setAttribute('stroke', '#22c55e');
|
||||
btn.disabled = false;
|
||||
btn.style.opacity = '1';
|
||||
btn.style.cursor = 'pointer';
|
||||
}
|
||||
|
||||
function importFileSelected(input) {
|
||||
if (input.files && input.files[0]) applyImportFile(input.files[0]);
|
||||
}
|
||||
|
||||
function importHandleDrop(event) {
|
||||
event.preventDefault();
|
||||
document.getElementById('import-dz').classList.remove('dz-active');
|
||||
var file = event.dataTransfer.files[0];
|
||||
if (file) {
|
||||
document.getElementById('import-file-input').files = event.dataTransfer.files;
|
||||
applyImportFile(file);
|
||||
}
|
||||
}
|
||||
|
||||
function submitImport() {
|
||||
if (!importFile) return;
|
||||
var btn = document.getElementById('import-submit-btn');
|
||||
btn.textContent = 'Importing…';
|
||||
btn.disabled = true;
|
||||
btn.style.opacity = '.7';
|
||||
|
||||
var formData = new FormData();
|
||||
formData.append('file', importFile);
|
||||
formData.append('_token', CSRF);
|
||||
|
||||
fetch(IMPORT_URL, { method: 'POST', headers: { 'Accept': 'application/json' }, body: formData })
|
||||
.then(function(r) { return r.json().then(function(body) { return { ok: r.ok, body: body }; }); })
|
||||
.then(function(res) {
|
||||
btn.textContent = 'Import';
|
||||
btn.disabled = false;
|
||||
btn.style.opacity = '1';
|
||||
if (res.ok && res.body.success) {
|
||||
closeImportModal();
|
||||
showToast(res.body.message, 'success');
|
||||
// Reload the page after a short delay so new companies/projects appear
|
||||
setTimeout(function() { window.location.reload(); }, 1200);
|
||||
} else {
|
||||
showToast(res.body.message || 'Import failed.', 'error');
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
btn.textContent = 'Import';
|
||||
btn.disabled = false;
|
||||
btn.style.opacity = '1';
|
||||
showToast('Upload failed. Check your connection.', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
// Location data store (keyed by loc ID) — avoids inline JSON in onclick attributes
|
||||
var LOC_DATA = {!! $allLocsJson !!};
|
||||
var DEPT_DATA = {!! $allDeptsJson !!};
|
||||
|
||||
@ -136,6 +136,8 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
||||
// Projects settings
|
||||
Route::get('settings/projects', [ProjectSettingController::class, 'index'])->name('settings.projects.index');
|
||||
Route::post('settings/projects', [ProjectSettingController::class, 'store'])->name('settings.projects.store');
|
||||
Route::post('settings/projects/import', [ProjectSettingController::class, 'import'])->name('settings.projects.import');
|
||||
Route::get('settings/projects/template', [ProjectSettingController::class, 'downloadTemplate'])->name('settings.projects.template');
|
||||
Route::post('settings/projects/companies', [ProjectSettingController::class, 'storeCompany'])->name('settings.projects.companies.store');
|
||||
Route::patch('settings/projects/companies/{company}', [ProjectSettingController::class, 'updateCompany'])->name('settings.projects.companies.update');
|
||||
Route::delete('settings/projects/companies/{company}', [ProjectSettingController::class, 'destroyCompany'])->name('settings.projects.companies.destroy');
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user