feat: add departments to projects — two-column layout with inline CRUD
This commit is contained in:
parent
58b3e9e0de
commit
60d57af630
@ -3,6 +3,7 @@
|
|||||||
namespace App\Http\Controllers\Settings;
|
namespace App\Http\Controllers\Settings;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Settings\Department;
|
||||||
use App\Models\Settings\Location;
|
use App\Models\Settings\Location;
|
||||||
use App\Models\Settings\ProjectSetting;
|
use App\Models\Settings\ProjectSetting;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@ -11,15 +12,17 @@ class ProjectSettingController extends Controller
|
|||||||
{
|
{
|
||||||
public function index()
|
public function index()
|
||||||
{
|
{
|
||||||
$projects = ProjectSetting::with(['locations' => function ($q) {
|
$projects = ProjectSetting::with([
|
||||||
$q->orderBy('name');
|
'locations' => fn ($q) => $q->orderBy('name'),
|
||||||
}])->orderBy('name')->get();
|
'departments' => fn ($q) => $q->orderBy('name'),
|
||||||
|
])->orderBy('name')->get();
|
||||||
|
|
||||||
$stats = [
|
$stats = [
|
||||||
'total_projects' => $projects->count(),
|
'total_projects' => $projects->count(),
|
||||||
'active_projects' => $projects->where('is_active', true)->count(),
|
'active_projects' => $projects->where('is_active', true)->count(),
|
||||||
'total_locations' => $projects->sum(fn ($p) => $p->locations->count()),
|
'total_locations' => $projects->sum(fn ($p) => $p->locations->count()),
|
||||||
'active_locations' => $projects->sum(fn ($p) => $p->locations->where('is_active', true)->count()),
|
'active_locations' => $projects->sum(fn ($p) => $p->locations->where('is_active', true)->count()),
|
||||||
|
'total_departments' => $projects->sum(fn ($p) => $p->departments->count()),
|
||||||
];
|
];
|
||||||
|
|
||||||
return view('settings.projects.index', compact('projects', 'stats'));
|
return view('settings.projects.index', compact('projects', 'stats'));
|
||||||
@ -113,4 +116,24 @@ class ProjectSettingController extends Controller
|
|||||||
$location->delete();
|
$location->delete();
|
||||||
return response()->json(['ok' => true]);
|
return response()->json(['ok' => true]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function storeDepartment(Request $request, ProjectSetting $project)
|
||||||
|
{
|
||||||
|
$validated = $request->validate(['name' => 'required|string|max:255']);
|
||||||
|
$dept = $project->departments()->create(['name' => $validated['name'], 'is_active' => true]);
|
||||||
|
return response()->json(['department' => ['id' => $dept->id, 'name' => $dept->name, 'is_active' => $dept->is_active]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateDepartment(Request $request, ProjectSetting $project, Department $department)
|
||||||
|
{
|
||||||
|
$validated = $request->validate(['name' => 'required|string|max:255']);
|
||||||
|
$department->update(['name' => $validated['name'], 'is_active' => $request->boolean('is_active', true)]);
|
||||||
|
return response()->json(['department' => ['id' => $department->id, 'name' => $department->name, 'is_active' => $department->is_active]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroyDepartment(ProjectSetting $project, Department $department)
|
||||||
|
{
|
||||||
|
$department->delete();
|
||||||
|
return response()->json(['ok' => true]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
19
app/Models/Settings/Department.php
Normal file
19
app/Models/Settings/Department.php
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models\Settings;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class Department extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'settings_departments';
|
||||||
|
|
||||||
|
protected $fillable = ['name', 'project_id', 'is_active'];
|
||||||
|
|
||||||
|
protected $casts = ['is_active' => 'boolean'];
|
||||||
|
|
||||||
|
public function project(): \Illuminate\Database\Eloquent\Relations\BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(ProjectSetting::class, 'project_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -21,4 +21,9 @@ class ProjectSetting extends Model
|
|||||||
{
|
{
|
||||||
return $this->hasMany(\App\Models\Settings\Location::class, 'project_id');
|
return $this->hasMany(\App\Models\Settings\Location::class, 'project_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function departments(): \Illuminate\Database\Eloquent\Relations\HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(\App\Models\Settings\Department::class, 'project_id');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,30 @@
|
|||||||
|
<?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('settings_departments', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('project_id')->constrained('settings_projects')->cascadeOnDelete();
|
||||||
|
$table->string('name', 255);
|
||||||
|
$table->boolean('is_active')->default(true);
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('settings_departments');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -8,7 +8,15 @@
|
|||||||
.proj-card { border:1px solid #e2e8f0; border-radius:0.875rem; overflow:hidden; margin-bottom:0.75rem; background:white; }
|
.proj-card { border:1px solid #e2e8f0; border-radius:0.875rem; overflow:hidden; margin-bottom:0.75rem; background:white; }
|
||||||
.proj-header { display:flex; align-items:center; justify-content:space-between; padding:0.875rem 1.25rem; background:white; }
|
.proj-header { display:flex; align-items:center; justify-content:space-between; padding:0.875rem 1.25rem; background:white; }
|
||||||
.proj-body { display:block; border-top:1px solid #e2e8f0; }
|
.proj-body { display:block; border-top:1px solid #e2e8f0; }
|
||||||
.loc-row { display:flex; align-items:flex-start; justify-content:space-between; padding:0.75rem 1.25rem 0.75rem 2.5rem; border-bottom:1px solid #f1f5f9; }
|
.proj-body-inner { display:grid; grid-template-columns:1fr 1fr; }
|
||||||
|
.proj-section { border-right:1px solid #e2e8f0; }
|
||||||
|
.proj-section:last-child { border-right:none; }
|
||||||
|
.proj-section-header { display:flex; align-items:center; justify-content:space-between; padding:0.6rem 1.25rem; background:#f8fafc; border-bottom:1px solid #e2e8f0; }
|
||||||
|
.proj-section-title { font-size:11px; font-weight:700; color:#64748b; text-transform:uppercase; letter-spacing:.05em; }
|
||||||
|
.loc-row { display:flex; align-items:flex-start; justify-content:space-between; padding:0.65rem 1rem 0.65rem 1.25rem; border-bottom:1px solid #f1f5f9; }
|
||||||
|
.dept-row { display:flex; align-items:center; justify-content:space-between; padding:0.6rem 1rem 0.6rem 1.25rem; border-bottom:1px solid #f1f5f9; }
|
||||||
|
.dept-edit-row { display:none; padding:0.5rem 1rem; background:#f0f9ff; border-bottom:1px solid #bae6fd; gap:6px; align-items:center; }
|
||||||
|
.dept-edit-row.open { display:flex; }
|
||||||
.proj-edit-wrap { display:none; padding:0.75rem 1.25rem; background:#f0f9ff; border-bottom:1px solid #bae6fd; align-items:center; gap:8px; }
|
.proj-edit-wrap { display:none; padding:0.75rem 1.25rem; background:#f0f9ff; border-bottom:1px solid #bae6fd; align-items:center; gap:8px; }
|
||||||
.proj-edit-wrap.open { display:flex; }
|
.proj-edit-wrap.open { display:flex; }
|
||||||
.badge-inactive { display:inline-block; padding:1px 7px; border-radius:9px; font-size:11px; font-weight:600; background:#fee2e2; color:#dc2626; margin-left:6px; }
|
.badge-inactive { display:inline-block; padding:1px 7px; border-radius:9px; font-size:11px; font-weight:600; background:#fee2e2; color:#dc2626; margin-left:6px; }
|
||||||
@ -25,13 +33,13 @@
|
|||||||
{{-- Page header --}}
|
{{-- Page header --}}
|
||||||
<div class="mb-5" style="display:flex; align-items:flex-start; justify-content:space-between; flex-wrap:wrap; gap:12px;">
|
<div class="mb-5" style="display:flex; align-items:flex-start; justify-content:space-between; flex-wrap:wrap; gap:12px;">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="page-title">Projects & Locations</h1>
|
<h1 class="page-title">Projects, Locations & Departments</h1>
|
||||||
<p class="page-subtitle">Manage projects and their physical sub-locations with addresses and GPS coordinates.</p>
|
<p class="page-subtitle">Manage projects with their sub-locations and departments.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- Stat boxes --}}
|
{{-- Stat boxes --}}
|
||||||
<div style="display:grid; grid-template-columns:repeat(4,1fr); gap:16px; margin-bottom:28px;">
|
<div style="display:grid; grid-template-columns:repeat(5,1fr); gap:16px; margin-bottom:28px;">
|
||||||
<div class="stat-card" style="border-top:3px solid #3b82f6;">
|
<div class="stat-card" style="border-top:3px solid #3b82f6;">
|
||||||
<div style="display:flex; align-items:center; gap:12px;">
|
<div style="display:flex; align-items:center; gap:12px;">
|
||||||
<div style="width:40px;height:40px;background:#eff6ff;border-radius:10px;display:flex;align-items:center;justify-content:center;flex-shrink:0;">
|
<div style="width:40px;height:40px;background:#eff6ff;border-radius:10px;display:flex;align-items:center;justify-content:center;flex-shrink:0;">
|
||||||
@ -65,6 +73,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="stat-card" style="border-top:3px solid #06b6d4;">
|
||||||
|
<div style="display:flex; align-items:center; gap:12px;">
|
||||||
|
<div style="width:40px;height:40px;background:#ecfeff;border-radius:10px;display:flex;align-items:center;justify-content:center;flex-shrink:0;">
|
||||||
|
<svg width="18" height="18" fill="none" stroke="#06b6d4" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-size:28px;font-weight:700;color:#1e293b;line-height:1;">{{ $stats['total_departments'] }}</div>
|
||||||
|
<div style="font-size:12px;color:#64748b;margin-top:3px;">Departments</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="stat-card" style="border-top:3px solid #f59e0b;">
|
<div class="stat-card" style="border-top:3px solid #f59e0b;">
|
||||||
<div style="display:flex; align-items:center; gap:12px;">
|
<div style="display:flex; align-items:center; gap:12px;">
|
||||||
<div style="width:40px;height:40px;background:#fffbeb;border-radius:10px;display:flex;align-items:center;justify-content:center;flex-shrink:0;">
|
<div style="width:40px;height:40px;background:#fffbeb;border-radius:10px;display:flex;align-items:center;justify-content:center;flex-shrink:0;">
|
||||||
@ -93,9 +112,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- Build location data map for safe JS passing --}}
|
{{-- Build location + department data maps for safe JS passing --}}
|
||||||
@php
|
@php
|
||||||
$allLocsData = [];
|
$allLocsData = [];
|
||||||
|
$allDeptsData = [];
|
||||||
foreach ($projects as $proj) {
|
foreach ($projects as $proj) {
|
||||||
foreach ($proj->locations as $loc) {
|
foreach ($proj->locations as $loc) {
|
||||||
$allLocsData[$loc->id] = [
|
$allLocsData[$loc->id] = [
|
||||||
@ -107,8 +127,16 @@ foreach ($projects as $proj) {
|
|||||||
'is_active' => $loc->is_active,
|
'is_active' => $loc->is_active,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
foreach ($proj->departments as $dept) {
|
||||||
|
$allDeptsData[$dept->id] = [
|
||||||
|
'id' => $dept->id,
|
||||||
|
'name' => $dept->name,
|
||||||
|
'is_active' => $dept->is_active,
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
$allLocsJson = json_encode($allLocsData);
|
$allLocsJson = json_encode($allLocsData);
|
||||||
|
$allDeptsJson = json_encode($allDeptsData);
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
{{-- Projects accordion --}}
|
{{-- Projects accordion --}}
|
||||||
@ -151,14 +179,24 @@ $allLocsJson = json_encode($allLocsData);
|
|||||||
|
|
||||||
{{-- Body --}}
|
{{-- Body --}}
|
||||||
<div class="proj-body" id="proj-body-{{ $project->id }}">
|
<div class="proj-body" id="proj-body-{{ $project->id }}">
|
||||||
|
<div class="proj-body-inner">
|
||||||
|
|
||||||
{{-- Location rows (sorted alphabetically by server) --}}
|
{{-- ── Locations column ── --}}
|
||||||
|
<div class="proj-section">
|
||||||
|
<div class="proj-section-header">
|
||||||
|
<span class="proj-section-title">
|
||||||
|
<svg width="12" height="12" fill="none" stroke="currentColor" viewBox="0 0 24 24" style="display:inline;vertical-align:middle;margin-right:4px;"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
||||||
|
Locations
|
||||||
|
</span>
|
||||||
|
<button type="button" onclick="openLocModal(null, {{ $project->id }})"
|
||||||
|
class="btn-primary" style="padding:3px 10px; font-size:11px;">+ Add</button>
|
||||||
|
</div>
|
||||||
<div id="loc-list-{{ $project->id }}">
|
<div id="loc-list-{{ $project->id }}">
|
||||||
@forelse($project->locations as $location)
|
@forelse($project->locations as $location)
|
||||||
<div id="loc-wrap-{{ $location->id }}">
|
<div id="loc-wrap-{{ $location->id }}">
|
||||||
<div class="loc-row" id="loc-row-{{ $location->id }}">
|
<div class="loc-row" id="loc-row-{{ $location->id }}">
|
||||||
<div style="display:flex; align-items:flex-start; gap:10px; flex:1; min-width:0;">
|
<div style="display:flex; align-items:flex-start; gap:8px; flex:1; min-width:0;">
|
||||||
<svg width="14" height="14" fill="none" stroke="{{ $location->is_active ? '#22c55e' : '#9ca3af' }}" viewBox="0 0 24 24" id="loc-icon-{{ $location->id }}" style="flex-shrink:0; margin-top:3px;">
|
<svg width="13" height="13" fill="none" stroke="{{ $location->is_active ? '#22c55e' : '#9ca3af' }}" viewBox="0 0 24 24" id="loc-icon-{{ $location->id }}" style="flex-shrink:0; margin-top:3px;">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"/>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||||
</svg>
|
</svg>
|
||||||
@ -184,20 +222,72 @@ $allLocsJson = json_encode($allLocsData);
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@empty
|
@empty
|
||||||
<div id="loc-empty-{{ $project->id }}" style="padding:14px 1.25rem 14px 2.5rem; color:#9ca3af; font-size:13px; border-bottom:1px solid #f1f5f9;">
|
<div id="loc-empty-{{ $project->id }}" style="padding:14px 1.25rem; color:#9ca3af; font-size:13px;">
|
||||||
No locations yet — add the first one below.
|
No locations yet.
|
||||||
</div>
|
</div>
|
||||||
@endforelse
|
@endforelse
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- Add Location button --}}
|
|
||||||
<div style="padding:0.875rem 1.25rem; background:#f8fafc; border-top:1px solid #e2e8f0;">
|
|
||||||
<button type="button" onclick="openLocModal(null, {{ $project->id }})"
|
|
||||||
class="btn-primary" style="padding:0.4rem 1rem; font-size:13px;">
|
|
||||||
+ Add Location to {{ $project->name }}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{-- ── Departments column ── --}}
|
||||||
|
<div class="proj-section">
|
||||||
|
<div class="proj-section-header">
|
||||||
|
<span class="proj-section-title">
|
||||||
|
<svg width="12" height="12" fill="none" stroke="currentColor" viewBox="0 0 24 24" style="display:inline;vertical-align:middle;margin-right:4px;"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
||||||
|
Departments
|
||||||
|
</span>
|
||||||
|
<button type="button" onclick="openAddDept({{ $project->id }})"
|
||||||
|
class="btn-primary" style="padding:3px 10px; font-size:11px;">+ Add</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Inline add row --}}
|
||||||
|
<div class="dept-edit-row" id="dept-add-row-{{ $project->id }}">
|
||||||
|
<input id="dept-add-name-{{ $project->id }}" type="text" class="form-input" style="flex:1; font-size:12px;" placeholder="Department name…"
|
||||||
|
onkeydown="if(event.key==='Enter') saveDeptAdd({{ $project->id }}); if(event.key==='Escape') closeAddDept({{ $project->id }})">
|
||||||
|
<button type="button" onclick="saveDeptAdd({{ $project->id }})" class="btn-primary" style="padding:4px 12px; font-size:12px; white-space:nowrap;">Save</button>
|
||||||
|
<button type="button" onclick="closeAddDept({{ $project->id }})" class="btn-secondary btn-sm">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="dept-list-{{ $project->id }}">
|
||||||
|
@forelse($project->departments as $dept)
|
||||||
|
<div id="dept-wrap-{{ $dept->id }}">
|
||||||
|
{{-- View row --}}
|
||||||
|
<div class="dept-row" id="dept-row-{{ $dept->id }}">
|
||||||
|
<div style="display:flex; align-items:center; gap:8px; flex:1; min-width:0;">
|
||||||
|
<svg width="13" height="13" fill="none" stroke="{{ $dept->is_active ? '#06b6d4' : '#9ca3af' }}" viewBox="0 0 24 24" id="dept-icon-{{ $dept->id }}" style="flex-shrink:0;">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||||
|
</svg>
|
||||||
|
<span id="dept-name-{{ $dept->id }}" style="font-size:13px; font-weight:600; color:#1e293b;">{{ $dept->name }}</span>
|
||||||
|
<span id="dept-inactive-{{ $dept->id }}" class="badge-inactive" style="{{ $dept->is_active ? 'display:none' : '' }}">Inactive</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; gap:4px; flex-shrink:0;">
|
||||||
|
<button type="button" onclick="openEditDept({{ $dept->id }}, {{ $project->id }}, '{{ addslashes($dept->name) }}', {{ $dept->is_active ? 'true' : 'false' }})"
|
||||||
|
class="btn-secondary btn-sm" style="padding:3px 8px; font-size:12px;">Edit</button>
|
||||||
|
<button type="button" onclick="deleteDept({{ $project->id }}, {{ $dept->id }}, '{{ addslashes($dept->name) }}')"
|
||||||
|
class="btn-danger btn-sm" style="padding:3px 8px; font-size:12px;">Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{-- Inline edit row --}}
|
||||||
|
<div class="dept-edit-row" id="dept-edit-row-{{ $dept->id }}">
|
||||||
|
<input id="dept-edit-name-{{ $dept->id }}" type="text" class="form-input" style="flex:1; font-size:12px;"
|
||||||
|
onkeydown="if(event.key==='Enter') saveDeptEdit({{ $dept->id }}, {{ $project->id }}); if(event.key==='Escape') closeEditDept({{ $dept->id }})">
|
||||||
|
<label style="display:flex;align-items:center;gap:4px;font-size:12px;color:#374151;white-space:nowrap;cursor:pointer;">
|
||||||
|
<input type="checkbox" id="dept-edit-active-{{ $dept->id }}" {{ $dept->is_active ? 'checked' : '' }} style="width:13px;height:13px;">
|
||||||
|
Active
|
||||||
|
</label>
|
||||||
|
<button type="button" onclick="saveDeptEdit({{ $dept->id }}, {{ $project->id }})" class="btn-primary" style="padding:4px 12px; font-size:12px; white-space:nowrap;">Save</button>
|
||||||
|
<button type="button" onclick="closeEditDept({{ $dept->id }})" class="btn-secondary btn-sm">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@empty
|
||||||
|
<div id="dept-empty-{{ $project->id }}" style="padding:14px 1.25rem; color:#9ca3af; font-size:13px;">
|
||||||
|
No departments yet.
|
||||||
|
</div>
|
||||||
|
@endforelse
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>{{-- end proj-body-inner --}}
|
||||||
</div>{{-- end proj-body --}}
|
</div>{{-- end proj-body --}}
|
||||||
</div>{{-- end proj-card --}}
|
</div>{{-- end proj-card --}}
|
||||||
|
|
||||||
@ -306,6 +396,7 @@ var BASE = '{{ url("settings/projects") }}';
|
|||||||
|
|
||||||
// Location data store (keyed by loc ID) — avoids inline JSON in onclick attributes
|
// Location data store (keyed by loc ID) — avoids inline JSON in onclick attributes
|
||||||
var LOC_DATA = {!! $allLocsJson !!};
|
var LOC_DATA = {!! $allLocsJson !!};
|
||||||
|
var DEPT_DATA = {!! $allDeptsJson !!};
|
||||||
|
|
||||||
// ── Core fetch helper ────────────────────────────────────────────────────────
|
// ── Core fetch helper ────────────────────────────────────────────────────────
|
||||||
function api(url, method, data) {
|
function api(url, method, data) {
|
||||||
@ -645,12 +736,147 @@ function buildProjectCard(p) {
|
|||||||
+ '<p id="edit-proj-error-' + p.id + '" class="field-error" style="margin:0;"></p>'
|
+ '<p id="edit-proj-error-' + p.id + '" class="field-error" style="margin:0;"></p>'
|
||||||
+ '</div>'
|
+ '</div>'
|
||||||
+ '<div class="proj-body" id="proj-body-' + p.id + '" style="display:block;">'
|
+ '<div class="proj-body" id="proj-body-' + p.id + '" style="display:block;">'
|
||||||
+ '<div id="loc-list-' + p.id + '">'
|
+ '<div class="proj-body-inner">'
|
||||||
+ '<div id="loc-empty-' + p.id + '" style="padding:14px 1.25rem 14px 2.5rem;color:#9ca3af;font-size:13px;border-bottom:1px solid #f1f5f9;">No locations yet — add the first one below.</div>'
|
+ '<div class="proj-section">'
|
||||||
|
+ '<div class="proj-section-header"><span class="proj-section-title">Locations</span>'
|
||||||
|
+ '<button type="button" onclick="openLocModal(null,' + p.id + ')" class="btn-primary" style="padding:3px 10px;font-size:11px;">+ Add</button></div>'
|
||||||
|
+ '<div id="loc-list-' + p.id + '"><div id="loc-empty-' + p.id + '" style="padding:14px 1.25rem;color:#9ca3af;font-size:13px;">No locations yet.</div></div>'
|
||||||
+ '</div>'
|
+ '</div>'
|
||||||
+ '<div style="padding:0.875rem 1.25rem;background:#f8fafc;border-top:1px solid #e2e8f0;">'
|
+ '<div class="proj-section">'
|
||||||
+ '<button type="button" onclick="openLocModal(null, ' + p.id + ')" class="btn-primary" style="padding:0.4rem 1rem;font-size:13px;">+ Add Location to ' + pName + '</button>'
|
+ '<div class="proj-section-header"><span class="proj-section-title">Departments</span>'
|
||||||
|
+ '<button type="button" onclick="openAddDept(' + p.id + ')" class="btn-primary" style="padding:3px 10px;font-size:11px;">+ Add</button></div>'
|
||||||
|
+ '<div class="dept-edit-row" id="dept-add-row-' + p.id + '">'
|
||||||
|
+ '<input id="dept-add-name-' + p.id + '" type="text" class="form-input" style="flex:1;font-size:12px;" placeholder="Department name…" onkeydown="if(event.key===\'Enter\') saveDeptAdd(' + p.id + '); if(event.key===\'Escape\') closeAddDept(' + p.id + ')">'
|
||||||
|
+ '<button type="button" onclick="saveDeptAdd(' + p.id + ')" class="btn-primary" style="padding:4px 12px;font-size:12px;white-space:nowrap;">Save</button>'
|
||||||
|
+ '<button type="button" onclick="closeAddDept(' + p.id + ')" class="btn-secondary btn-sm">✕</button>'
|
||||||
+ '</div>'
|
+ '</div>'
|
||||||
|
+ '<div id="dept-list-' + p.id + '"><div id="dept-empty-' + p.id + '" style="padding:14px 1.25rem;color:#9ca3af;font-size:13px;">No departments yet.</div></div>'
|
||||||
|
+ '</div>'
|
||||||
|
+ '</div>'
|
||||||
|
+ '</div>'
|
||||||
|
+ '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Department CRUD ───────────────────────────────────────────────────────────
|
||||||
|
function openAddDept(projectId) {
|
||||||
|
var row = document.getElementById('dept-add-row-' + projectId);
|
||||||
|
row.classList.add('open');
|
||||||
|
document.getElementById('dept-add-name-' + projectId).value = '';
|
||||||
|
document.getElementById('dept-add-name-' + projectId).focus();
|
||||||
|
}
|
||||||
|
function closeAddDept(projectId) {
|
||||||
|
document.getElementById('dept-add-row-' + projectId).classList.remove('open');
|
||||||
|
}
|
||||||
|
function saveDeptAdd(projectId) {
|
||||||
|
var input = document.getElementById('dept-add-name-' + projectId);
|
||||||
|
var name = input.value.trim();
|
||||||
|
if (!name) { showToast('Department name is required.', 'warn'); return; }
|
||||||
|
api(BASE + '/' + projectId + '/departments', 'POST', { name: name })
|
||||||
|
.then(function(data) {
|
||||||
|
var dept = data.department;
|
||||||
|
DEPT_DATA[dept.id] = dept;
|
||||||
|
closeAddDept(projectId);
|
||||||
|
var emptyEl = document.getElementById('dept-empty-' + projectId);
|
||||||
|
if (emptyEl) emptyEl.remove();
|
||||||
|
var list = document.getElementById('dept-list-' + projectId);
|
||||||
|
var div = document.createElement('div');
|
||||||
|
div.innerHTML = buildDeptRow(projectId, dept);
|
||||||
|
insertDeptInOrder(projectId, div.firstElementChild);
|
||||||
|
updateDeptCount(projectId);
|
||||||
|
showToast('Department "' + esc(dept.name) + '" added.', 'success');
|
||||||
|
})
|
||||||
|
.catch(function(e) { showToast(firstError(e), 'error'); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditDept(deptId, projectId, name, isActive) {
|
||||||
|
document.getElementById('dept-row-' + deptId).style.display = 'none';
|
||||||
|
var row = document.getElementById('dept-edit-row-' + deptId);
|
||||||
|
row.classList.add('open');
|
||||||
|
document.getElementById('dept-edit-name-' + deptId).value = name;
|
||||||
|
document.getElementById('dept-edit-active-' + deptId).checked = isActive;
|
||||||
|
document.getElementById('dept-edit-name-' + deptId).focus();
|
||||||
|
}
|
||||||
|
function closeEditDept(deptId) {
|
||||||
|
document.getElementById('dept-edit-row-' + deptId).classList.remove('open');
|
||||||
|
document.getElementById('dept-row-' + deptId).style.display = '';
|
||||||
|
}
|
||||||
|
function saveDeptEdit(deptId, projectId) {
|
||||||
|
var name = document.getElementById('dept-edit-name-' + deptId).value.trim();
|
||||||
|
var isActive = document.getElementById('dept-edit-active-' + deptId).checked;
|
||||||
|
if (!name) { showToast('Department name is required.', 'warn'); return; }
|
||||||
|
api(BASE + '/' + projectId + '/departments/' + deptId, 'PATCH', { name: name, is_active: isActive ? 1 : 0 })
|
||||||
|
.then(function(data) {
|
||||||
|
var dept = data.department;
|
||||||
|
DEPT_DATA[dept.id] = dept;
|
||||||
|
closeEditDept(deptId);
|
||||||
|
document.getElementById('dept-name-' + deptId).textContent = dept.name;
|
||||||
|
var badge = document.getElementById('dept-inactive-' + deptId);
|
||||||
|
if (badge) badge.style.display = dept.is_active ? 'none' : '';
|
||||||
|
var icon = document.getElementById('dept-icon-' + deptId);
|
||||||
|
if (icon) icon.setAttribute('stroke', dept.is_active ? '#06b6d4' : '#9ca3af');
|
||||||
|
showToast('Department updated.', 'success');
|
||||||
|
})
|
||||||
|
.catch(function(e) { showToast(firstError(e), 'error'); });
|
||||||
|
}
|
||||||
|
function deleteDept(projectId, deptId, name) {
|
||||||
|
confirmAction('Delete Department', 'Delete "' + name + '"?', function() {
|
||||||
|
api(BASE + '/' + projectId + '/departments/' + deptId, 'DELETE')
|
||||||
|
.then(function() {
|
||||||
|
delete DEPT_DATA[deptId];
|
||||||
|
var wrap = document.getElementById('dept-wrap-' + deptId);
|
||||||
|
if (wrap) wrap.remove();
|
||||||
|
updateDeptCount(projectId);
|
||||||
|
var list = document.getElementById('dept-list-' + projectId);
|
||||||
|
if (list && !list.querySelector('[id^="dept-wrap-"]')) {
|
||||||
|
list.innerHTML = '<div id="dept-empty-' + projectId + '" style="padding:14px 1.25rem;color:#9ca3af;font-size:13px;">No departments yet.</div>';
|
||||||
|
}
|
||||||
|
showToast('Department "' + esc(name) + '" deleted.', 'success');
|
||||||
|
})
|
||||||
|
.catch(function(e) { showToast(firstError(e), 'error'); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDeptCount(projectId) {
|
||||||
|
var list = document.getElementById('dept-list-' + projectId);
|
||||||
|
var count = list ? list.querySelectorAll('[id^="dept-wrap-"]').length : 0;
|
||||||
|
var el = document.getElementById('proj-dept-count-' + projectId);
|
||||||
|
if (el) el.textContent = count + ' ' + (count === 1 ? 'dept' : 'depts');
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertDeptInOrder(projectId, newWrapEl) {
|
||||||
|
var list = document.getElementById('dept-list-' + projectId);
|
||||||
|
var newName = (newWrapEl.querySelector('[id^="dept-name-"]').textContent || '').trim().toLowerCase();
|
||||||
|
var wraps = list.querySelectorAll('[id^="dept-wrap-"]');
|
||||||
|
var inserted = false;
|
||||||
|
for (var i = 0; i < wraps.length; i++) {
|
||||||
|
var nameEl = wraps[i].querySelector('[id^="dept-name-"]');
|
||||||
|
if (nameEl && newName < nameEl.textContent.trim().toLowerCase()) {
|
||||||
|
list.insertBefore(newWrapEl, wraps[i]);
|
||||||
|
inserted = true; break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!inserted) list.appendChild(newWrapEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDeptRow(projectId, dept) {
|
||||||
|
var safeName = (dept.name || '').replace(/\\/g,'\\\\').replace(/'/g,"\\'");
|
||||||
|
return '<div id="dept-wrap-' + dept.id + '">'
|
||||||
|
+ '<div class="dept-row" id="dept-row-' + dept.id + '">'
|
||||||
|
+ '<div style="display:flex;align-items:center;gap:8px;flex:1;min-width:0;">'
|
||||||
|
+ '<svg width="13" height="13" fill="none" stroke="#06b6d4" viewBox="0 0 24 24" id="dept-icon-' + dept.id + '" style="flex-shrink:0;"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/></svg>'
|
||||||
|
+ '<span id="dept-name-' + dept.id + '" style="font-size:13px;font-weight:600;color:#1e293b;">' + esc(dept.name) + '</span>'
|
||||||
|
+ '<span id="dept-inactive-' + dept.id + '" class="badge-inactive" style="display:none;">Inactive</span>'
|
||||||
|
+ '</div>'
|
||||||
|
+ '<div style="display:flex;gap:4px;flex-shrink:0;">'
|
||||||
|
+ '<button type="button" onclick="openEditDept(' + dept.id + ',' + projectId + ',\'' + safeName + '\',true)" class="btn-secondary btn-sm" style="padding:3px 8px;font-size:12px;">Edit</button>'
|
||||||
|
+ '<button type="button" onclick="deleteDept(' + projectId + ',' + dept.id + ',\'' + safeName + '\')" class="btn-danger btn-sm" style="padding:3px 8px;font-size:12px;">Delete</button>'
|
||||||
|
+ '</div>'
|
||||||
|
+ '</div>'
|
||||||
|
+ '<div class="dept-edit-row" id="dept-edit-row-' + dept.id + '">'
|
||||||
|
+ '<input id="dept-edit-name-' + dept.id + '" type="text" class="form-input" style="flex:1;font-size:12px;" onkeydown="if(event.key===\'Enter\') saveDeptEdit(' + dept.id + ',' + projectId + '); if(event.key===\'Escape\') closeEditDept(' + dept.id + ')">'
|
||||||
|
+ '<label style="display:flex;align-items:center;gap:4px;font-size:12px;color:#374151;white-space:nowrap;cursor:pointer;"><input type="checkbox" id="dept-edit-active-' + dept.id + '" checked style="width:13px;height:13px;"> Active</label>'
|
||||||
|
+ '<button type="button" onclick="saveDeptEdit(' + dept.id + ',' + projectId + ')" class="btn-primary" style="padding:4px 12px;font-size:12px;white-space:nowrap;">Save</button>'
|
||||||
|
+ '<button type="button" onclick="closeEditDept(' + dept.id + ')" class="btn-secondary btn-sm">✕</button>'
|
||||||
+ '</div>'
|
+ '</div>'
|
||||||
+ '</div>';
|
+ '</div>';
|
||||||
}
|
}
|
||||||
|
|||||||
@ -141,6 +141,9 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
|||||||
Route::post('settings/projects/{project}/locations', [ProjectSettingController::class, 'storeLocation'])->name('settings.projects.locations.store');
|
Route::post('settings/projects/{project}/locations', [ProjectSettingController::class, 'storeLocation'])->name('settings.projects.locations.store');
|
||||||
Route::patch('settings/projects/{project}/locations/{location}', [ProjectSettingController::class, 'updateLocation'])->name('settings.projects.locations.update');
|
Route::patch('settings/projects/{project}/locations/{location}', [ProjectSettingController::class, 'updateLocation'])->name('settings.projects.locations.update');
|
||||||
Route::delete('settings/projects/{project}/locations/{location}', [ProjectSettingController::class, 'destroyLocation'])->name('settings.projects.locations.destroy');
|
Route::delete('settings/projects/{project}/locations/{location}', [ProjectSettingController::class, 'destroyLocation'])->name('settings.projects.locations.destroy');
|
||||||
|
Route::post('settings/projects/{project}/departments', [ProjectSettingController::class, 'storeDepartment'])->name('settings.projects.departments.store');
|
||||||
|
Route::patch('settings/projects/{project}/departments/{department}', [ProjectSettingController::class, 'updateDepartment'])->name('settings.projects.departments.update');
|
||||||
|
Route::delete('settings/projects/{project}/departments/{department}', [ProjectSettingController::class, 'destroyDepartment'])->name('settings.projects.departments.destroy');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user