Compare commits
25 Commits
ee3086c34b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 433c15a183 | |||
| 5587026446 | |||
| 5092896a1e | |||
| 938e704a67 | |||
| 828e70fbe2 | |||
| da0c8bd134 | |||
| 316e0ede39 | |||
| 564b433a62 | |||
| 7df6d208d9 | |||
| 860c502f32 | |||
| 8101f22413 | |||
| fe57388f05 | |||
| 75c07aa0d4 | |||
| 558b1732aa | |||
| 19fef5aa25 | |||
| 238310180f | |||
| 0fca7387e0 | |||
| ffd377cd39 | |||
| 24976e28da | |||
| de68638d7c | |||
| 3fd4d62df1 | |||
| 25f61cdb7d | |||
| 6e66f707d5 | |||
| 941dbd5997 | |||
| c44958ac16 |
@@ -13,26 +13,14 @@ use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class OfflineSyncController extends Controller
|
||||
{
|
||||
/**
|
||||
* Allowed mediable model types (whitelist to prevent RCE via dynamic instantiation).
|
||||
*/
|
||||
private const ALLOWED_MEDIABLE_TYPES = [
|
||||
'project' => \App\Models\Project::class,
|
||||
'phase' => \App\Models\Phase::class,
|
||||
'layer' => \App\Models\Layer::class,
|
||||
'feature' => \App\Models\Feature::class,
|
||||
'inspection' => \App\Models\Inspection::class,
|
||||
'issue' => \App\Models\Issue::class,
|
||||
];
|
||||
|
||||
public function storePending(Request $request)
|
||||
{
|
||||
$payload = $request->validate([
|
||||
'action' => 'required|in:progress_update,inspection,feature_create,media_upload,task_complete',
|
||||
'payload' => 'required|array',
|
||||
]);
|
||||
PendingSync::create([
|
||||
'user_id' => Auth::id(),
|
||||
$pending = PendingSync::create([
|
||||
'user_id' => Auth::id() ?? 1,
|
||||
'action' => $payload['action'],
|
||||
'payload' => $payload['payload'],
|
||||
]);
|
||||
@@ -44,111 +32,65 @@ class OfflineSyncController extends Controller
|
||||
$user = Auth::user();
|
||||
$pendings = PendingSync::where('user_id', $user->id)->whereNull('synced_at')->get();
|
||||
$results = [];
|
||||
|
||||
foreach ($pendings as $pending) {
|
||||
$result = ['id' => $pending->id, 'action' => $pending->action, 'success' => false, 'error' => null];
|
||||
|
||||
try {
|
||||
if ($pending->action === 'progress_update') {
|
||||
$phaseId = (int) ($pending->payload['phase_id'] ?? 0);
|
||||
$progress = (int) ($pending->payload['progress'] ?? 0);
|
||||
$progress = max(0, min(100, $progress));
|
||||
|
||||
$phase = Phase::find($phaseId);
|
||||
$phase = Phase::find($pending->payload['phase_id']);
|
||||
if ($phase) {
|
||||
// Verify user has access to this phase's project
|
||||
if (!$user->hasRole('Admin') && !$phase->project->users()->where('user_id', $user->id)->exists()) {
|
||||
$result['error'] = 'Access denied to this project.';
|
||||
} else {
|
||||
$phase->progress_percent = $progress;
|
||||
$phase->progress_percent = $pending->payload['progress'];
|
||||
$phase->save();
|
||||
$phase->progressUpdates()->create([
|
||||
'user_id' => $user->id,
|
||||
'progress_percent' => $progress,
|
||||
'comment' => substr($pending->payload['comment'] ?? '', 0, 500),
|
||||
'progress_percent' => $pending->payload['progress'],
|
||||
'comment' => $pending->payload['comment'] ?? '',
|
||||
'location' => $pending->payload['location'] ?? null,
|
||||
]);
|
||||
}
|
||||
$result['success'] = true;
|
||||
}
|
||||
} else {
|
||||
$result['error'] = 'Phase not found.';
|
||||
}
|
||||
|
||||
} elseif ($pending->action === 'inspection') {
|
||||
$p = $pending->payload;
|
||||
$inspection = Inspection::create([
|
||||
'project_id' => (int) ($p['project_id'] ?? 0),
|
||||
'feature_id' => isset($p['feature_id']) ? (int) $p['feature_id'] : null,
|
||||
'layer_id' => isset($p['layer_id']) ? (int) $p['layer_id'] : null,
|
||||
'template_id' => isset($p['template_id'])? (int) $p['template_id']: null,
|
||||
'user_id' => $user->id,
|
||||
'inspector_user_id' => $user->id,
|
||||
'status' => 'completed',
|
||||
'completed_at' => now(),
|
||||
'result' => in_array($p['result'] ?? '', Inspection::RESULTS) ? $p['result'] : null,
|
||||
'notes' => substr($p['notes'] ?? '', 0, 2000),
|
||||
'data' => is_array($p['data'] ?? null) ? $p['data'] : [],
|
||||
]);
|
||||
$inspection = Inspection::create($pending->payload);
|
||||
$result['success'] = true;
|
||||
$result['data'] = ['inspection_id' => $inspection->id];
|
||||
|
||||
} elseif ($pending->action === 'feature_create') {
|
||||
$p = $pending->payload;
|
||||
$feature = Feature::create([
|
||||
'layer_id' => (int) ($p['layer_id'] ?? 0),
|
||||
'name' => substr($p['name'] ?? 'Elemento', 0, 255),
|
||||
'geometry' => is_array($p['geometry'] ?? null) ? $p['geometry'] : null,
|
||||
'properties' => is_array($p['properties'] ?? null) ? $p['properties'] : [],
|
||||
'template_id' => isset($p['template_id']) ? (int) $p['template_id'] : null,
|
||||
'progress' => max(0, min(100, (int) ($p['progress'] ?? 0))),
|
||||
'status' => in_array($p['status'] ?? '', Feature::STATUSES) ? $p['status'] : 'planned',
|
||||
'responsible' => isset($p['responsible']) ? substr($p['responsible'], 0, 255) : null,
|
||||
]);
|
||||
$feature = Feature::create($pending->payload);
|
||||
$result['success'] = true;
|
||||
$result['data'] = ['feature_id' => $feature->id];
|
||||
|
||||
} elseif ($pending->action === 'media_upload') {
|
||||
// Assuming payload has: 'file' (base64), 'path', 'model_type', 'model_id'
|
||||
// We'll decode the base64 and store the file
|
||||
if (isset($pending->payload['file'], $pending->payload['path'])) {
|
||||
// Restrict path to safe uploads directory
|
||||
$safePath = 'uploads/' . ltrim(basename($pending->payload['path']), '/');
|
||||
$decoded = base64_decode($pending->payload['file'], true);
|
||||
|
||||
$decoded = base64_decode($pending->payload['file']);
|
||||
if ($decoded !== false) {
|
||||
Storage::disk('public')->put($safePath, $decoded);
|
||||
|
||||
// Whitelist-based model type resolution (prevents RCE)
|
||||
$path = Storage::put($pending->payload['path'], $decoded);
|
||||
// Attach to model if model_type and model_id are provided
|
||||
if (isset($pending->payload['model_type'], $pending->payload['model_id'])) {
|
||||
$typeKey = strtolower(trim($pending->payload['model_type']));
|
||||
if (array_key_exists($typeKey, self::ALLOWED_MEDIABLE_TYPES)) {
|
||||
$modelClass = self::ALLOWED_MEDIABLE_TYPES[$typeKey];
|
||||
$model = $modelClass::find((int) $pending->payload['model_id']);
|
||||
$model = new $pending->payload['model_type'];
|
||||
$model = $model->find($pending->payload['model_id']);
|
||||
if ($model) {
|
||||
$model->media()->create([
|
||||
'name' => substr($pending->payload['name'] ?? 'unnamed', 0, 255),
|
||||
'file_path' => $safePath,
|
||||
'file_type' => substr($pending->payload['mime_type'] ?? 'application/octet-stream', 0, 100),
|
||||
'file_extension' => pathinfo($safePath, PATHINFO_EXTENSION),
|
||||
'file_size' => strlen($decoded),
|
||||
'category' => 'other',
|
||||
'uploaded_by' => $user->id,
|
||||
'name' => $pending->payload['name'] ?? 'unnamed',
|
||||
'path' => $path,
|
||||
'mime_type' => $pending->payload['mime_type'] ?? 'application/octet-stream',
|
||||
'disk' => 'public',
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
$result['success'] = true;
|
||||
$result['data'] = ['path' => $safePath];
|
||||
$result['data'] = ['path' => $path];
|
||||
} else {
|
||||
$result['error'] = 'Failed to decode base64 file.';
|
||||
$result['error'] = 'Failed to decode base64 file';
|
||||
}
|
||||
} else {
|
||||
$result['error'] = 'Missing file or path in payload.';
|
||||
$result['error'] = 'Missing file or path in payload';
|
||||
}
|
||||
|
||||
} elseif ($pending->action === 'task_complete') {
|
||||
// No-op placeholder, just mark as synced
|
||||
// Example: mark a task as complete (you can adjust as needed)
|
||||
// For now, just log and mark as success
|
||||
\Log::info('Task completed offline', $pending->payload);
|
||||
$result['success'] = true;
|
||||
|
||||
} else {
|
||||
$result['error'] = 'Unknown action type.';
|
||||
$result['error'] = 'Unknown action type';
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$result['error'] = $e->getMessage();
|
||||
@@ -161,7 +103,6 @@ class OfflineSyncController extends Controller
|
||||
|
||||
$results[] = $result;
|
||||
}
|
||||
|
||||
return response()->json(['synced' => $results]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,15 +19,6 @@ class ProjectController extends Controller
|
||||
return view('projects.index');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for creating a new resource.
|
||||
*/
|
||||
public function create()
|
||||
{
|
||||
Gate::authorize('create projects');
|
||||
return view('projects.create');
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created resource in storage.
|
||||
*/
|
||||
@@ -58,15 +49,6 @@ class ProjectController extends Controller
|
||||
return redirect()->route('projects.map', $project);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for editing the specified resource.
|
||||
*/
|
||||
public function edit(Project $project) // <--- ROUTE MODEL BINDING
|
||||
{
|
||||
Gate::authorize('edit projects', $project);
|
||||
return view('projects.edit', compact('project'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified resource in storage.
|
||||
*/
|
||||
|
||||
@@ -11,25 +11,21 @@ use App\Exports\ProjectsExport;
|
||||
use App\Exports\PhasesExport;
|
||||
use App\Exports\InspectionsExport;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class ExportController extends Controller
|
||||
{
|
||||
public function exportProjects(Request $request)
|
||||
{
|
||||
Gate::authorize('manage all');
|
||||
return Excel::download(new ProjectsExport, 'projects.xlsx');
|
||||
}
|
||||
|
||||
public function exportPhases(Request $request)
|
||||
{
|
||||
Gate::authorize('manage all');
|
||||
return Excel::download(new PhasesExport, 'phases.xlsx');
|
||||
}
|
||||
|
||||
public function exportInspections(Request $request)
|
||||
{
|
||||
Gate::authorize('manage all');
|
||||
return Excel::download(new InspectionsExport, 'inspections.xlsx');
|
||||
}
|
||||
}
|
||||
|
||||
+14
-33
@@ -9,53 +9,34 @@ use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class AdminUsers extends Component
|
||||
{
|
||||
public $users;
|
||||
public string $search = '';
|
||||
public $roles;
|
||||
|
||||
public function mount()
|
||||
public function mount(): void
|
||||
{
|
||||
if (!Auth::user()->hasRole('Admin')) {
|
||||
abort(403);
|
||||
}
|
||||
$this->roles = Role::all();
|
||||
$this->loadUsers();
|
||||
if (!Auth::user()->hasRole('Admin')) abort(403);
|
||||
$this->roles = Role::orderBy('name')->get();
|
||||
}
|
||||
|
||||
public function loadUsers()
|
||||
public function getUsersProperty()
|
||||
{
|
||||
$this->users = User::with('roles')->orderBy('name')->get();
|
||||
}
|
||||
|
||||
public function updateRole($userId, $roleName)
|
||||
{
|
||||
$user = Auth::user();
|
||||
if (!$user->hasRole('Admin')) {
|
||||
session()->flash('error', 'Solo administradores.');
|
||||
return;
|
||||
}
|
||||
|
||||
$targetUser = User::findOrFail($userId);
|
||||
if ($targetUser->id === $user->id && $targetUser->hasRole('Admin')) {
|
||||
session()->flash('error', 'No puedes cambiarte el rol a ti mismo.');
|
||||
return;
|
||||
}
|
||||
|
||||
$targetUser->syncRoles([$roleName]);
|
||||
$this->loadUsers();
|
||||
$this->dispatch('notify', 'Rol actualizado.');
|
||||
return User::with('roles')
|
||||
->when($this->search, fn($q) =>
|
||||
$q->where(fn($q2) => $q2
|
||||
->where('name', 'like', '%' . $this->search . '%')
|
||||
->orWhere('email', 'like', '%' . $this->search . '%')))
|
||||
->orderBy('name')
|
||||
->get();
|
||||
}
|
||||
|
||||
public function deleteUser(int $userId): void
|
||||
{
|
||||
if (!Auth::user()->hasRole('Admin')) abort(403);
|
||||
|
||||
if ($userId === Auth::id()) {
|
||||
session()->flash('error', 'No puedes eliminarte a ti mismo.');
|
||||
$this->dispatch('notify', 'No puedes eliminarte a ti mismo.');
|
||||
return;
|
||||
}
|
||||
User::findOrFail($userId)->delete();
|
||||
session()->flash('message', 'Usuario eliminado.');
|
||||
$this->loadUsers();
|
||||
$this->dispatch('notify', 'Usuario eliminado.');
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
||||
@@ -4,7 +4,11 @@ namespace App\Livewire\Client;
|
||||
|
||||
use Livewire\Component;
|
||||
use App\Models\Project;
|
||||
use App\Models\Phase;
|
||||
use App\Models\Inspection;
|
||||
use App\Models\Feature;
|
||||
use App\Models\ChangeOrder;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class ClientProjects extends Component
|
||||
{
|
||||
@@ -21,33 +25,20 @@ class ClientProjects extends Component
|
||||
|
||||
public function loadProjects()
|
||||
{
|
||||
// Get projects where the user has the 'client' role
|
||||
$user = auth()->user();
|
||||
$this->projects = $user->projects()
|
||||
->wherePivot('role_in_project', 'client')
|
||||
->with(['phases' => function ($query) {
|
||||
->with(['phases' => function($query) {
|
||||
$query->select('id', 'project_id', 'name', 'progress_percent');
|
||||
}])
|
||||
->get()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return only project IDs the current user can access as client.
|
||||
*/
|
||||
private function accessibleProjectIds(): \Illuminate\Support\Collection
|
||||
{
|
||||
return auth()->user()->projects()
|
||||
->wherePivot('role_in_project', 'client')
|
||||
->pluck('projects.id');
|
||||
}
|
||||
|
||||
public function selectProject($projectId)
|
||||
{
|
||||
// Verify the project is one the user is a client on
|
||||
if (!$this->accessibleProjectIds()->contains((int) $projectId)) {
|
||||
abort(403);
|
||||
}
|
||||
$this->selectedProject = (int) $projectId;
|
||||
$this->selectedProject = $projectId;
|
||||
$this->loadProjectDetails();
|
||||
}
|
||||
|
||||
@@ -57,14 +48,10 @@ class ClientProjects extends Component
|
||||
return;
|
||||
}
|
||||
|
||||
// Re-verify ownership on every load
|
||||
if (!$this->accessibleProjectIds()->contains($this->selectedProject)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$project = Project::with([
|
||||
'phases',
|
||||
'changeOrders',
|
||||
'phases.features',
|
||||
'inspections.template',
|
||||
'changeOrders' // Load change orders for this project
|
||||
])->find($this->selectedProject);
|
||||
|
||||
if (!$project) {
|
||||
@@ -74,90 +61,111 @@ class ClientProjects extends Component
|
||||
$this->projectDetails = [
|
||||
'id' => $project->id,
|
||||
'name' => $project->name,
|
||||
'description'=> $project->description ?? '',
|
||||
'description' => $project->description,
|
||||
'start_date' => $project->start_date,
|
||||
'end_date' => $project->end_date_estimated,
|
||||
'end_date' => $project->end_date,
|
||||
'status' => $project->status,
|
||||
'progress' => round($project->phases->avg('progress_percent') ?? 0),
|
||||
'progress' => $project->phases->avg('progress_percent') ?? 0,
|
||||
];
|
||||
|
||||
// Get recent images (we can fetch from media table if needed, but for now we'll keep simulated or link to real)
|
||||
// For simplicity, we'll try to get some media images for the project
|
||||
$mediaImages = $project->media()
|
||||
->where('category', 'image')
|
||||
->latest()
|
||||
->take(3)
|
||||
->get()
|
||||
->map(fn ($media) => [
|
||||
->map(function($media) {
|
||||
return [
|
||||
'url' => $media->url,
|
||||
'title' => $media->name,
|
||||
'date' => $media->created_at->format('d/m/Y'),
|
||||
])
|
||||
'date' => $media->created_at->format('d/m/Y')
|
||||
];
|
||||
})
|
||||
->toArray();
|
||||
|
||||
$this->galleryImages = $mediaImages ?: [];
|
||||
// If we don't have 3 images, we can fallback to placeholders or just use what we have
|
||||
if (count($mediaImages) > 0) {
|
||||
$this->galleryImages = $mediaImages;
|
||||
} else {
|
||||
// Fallback to placeholders
|
||||
$this->galleryImages = [
|
||||
[
|
||||
'url' => 'https://via.placeholder.com/400x300?text=Avance+1',
|
||||
'title' => 'Avance inicial',
|
||||
'date' => now()->subDays(30)->format('d/m/Y')
|
||||
],
|
||||
[
|
||||
'url' => 'https://via.placeholder.com/400x300?text=Avance+2',
|
||||
'title' => 'Estructura levantada',
|
||||
'date' => now()->subDays(15)->format('d/m/Y')
|
||||
],
|
||||
[
|
||||
'url' => 'https://via.placeholder.com/400x300?text=Avance+3',
|
||||
'title' => 'Instalaciones',
|
||||
'date' => now()->subDays(5)->format('d/m/Y')
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
// Get change orders for this project
|
||||
$this->changeOrders = $project->changeOrders
|
||||
->sortByDesc('requested_at')
|
||||
->map(fn ($order) => [
|
||||
->orderBy('requested_at', 'desc')
|
||||
->get()
|
||||
->map(function($order) {
|
||||
return [
|
||||
'id' => $order->id,
|
||||
'title' => $order->title,
|
||||
'description' => $order->description,
|
||||
'status' => $order->status,
|
||||
'requested_at' => $order->requested_at?->format('d/m/Y') ?? '',
|
||||
'amount' => $order->amount,
|
||||
])
|
||||
->values()
|
||||
'requested_at' => $order->requested_at->format('d/m/Y'),
|
||||
'amount' => $order->amount
|
||||
];
|
||||
})
|
||||
->toArray();
|
||||
}
|
||||
|
||||
public function approveChangeOrder($orderId)
|
||||
{
|
||||
$changeOrder = ChangeOrder::where('id', $orderId)
|
||||
->where('project_id', $this->selectedProject)
|
||||
->first();
|
||||
|
||||
if (!$changeOrder) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
// Verify this project is accessible by the current user
|
||||
if (!$this->accessibleProjectIds()->contains($this->selectedProject)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$changeOrder->update([
|
||||
'status' => 'approved',
|
||||
'responded_at' => now()->toDateString(),
|
||||
'responded_by' => auth()->id(),
|
||||
]);
|
||||
// Update the change order in the database
|
||||
$changeOrder = ChangeOrder::find($orderId);
|
||||
if ($changeOrder) {
|
||||
// Check that the change order belongs to the selected project (security)
|
||||
if ($changeOrder->project_id == $this->selectedProject) {
|
||||
$changeOrder->status = 'approved';
|
||||
$changeOrder->responded_at = now()->toDateString();
|
||||
$changeOrder->responded_by = auth()->id();
|
||||
$changeOrder->save();
|
||||
|
||||
// Refresh the change orders list
|
||||
$this->loadProjectDetails();
|
||||
|
||||
// Notify any listeners (optional)
|
||||
$this->dispatch('changeOrderUpdated', ['id' => $orderId, 'status' => 'approved']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function rejectChangeOrder($orderId)
|
||||
{
|
||||
$changeOrder = ChangeOrder::where('id', $orderId)
|
||||
->where('project_id', $this->selectedProject)
|
||||
->first();
|
||||
|
||||
if (!$changeOrder) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
// Verify this project is accessible by the current user
|
||||
if (!$this->accessibleProjectIds()->contains($this->selectedProject)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$changeOrder->update([
|
||||
'status' => 'rejected',
|
||||
'responded_at' => now()->toDateString(),
|
||||
'responded_by' => auth()->id(),
|
||||
]);
|
||||
// Update the change order in the database
|
||||
$changeOrder = ChangeOrder::find($orderId);
|
||||
if ($changeOrder) {
|
||||
// Check that the change order belongs to the selected project (security)
|
||||
if ($changeOrder->project_id == $this->selectedProject) {
|
||||
$changeOrder->status = 'rejected';
|
||||
$changeOrder->responded_at = now()->toDateString();
|
||||
$changeOrder->responded_by = auth()->id();
|
||||
$changeOrder->save();
|
||||
|
||||
// Refresh the change orders list
|
||||
$this->loadProjectDetails();
|
||||
|
||||
// Notify any listeners (optional)
|
||||
$this->dispatch('changeOrderUpdated', ['id' => $orderId, 'status' => 'rejected']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
|
||||
@@ -4,242 +4,64 @@ namespace App\Livewire;
|
||||
|
||||
use Livewire\Component;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\WithFileUploads;
|
||||
use App\Models\Company;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
#[Layout('layouts.app')]
|
||||
class CompanyManagement extends Component
|
||||
{
|
||||
use WithFileUploads;
|
||||
|
||||
// Form state
|
||||
public $name = '';
|
||||
public $tax_id = '';
|
||||
public $address = '';
|
||||
public $email = '';
|
||||
public $website = '';
|
||||
public $type = 'other';
|
||||
public $notes = '';
|
||||
public $apodo = '';
|
||||
public $estado = 'activo';
|
||||
public $logo = null;
|
||||
|
||||
// UI state
|
||||
public $showCreateForm = false;
|
||||
public $showEditForm = false;
|
||||
public $editingCompanyId = null;
|
||||
public $search = '';
|
||||
|
||||
// Filter state
|
||||
public $filterType = '';
|
||||
public $filterEstado = '';
|
||||
|
||||
// Validation rules
|
||||
protected $rules = [
|
||||
'name' => 'required|string|max:255',
|
||||
'apodo' => 'nullable|string|max:100',
|
||||
'tax_id' => 'nullable|string|max:50|unique:companies,tax_id,',
|
||||
'estado' => 'required|in:activo,inactivo,suspendido',
|
||||
'address' => 'nullable|string',
|
||||
'phone' => 'nullable|string|max:20',
|
||||
'email' => 'nullable|email|max:255',
|
||||
'website' => 'nullable|url|max:255',
|
||||
'type' => 'required|in:owner,constructor,subcontractor,consultant,supplier,other',
|
||||
'notes' => 'nullable|string',
|
||||
'logo' => 'nullable|image|max:2048', // 2MB max
|
||||
];
|
||||
|
||||
public function mount()
|
||||
{
|
||||
if (!Auth::user()->hasRole('Admin')) abort(403);
|
||||
$this->resetForm();
|
||||
}
|
||||
|
||||
public function resetForm()
|
||||
{
|
||||
$this->name = '';
|
||||
$this->tax_id = '';
|
||||
$this->address = '';
|
||||
$this->phone = '';
|
||||
$this->email = '';
|
||||
$this->website = '';
|
||||
$this->type = 'other';
|
||||
$this->notes = '';
|
||||
$this->apodo = '';
|
||||
$this->estado = 'activo';
|
||||
$this->logo = null;
|
||||
$this->editingCompanyId = null;
|
||||
$this->showCreateForm = false;
|
||||
$this->showEditForm = false;
|
||||
$this->resetErrorBag();
|
||||
$this->resetValidation();
|
||||
}
|
||||
|
||||
public function resetFilters()
|
||||
{
|
||||
$this->search = '';
|
||||
$this->filterType = '';
|
||||
$this->filterEstado = '';
|
||||
}
|
||||
|
||||
public function toggleCreateForm()
|
||||
{
|
||||
$this->showCreateForm = !$this->showCreateForm;
|
||||
if ($this->showCreateForm) {
|
||||
$this->showEditForm = false;
|
||||
$this->resetForm();
|
||||
}
|
||||
}
|
||||
|
||||
public function editCompany(Company $company)
|
||||
{
|
||||
$this->editingCompanyId = $company->id;
|
||||
$this->name = $company->name;
|
||||
$this->tax_id = $company->tax_id;
|
||||
$this->address = $company->address;
|
||||
$this->phone = $company->phone;
|
||||
$this->email = $company->email;
|
||||
$this->website = $company->website;
|
||||
$this->type = $company->type;
|
||||
$this->notes = $company->notes;
|
||||
$this->apodo = $company->apodo;
|
||||
$this->estado = $company->estado;
|
||||
// Note: logo is not populated for security reasons
|
||||
$this->showEditForm = true;
|
||||
$this->showCreateForm = false;
|
||||
}
|
||||
|
||||
public function updateCompany()
|
||||
{
|
||||
if (!Auth::user()->hasRole('Admin')) abort(403);
|
||||
$this->validate();
|
||||
|
||||
$company = Company::findOrFail($this->editingCompanyId);
|
||||
|
||||
$data = [
|
||||
'name' => $this->name,
|
||||
'tax_id' => $this->tax_id,
|
||||
'address' => $this->address,
|
||||
'phone' => $this->phone,
|
||||
'email' => $this->email,
|
||||
'website' => $this->website,
|
||||
'type' => $this->type,
|
||||
'notes' => $this->notes,
|
||||
];
|
||||
|
||||
if ($this->logo) {
|
||||
$logoPath = $this->logo->store('company-logos', 'public');
|
||||
$data['logo_path'] = $logoPath;
|
||||
}
|
||||
|
||||
$company->update($data);
|
||||
|
||||
session()->flash('message', 'Empresa actualizada correctamente.');
|
||||
$this->resetForm();
|
||||
}
|
||||
|
||||
public function createCompany()
|
||||
{
|
||||
if (!Auth::user()->hasRole('Admin')) abort(403);
|
||||
$this->validate();
|
||||
|
||||
$data = [
|
||||
'name' => $this->name,
|
||||
'tax_id' => $this->tax_id,
|
||||
'address' => $this->address,
|
||||
'phone' => $this->phone,
|
||||
'email' => $this->email,
|
||||
'website' => $this->website,
|
||||
'type' => $this->type,
|
||||
'notes' => $this->notes,
|
||||
];
|
||||
|
||||
if ($this->logo) {
|
||||
$logoPath = $this->logo->store('company-logos', 'public');
|
||||
$data['logo_path'] = $logoPath;
|
||||
}
|
||||
|
||||
Company::create($data);
|
||||
|
||||
session()->flash('message', 'Empresa creada correctamente.');
|
||||
$this->resetForm();
|
||||
}
|
||||
|
||||
public function deleteCompany(Company $company)
|
||||
{
|
||||
if (!Auth::user()->hasRole('Admin')) abort(403);
|
||||
$company->delete(); // Soft delete
|
||||
session()->flash('message', 'Empresa eliminada correctamente.');
|
||||
}
|
||||
public string $search = '';
|
||||
public string $filterType = '';
|
||||
public string $filterEstado = '';
|
||||
|
||||
public function getCompaniesProperty()
|
||||
{
|
||||
return Company::when($this->search, function ($query) {
|
||||
$query->where('name', 'like', '%' . $this->search . '%')
|
||||
->orWhere('apodo', 'like', '%' . $this->search . '%')
|
||||
->orWhere('tax_id', 'like', '%' . $this->search . '%');
|
||||
return Company::when($this->search, function ($q) {
|
||||
$s = '%' . $this->search . '%';
|
||||
$q->where(fn($q2) => $q2
|
||||
->where('name', 'like', $s)
|
||||
->orWhere('apodo', 'like', $s)
|
||||
->orWhere('tax_id', 'like', $s));
|
||||
})
|
||||
->when($this->filterType, function ($query) {
|
||||
$query->where('type', $this->filterType);
|
||||
})
|
||||
->when($this->filterEstado, function ($query) {
|
||||
$query->where('estado', $this->filterEstado);
|
||||
})
|
||||
->withCount('projects') // Eager load project count
|
||||
->when($this->filterType, fn($q) => $q->where('type', $this->filterType))
|
||||
->when($this->filterEstado, fn($q) => $q->where('estado', $this->filterEstado))
|
||||
->withCount('projects')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
}
|
||||
|
||||
public function deleteCompany(Company $company): void
|
||||
{
|
||||
if ($company->logo_path) {
|
||||
Storage::disk('public')->delete($company->logo_path);
|
||||
}
|
||||
$company->delete();
|
||||
$this->dispatch('notify', 'Empresa eliminada.');
|
||||
}
|
||||
|
||||
public function exportCsv()
|
||||
{
|
||||
$companies = $this->getCompaniesProperty();
|
||||
|
||||
// Create CSV content
|
||||
$headers = [
|
||||
"Content-type: text/csv",
|
||||
"Content-Disposition: attachment; filename=empresas.csv",
|
||||
"Pragma: no-cache",
|
||||
"Cache-Control: must-revalidate, post-check=0, pre-check=0",
|
||||
"Expires: 0"
|
||||
];
|
||||
|
||||
$callback = function() use ($companies) {
|
||||
return response()->streamDownload(function () use ($companies) {
|
||||
$handle = fopen('php://output', 'w');
|
||||
// Add BOM for UTF-8 in Excel
|
||||
fprintf($handle, chr(0xEF).chr(0xBB).chr(0xBF));
|
||||
|
||||
// Header row
|
||||
fputcsv($handle, ['Nombre', 'Apodo', 'NIF/Tax ID', 'Tipo', 'Estado', 'Dirección', 'Teléfono', 'Email', 'Website', 'Proyectos Asociados', 'Fecha Creación']);
|
||||
|
||||
foreach ($companies as $company) {
|
||||
fprintf($handle, chr(0xEF) . chr(0xBB) . chr(0xBF));
|
||||
fputcsv($handle, ['Nombre', 'Apodo', 'NIF/Tax ID', 'Tipo', 'Estado', 'Dirección', 'Teléfono', 'Email', 'Website', 'Proyectos', 'Creación']);
|
||||
foreach ($companies as $c) {
|
||||
fputcsv($handle, [
|
||||
$company->name,
|
||||
$company->apodo ?? '',
|
||||
$company->tax_id ?? '',
|
||||
$company->type,
|
||||
$company->estado,
|
||||
$company->address ?? '',
|
||||
$company->phone ?? '',
|
||||
$company->email ?? '',
|
||||
$company->website ?? '',
|
||||
$company->projects_count ?? 0,
|
||||
$company->created_at ? $company->created_at->format('d/m/Y H:i') : ''
|
||||
$c->name, $c->apodo ?? '', $c->tax_id ?? '',
|
||||
$c->type, $c->estado, $c->address ?? '',
|
||||
$c->phone ?? '', $c->email ?? '', $c->website ?? '',
|
||||
$c->projects_count ?? 0,
|
||||
$c->created_at?->format('d/m/Y'),
|
||||
]);
|
||||
}
|
||||
|
||||
fclose($handle);
|
||||
};
|
||||
|
||||
return response()->stream($callback, 200, $headers);
|
||||
}, 'empresas.csv', ['Content-Type' => 'text/csv; charset=UTF-8']);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.company-management', [
|
||||
'companies' => $this->getCompaniesProperty(),
|
||||
]);
|
||||
return view('livewire.company-management');
|
||||
}
|
||||
}
|
||||
+100
-44
@@ -14,22 +14,29 @@ class IssueManager extends Component
|
||||
{
|
||||
public Project $project;
|
||||
|
||||
public $editing = false;
|
||||
public $editingId = null;
|
||||
public $issues = [];
|
||||
public $projectUsers = [];
|
||||
|
||||
// Form / modal state
|
||||
public $showForm = false;
|
||||
public $editingIssue = null; // issue id when editing, null when creating
|
||||
|
||||
// Form fields
|
||||
public $title = '';
|
||||
public $description = '';
|
||||
public $status = 'open';
|
||||
public $priority = 'medium';
|
||||
public $assignedTo = '';
|
||||
public $resolutionNotes = '';
|
||||
|
||||
// Optional context (e.g. when reporting from a map feature)
|
||||
public $featureId = null;
|
||||
public $inspectionId = null;
|
||||
public $assignedTo = null;
|
||||
|
||||
public $issues = [];
|
||||
|
||||
public function mount(Project $project)
|
||||
{
|
||||
$this->project = $project;
|
||||
$this->loadProjectUsers();
|
||||
$this->loadIssues();
|
||||
}
|
||||
|
||||
@@ -41,79 +48,134 @@ class IssueManager extends Component
|
||||
->get();
|
||||
}
|
||||
|
||||
public function create()
|
||||
public function loadProjectUsers()
|
||||
{
|
||||
$this->reset(['title', 'description', 'status', 'priority', 'featureId', 'inspectionId', 'assignedTo', 'editingId']);
|
||||
$this->status = 'open';
|
||||
$this->priority = 'medium';
|
||||
$this->editing = true;
|
||||
$this->projectUsers = $this->project->users()->orderBy('name')->get();
|
||||
}
|
||||
|
||||
public function edit($issueId)
|
||||
protected function rules(): array
|
||||
{
|
||||
$issue = Issue::findOrFail($issueId);
|
||||
$this->editingId = $issue->id;
|
||||
return [
|
||||
'title' => 'required|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'status' => 'required|in:' . implode(',', Issue::STATUSES),
|
||||
'priority' => 'required|in:' . implode(',', Issue::PRIORITIES),
|
||||
'assignedTo' => 'nullable|exists:users,id',
|
||||
'resolutionNotes' => 'nullable|string',
|
||||
];
|
||||
}
|
||||
|
||||
public function openForm($issueId = null)
|
||||
{
|
||||
$this->resetForm();
|
||||
|
||||
if ($issueId) {
|
||||
$issue = Issue::where('project_id', $this->project->id)->findOrFail($issueId);
|
||||
$this->editingIssue = $issue->id;
|
||||
$this->title = $issue->title;
|
||||
$this->description = $issue->description ?? '';
|
||||
$this->status = $issue->status;
|
||||
$this->priority = $issue->priority;
|
||||
$this->assignedTo = $issue->assigned_to ?? '';
|
||||
$this->resolutionNotes = $issue->resolution_notes ?? '';
|
||||
$this->featureId = $issue->feature_id;
|
||||
$this->inspectionId = $issue->inspection_id;
|
||||
$this->assignedTo = $issue->assigned_to;
|
||||
$this->editing = true;
|
||||
}
|
||||
|
||||
$this->showForm = true;
|
||||
}
|
||||
|
||||
public function closeForm()
|
||||
{
|
||||
$this->showForm = false;
|
||||
$this->resetForm();
|
||||
}
|
||||
|
||||
private function resetForm(): void
|
||||
{
|
||||
$this->reset([
|
||||
'title', 'description', 'assignedTo', 'resolutionNotes',
|
||||
'featureId', 'inspectionId', 'editingIssue',
|
||||
]);
|
||||
$this->status = 'open';
|
||||
$this->priority = 'medium';
|
||||
$this->resetErrorBag();
|
||||
}
|
||||
|
||||
public function save()
|
||||
{
|
||||
$this->validate([
|
||||
'title' => 'required|string|max:255',
|
||||
'status' => 'required|in:' . implode(',', Issue::STATUSES),
|
||||
'priority' => 'required|in:' . implode(',', Issue::PRIORITIES),
|
||||
]);
|
||||
$this->validate();
|
||||
|
||||
if ($this->editingId) {
|
||||
$issue = Issue::findOrFail($this->editingId);
|
||||
$issue->update([
|
||||
$payload = [
|
||||
'title' => $this->title,
|
||||
'description' => $this->description,
|
||||
'status' => $this->status,
|
||||
'priority' => $this->priority,
|
||||
'feature_id' => $this->featureId,
|
||||
'inspection_id' => $this->inspectionId,
|
||||
'assigned_to' => $this->assignedTo,
|
||||
]);
|
||||
'assigned_to' => $this->assignedTo ?: null,
|
||||
'resolution_notes' => $this->resolutionNotes ?: null,
|
||||
];
|
||||
|
||||
// Keep resolved_at in sync with the status
|
||||
if (in_array($this->status, ['resolved', 'closed'])) {
|
||||
$payload['resolved_at'] = now();
|
||||
} else {
|
||||
$issue = Issue::create([
|
||||
$payload['resolved_at'] = null;
|
||||
}
|
||||
|
||||
if ($this->editingIssue) {
|
||||
$issue = Issue::where('project_id', $this->project->id)->findOrFail($this->editingIssue);
|
||||
// Don't overwrite an existing resolved date if it was already resolved
|
||||
if ($issue->resolved_at && in_array($this->status, ['resolved', 'closed'])) {
|
||||
unset($payload['resolved_at']);
|
||||
}
|
||||
$issue->update($payload);
|
||||
} else {
|
||||
$issue = Issue::create(array_merge($payload, [
|
||||
'project_id' => $this->project->id,
|
||||
'title' => $this->title,
|
||||
'description' => $this->description,
|
||||
'status' => $this->status,
|
||||
'priority' => $this->priority,
|
||||
'feature_id' => $this->featureId,
|
||||
'inspection_id' => $this->inspectionId,
|
||||
'reported_by' => Auth::id(),
|
||||
'assigned_to' => $this->assignedTo,
|
||||
]);
|
||||
]));
|
||||
|
||||
if ($issue->wasRecentlyCreated) {
|
||||
$issue->load(['feature', 'assignee']);
|
||||
|
||||
$creator = $this->project->creator;
|
||||
if ($creator && $creator->id !== Auth::id()) {
|
||||
$creator->notify(new IssueReportedNotification($issue));
|
||||
}
|
||||
|
||||
if ($issue->assignee && $issue->assignee->id !== Auth::id()) {
|
||||
$issue->assignee->notify(new IssueReportedNotification($issue));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->editing = false;
|
||||
$this->closeForm();
|
||||
$this->loadIssues();
|
||||
$this->dispatch('notify', 'Issue guardado correctamente');
|
||||
}
|
||||
|
||||
public function resolve($issueId)
|
||||
{
|
||||
$issue = Issue::where('project_id', $this->project->id)->findOrFail($issueId);
|
||||
$issue->update([
|
||||
'status' => 'resolved',
|
||||
'resolved_at' => $issue->resolved_at ?? now(),
|
||||
]);
|
||||
$this->loadIssues();
|
||||
$this->dispatch('notify', 'Issue marcado como resuelto');
|
||||
}
|
||||
|
||||
public function close($issueId)
|
||||
{
|
||||
$issue = Issue::where('project_id', $this->project->id)->findOrFail($issueId);
|
||||
$issue->update([
|
||||
'status' => 'closed',
|
||||
'resolved_at' => $issue->resolved_at ?? now(),
|
||||
]);
|
||||
$this->loadIssues();
|
||||
$this->dispatch('notify', 'Issue cerrado');
|
||||
}
|
||||
|
||||
public function delete($issueId)
|
||||
{
|
||||
$issue = Issue::where('project_id', $this->project->id)->findOrFail($issueId);
|
||||
@@ -122,14 +184,8 @@ class IssueManager extends Component
|
||||
$this->dispatch('notify', 'Issue eliminado');
|
||||
}
|
||||
|
||||
public function cancel()
|
||||
{
|
||||
$this->editing = false;
|
||||
$this->reset(['title', 'description', 'status', 'priority', 'featureId', 'inspectionId', 'assignedTo', 'editingId']);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.issue-manager');
|
||||
return view('livewire.issues.issue-manager');
|
||||
}
|
||||
}
|
||||
|
||||
+212
-132
@@ -8,9 +8,11 @@ use Livewire\Attributes\Layout;
|
||||
use App\Models\Project;
|
||||
use App\Models\Phase;
|
||||
use App\Models\Layer;
|
||||
use App\Services\SpatialFileConverter;
|
||||
use App\Models\Feature;
|
||||
use App\Models\InspectionTemplate;
|
||||
use App\Services\SpatialFileConverter;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
#[Layout('layouts.app')]
|
||||
@@ -22,101 +24,106 @@ class LayerManager extends Component
|
||||
public Phase $phase;
|
||||
public $layers;
|
||||
public $selectedLayer = null;
|
||||
public $visibleLayers = []; // IDs de capas visibles
|
||||
public $visibleLayers = [];
|
||||
|
||||
public $uploadFile = null;
|
||||
public $layerName = '';
|
||||
public $layerColor = '#3b82f6';
|
||||
public $manualGeojson = null;
|
||||
public $drawingMode = false;
|
||||
|
||||
protected $rules = [
|
||||
'uploadFile' => 'required|file|mimes:geojson,kmz,kml,shp,dwg,zip|max:51200',
|
||||
'layerName' => 'required|string|max:255',
|
||||
'layerColor' => 'nullable|string|size:7',
|
||||
];
|
||||
// Batch assign
|
||||
public $templates = [];
|
||||
public $batchTemplateId = null;
|
||||
public $batchStatus = '';
|
||||
|
||||
public function mount(Project $project, Phase $phase)
|
||||
{
|
||||
$this->project = $project;
|
||||
$this->phase = $phase;
|
||||
|
||||
if ($this->phase->project_id !== $this->project->id) {
|
||||
abort(404);
|
||||
}
|
||||
if ($this->phase->project_id !== $this->project->id) abort(404);
|
||||
|
||||
$user = Auth::user();
|
||||
if (!$user->hasRole('Admin') && !$project->users()->where('user_id', $user->id)->exists()) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$this->templates = InspectionTemplate::where('project_id', $this->project->id)->get();
|
||||
$this->loadLayers();
|
||||
// Por defecto todas visibles
|
||||
$this->visibleLayers = $this->layers->pluck('id')->toArray();
|
||||
$this->emitInitialLayersData();
|
||||
}
|
||||
|
||||
// ── Data loaders ──────────────────────────────────────────────────────────
|
||||
|
||||
public function loadLayers()
|
||||
{
|
||||
$this->layers = Layer::with('features')->where('phase_id', $this->phase->id)->latest()->get();
|
||||
$this->visibleLayers = array_intersect($this->visibleLayers, $this->layers->pluck('id')->toArray());
|
||||
$this->layers = Layer::withCount('features')
|
||||
->withAvg('features', 'progress')
|
||||
->where('phase_id', $this->phase->id)
|
||||
->latest()
|
||||
->get();
|
||||
|
||||
$this->visibleLayers = array_values(
|
||||
array_intersect($this->visibleLayers, $this->layers->pluck('id')->toArray())
|
||||
);
|
||||
}
|
||||
|
||||
private function buildLayerPayload(Layer $layer): array
|
||||
{
|
||||
$color = $layer->color ?: '#3b82f6';
|
||||
$features = ($layer->relationLoaded('features') ? $layer->features : $layer->features()->get())
|
||||
->map(fn($f) => [
|
||||
'type' => 'Feature',
|
||||
'id' => $f->id,
|
||||
'geometry' => $f->geometry,
|
||||
'properties' => [
|
||||
'name' => $f->name ?? 'Elemento',
|
||||
'progress' => $f->progress,
|
||||
'status' => $f->status ?? 'planned',
|
||||
'responsible' => $f->responsible,
|
||||
'template_id' => $f->template_id,
|
||||
],
|
||||
])->values()->toArray();
|
||||
|
||||
return [
|
||||
'id' => $layer->id,
|
||||
'color' => $color,
|
||||
'geojson' => [
|
||||
'type' => 'FeatureCollection',
|
||||
'features' => $features,
|
||||
'style' => ['color' => $color],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function emitInitialLayersData()
|
||||
{
|
||||
$layersData = $this->layers->map(function($layer) {
|
||||
// Usar el color guardado en BD o el color del formulario
|
||||
$color = $layer->color ?: ($this->layerColor ?: '#3b82f6');
|
||||
|
||||
// Construir FeatureCollection a partir de los features de esta capa
|
||||
$features = $layer->features->map(function($feature) {
|
||||
return [
|
||||
'type' => 'Feature',
|
||||
'id' => $feature->id,
|
||||
'geometry' => $feature->geometry,
|
||||
'properties' => [
|
||||
'name' => $feature->name,
|
||||
'progress' => $feature->progress,
|
||||
'responsible' => $feature->responsible,
|
||||
'template_id' => $feature->template_id,
|
||||
]
|
||||
];
|
||||
})->values()->toArray();
|
||||
|
||||
$geojson = [
|
||||
'type' => 'FeatureCollection',
|
||||
'features' => $features,
|
||||
'style' => ['color' => $color]
|
||||
];
|
||||
|
||||
return [
|
||||
'id' => $layer->id,
|
||||
'geojson' => $geojson,
|
||||
'color' => $color,
|
||||
];
|
||||
});
|
||||
|
||||
$this->layers->loadMissing('features');
|
||||
$this->dispatch('initialLayersData', [
|
||||
'layers' => $layersData,
|
||||
'layers' => $this->layers->map(fn($l) => $this->buildLayerPayload($l)),
|
||||
'visibleLayers' => $this->visibleLayers,
|
||||
'selectedLayerId' => $this->selectedLayer?->id,
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Visibility ────────────────────────────────────────────────────────────
|
||||
|
||||
public function toggleLayerVisibility($layerId)
|
||||
{
|
||||
if ($this->selectedLayer && $this->selectedLayer->id == $layerId) {
|
||||
session()->flash('info', 'No puedes ocultar la capa que estás editando.');
|
||||
$this->dispatch('notify', 'No puedes ocultar la capa que estás editando');
|
||||
return;
|
||||
}
|
||||
if (in_array($layerId, $this->visibleLayers)) {
|
||||
$this->visibleLayers = array_diff($this->visibleLayers, [$layerId]);
|
||||
$this->visibleLayers = array_values(array_diff($this->visibleLayers, [$layerId]));
|
||||
} else {
|
||||
$this->visibleLayers[] = $layerId;
|
||||
}
|
||||
$this->dispatch('visibilityChanged', $this->visibleLayers);
|
||||
}
|
||||
|
||||
// ── Select ────────────────────────────────────────────────────────────────
|
||||
|
||||
public function selectLayer($layerId)
|
||||
{
|
||||
$this->selectedLayer = Layer::with('features')->find($layerId);
|
||||
@@ -127,119 +134,105 @@ class LayerManager extends Component
|
||||
$this->dispatch('visibilityChanged', $this->visibleLayers);
|
||||
}
|
||||
|
||||
// Construir el GeoJSON desde los features de la capa seleccionada
|
||||
$features = $this->selectedLayer->features->map(function($feature) {
|
||||
return [
|
||||
'type' => 'Feature',
|
||||
'id' => $feature->id,
|
||||
'geometry' => $feature->geometry,
|
||||
'properties' => [
|
||||
'name' => $feature->name,
|
||||
'progress' => $feature->progress,
|
||||
'responsible' => $feature->responsible,
|
||||
'template_id' => $feature->template_id,
|
||||
]
|
||||
];
|
||||
})->values()->toArray();
|
||||
|
||||
$color = $this->selectedLayer->color ?: ($this->layerColor ?: '#3b82f6');
|
||||
$geojson = [
|
||||
'type' => 'FeatureCollection',
|
||||
'features' => $features,
|
||||
'style' => ['color' => $color]
|
||||
];
|
||||
|
||||
$payload = $this->buildLayerPayload($this->selectedLayer);
|
||||
$this->dispatch('layerSelectedForEdit', [
|
||||
'layerId' => $layerId,
|
||||
'geojson' => $geojson,
|
||||
'color' => $color,
|
||||
'geojson' => $payload['geojson'],
|
||||
'color' => $payload['color'],
|
||||
]);
|
||||
session()->flash('info', 'Editando capa: ' . $this->selectedLayer->name);
|
||||
$this->dispatch('notify', 'Editando: ' . $this->selectedLayer->name);
|
||||
}
|
||||
|
||||
// ── Import file ───────────────────────────────────────────────────────────
|
||||
|
||||
public function importFile()
|
||||
{
|
||||
$user = Auth::user();
|
||||
if (!$user->can('upload layers') && !$user->hasRole('Admin')) {
|
||||
session()->flash('error', 'Sin permisos.');
|
||||
$this->dispatch('notify', 'Sin permisos para subir capas');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validar campos obligatorios y tamaño máximo
|
||||
$this->validate([
|
||||
'uploadFile' => 'required|file|max:51200',
|
||||
'layerName' => 'required|string|max:255',
|
||||
'layerColor' => 'nullable|string|size:7',
|
||||
]);
|
||||
|
||||
$extension = strtolower($this->uploadFile->getClientOriginalExtension());
|
||||
$mime = $this->uploadFile->getMimeType();
|
||||
|
||||
$allowedExtensions = ['geojson', 'kmz', 'kml', 'shp', 'dwg', 'zip'];
|
||||
$allowedMimes = [
|
||||
'application/vnd.google-earth.kml+xml',
|
||||
'application/vnd.google-earth.kmz',
|
||||
'application/zip',
|
||||
'application/x-zip-compressed',
|
||||
'application/x-shapefile',
|
||||
'image/vnd.dwg',
|
||||
'application/acad',
|
||||
'application/geo+json',
|
||||
'text/xml', // ✅ Aceptar KML con text/xml
|
||||
'application/xml', // ✅ Alternativa
|
||||
];
|
||||
|
||||
if (!in_array($extension, $allowedExtensions) && !in_array($mime, $allowedMimes)) {
|
||||
session()->flash('error', 'Tipo de archivo no permitido. Extensiones válidas: ' . implode(', ', $allowedExtensions));
|
||||
$ext = strtolower($this->uploadFile->getClientOriginalExtension());
|
||||
$allowed = ['geojson', 'json', 'kmz', 'kml', 'shp', 'dwg', 'zip'];
|
||||
if (!in_array($ext, $allowed)) {
|
||||
$this->dispatch('notify', 'Extensión no permitida. Válidas: ' . implode(', ', $allowed));
|
||||
return;
|
||||
}
|
||||
|
||||
$projectDir = "uploads/projects/{$this->project->id}/layers";
|
||||
$originalPath = $this->uploadFile->store($projectDir, 'public');
|
||||
$geojson = SpatialFileConverter::convertToGeoJson($this->uploadFile);
|
||||
|
||||
if (!$geojson) {
|
||||
session()->flash('error', 'Conversión fallida. Asegúrate de que el archivo sea válido (KML, GeoJSON, etc.).');
|
||||
$this->dispatch('notify', 'No se pudo convertir el archivo. Comprueba que sea GeoJSON, KML o Shapefile válido.');
|
||||
return;
|
||||
}
|
||||
|
||||
$layerColor = $this->layerColor ?: '#3b82f6';
|
||||
$geojson['style'] = ['color' => $layerColor];
|
||||
$layerName = $this->layerName;
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($geojson, $layerColor, $layerName, $user) {
|
||||
$path = $this->uploadFile->store(
|
||||
"uploads/projects/{$this->project->id}/layers", 'public'
|
||||
);
|
||||
|
||||
$layer = Layer::create([
|
||||
'project_id' => $this->project->id,
|
||||
'phase_id' => $this->phase->id,
|
||||
'name' => $this->layerName,
|
||||
'name' => $layerName,
|
||||
'color' => $layerColor,
|
||||
'original_file' => $originalPath,
|
||||
'original_file' => $path,
|
||||
'uploaded_by' => $user->id,
|
||||
]);
|
||||
|
||||
// Crear features a partir del GeoJSON
|
||||
if (isset($geojson['features'])) {
|
||||
foreach ($geojson['features'] as $featureData) {
|
||||
$idx = 0;
|
||||
foreach ($geojson['features'] ?? [] as $fd) {
|
||||
$idx++;
|
||||
$name = trim($fd['properties']['name'] ?? '');
|
||||
if ($name === '') $name = $layerName . ' — Elemento ' . $idx;
|
||||
|
||||
Feature::create([
|
||||
'layer_id' => $layer->id,
|
||||
'name' => $featureData['properties']['name'] ?? null,
|
||||
'geometry' => $featureData['geometry'],
|
||||
'properties' => $featureData['properties'] ?? [],
|
||||
'template_id' => $featureData['properties']['template_id'] ?? null,
|
||||
'progress' => $featureData['properties']['progress'] ?? 0,
|
||||
'responsible' => $featureData['properties']['responsible'] ?? null,
|
||||
'name' => $name,
|
||||
'geometry' => $fd['geometry'],
|
||||
'properties' => $fd['properties'] ?? [],
|
||||
'template_id' => $fd['properties']['template_id'] ?? null,
|
||||
'progress' => $fd['properties']['progress'] ?? 0,
|
||||
'status' => in_array($fd['properties']['status'] ?? '', Feature::STATUSES)
|
||||
? $fd['properties']['status']
|
||||
: 'planned',
|
||||
'responsible' => $fd['properties']['responsible'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
$this->visibleLayers[] = $layer->id;
|
||||
});
|
||||
} catch (\Throwable $e) {
|
||||
$this->dispatch('notify', 'Error al importar: ' . $e->getMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
$this->loadLayers();
|
||||
$this->visibleLayers[] = $layer->id;
|
||||
$this->reset(['uploadFile', 'layerName']);
|
||||
$this->emitInitialLayersData();
|
||||
session()->flash('message', 'Capa importada correctamente.');
|
||||
$this->dispatch('notify', 'Capa importada correctamente');
|
||||
}
|
||||
|
||||
// ── Create empty layer ────────────────────────────────────────────────────
|
||||
|
||||
public function createEmptyLayer()
|
||||
{
|
||||
$user = Auth::user();
|
||||
if (!$user->can('upload layers') && !$user->hasRole('Admin')) {
|
||||
$this->dispatch('notify', 'Sin permisos para crear capas');
|
||||
return;
|
||||
}
|
||||
|
||||
$layer = Layer::create([
|
||||
'project_id' => $this->project->id,
|
||||
'phase_id' => $this->phase->id,
|
||||
@@ -248,65 +241,152 @@ class LayerManager extends Component
|
||||
'original_file' => null,
|
||||
'uploaded_by' => $user->id,
|
||||
]);
|
||||
|
||||
$this->loadLayers();
|
||||
$this->visibleLayers[] = $layer->id;
|
||||
$this->selectLayer($layer->id);
|
||||
$this->emitInitialLayersData();
|
||||
session()->flash('message', 'Capa vacía creada. Usa el editor para añadir elementos.');
|
||||
$this->dispatch('notify', 'Capa vacía creada. Dibuja elementos en el mapa.');
|
||||
}
|
||||
|
||||
// ── Save drawn GeoJSON ────────────────────────────────────────────────────
|
||||
|
||||
public function saveManualGeojson($geojsonString)
|
||||
{
|
||||
if (!$this->selectedLayer) {
|
||||
session()->flash('error', 'No hay capa seleccionada.');
|
||||
$this->dispatch('notify', 'No hay capa seleccionada');
|
||||
return;
|
||||
}
|
||||
|
||||
$geojson = json_decode($geojsonString, true);
|
||||
if (json_last_error() !== JSON_ERROR_NONE || !isset($geojson['features'])) {
|
||||
session()->flash('error', 'GeoJSON inválido.');
|
||||
$this->dispatch('notify', 'GeoJSON inválido');
|
||||
return;
|
||||
}
|
||||
|
||||
// Eliminar todos los features existentes de esta capa
|
||||
$this->selectedLayer->features()->delete();
|
||||
$layerId = $this->selectedLayer->id;
|
||||
$layerName = $this->selectedLayer->name;
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($geojson, $layerId, $layerName) {
|
||||
// forceDelete: reemplazamos completamente los elementos de la capa
|
||||
Feature::where('layer_id', $layerId)->forceDelete();
|
||||
|
||||
$idx = 0;
|
||||
foreach ($geojson['features'] as $fd) {
|
||||
$idx++;
|
||||
$name = trim($fd['properties']['name'] ?? '');
|
||||
if ($name === '') $name = $layerName . ' — Elemento ' . $idx;
|
||||
|
||||
// Crear nuevos features a partir del GeoJSON
|
||||
foreach ($geojson['features'] as $featureData) {
|
||||
Feature::create([
|
||||
'layer_id' => $this->selectedLayer->id,
|
||||
'name' => $featureData['properties']['name'] ?? null,
|
||||
'geometry' => $featureData['geometry'],
|
||||
'properties' => $featureData['properties'] ?? [],
|
||||
'template_id' => $featureData['properties']['template_id'] ?? null,
|
||||
'progress' => $featureData['properties']['progress'] ?? 0,
|
||||
'responsible' => $featureData['properties']['responsible'] ?? null,
|
||||
'layer_id' => $layerId,
|
||||
'name' => $name,
|
||||
'geometry' => $fd['geometry'],
|
||||
'properties' => $fd['properties'] ?? [],
|
||||
'template_id' => $fd['properties']['template_id'] ?? null,
|
||||
'progress' => $fd['properties']['progress'] ?? 0,
|
||||
'status' => in_array($fd['properties']['status'] ?? '', Feature::STATUSES)
|
||||
? $fd['properties']['status']
|
||||
: 'planned',
|
||||
'responsible' => $fd['properties']['responsible'] ?? null,
|
||||
]);
|
||||
}
|
||||
});
|
||||
} catch (\Throwable $e) {
|
||||
$this->dispatch('notify', 'Error al guardar: ' . $e->getMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
$this->loadLayers();
|
||||
$this->selectLayer($this->selectedLayer->id);
|
||||
$this->emitInitialLayersData();
|
||||
session()->flash('message', 'Capa guardada con ' . count($geojson['features']) . ' elementos.');
|
||||
$this->dispatch('notify', count($geojson['features']) . ' elementos guardados');
|
||||
}
|
||||
|
||||
// ── Delete layer ──────────────────────────────────────────────────────────
|
||||
|
||||
public function deleteLayer($layerId)
|
||||
{
|
||||
$user = Auth::user();
|
||||
if (!$user->can('delete layers') && !$user->hasRole('Admin')) abort(403);
|
||||
// Verify layer belongs to this phase (prevents cross-project deletion)
|
||||
|
||||
// Verify it belongs to this phase (prevents cross-project deletion)
|
||||
$layer = Layer::where('id', $layerId)->where('phase_id', $this->phase->id)->first();
|
||||
if (!$layer) return;
|
||||
|
||||
if ($layer->original_file) Storage::disk('public')->delete($layer->original_file);
|
||||
$layer->features()->delete(); // opcional, si no usas cascade
|
||||
$layer->features()->delete();
|
||||
$layer->delete();
|
||||
|
||||
$this->loadLayers();
|
||||
if ($this->selectedLayer && $this->selectedLayer->id == $layerId) {
|
||||
$this->selectedLayer = null;
|
||||
$this->dispatch('layerSelectedForEdit', null);
|
||||
}
|
||||
$this->emitInitialLayersData();
|
||||
session()->flash('message', 'Capa eliminada.');
|
||||
$this->dispatch('notify', 'Capa eliminada');
|
||||
}
|
||||
|
||||
// ── Export GeoJSON ────────────────────────────────────────────────────────
|
||||
|
||||
public function exportLayer($layerId)
|
||||
{
|
||||
$layer = Layer::with('features')
|
||||
->where('id', $layerId)
|
||||
->where('phase_id', $this->phase->id)
|
||||
->first();
|
||||
if (!$layer) return;
|
||||
|
||||
$fc = [
|
||||
'type' => 'FeatureCollection',
|
||||
'name' => $layer->name,
|
||||
'features' => $layer->features->map(fn($f) => [
|
||||
'type' => 'Feature',
|
||||
'geometry' => $f->geometry,
|
||||
'properties' => array_merge($f->properties ?? [], [
|
||||
'name' => $f->name,
|
||||
'progress' => $f->progress,
|
||||
'status' => $f->status,
|
||||
'responsible' => $f->responsible,
|
||||
'template_id' => $f->template_id,
|
||||
]),
|
||||
])->values()->toArray(),
|
||||
];
|
||||
|
||||
$filename = preg_replace('/[^a-z0-9_\-]/i', '_', $layer->name) . '.geojson';
|
||||
|
||||
return response()->streamDownload(function () use ($fc) {
|
||||
echo json_encode($fc, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
}, $filename, ['Content-Type' => 'application/geo+json']);
|
||||
}
|
||||
|
||||
// ── Batch assign template / status ────────────────────────────────────────
|
||||
|
||||
public function batchAssign($layerId)
|
||||
{
|
||||
$layer = Layer::where('id', $layerId)->where('phase_id', $this->phase->id)->first();
|
||||
if (!$layer) return;
|
||||
|
||||
$data = [];
|
||||
if ($this->batchStatus && in_array($this->batchStatus, Feature::STATUSES)) {
|
||||
$data['status'] = $this->batchStatus;
|
||||
}
|
||||
if ($this->batchTemplateId) {
|
||||
$data['template_id'] = (int) $this->batchTemplateId;
|
||||
}
|
||||
if (empty($data)) {
|
||||
$this->dispatch('notify', 'Selecciona un estado o template para asignar');
|
||||
return;
|
||||
}
|
||||
|
||||
$count = $layer->features()->update($data);
|
||||
$this->loadLayers();
|
||||
$this->emitInitialLayersData();
|
||||
$this->dispatch('notify', "$count elemento(s) actualizados");
|
||||
}
|
||||
|
||||
// ── Cancel editing ────────────────────────────────────────────────────────
|
||||
|
||||
public function cancelEditing()
|
||||
{
|
||||
$this->selectedLayer = null;
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use Livewire\Component;
|
||||
use Livewire\WithFileUploads;
|
||||
use App\Models\Project;
|
||||
use App\Models\Phase;
|
||||
use App\Models\Layer;
|
||||
use App\Models\Feature;
|
||||
use App\Services\SpatialFileConverter;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class LayerUpload extends Component
|
||||
{
|
||||
use WithFileUploads;
|
||||
|
||||
public $projectId;
|
||||
public $phaseId;
|
||||
public $uploadFile = null;
|
||||
public $layerName = '';
|
||||
public $layerColor = '#3b82f6';
|
||||
|
||||
protected $rules = [
|
||||
'uploadFile' => 'required|file|max:51200',
|
||||
'layerName' => 'required|string|max:255',
|
||||
'layerColor' => 'nullable|string|size:7',
|
||||
];
|
||||
|
||||
public function mount($projectId = null, $phaseId = null)
|
||||
{
|
||||
$this->projectId = $projectId;
|
||||
$this->phaseId = $phaseId;
|
||||
}
|
||||
|
||||
public function upload()
|
||||
{
|
||||
$user = Auth::user();
|
||||
if (!$user->can('upload layers') && !$user->hasRole('Admin')) {
|
||||
session()->flash('error', 'Sin permisos.');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->validate();
|
||||
|
||||
if (!$this->projectId || !$this->phaseId) {
|
||||
session()->flash('error', 'Faltan datos del proyecto/fase.');
|
||||
return;
|
||||
}
|
||||
|
||||
$project = Project::findOrFail($this->projectId);
|
||||
$phase = Phase::findOrFail($this->phaseId);
|
||||
|
||||
$extension = strtolower($this->uploadFile->getClientOriginalExtension());
|
||||
$mime = $this->uploadFile->getMimeType();
|
||||
|
||||
$allowedExtensions = ['geojson', 'kmz', 'kml', 'shp', 'dwg', 'zip'];
|
||||
$allowedMimes = [
|
||||
'application/vnd.google-earth.kml+xml',
|
||||
'application/vnd.google-earth.kmz',
|
||||
'application/zip',
|
||||
'application/x-zip-compressed',
|
||||
'application/x-shapefile',
|
||||
'image/vnd.dwg',
|
||||
'application/acad',
|
||||
'application/geo+json',
|
||||
'text/xml',
|
||||
'application/xml',
|
||||
];
|
||||
|
||||
if (!in_array($extension, $allowedExtensions) && !in_array($mime, $allowedMimes)) {
|
||||
session()->flash('error', 'Tipo de archivo no permitido.');
|
||||
return;
|
||||
}
|
||||
|
||||
$projectDir = "uploads/projects/{$project->id}/layers";
|
||||
$originalPath = $this->uploadFile->store($projectDir, 'public');
|
||||
$geojson = SpatialFileConverter::convertToGeoJson($this->uploadFile);
|
||||
|
||||
if (!$geojson) {
|
||||
session()->flash('error', 'Conversión fallida.');
|
||||
return;
|
||||
}
|
||||
|
||||
$layerColor = $this->layerColor ?: '#3b82f6';
|
||||
$geojson['style'] = ['color' => $layerColor];
|
||||
|
||||
$layer = Layer::create([
|
||||
'project_id' => $project->id,
|
||||
'phase_id' => $phase->id,
|
||||
'name' => $this->layerName,
|
||||
'color' => $layerColor,
|
||||
'original_file' => $originalPath,
|
||||
'uploaded_by' => $user->id,
|
||||
]);
|
||||
|
||||
if (isset($geojson['features'])) {
|
||||
foreach ($geojson['features'] as $featureData) {
|
||||
Feature::create([
|
||||
'layer_id' => $layer->id,
|
||||
'name' => $featureData['properties']['name'] ?? null,
|
||||
'geometry' => $featureData['geometry'],
|
||||
'properties' => $featureData['properties'] ?? [],
|
||||
'template_id' => $featureData['properties']['template_id'] ?? null,
|
||||
'progress' => $featureData['properties']['progress'] ?? 0,
|
||||
'responsible' => $featureData['properties']['responsible'] ?? null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$this->reset(['uploadFile', 'layerName']);
|
||||
session()->flash('message', "Capa '{$layer->name}' importada correctamente con " . count($geojson['features'] ?? []) . ' elementos.');
|
||||
$this->dispatch('layerUploaded', projectId: $project->id);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$projects = Project::accessibleBy(Auth::user())->get();
|
||||
$phases = $this->projectId ? Phase::where('project_id', $this->projectId)->orderBy('order')->get() : collect();
|
||||
|
||||
return view('livewire.layer-upload', compact('projects', 'phases'));
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ namespace App\Livewire;
|
||||
|
||||
use Livewire\Component;
|
||||
use Livewire\WithFileUploads;
|
||||
use Livewire\Attributes\On;
|
||||
use App\Models\Media;
|
||||
use App\Models\Project;
|
||||
use App\Models\Phase;
|
||||
@@ -11,60 +12,44 @@ use App\Models\Layer;
|
||||
use App\Models\Feature;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class MediaManager extends Component
|
||||
{
|
||||
use WithFileUploads;
|
||||
|
||||
/**
|
||||
* Whitelist of allowed mediable types (prevents arbitrary class instantiation).
|
||||
* Keys are the public string accepted in mount(); values are FQCN.
|
||||
*/
|
||||
private const ALLOWED_TYPES = [
|
||||
'App\\Models\\Project' => \App\Models\Project::class,
|
||||
'App\\Models\\Phase' => \App\Models\Phase::class,
|
||||
'App\\Models\\Layer' => \App\Models\Layer::class,
|
||||
'App\\Models\\Feature' => \App\Models\Feature::class,
|
||||
'App\\Models\\Inspection' => \App\Models\Inspection::class,
|
||||
'App\\Models\\Issue' => \App\Models\Issue::class,
|
||||
];
|
||||
|
||||
// Polimórfico: a qué entidad pertenece
|
||||
public $mediableType;
|
||||
public $mediableId;
|
||||
|
||||
public $entity;
|
||||
public $entity; // instancia cargada
|
||||
public $mediaItems = [];
|
||||
|
||||
// Subida
|
||||
public $uploadFiles = [];
|
||||
public $uploadDescription = '';
|
||||
public $uploadCategory = 'image';
|
||||
|
||||
// Modal visor
|
||||
public $showViewer = false;
|
||||
public $viewingMedia = null;
|
||||
|
||||
protected $rules = [
|
||||
'uploadFiles.*' => 'required|file|max:51200', // 50 MB per file
|
||||
'uploadFiles.*' => 'required|file|max:102400', // 100MB total
|
||||
'uploadDescription' => 'nullable|string|max:500',
|
||||
'uploadCategory' => 'required|in:image,document,other',
|
||||
];
|
||||
|
||||
protected $messages = [
|
||||
'uploadFiles.*.max' => 'Cada archivo debe pesar menos de 50 MB.',
|
||||
'uploadFiles.*.max' => 'Cada archivo debe pesar menos de 100MB.',
|
||||
];
|
||||
|
||||
public function mount($mediableType, $mediableId)
|
||||
{
|
||||
// Validate type against whitelist to prevent RCE via class instantiation
|
||||
if (!array_key_exists($mediableType, self::ALLOWED_TYPES)) {
|
||||
abort(400, 'Invalid mediable type.');
|
||||
}
|
||||
|
||||
$this->mediableType = $mediableType;
|
||||
$this->mediableId = (int) $mediableId;
|
||||
|
||||
$modelClass = self::ALLOWED_TYPES[$mediableType];
|
||||
$this->entity = $modelClass::findOrFail($this->mediableId);
|
||||
$this->mediableId = $mediableId;
|
||||
|
||||
$this->entity = $mediableType::findOrFail($mediableId);
|
||||
$this->loadMedia();
|
||||
}
|
||||
|
||||
@@ -92,43 +77,22 @@ class MediaManager extends Component
|
||||
return;
|
||||
}
|
||||
|
||||
// Allowed MIME types (server-side validation)
|
||||
$allowedMimes = [
|
||||
'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml',
|
||||
'application/pdf',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'text/plain', 'text/csv',
|
||||
'application/zip', 'application/x-zip-compressed',
|
||||
];
|
||||
|
||||
$uploaded = 0;
|
||||
foreach ($this->uploadFiles as $file) {
|
||||
$mime = $file->getMimeType();
|
||||
|
||||
if (!in_array($mime, $allowedMimes, true)) {
|
||||
session()->flash('error', "Tipo de archivo no permitido: {$mime}");
|
||||
continue;
|
||||
}
|
||||
|
||||
$ext = $file->getClientOriginalExtension();
|
||||
$size = $file->getSize();
|
||||
$name = substr($file->getClientOriginalName(), 0, 255);
|
||||
$name = $file->getClientOriginalName();
|
||||
|
||||
// Determinar categoría automática
|
||||
$category = $this->uploadCategory;
|
||||
if (str_starts_with($mime, 'image/')) {
|
||||
$category = 'image';
|
||||
} elseif (in_array($mime, [
|
||||
'application/pdf', 'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
], true)) {
|
||||
} elseif (in_array($mime, ['application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'])) {
|
||||
$category = 'document';
|
||||
}
|
||||
|
||||
// Guardar en disco
|
||||
$entityType = class_basename($this->entity);
|
||||
$dir = "uploads/{$entityType}s/{$this->mediableId}/media";
|
||||
$path = $file->store($dir, 'public');
|
||||
@@ -152,21 +116,18 @@ class MediaManager extends Component
|
||||
$this->reset(['uploadFiles', 'uploadDescription']);
|
||||
$this->loadMedia();
|
||||
|
||||
// Notificar al mapa si corresponde
|
||||
$this->dispatch('mediaUploaded', [
|
||||
'mediableType' => $this->mediableType,
|
||||
'mediableId' => $this->mediableId,
|
||||
]);
|
||||
|
||||
session()->flash('message', "{$uploaded} archivo(s) subido(s) correctamente.");
|
||||
session()->flash('message', "$uploaded archivo(s) subido(s) correctamente.");
|
||||
}
|
||||
|
||||
public function deleteMedia($mediaId)
|
||||
{
|
||||
// Ensure the media belongs to the entity this component manages (IDOR prevention)
|
||||
$media = Media::where('id', $mediaId)
|
||||
->where('mediable_type', $this->mediableType)
|
||||
->where('mediable_id', $this->mediableId)
|
||||
->firstOrFail();
|
||||
$media = Media::findOrFail($mediaId);
|
||||
|
||||
$user = Auth::user();
|
||||
if (!$user->hasRole('Admin') && $media->uploaded_by !== $user->id) {
|
||||
@@ -181,12 +142,9 @@ class MediaManager extends Component
|
||||
|
||||
public function viewMedia($mediaId)
|
||||
{
|
||||
$media = Media::where('id', $mediaId)
|
||||
->where('mediable_type', $this->mediableType)
|
||||
->where('mediable_id', $this->mediableId)
|
||||
->firstOrFail();
|
||||
|
||||
$media = Media::findOrFail($mediaId);
|
||||
if (!$media->is_image) {
|
||||
// Si no es imagen, abrir en nueva pestaña
|
||||
$this->dispatch('openUrl', $media->url);
|
||||
return;
|
||||
}
|
||||
@@ -204,8 +162,8 @@ class MediaManager extends Component
|
||||
{
|
||||
return view('livewire.media-manager', [
|
||||
'entityName' => class_basename($this->entity) . ': ' . ($this->entity->name ?? $this->entity->id),
|
||||
'images' => $this->mediaItems->filter(fn ($m) => $m->is_image),
|
||||
'documents' => $this->mediaItems->filter(fn ($m) => !$m->is_image),
|
||||
'images' => $this->mediaItems->filter(fn($m) => $m->is_image),
|
||||
'documents' => $this->mediaItems->filter(fn($m) => !$m->is_image),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,6 @@ namespace App\Livewire;
|
||||
use Livewire\Component;
|
||||
use App\Models\Project;
|
||||
use App\Models\Phase;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class PhaseList extends Component
|
||||
{
|
||||
@@ -15,19 +13,16 @@ class PhaseList extends Component
|
||||
|
||||
public function mount(Project $project)
|
||||
{
|
||||
Gate::authorize('edit projects', $project);
|
||||
$this->project = $project;
|
||||
$this->phases = $project->phases;
|
||||
}
|
||||
|
||||
public function addPhase()
|
||||
{
|
||||
Gate::authorize('edit projects', $this->project);
|
||||
|
||||
$this->project->phases()->create([
|
||||
'name' => 'Nueva fase',
|
||||
'order' => $this->phases->count() + 1,
|
||||
'color' => '#' . substr(md5(random_int(0, PHP_INT_MAX)), 0, 6),
|
||||
'color' => '#'.substr(md5(rand()), 0, 6)
|
||||
]);
|
||||
$this->phases = $this->project->phases()->get();
|
||||
session()->flash('message', 'Fase agregada');
|
||||
@@ -35,16 +30,8 @@ class PhaseList extends Component
|
||||
|
||||
public function deletePhase($phaseId)
|
||||
{
|
||||
Gate::authorize('edit projects', $this->project);
|
||||
|
||||
// Scope to this project to prevent IDOR deletion of another project's phase
|
||||
Phase::where('id', $phaseId)
|
||||
->where('project_id', $this->project->id)
|
||||
->firstOrFail()
|
||||
->delete();
|
||||
|
||||
Phase::find($phaseId)->delete();
|
||||
$this->phases = $this->project->phases()->get();
|
||||
session()->flash('message', 'Fase eliminada');
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
||||
@@ -5,7 +5,6 @@ namespace App\Livewire;
|
||||
use Livewire\Component;
|
||||
use Livewire\Attributes\Layout;
|
||||
use App\Models\Phase;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
#[Layout('layouts.app')]
|
||||
class PhaseProgress extends Component
|
||||
@@ -16,21 +15,12 @@ class PhaseProgress extends Component
|
||||
|
||||
public function mount(Phase $phase)
|
||||
{
|
||||
$user = Auth::user();
|
||||
if (!$user->hasRole('Admin') && !$phase->project->users()->where('user_id', $user->id)->exists()) {
|
||||
abort(403);
|
||||
}
|
||||
$this->phase = $phase->load('progressUpdates');
|
||||
$this->progress = $phase->progress_percent;
|
||||
}
|
||||
|
||||
public function updateProgressManual()
|
||||
{
|
||||
$user = Auth::user();
|
||||
if (!$user->can('update progress') && !$user->hasRole('Admin')) {
|
||||
session()->flash('error', 'Sin permisos para actualizar el progreso.');
|
||||
return;
|
||||
}
|
||||
$this->validate(['progress' => 'required|integer|min:0|max:100']);
|
||||
$this->phase->progress_percent = $this->progress;
|
||||
$this->phase->save();
|
||||
|
||||
@@ -17,10 +17,6 @@ class ProjectCompanies extends Component
|
||||
|
||||
public function mount(Project $project)
|
||||
{
|
||||
$user = Auth::user();
|
||||
if (!$user->hasRole('Admin') && !$project->users()->where('user_id', $user->id)->exists()) {
|
||||
abort(403);
|
||||
}
|
||||
$this->project = $project;
|
||||
$this->loadCompanies();
|
||||
}
|
||||
@@ -69,11 +65,6 @@ class ProjectCompanies extends Component
|
||||
|
||||
public function changeRole($companyId, $role)
|
||||
{
|
||||
$user = Auth::user();
|
||||
if (!$user->can('assign users') && !$user->hasRole('Admin')) {
|
||||
session()->flash('error', 'Sin permisos.');
|
||||
return;
|
||||
}
|
||||
if (!in_array($role, ['owner', 'constructor', 'subcontractor', 'consultant', 'supplier', 'other'])) return;
|
||||
|
||||
$this->project->companies()->updateExistingPivot($companyId, [
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use Livewire\Component;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class ProjectEditTabs extends Component
|
||||
{
|
||||
public Project $project;
|
||||
public string $activeTab = 'project-data';
|
||||
|
||||
public function mount(Project $project)
|
||||
{
|
||||
Gate::authorize('edit projects', $project);
|
||||
$this->project = $project;
|
||||
}
|
||||
|
||||
public function setActiveTab($tab)
|
||||
{
|
||||
$this->activeTab = $tab;
|
||||
}
|
||||
|
||||
public function tabChanged($tab, $projectId)
|
||||
{
|
||||
if ($projectId == $this->project->id) {
|
||||
$this->activeTab = $tab;
|
||||
}
|
||||
}
|
||||
|
||||
public function updateProject()
|
||||
{
|
||||
Gate::authorize('edit projects', $this->project);
|
||||
$this->project->save();
|
||||
|
||||
session()->flash('message', __('Project updated successfully.'));
|
||||
$this->dispatch('project-updated');
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.project-edit-tabs');
|
||||
}
|
||||
}
|
||||
@@ -114,6 +114,28 @@ class ProjectForm extends Component
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.projects.project-form');
|
||||
return view('livewire.projects.project-form', [
|
||||
'countryList' => $this->countryList(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* ISO alpha-2 (lowercase, matches flagcdn) => display name.
|
||||
*/
|
||||
private function countryList(): array
|
||||
{
|
||||
return [
|
||||
'es' => 'España', 'pt' => 'Portugal', 'fr' => 'Francia', 'it' => 'Italia',
|
||||
'de' => 'Alemania', 'gb' => 'Reino Unido', 'ie' => 'Irlanda', 'nl' => 'Países Bajos',
|
||||
'be' => 'Bélgica', 'ch' => 'Suiza', 'at' => 'Austria', 'lu' => 'Luxemburgo',
|
||||
'se' => 'Suecia', 'no' => 'Noruega', 'dk' => 'Dinamarca', 'fi' => 'Finlandia',
|
||||
'pl' => 'Polonia', 'cz' => 'Chequia', 'gr' => 'Grecia', 'ro' => 'Rumanía',
|
||||
'us' => 'Estados Unidos', 'ca' => 'Canadá', 'mx' => 'México', 'gt' => 'Guatemala',
|
||||
'cr' => 'Costa Rica', 'pa' => 'Panamá', 'co' => 'Colombia', 've' => 'Venezuela',
|
||||
'ec' => 'Ecuador', 'pe' => 'Perú', 'bo' => 'Bolivia', 'cl' => 'Chile',
|
||||
'ar' => 'Argentina', 'uy' => 'Uruguay', 'py' => 'Paraguay', 'br' => 'Brasil',
|
||||
'do' => 'República Dominicana', 'ma' => 'Marruecos', 'gq' => 'Guinea Ecuatorial',
|
||||
'ao' => 'Angola', 'cv' => 'Cabo Verde', 'us' => 'Estados Unidos',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
namespace App\Livewire;
|
||||
|
||||
use Livewire\Component;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\WithPagination;
|
||||
use Livewire\Attributes\Layout;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
@@ -18,16 +18,12 @@ class ProjectList extends Component
|
||||
|
||||
public function deleteProject($id)
|
||||
{
|
||||
$user = Auth::user();
|
||||
if (!$user->can('delete projects')) {
|
||||
session()->flash('error', 'Sin permisos para eliminar proyectos.');
|
||||
return;
|
||||
}
|
||||
// Scope to accessible projects to prevent IDOR (deleting another user's project by ID)
|
||||
$project = Project::accessibleBy($user)->findOrFail($id);
|
||||
$project = Project::findOrFail($id);
|
||||
if (Auth::user()->can('delete projects')) {
|
||||
$project->delete();
|
||||
session()->flash('message', 'Proyecto eliminado');
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
|
||||
+207
-103
@@ -10,27 +10,28 @@ use App\Models\Layer;
|
||||
use App\Models\Feature;
|
||||
use App\Models\Inspection;
|
||||
use App\Models\InspectionTemplate;
|
||||
use App\Models\Issue;
|
||||
|
||||
class ProjectMap extends Component
|
||||
{
|
||||
public Project $project;
|
||||
public $phases;
|
||||
public $activeLayers = [];
|
||||
public $activeLayers = []; // Now stores Layer IDs (not Phase IDs)
|
||||
public $showLayerModal = false;
|
||||
|
||||
// Editor properties
|
||||
public $selectedFeature = null; // será instancia de Feature
|
||||
public $selectedFeature = null;
|
||||
public $selectedPhaseId = null;
|
||||
public $editProgress = 0;
|
||||
public $editComment = '';
|
||||
public $editResponsible = '';
|
||||
public $editPhotos = [];
|
||||
public $formFullscreen = false;
|
||||
// Tab management
|
||||
public $activeTab = 'edit'; // edit, features, inspections
|
||||
public $allFeatures = [];
|
||||
public $allInspections = [];
|
||||
|
||||
// Tab management
|
||||
public $activeTab = 'edit';
|
||||
public $allFeatures;
|
||||
public $allInspections;
|
||||
|
||||
// Templates e inspecciones
|
||||
public $templates = [];
|
||||
@@ -42,19 +43,61 @@ class ProjectMap extends Component
|
||||
public $showFeatureImages = false;
|
||||
public $featureImageMarkers = [];
|
||||
|
||||
// Filters
|
||||
public $filterStatus = '';
|
||||
public $filterResponsible = '';
|
||||
public $filterProgressMin = 0;
|
||||
public $filterProgressMax = 100;
|
||||
public $showFilters = false;
|
||||
|
||||
// Inspection workflow
|
||||
public $inspectionResult = '';
|
||||
public $inspectionNotes = '';
|
||||
|
||||
// Issues
|
||||
public $openIssuesCount = 0;
|
||||
|
||||
// Inspection viewer
|
||||
public $viewingInspection = null;
|
||||
|
||||
public function mount(Project $project)
|
||||
{
|
||||
$user = Auth::user();
|
||||
if (!$user->hasRole('Admin') && !$project->users()->where('user_id', $user->id)->exists()) {
|
||||
abort(403);
|
||||
$this->project = $project;
|
||||
$this->authorizeProjectAccess();
|
||||
|
||||
$this->phases = $project->phases()->with([
|
||||
'layers' => fn($q) => $q->withCount('features'),
|
||||
'layers.features',
|
||||
'layers.features.images',
|
||||
])->get();
|
||||
|
||||
// Initialize activeLayers with ALL layer IDs (not phase IDs)
|
||||
$this->activeLayers = $this->phases
|
||||
->flatMap(fn($p) => $p->layers->pluck('id'))
|
||||
->map(fn($id) => (int) $id)
|
||||
->toArray();
|
||||
|
||||
$this->loadTemplates();
|
||||
|
||||
$this->allFeatures = Feature::whereHas('layer.phase', function($q) use ($project) {
|
||||
$q->where('project_id', $project->id);
|
||||
})->with(['layer.phase', 'template'])->get();
|
||||
|
||||
$this->allInspections = Inspection::where('project_id', $project->id)
|
||||
->with(['feature.layer.phase', 'template', 'user'])
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
|
||||
$this->openIssuesCount = Issue::where('project_id', $project->id)
|
||||
->where('status', 'open')
|
||||
->count();
|
||||
}
|
||||
|
||||
$this->project = $project;
|
||||
$this->phases = $project->phases()->with(['layers' => function ($q) {
|
||||
$q->withCount('features');
|
||||
}, 'layers.features'])->get();
|
||||
$this->activeLayers = $this->phases->pluck('id')->toArray();
|
||||
$this->loadTemplates();
|
||||
private function authorizeProjectAccess(): void
|
||||
{
|
||||
$user = Auth::user();
|
||||
if ($user->hasRole('Admin')) return;
|
||||
if (!$this->project->users()->where('user_id', $user->id)->exists()) abort(403);
|
||||
}
|
||||
|
||||
public function loadTemplates()
|
||||
@@ -62,74 +105,113 @@ class ProjectMap extends Component
|
||||
$this->templates = InspectionTemplate::where('project_id', $this->project->id)->get();
|
||||
}
|
||||
|
||||
public function toggleLayer($phaseId)
|
||||
// ─── Layer / Phase visibility ────────────────────────────────────────────────
|
||||
|
||||
public function toggleLayer($layerId)
|
||||
{
|
||||
if (in_array($phaseId, $this->activeLayers)) {
|
||||
$this->activeLayers = array_diff($this->activeLayers, [$phaseId]);
|
||||
$layerId = (int) $layerId;
|
||||
if (in_array($layerId, $this->activeLayers)) {
|
||||
$this->activeLayers = array_values(array_diff($this->activeLayers, [$layerId]));
|
||||
} else {
|
||||
$this->activeLayers[] = $phaseId;
|
||||
$this->activeLayers[] = $layerId;
|
||||
}
|
||||
$this->dispatch('layersUpdated', $this->activeLayers);
|
||||
}
|
||||
|
||||
public function openLayerModal()
|
||||
public function togglePhase($phaseId)
|
||||
{
|
||||
$this->showLayerModal = true;
|
||||
$phase = $this->phases->find($phaseId);
|
||||
if (!$phase) return;
|
||||
$layerIds = $phase->layers->pluck('id')->map(fn($id) => (int) $id)->toArray();
|
||||
$allActive = !empty($layerIds) && collect($layerIds)->every(fn($id) => in_array($id, $this->activeLayers));
|
||||
if ($allActive) {
|
||||
$this->activeLayers = array_values(array_diff($this->activeLayers, $layerIds));
|
||||
} else {
|
||||
$this->activeLayers = array_values(array_unique(array_merge($this->activeLayers, $layerIds)));
|
||||
}
|
||||
$this->dispatch('layersUpdated', $this->activeLayers);
|
||||
}
|
||||
|
||||
public function closeLayerModal()
|
||||
public function openLayerModal() { $this->showLayerModal = true; }
|
||||
public function closeLayerModal() { $this->showLayerModal = false; }
|
||||
|
||||
// ─── Filters ────────────────────────────────────────────────────────────────
|
||||
|
||||
public function updatedFilterStatus() { $this->applyFilters(); }
|
||||
public function updatedFilterResponsible() { $this->applyFilters(); }
|
||||
public function updatedFilterProgressMin() { $this->applyFilters(); }
|
||||
public function updatedFilterProgressMax() { $this->applyFilters(); }
|
||||
|
||||
public function applyFilters()
|
||||
{
|
||||
$this->showLayerModal = false;
|
||||
$filtered = $this->allFeatures->filter(function($f) {
|
||||
if ($this->filterStatus && $f->status !== $this->filterStatus) return false;
|
||||
if ($this->filterResponsible && !str_contains(strtolower($f->responsible ?? ''), strtolower($this->filterResponsible))) return false;
|
||||
if ($f->progress < $this->filterProgressMin || $f->progress > $this->filterProgressMax) return false;
|
||||
return true;
|
||||
});
|
||||
$this->dispatch('filtersChanged', $filtered->pluck('id')->values()->toArray());
|
||||
}
|
||||
|
||||
public function clearFilters()
|
||||
{
|
||||
$this->filterStatus = '';
|
||||
$this->filterResponsible = '';
|
||||
$this->filterProgressMin = 0;
|
||||
$this->filterProgressMax = 100;
|
||||
$this->dispatch('filtersChanged', $this->allFeatures->pluck('id')->values()->toArray());
|
||||
}
|
||||
|
||||
// ─── Feature status ─────────────────────────────────────────────────────────
|
||||
|
||||
public function editFeatureStatus($status)
|
||||
{
|
||||
if (!$this->selectedFeature) return;
|
||||
$feature = Feature::with('layer.phase')->findOrFail($this->selectedFeature->id);
|
||||
if ($feature->layer->phase->project_id !== $this->project->id) abort(403);
|
||||
$feature->status = $status;
|
||||
if ($status === 'completed') $feature->progress = 100;
|
||||
if ($status === 'planned') $feature->progress = 0;
|
||||
$feature->save();
|
||||
$this->selectedFeature = $feature;
|
||||
$this->editProgress = $feature->progress;
|
||||
$this->allFeatures = $this->allFeatures->map(fn($f) => $f->id === $feature->id ? $feature : $f);
|
||||
$this->dispatch('featureStatusChanged', $feature->id, $feature->status, $feature->status_color);
|
||||
$this->dispatch('notify', 'Estado actualizado');
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualizar el progreso de un Feature y recalcular el progreso de la fase.
|
||||
*/
|
||||
public function updateProgress($featureId, $newProgress, $comment = null)
|
||||
{
|
||||
$feature = Feature::with('layer.phase')->findOrFail($featureId);
|
||||
// Verify feature belongs to this project (IDOR prevention)
|
||||
if ($feature->layer->phase->project_id !== $this->project->id) abort(403);
|
||||
|
||||
$user = Auth::user();
|
||||
if (!$user->can('update progress') && !$user->hasRole('Admin')) {
|
||||
$this->dispatch('notify', 'Sin permisos');
|
||||
return;
|
||||
}
|
||||
|
||||
if ($feature->layer->phase->project_id !== $this->project->id) abort(403);
|
||||
$feature->progress = min(100, max(0, $newProgress));
|
||||
$feature->save();
|
||||
|
||||
$phase = Phase::find($feature->layer->phase_id);
|
||||
$phase = $feature->layer->phase;
|
||||
$phase->progress_percent = $phase->features()->avg('progress') ?: 0;
|
||||
$phase->save();
|
||||
|
||||
// Registrar la actualización en progress_updates
|
||||
$phase->progressUpdates()->create([
|
||||
'user_id' => $user->id,
|
||||
'progress_percent' => $phase->progress_percent,
|
||||
'comment' => $comment,
|
||||
]);
|
||||
|
||||
$this->dispatch('progressUpdated', $featureId, $feature->progress);
|
||||
$this->dispatch('notify', 'Progreso actualizado');
|
||||
|
||||
// Si el feature seleccionado es el mismo, actualizar la propiedad local
|
||||
if ($this->selectedFeature && $this->selectedFeature->id == $featureId) {
|
||||
$this->selectedFeature->progress = $feature->progress;
|
||||
$this->editProgress = $feature->progress;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Seleccionar un Feature al hacer clic en el mapa.
|
||||
*/
|
||||
public function selectFeature($featureId)
|
||||
{
|
||||
$this->selectedFeature = null;
|
||||
$feature = Feature::with(['template', 'layer.phase'])->find($featureId);
|
||||
if (!$feature) return;
|
||||
// Verify feature belongs to this project
|
||||
if ($feature->layer->phase->project_id !== $this->project->id) abort(403);
|
||||
|
||||
$this->selectedFeature = $feature;
|
||||
@@ -138,16 +220,14 @@ class ProjectMap extends Component
|
||||
$this->editResponsible = $feature->responsible ?? '';
|
||||
$this->editPhotos = $feature->properties['photos'] ?? [];
|
||||
$this->selectedTemplateId = $feature->template_id;
|
||||
$this->activeTab = 'edit';
|
||||
|
||||
$this->loadInspectionHistory();
|
||||
$this->resetInspectionForm();
|
||||
|
||||
$this->dispatch('featureSelected', $featureId);
|
||||
$this->dispatch('featureSelected', $featureId, $feature->name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cargar el historial de inspecciones del feature seleccionado.
|
||||
*/
|
||||
public function loadInspectionHistory()
|
||||
{
|
||||
if (!$this->selectedFeature) {
|
||||
@@ -160,12 +240,11 @@ class ProjectMap extends Component
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reiniciar el formulario de inspección según el template seleccionado.
|
||||
*/
|
||||
public function resetInspectionForm()
|
||||
{
|
||||
$this->inspectionFormData = [];
|
||||
$this->inspectionResult = '';
|
||||
$this->inspectionNotes = '';
|
||||
if ($this->selectedTemplateId) {
|
||||
$template = InspectionTemplate::find($this->selectedTemplateId);
|
||||
if ($template) {
|
||||
@@ -176,20 +255,18 @@ class ProjectMap extends Component
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Guardar una nueva inspección.
|
||||
*/
|
||||
public function saveInspection()
|
||||
{
|
||||
if (!$this->selectedFeature || !$this->selectedTemplateId) {
|
||||
$this->dispatch('notify', 'Selecciona un elemento y un template.');
|
||||
return;
|
||||
}
|
||||
$feature = Feature::with('layer.phase')->find($this->selectedFeature->id);
|
||||
if (!$feature || $feature->layer->phase->project_id !== $this->project->id) abort(403);
|
||||
|
||||
// Verify the template belongs to this project
|
||||
$template = InspectionTemplate::where('id', $this->selectedTemplateId)
|
||||
->where('project_id', $this->project->id)
|
||||
->firstOrFail();
|
||||
$this->validate(['selectedTemplateId' => 'required|exists:inspection_templates,id']);
|
||||
|
||||
$template = InspectionTemplate::find($this->selectedTemplateId);
|
||||
foreach ($template->fields as $field) {
|
||||
if (($field['required'] ?? false) && empty($this->inspectionFormData[$field['name']])) {
|
||||
$this->dispatch('notify', "El campo {$field['label']} es obligatorio.");
|
||||
@@ -203,33 +280,52 @@ class ProjectMap extends Component
|
||||
'feature_id' => $this->selectedFeature->id,
|
||||
'template_id' => $this->selectedTemplateId,
|
||||
'user_id' => auth()->id(),
|
||||
'inspector_user_id' => auth()->id(),
|
||||
'status' => 'completed',
|
||||
'completed_at' => now(),
|
||||
'result' => $this->inspectionResult ?: null,
|
||||
'notes' => $this->inspectionNotes ?: null,
|
||||
'data' => $this->inspectionFormData,
|
||||
]);
|
||||
|
||||
// Si el template tiene un campo llamado 'progress', actualizar el progreso del feature
|
||||
if ($this->inspectionResult === 'fail') {
|
||||
Issue::create([
|
||||
'project_id' => $this->project->id,
|
||||
'feature_id' => $this->selectedFeature->id,
|
||||
'inspection_id' => $inspection->id,
|
||||
'title' => 'Fallo en inspección: ' . ($template->name ?? 'Sin nombre'),
|
||||
'description' => $this->inspectionNotes,
|
||||
'priority' => 'high',
|
||||
'status' => 'open',
|
||||
'reported_by' => auth()->id(),
|
||||
]);
|
||||
$this->openIssuesCount = Issue::where('project_id', $this->project->id)
|
||||
->where('status', 'open')->count();
|
||||
$this->dispatch('notify', 'Inspección fallida — Issue creado automáticamente');
|
||||
} else {
|
||||
if (isset($this->inspectionFormData['progress'])) {
|
||||
$this->updateProgress($this->selectedFeature->id, (int)$this->inspectionFormData['progress'], 'Inspección registrada');
|
||||
}
|
||||
|
||||
$this->loadInspectionHistory();
|
||||
$this->resetInspectionForm();
|
||||
$this->dispatch('notify', 'Inspección guardada correctamente');
|
||||
}
|
||||
|
||||
/**
|
||||
* Asignar un template al feature seleccionado.
|
||||
*/
|
||||
// Reload global list
|
||||
$this->allInspections = Inspection::where('project_id', $this->project->id)
|
||||
->with(['feature.layer.phase', 'template', 'user'])
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
|
||||
$this->loadInspectionHistory();
|
||||
$this->resetInspectionForm();
|
||||
}
|
||||
|
||||
public function assignTemplateToFeature($templateId)
|
||||
{
|
||||
if (!$this->selectedFeature) return;
|
||||
// Verify template belongs to this project (IDOR prevention)
|
||||
$template = InspectionTemplate::where('id', $templateId)
|
||||
->where('project_id', $this->project->id)
|
||||
->firstOrFail();
|
||||
|
||||
$feature = Feature::with('layer.phase')->findOrFail($this->selectedFeature->id);
|
||||
if ($feature->layer->phase->project_id !== $this->project->id) abort(403);
|
||||
|
||||
->where('project_id', $this->project->id)->first();
|
||||
if (!$template) abort(403);
|
||||
$feature = Feature::findOrFail($this->selectedFeature->id);
|
||||
$feature->template_id = $templateId;
|
||||
$feature->save();
|
||||
$this->selectedFeature = $feature;
|
||||
@@ -238,40 +334,58 @@ class ProjectMap extends Component
|
||||
$this->dispatch('notify', 'Template asignado al elemento');
|
||||
}
|
||||
|
||||
/**
|
||||
* Guardar progreso y responsable del feature seleccionado.
|
||||
*/
|
||||
public function saveFeatureProgress()
|
||||
{
|
||||
if (!$this->selectedFeature) return;
|
||||
|
||||
$feature = Feature::with('layer.phase')->findOrFail($this->selectedFeature->id);
|
||||
if ($feature->layer->phase->project_id !== $this->project->id) abort(403);
|
||||
|
||||
$feature->progress = min(100, max(0, (int) $this->editProgress));
|
||||
$feature->progress = min(100, max(0, (int)$this->editProgress));
|
||||
$feature->responsible = $this->editResponsible;
|
||||
$feature->save();
|
||||
$this->selectedFeature = $feature;
|
||||
|
||||
$phase = Phase::find($feature->layer->phase_id);
|
||||
$phase = $feature->layer->phase;
|
||||
$phase->progress_percent = $phase->features()->avg('progress') ?: 0;
|
||||
$phase->save();
|
||||
|
||||
$this->dispatch('progressUpdated', $phase->id, $phase->progress_percent);
|
||||
$this->dispatch('notify', 'Progreso guardado');
|
||||
}
|
||||
|
||||
/**
|
||||
* Cuando cambia el template seleccionado, reiniciar el formulario.
|
||||
*/
|
||||
public function onTemplateChange()
|
||||
{
|
||||
$this->resetInspectionForm();
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle mostrar imágenes en el mapa.
|
||||
*/
|
||||
// ─── Inspection viewer ───────────────────────────────────────────────────────
|
||||
|
||||
public function viewInspection($id)
|
||||
{
|
||||
$ins = Inspection::where('project_id', $this->project->id)
|
||||
->with(['feature.layer.phase', 'template', 'user'])
|
||||
->find($id);
|
||||
if (!$ins) return;
|
||||
$this->viewingInspection = [
|
||||
'id' => $ins->id,
|
||||
'feature_name' => $ins->feature?->name ?? '—',
|
||||
'layer_name' => $ins->feature?->layer?->name ?? '—',
|
||||
'phase_name' => $ins->feature?->layer?->phase?->name ?? '—',
|
||||
'template_name' => $ins->template?->name ?? '—',
|
||||
'user_name' => $ins->user?->name ?? '—',
|
||||
'date' => $ins->created_at->format('d/m/Y H:i'),
|
||||
'status' => $ins->status,
|
||||
'result' => $ins->result,
|
||||
'notes' => $ins->notes,
|
||||
'data' => $ins->data ?? [],
|
||||
'fields' => $ins->template?->fields ?? [],
|
||||
];
|
||||
}
|
||||
|
||||
public function closeViewInspection()
|
||||
{
|
||||
$this->viewingInspection = null;
|
||||
}
|
||||
|
||||
// ─── Feature images ──────────────────────────────────────────────────────────
|
||||
|
||||
public function toggleFeatureImages()
|
||||
{
|
||||
$this->showFeatureImages = !$this->showFeatureImages;
|
||||
@@ -279,35 +393,22 @@ class ProjectMap extends Component
|
||||
$this->dispatch('featureImagesToggled', $this->showFeatureImages, $this->featureImageMarkers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cargar marcadores de imágenes para el mapa.
|
||||
*/
|
||||
public function loadFeatureImageMarkers()
|
||||
{
|
||||
if (!$this->showFeatureImages) {
|
||||
$this->featureImageMarkers = [];
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->showFeatureImages) { $this->featureImageMarkers = []; return; }
|
||||
$markers = [];
|
||||
foreach ($this->phases as $phase) {
|
||||
foreach ($phase->layers as $layer) {
|
||||
foreach ($layer->features as $feature) {
|
||||
$image = $feature->images()->first();
|
||||
$image = $feature->images->first();
|
||||
if ($image) {
|
||||
$geo = $feature->geometry;
|
||||
$coords = null;
|
||||
if ($geo && isset($geo['coordinates'])) {
|
||||
if ($geo['type'] === 'Point') {
|
||||
$coords = [
|
||||
'lat' => $geo['coordinates'][1],
|
||||
'lng' => $geo['coordinates'][0],
|
||||
];
|
||||
$coords = ['lat' => $geo['coordinates'][1], 'lng' => $geo['coordinates'][0]];
|
||||
} elseif (in_array($geo['type'], ['Polygon', 'LineString'])) {
|
||||
$coords = [
|
||||
'lat' => $geo['coordinates'][0][1] ?? null,
|
||||
'lng' => $geo['coordinates'][0][0] ?? null,
|
||||
];
|
||||
$coords = ['lat' => $geo['coordinates'][0][1] ?? null, 'lng' => $geo['coordinates'][0][0] ?? null];
|
||||
}
|
||||
}
|
||||
if ($coords && $coords['lat'] && $coords['lng']) {
|
||||
@@ -330,9 +431,12 @@ class ProjectMap extends Component
|
||||
public function toggleFullscreen()
|
||||
{
|
||||
$this->formFullscreen = !$this->formFullscreen;
|
||||
if (!$this->formFullscreen) {
|
||||
$this->dispatch('mapResize');
|
||||
if (!$this->formFullscreen) $this->dispatch('mapResize');
|
||||
}
|
||||
|
||||
public function setActiveTab($tab)
|
||||
{
|
||||
$this->activeTab = $tab;
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
||||
@@ -17,10 +17,6 @@ class ProjectUsers extends Component
|
||||
|
||||
public function mount(Project $project)
|
||||
{
|
||||
$user = Auth::user();
|
||||
if (!$user->hasRole('Admin') && !$project->users()->where('user_id', $user->id)->exists()) {
|
||||
abort(403);
|
||||
}
|
||||
$this->project = $project;
|
||||
$this->loadUsers();
|
||||
}
|
||||
@@ -69,11 +65,6 @@ class ProjectUsers extends Component
|
||||
|
||||
public function changeRole($userId, $role)
|
||||
{
|
||||
$user = Auth::user();
|
||||
if (!$user->can('assign users') && !$user->hasRole('Admin')) {
|
||||
session()->flash('error', 'Sin permisos.');
|
||||
return;
|
||||
}
|
||||
if (!in_array($role, ['supervisor', 'consultant', 'client', 'viewer'])) return;
|
||||
|
||||
$this->project->users()->updateExistingPivot($userId, [
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
namespace App\Livewire\Reports;
|
||||
|
||||
use Livewire\Component;
|
||||
use Livewire\Attributes\Layout;
|
||||
use App\Models\Project;
|
||||
use App\Models\Phase;
|
||||
use App\Models\Inspection;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
#[Layout('layouts.app')]
|
||||
class ReportsDashboard extends Component
|
||||
{
|
||||
public $dateRange = 'month'; // week, month, quarter, year
|
||||
@@ -16,7 +17,6 @@ class ReportsDashboard extends Component
|
||||
|
||||
public function mount()
|
||||
{
|
||||
if (!Auth::user()->hasRole('Admin')) abort(403);
|
||||
$this->loadChartData();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use Livewire\Component;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Spatie\Permission\Models\Role;
|
||||
use Spatie\Permission\PermissionRegistrar;
|
||||
|
||||
#[Layout('layouts.app')]
|
||||
class RoleForm extends Component
|
||||
{
|
||||
public ?Role $role = null;
|
||||
|
||||
public string $name = '';
|
||||
public string $description = '';
|
||||
|
||||
private const PROTECTED_ROLES = ['Admin'];
|
||||
private const CORE_PERMISSION = 'manage all';
|
||||
|
||||
public function mount(?Role $role = null): void
|
||||
{
|
||||
abort_unless(Auth::user()?->can(self::CORE_PERMISSION), 403);
|
||||
|
||||
if ($role && $role->exists) {
|
||||
$this->role = $role;
|
||||
$this->name = $role->name;
|
||||
$this->description = $role->description ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
public function save()
|
||||
{
|
||||
$this->validate([
|
||||
'name' => 'required|string|max:50|unique:roles,name' . ($this->role ? ',' . $this->role->id : ''),
|
||||
'description' => 'nullable|string|max:255',
|
||||
], [], ['name' => 'nombre', 'description' => 'descripción']);
|
||||
|
||||
if ($this->role) {
|
||||
// Protected roles can't be renamed
|
||||
if (! in_array($this->role->name, self::PROTECTED_ROLES, true)) {
|
||||
$this->role->name = $this->name;
|
||||
}
|
||||
$this->role->description = $this->description ?: null;
|
||||
$this->role->save();
|
||||
} else {
|
||||
Role::create([
|
||||
'name' => $this->name,
|
||||
'description' => $this->description ?: null,
|
||||
]);
|
||||
}
|
||||
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
session()->flash('message', 'Rol guardado correctamente.');
|
||||
|
||||
return $this->redirect(route('admin.roles'), navigate: true);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.roles.role-form', [
|
||||
'isProtected' => $this->role && in_array($this->role->name, self::PROTECTED_ROLES, true),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use Livewire\Component;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Spatie\Permission\Models\Role;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
use Spatie\Permission\PermissionRegistrar;
|
||||
|
||||
#[Layout('layouts.app')]
|
||||
class RolePermissionManager extends Component
|
||||
{
|
||||
public string $newRole = '';
|
||||
public string $newPermission = '';
|
||||
|
||||
/** Roles that must not be deleted or stripped of core powers. */
|
||||
private const PROTECTED_ROLES = ['Admin'];
|
||||
private const CORE_PERMISSION = 'manage all';
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
abort_unless(Auth::user()?->can(self::CORE_PERMISSION), 403);
|
||||
}
|
||||
|
||||
private function flushCache(): void
|
||||
{
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
}
|
||||
|
||||
public function togglePermission(int $roleId, string $permissionName): void
|
||||
{
|
||||
$role = Role::findOrFail($roleId);
|
||||
|
||||
if ($role->hasPermissionTo($permissionName)) {
|
||||
// Admin must always keep the core permission
|
||||
if ($role->name === 'Admin' && $permissionName === self::CORE_PERMISSION) {
|
||||
$this->dispatch('notify', "El rol Admin no puede perder '" . self::CORE_PERMISSION . "'.");
|
||||
return;
|
||||
}
|
||||
$role->revokePermissionTo($permissionName);
|
||||
} else {
|
||||
$role->givePermissionTo($permissionName);
|
||||
}
|
||||
|
||||
$this->flushCache();
|
||||
$this->dispatch('notify', 'Permisos actualizados');
|
||||
}
|
||||
|
||||
public function addRole(): void
|
||||
{
|
||||
$this->validate([
|
||||
'newRole' => 'required|string|max:50|unique:roles,name',
|
||||
], [], ['newRole' => 'nombre de rol']);
|
||||
|
||||
Role::create(['name' => trim($this->newRole)]);
|
||||
$this->newRole = '';
|
||||
$this->flushCache();
|
||||
$this->dispatch('notify', 'Rol creado');
|
||||
}
|
||||
|
||||
public function deleteRole(int $roleId): void
|
||||
{
|
||||
$role = Role::findOrFail($roleId);
|
||||
|
||||
if (in_array($role->name, self::PROTECTED_ROLES, true)) {
|
||||
$this->dispatch('notify', "El rol '{$role->name}' está protegido y no se puede borrar.");
|
||||
return;
|
||||
}
|
||||
|
||||
$role->delete();
|
||||
$this->flushCache();
|
||||
$this->dispatch('notify', 'Rol eliminado');
|
||||
}
|
||||
|
||||
public function addPermission(): void
|
||||
{
|
||||
$this->validate([
|
||||
'newPermission' => 'required|string|max:50|unique:permissions,name',
|
||||
], [], ['newPermission' => 'nombre de permiso']);
|
||||
|
||||
Permission::create(['name' => trim($this->newPermission)]);
|
||||
$this->newPermission = '';
|
||||
$this->flushCache();
|
||||
$this->dispatch('notify', 'Permiso creado');
|
||||
}
|
||||
|
||||
public function deletePermission(int $permissionId): void
|
||||
{
|
||||
$permission = Permission::findOrFail($permissionId);
|
||||
|
||||
if ($permission->name === self::CORE_PERMISSION) {
|
||||
$this->dispatch('notify', "El permiso '" . self::CORE_PERMISSION . "' está protegido y no se puede borrar.");
|
||||
return;
|
||||
}
|
||||
|
||||
$permission->delete();
|
||||
$this->flushCache();
|
||||
$this->dispatch('notify', 'Permiso eliminado');
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.role-permission-manager', [
|
||||
'roles' => Role::with('permissions')->orderBy('name')->get(),
|
||||
'permissions' => Permission::orderBy('name')->get(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use Rappasoft\LaravelLivewireTables\DataTableComponent;
|
||||
use Rappasoft\LaravelLivewireTables\Views\Column;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Spatie\Permission\Models\Role;
|
||||
use Spatie\Permission\PermissionRegistrar;
|
||||
|
||||
class RoleTable extends DataTableComponent
|
||||
{
|
||||
protected $model = Role::class;
|
||||
|
||||
private const PROTECTED_ROLES = ['Admin'];
|
||||
|
||||
public function configure(): void
|
||||
{
|
||||
$this->setPrimaryKey('id')
|
||||
->setDefaultSort('name', 'asc')
|
||||
->setSortingPillsEnabled(false);
|
||||
}
|
||||
|
||||
public function builder(): Builder
|
||||
{
|
||||
return Role::withCount(['permissions', 'users']);
|
||||
}
|
||||
|
||||
public function columns(): array
|
||||
{
|
||||
return [
|
||||
Column::make(__('Name'), 'name')
|
||||
->sortable()
|
||||
->searchable()
|
||||
->format(fn ($value, $row) =>
|
||||
'<a href="'.route('admin.roles.show', $row->id).'" class="font-semibold text-primary hover:underline" wire:navigate>'.e($value).'</a>'
|
||||
. (in_array($row->name, self::PROTECTED_ROLES, true) ? ' <span class="badge badge-ghost badge-xs">protegido</span>' : '')
|
||||
)
|
||||
->html(),
|
||||
|
||||
Column::make(__('Description'), 'description')
|
||||
->sortable()
|
||||
->searchable()
|
||||
->format(fn ($value) => $value
|
||||
? '<span class="text-sm text-gray-500">'.e($value).'</span>'
|
||||
: '<span class="text-gray-300">—</span>')
|
||||
->html(),
|
||||
|
||||
Column::make(__('Permissions'))
|
||||
->label(fn ($row) => '<span class="badge badge-outline badge-sm">'.(int) $row->permissions_count.'</span>')
|
||||
->html(),
|
||||
|
||||
Column::make(__('Users'))
|
||||
->label(fn ($row) => '<span class="badge badge-ghost badge-sm">'.(int) $row->users_count.'</span>')
|
||||
->html(),
|
||||
|
||||
Column::make(__('Actions'))
|
||||
->label(function ($row) {
|
||||
$show = route('admin.roles.show', $row->id);
|
||||
$edit = route('admin.roles.edit', $row->id);
|
||||
$eye = '<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>';
|
||||
$pencil = '<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>';
|
||||
$trash = '<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>';
|
||||
|
||||
$html = '<div class="flex items-center gap-1">';
|
||||
$html .= '<a href="'.$show.'" class="btn btn-xs btn-ghost" title="Ver" wire:navigate>'.$eye.'</a>';
|
||||
$html .= '<a href="'.$edit.'" class="btn btn-xs btn-ghost text-info" title="Editar" wire:navigate>'.$pencil.'</a>';
|
||||
if (! in_array($row->name, self::PROTECTED_ROLES, true)) {
|
||||
$html .= '<button wire:click="deleteRole('.$row->id.')" wire:confirm="¿Eliminar el rol \''.e($row->name).'\'?" class="btn btn-xs btn-ghost text-error" title="Eliminar">'.$trash.'</button>';
|
||||
}
|
||||
$html .= '</div>';
|
||||
return $html;
|
||||
})
|
||||
->html(),
|
||||
];
|
||||
}
|
||||
|
||||
public function bulkActions(): array
|
||||
{
|
||||
return ['bulkDelete' => __('Delete selected')];
|
||||
}
|
||||
|
||||
public function bulkDelete(): void
|
||||
{
|
||||
$roles = Role::whereIn('id', $this->selected)->get();
|
||||
foreach ($roles as $role) {
|
||||
if (in_array($role->name, self::PROTECTED_ROLES, true)) continue;
|
||||
$role->delete();
|
||||
}
|
||||
$this->clearSelected();
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
$this->dispatch('notify', __('Roles deleted'));
|
||||
}
|
||||
|
||||
public function deleteRole(int $id): void
|
||||
{
|
||||
$role = Role::findOrFail($id);
|
||||
if (in_array($role->name, self::PROTECTED_ROLES, true)) {
|
||||
return;
|
||||
}
|
||||
$role->delete();
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
$this->dispatch('notify', __('Role deleted'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use Livewire\Component;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Str;
|
||||
use Spatie\Permission\Models\Role;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
use Spatie\Permission\PermissionRegistrar;
|
||||
|
||||
#[Layout('layouts.app')]
|
||||
class RoleView extends Component
|
||||
{
|
||||
public Role $role;
|
||||
public string $tab = 'ficha'; // ficha | permisos
|
||||
|
||||
private const PROTECTED_ROLES = ['Admin'];
|
||||
private const CORE_PERMISSION = 'manage all';
|
||||
|
||||
public function mount(Role $role): void
|
||||
{
|
||||
abort_unless(Auth::user()?->can(self::CORE_PERMISSION), 403);
|
||||
$this->role = $role;
|
||||
}
|
||||
|
||||
public function setTab(string $tab): void
|
||||
{
|
||||
$this->tab = in_array($tab, ['ficha', 'permisos'], true) ? $tab : 'ficha';
|
||||
}
|
||||
|
||||
public function togglePermission(string $permissionName): void
|
||||
{
|
||||
// Admin must always keep the core permission
|
||||
if ($this->role->name === 'Admin'
|
||||
&& $permissionName === self::CORE_PERMISSION
|
||||
&& $this->role->hasPermissionTo($permissionName)) {
|
||||
$this->dispatch('notify', "El rol Admin no puede perder '" . self::CORE_PERMISSION . "'.");
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->role->hasPermissionTo($permissionName)) {
|
||||
$this->role->revokePermissionTo($permissionName);
|
||||
} else {
|
||||
$this->role->givePermissionTo($permissionName);
|
||||
}
|
||||
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
$this->role->load('permissions');
|
||||
$this->dispatch('notify', 'Permisos actualizados');
|
||||
}
|
||||
|
||||
public function delete()
|
||||
{
|
||||
if (in_array($this->role->name, self::PROTECTED_ROLES, true)) {
|
||||
$this->dispatch('notify', "El rol '{$this->role->name}' está protegido y no se puede borrar.");
|
||||
return;
|
||||
}
|
||||
$this->role->delete();
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
session()->flash('message', 'Rol eliminado.');
|
||||
|
||||
return $this->redirect(route('admin.roles'), navigate: true);
|
||||
}
|
||||
|
||||
/** Section title for a permission name (groups by the resource / last word). */
|
||||
private function sectionFor(string $name): string
|
||||
{
|
||||
if ($name === self::CORE_PERMISSION) {
|
||||
return 'General';
|
||||
}
|
||||
$resource = Str::afterLast($name, ' ');
|
||||
return Str::headline($resource ?: 'General');
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$users = $this->role->users()
|
||||
->orderBy('first_name')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
$order = [
|
||||
'Proyectos', 'Fases y progreso', 'Capas y elementos', 'Inspecciones',
|
||||
'Incidencias', 'Empresas', 'Usuarios', 'Roles', 'Informes', 'Archivos', 'General',
|
||||
];
|
||||
|
||||
$grouped = Permission::orderBy('name')->get()
|
||||
->groupBy(fn ($perm) => $perm->group ?: $this->sectionFor($perm->name))
|
||||
->sortBy(function ($perms, $section) use ($order) {
|
||||
$i = array_search($section, $order, true);
|
||||
return $i === false ? 999 : $i;
|
||||
});
|
||||
|
||||
return view('livewire.roles.role-view', [
|
||||
'users' => $users,
|
||||
'grouped' => $grouped,
|
||||
'rolePerms' => $this->role->permissions->pluck('name')->toArray(),
|
||||
'isProtected' => in_array($this->role->name, self::PROTECTED_ROLES, true),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -3,24 +3,45 @@
|
||||
namespace App\Livewire;
|
||||
|
||||
use Livewire\Component;
|
||||
use Livewire\WithFileUploads;
|
||||
use App\Models\InspectionTemplate;
|
||||
use App\Models\Project;
|
||||
use App\Models\Phase;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
|
||||
class TemplateManager extends Component
|
||||
{
|
||||
use WithFileUploads;
|
||||
|
||||
public $project;
|
||||
public $templates;
|
||||
public $phases;
|
||||
|
||||
// ── Formulario principal ───────────────────────────────────────────────
|
||||
public $editingTemplate = null;
|
||||
public $showForm = false; // Controla si mostrar el formulario
|
||||
public $showForm = false;
|
||||
public $form = [
|
||||
'name' => '',
|
||||
'description' => '',
|
||||
'phase_id' => null,
|
||||
'fields' => [],
|
||||
];
|
||||
|
||||
// ── Importar desde CSV/Excel ───────────────────────────────────────────
|
||||
public $showImportFileModal = false;
|
||||
public $importFile = null;
|
||||
public $importPreviewFields = [];
|
||||
public $importTemplateName = '';
|
||||
public $importError = '';
|
||||
|
||||
// ── Importar desde otro proyecto ──────────────────────────────────────
|
||||
public $showImportProjectModal = false;
|
||||
public $availableProjects = [];
|
||||
public $importProjectId = null;
|
||||
public $importableTemplates = [];
|
||||
public $selectedImportTemplateIds = [];
|
||||
|
||||
public $fieldTypes = [
|
||||
'text' => 'Texto corto',
|
||||
'textarea' => 'Texto largo',
|
||||
@@ -32,14 +53,8 @@ class TemplateManager extends Component
|
||||
'select' => 'Lista desplegable',
|
||||
];
|
||||
|
||||
protected $listeners = ['showTemplateForm' => 'newTemplate'];
|
||||
|
||||
public function mount(Project $project)
|
||||
{
|
||||
$user = Auth::user();
|
||||
if (!$user->hasRole('Admin') && !$project->users()->where('user_id', $user->id)->exists()) {
|
||||
abort(403);
|
||||
}
|
||||
$this->project = $project;
|
||||
$this->loadPhases();
|
||||
$this->loadTemplates();
|
||||
@@ -52,22 +67,28 @@ class TemplateManager extends Component
|
||||
|
||||
public function loadTemplates()
|
||||
{
|
||||
$this->templates = InspectionTemplate::where('project_id', $this->project->id)->get();
|
||||
$this->templates = InspectionTemplate::where('project_id', $this->project->id)
|
||||
->with('phase')
|
||||
->get();
|
||||
}
|
||||
|
||||
// ── Formulario manual ─────────────────────────────────────────────────
|
||||
|
||||
public function newTemplate()
|
||||
{
|
||||
$this->resetForm();
|
||||
$this->editingTemplate = null;
|
||||
$this->showForm = true;
|
||||
}
|
||||
|
||||
public function editTemplate($id)
|
||||
{
|
||||
$template = InspectionTemplate::where('id', $id)
|
||||
->where('project_id', $this->project->id)
|
||||
->firstOrFail();
|
||||
$this->form = $template->only(['name', 'description', 'phase_id', 'fields']);
|
||||
$template = InspectionTemplate::findOrFail($id);
|
||||
$this->form = [
|
||||
'name' => $template->name,
|
||||
'description' => $template->description ?? '',
|
||||
'phase_id' => $template->phase_id,
|
||||
'fields' => $template->fields ?? [],
|
||||
];
|
||||
$this->editingTemplate = $id;
|
||||
$this->showForm = true;
|
||||
}
|
||||
@@ -95,7 +116,7 @@ class TemplateManager extends Component
|
||||
'name' => '',
|
||||
'label' => '',
|
||||
'type' => 'text',
|
||||
'options' => [],
|
||||
'options' => '',
|
||||
'required' => false,
|
||||
'min' => null,
|
||||
'max' => null,
|
||||
@@ -117,26 +138,20 @@ class TemplateManager extends Component
|
||||
'form.fields' => 'array',
|
||||
]);
|
||||
|
||||
if ($this->editingTemplate) {
|
||||
$template = InspectionTemplate::where('id', $this->editingTemplate)
|
||||
->where('project_id', $this->project->id)
|
||||
->firstOrFail();
|
||||
$template->update([
|
||||
'name' => $this->form['name'],
|
||||
'description' => $this->form['description'],
|
||||
'phase_id' => $this->form['phase_id'],
|
||||
'fields' => $this->form['fields'],
|
||||
]);
|
||||
session()->flash('message', 'Template actualizado');
|
||||
} else {
|
||||
InspectionTemplate::create([
|
||||
$data = [
|
||||
'name' => $this->form['name'],
|
||||
'description' => $this->form['description'],
|
||||
'project_id' => $this->project->id,
|
||||
'phase_id' => $this->form['phase_id'],
|
||||
'fields' => $this->form['fields'],
|
||||
]);
|
||||
session()->flash('message', 'Template creado');
|
||||
'phase_id' => $this->form['phase_id'] ?: null,
|
||||
'fields' => array_values($this->form['fields']),
|
||||
];
|
||||
|
||||
if ($this->editingTemplate) {
|
||||
InspectionTemplate::findOrFail($this->editingTemplate)->update($data);
|
||||
$this->dispatch('notify', 'Template actualizado correctamente');
|
||||
} else {
|
||||
InspectionTemplate::create($data);
|
||||
$this->dispatch('notify', 'Template creado correctamente');
|
||||
}
|
||||
|
||||
$this->cancelForm();
|
||||
@@ -145,12 +160,272 @@ class TemplateManager extends Component
|
||||
|
||||
public function deleteTemplate($id)
|
||||
{
|
||||
InspectionTemplate::where('id', $id)
|
||||
->where('project_id', $this->project->id)
|
||||
->firstOrFail()
|
||||
->delete();
|
||||
InspectionTemplate::findOrFail($id)->delete();
|
||||
$this->loadTemplates();
|
||||
session()->flash('message', 'Template eliminado');
|
||||
$this->dispatch('notify', 'Template eliminado');
|
||||
}
|
||||
|
||||
// ── Exportar template a CSV ────────────────────────────────────────────
|
||||
|
||||
public function exportTemplate($id)
|
||||
{
|
||||
$template = InspectionTemplate::findOrFail($id);
|
||||
$rows = [];
|
||||
$rows[] = ['name', 'label', 'type', 'required', 'options', 'min', 'max', 'step'];
|
||||
|
||||
foreach ($template->fields as $field) {
|
||||
$rows[] = [
|
||||
$field['name'] ?? '',
|
||||
$field['label'] ?? '',
|
||||
$field['type'] ?? 'text',
|
||||
($field['required'] ?? false) ? '1' : '0',
|
||||
$field['options'] ?? '',
|
||||
$field['min'] ?? '',
|
||||
$field['max'] ?? '',
|
||||
$field['step'] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
$filename = preg_replace('/[^a-z0-9_\-]/i', '_', $template->name) . '.csv';
|
||||
|
||||
return response()->streamDownload(function () use ($rows) {
|
||||
$out = fopen('php://output', 'w');
|
||||
// BOM para Excel con UTF-8
|
||||
fwrite($out, "\xEF\xBB\xBF");
|
||||
foreach ($rows as $row) {
|
||||
fputcsv($out, $row);
|
||||
}
|
||||
fclose($out);
|
||||
}, $filename, ['Content-Type' => 'text/csv; charset=UTF-8']);
|
||||
}
|
||||
|
||||
public function downloadExampleCsv()
|
||||
{
|
||||
$rows = [
|
||||
['name', 'label', 'type', 'required', 'options', 'min', 'max', 'step'],
|
||||
['altura_viga', 'Altura de viga (mm)', 'decimal', '1', '', '0', '2000', '1'],
|
||||
['estado_armado', 'Estado del armado', 'select', '1', 'Conforme,No conforme,Obs.', '', '', ''],
|
||||
['fotos_tomadas', '¿Fotos tomadas?', 'boolean', '1', '', '', '', ''],
|
||||
['observaciones', 'Observaciones generales','textarea', '0', '', '', '', ''],
|
||||
['fecha_visita', 'Fecha de visita', 'date', '0', '', '', '', ''],
|
||||
['avance_pct', 'Avance medido (%)', 'percentage', '0', '', '0', '100', '1'],
|
||||
];
|
||||
|
||||
return response()->streamDownload(function () use ($rows) {
|
||||
$out = fopen('php://output', 'w');
|
||||
fwrite($out, "\xEF\xBB\xBF");
|
||||
foreach ($rows as $row) {
|
||||
fputcsv($out, $row);
|
||||
}
|
||||
fclose($out);
|
||||
}, 'ejemplo-template.csv', ['Content-Type' => 'text/csv; charset=UTF-8']);
|
||||
}
|
||||
|
||||
// ── Importar desde CSV / Excel ─────────────────────────────────────────
|
||||
|
||||
public function openImportFileModal()
|
||||
{
|
||||
$this->importFile = null;
|
||||
$this->importPreviewFields = [];
|
||||
$this->importTemplateName = '';
|
||||
$this->importError = '';
|
||||
$this->showImportFileModal = true;
|
||||
}
|
||||
|
||||
public function parseImportFile()
|
||||
{
|
||||
$this->importError = '';
|
||||
$this->validate([
|
||||
'importFile' => 'required|file|mimes:csv,txt,xlsx,xls|max:5120',
|
||||
'importTemplateName' => 'required|string|max:255',
|
||||
], [
|
||||
'importFile.required' => 'Selecciona un archivo.',
|
||||
'importFile.mimes' => 'Solo se aceptan archivos CSV o Excel (xlsx/xls).',
|
||||
'importTemplateName.required' => 'Escribe un nombre para el template.',
|
||||
]);
|
||||
|
||||
try {
|
||||
$rows = $this->readFileRows();
|
||||
} catch (\Throwable $e) {
|
||||
$this->importError = 'No se pudo leer el archivo: ' . $e->getMessage();
|
||||
return;
|
||||
}
|
||||
|
||||
$fields = $this->parseRows($rows);
|
||||
|
||||
if (empty($fields)) {
|
||||
$this->importError = 'No se encontraron filas de datos válidas. Revisa que el archivo tenga el formato correcto.';
|
||||
return;
|
||||
}
|
||||
|
||||
$this->importPreviewFields = $fields;
|
||||
$this->dispatch('notify', count($fields) . ' campos detectados. Revisa la vista previa.');
|
||||
}
|
||||
|
||||
public function confirmImportFile()
|
||||
{
|
||||
if (empty($this->importPreviewFields) || empty($this->importTemplateName)) return;
|
||||
|
||||
InspectionTemplate::create([
|
||||
'name' => $this->importTemplateName,
|
||||
'description' => 'Importado desde archivo',
|
||||
'project_id' => $this->project->id,
|
||||
'phase_id' => null,
|
||||
'fields' => array_values($this->importPreviewFields),
|
||||
]);
|
||||
|
||||
$this->showImportFileModal = false;
|
||||
$this->importPreviewFields = [];
|
||||
$this->importTemplateName = '';
|
||||
$this->importFile = null;
|
||||
$this->loadTemplates();
|
||||
$this->dispatch('notify', 'Template importado correctamente desde archivo');
|
||||
}
|
||||
|
||||
private function readFileRows(): array
|
||||
{
|
||||
$ext = strtolower($this->importFile->getClientOriginalExtension());
|
||||
$path = $this->importFile->getRealPath();
|
||||
|
||||
if ($ext === 'xlsx' || $ext === 'xls') {
|
||||
$spreadsheet = IOFactory::load($path);
|
||||
$sheet = $spreadsheet->getActiveSheet();
|
||||
$rows = $sheet->toArray(null, true, true, false);
|
||||
array_shift($rows); // quitar cabecera
|
||||
return array_filter($rows, fn($r) => !empty($r[0]));
|
||||
}
|
||||
|
||||
// CSV / TXT
|
||||
$rows = [];
|
||||
$handle = fopen($path, 'r');
|
||||
// Detectar y descartar BOM UTF-8
|
||||
$bom = fread($handle, 3);
|
||||
if ($bom !== "\xEF\xBB\xBF") rewind($handle);
|
||||
|
||||
fgetcsv($handle); // cabecera
|
||||
while (($row = fgetcsv($handle)) !== false) {
|
||||
if (!empty($row[0])) $rows[] = $row;
|
||||
}
|
||||
fclose($handle);
|
||||
return $rows;
|
||||
}
|
||||
|
||||
private function parseRows(array $rows): array
|
||||
{
|
||||
$fields = [];
|
||||
foreach ($rows as $row) {
|
||||
$row = array_values((array) $row);
|
||||
$rawName = trim($row[0] ?? '');
|
||||
if ($rawName === '') continue;
|
||||
|
||||
$fields[] = [
|
||||
'name' => $this->slugify($rawName),
|
||||
'label' => trim($row[1] ?? $rawName),
|
||||
'type' => $this->normalizeType($row[2] ?? 'text'),
|
||||
'required' => in_array(strtolower(trim($row[3] ?? '0')), ['1', 'si', 'sí', 'yes', 'true']),
|
||||
'options' => trim($row[4] ?? ''),
|
||||
'min' => $row[5] !== '' && $row[5] !== null ? $row[5] : null,
|
||||
'max' => $row[6] !== '' && $row[6] !== null ? $row[6] : null,
|
||||
'step' => $row[7] !== '' && $row[7] !== null ? $row[7] : null,
|
||||
];
|
||||
}
|
||||
return $fields;
|
||||
}
|
||||
|
||||
private function slugify(string $str): string
|
||||
{
|
||||
$str = mb_strtolower(trim($str));
|
||||
$str = preg_replace('/\s+/', '_', $str);
|
||||
$str = preg_replace('/[^a-z0-9_]/i', '', $str);
|
||||
return trim($str, '_') ?: 'campo';
|
||||
}
|
||||
|
||||
private function normalizeType(string $type): string
|
||||
{
|
||||
$map = [
|
||||
'texto' => 'text', 'text' => 'text', 'string' => 'text', 'corto' => 'text',
|
||||
'textarea' => 'textarea', 'largo' => 'textarea', 'parrafo' => 'textarea',
|
||||
'integer' => 'integer', 'entero' => 'integer', 'int' => 'integer', 'numero' => 'integer',
|
||||
'decimal' => 'decimal', 'float' => 'decimal', 'number' => 'decimal', 'numerico' => 'decimal',
|
||||
'percentage' => 'percentage', 'porcentaje' => 'percentage', 'pct' => 'percentage', '%' => 'percentage',
|
||||
'boolean' => 'boolean', 'bool' => 'boolean', 'checkbox' => 'boolean', 'sino' => 'boolean', 'si/no' => 'boolean',
|
||||
'date' => 'date', 'fecha' => 'date',
|
||||
'select' => 'select', 'lista' => 'select', 'dropdown' => 'select', 'opciones' => 'select',
|
||||
];
|
||||
return $map[strtolower(trim($type))] ?? 'text';
|
||||
}
|
||||
|
||||
// ── Importar desde otro proyecto ──────────────────────────────────────
|
||||
|
||||
public function openImportProjectModal()
|
||||
{
|
||||
$user = Auth::user();
|
||||
$this->availableProjects = Project::accessibleBy($user)
|
||||
->where('id', '!=', $this->project->id)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
$this->importProjectId = null;
|
||||
$this->importableTemplates = [];
|
||||
$this->selectedImportTemplateIds = [];
|
||||
$this->showImportProjectModal = true;
|
||||
}
|
||||
|
||||
public function updatedImportProjectId()
|
||||
{
|
||||
$this->selectedImportTemplateIds = [];
|
||||
if (!$this->importProjectId) {
|
||||
$this->importableTemplates = [];
|
||||
return;
|
||||
}
|
||||
// Solo mostrar templates de proyectos accesibles
|
||||
$user = Auth::user();
|
||||
$allowed = Project::accessibleBy($user)->pluck('id');
|
||||
if (!$allowed->contains($this->importProjectId)) {
|
||||
$this->importableTemplates = [];
|
||||
return;
|
||||
}
|
||||
$this->importableTemplates = InspectionTemplate::where('project_id', $this->importProjectId)->get();
|
||||
}
|
||||
|
||||
public function importFromProject()
|
||||
{
|
||||
if (empty($this->selectedImportTemplateIds)) {
|
||||
$this->dispatch('notify', 'Selecciona al menos un template.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Verificar que los templates pertenecen a un proyecto accesible
|
||||
$user = Auth::user();
|
||||
$allowed = Project::accessibleBy($user)->pluck('id');
|
||||
|
||||
$imported = 0;
|
||||
foreach ($this->selectedImportTemplateIds as $templateId) {
|
||||
$source = InspectionTemplate::find($templateId);
|
||||
if (!$source || !$allowed->contains($source->project_id)) continue;
|
||||
|
||||
// Evitar duplicados por nombre
|
||||
$name = $source->name;
|
||||
if (InspectionTemplate::where('project_id', $this->project->id)->where('name', $name)->exists()) {
|
||||
$name .= ' (copia)';
|
||||
}
|
||||
|
||||
InspectionTemplate::create([
|
||||
'name' => $name,
|
||||
'description' => $source->description,
|
||||
'project_id' => $this->project->id,
|
||||
'phase_id' => null,
|
||||
'fields' => $source->fields,
|
||||
]);
|
||||
$imported++;
|
||||
}
|
||||
|
||||
$this->showImportProjectModal = false;
|
||||
$this->importProjectId = null;
|
||||
$this->importableTemplates = [];
|
||||
$this->selectedImportTemplateIds = [];
|
||||
$this->loadTemplates();
|
||||
$this->dispatch('notify', "$imported template(s) importado(s) desde otro proyecto");
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
||||
+8
-7
@@ -20,11 +20,10 @@ class User extends Authenticatable
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'email',
|
||||
'password', // Intentionally kept: required for registration factory and seeding.
|
||||
// Sensitive — never pass unvalidated user input directly.
|
||||
// email_verified_at and remember_token are intentionally excluded.
|
||||
'name', 'title', 'first_name', 'last_name',
|
||||
'email', 'password',
|
||||
'status', 'valid_from', 'valid_until',
|
||||
'company_id', 'phone', 'address', 'notes',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -47,13 +46,15 @@ class User extends Authenticatable
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
'valid_from' => 'date',
|
||||
'valid_until' => 'date',
|
||||
];
|
||||
}
|
||||
|
||||
public function company()
|
||||
{
|
||||
return $this->belongsTo(Company::class);
|
||||
return $this->belongsTo(\App\Models\Company::class);
|
||||
}
|
||||
|
||||
// Many-to-many with projects
|
||||
public function projects()
|
||||
{
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
@@ -19,6 +20,15 @@ class AppServiceProvider extends ServiceProvider
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
// Super-admin bypass: anyone with the "manage all" permission
|
||||
// (the Admin role has it) passes every authorization check.
|
||||
// Return true to allow, or null to let normal checks run — never false.
|
||||
Gate::before(function ($user, $ability) {
|
||||
try {
|
||||
return $user->hasPermissionTo('manage all') ? true : null;
|
||||
} catch (\Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,13 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
$middleware->appendToGroup('web', \App\Http\Middleware\SetLocale::class);
|
||||
|
||||
// Spatie permission middleware aliases
|
||||
$middleware->alias([
|
||||
'role' => \Spatie\Permission\Middleware\RoleMiddleware::class,
|
||||
'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class,
|
||||
'role_or_permission' => \Spatie\Permission\Middleware\RoleOrPermissionMiddleware::class,
|
||||
]);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
//
|
||||
|
||||
+1
-1
@@ -169,7 +169,7 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'secure' => env('SESSION_SECURE_COOKIE', env('APP_ENV', 'production') === 'production'),
|
||||
'secure' => env('SESSION_SECURE_COOKIE'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$table = config('permission.table_names.roles', 'roles');
|
||||
|
||||
Schema::table($table, function (Blueprint $table) {
|
||||
if (! Schema::hasColumn($table->getTable(), 'description')) {
|
||||
$table->string('description')->nullable()->after('name');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
$table = config('permission.table_names.roles', 'roles');
|
||||
|
||||
Schema::table($table, function (Blueprint $table) {
|
||||
if (Schema::hasColumn($table->getTable(), 'description')) {
|
||||
$table->dropColumn('description');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$table = config('permission.table_names.permissions', 'permissions');
|
||||
|
||||
Schema::table($table, function (Blueprint $table) {
|
||||
if (! Schema::hasColumn($table->getTable(), 'group')) {
|
||||
$table->string('group')->nullable()->after('name');
|
||||
}
|
||||
if (! Schema::hasColumn($table->getTable(), 'description')) {
|
||||
$table->string('description')->nullable()->after('group');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
$table = config('permission.table_names.permissions', 'permissions');
|
||||
|
||||
Schema::table($table, function (Blueprint $table) {
|
||||
foreach (['group', 'description'] as $col) {
|
||||
if (Schema::hasColumn($table->getTable(), $col)) {
|
||||
$table->dropColumn($col);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -25,6 +25,7 @@ class DatabaseSeeder extends Seeder
|
||||
|
||||
$this->call([
|
||||
RolesAndPermissionsSeeder::class,
|
||||
PermissionCatalogSeeder::class,
|
||||
ProjectExampleSeeder::class,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use Illuminate\Database\Seeder;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
use Spatie\Permission\PermissionRegistrar;
|
||||
|
||||
class PermissionCatalogSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Full permission catalogue, grouped by section.
|
||||
* Idempotent: updates group/description on existing permissions and
|
||||
* creates the missing ones. Does NOT change role assignments.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
$guard = config('auth.defaults.guard', 'web');
|
||||
|
||||
$catalog = [
|
||||
'Proyectos' => [
|
||||
'view projects' => 'Ver listado y fichas de proyectos',
|
||||
'create projects' => 'Crear proyectos',
|
||||
'edit projects' => 'Editar datos del proyecto',
|
||||
'delete projects' => 'Eliminar proyectos',
|
||||
'export projects' => 'Exportar proyectos (Excel/PDF)',
|
||||
],
|
||||
'Fases y progreso' => [
|
||||
'view phases' => 'Ver fases del proyecto',
|
||||
'manage phases' => 'Crear, editar, ordenar y eliminar fases',
|
||||
'update progress' => 'Actualizar el porcentaje de progreso',
|
||||
],
|
||||
'Capas y elementos' => [
|
||||
'view layers' => 'Ver capas y elementos en el mapa',
|
||||
'upload layers' => 'Subir/importar capas',
|
||||
'edit layers' => 'Editar capas y elementos',
|
||||
'delete layers' => 'Eliminar capas/elementos',
|
||||
],
|
||||
'Inspecciones' => [
|
||||
'view inspections' => 'Ver inspecciones e historial',
|
||||
'create inspections' => 'Registrar inspecciones',
|
||||
'delete inspections' => 'Eliminar inspecciones',
|
||||
'manage templates' => 'Gestionar plantillas de inspección',
|
||||
],
|
||||
'Incidencias' => [
|
||||
'view issues' => 'Ver incidencias',
|
||||
'create issues' => 'Crear incidencias',
|
||||
'edit issues' => 'Editar, resolver y cerrar incidencias',
|
||||
'delete issues' => 'Eliminar incidencias',
|
||||
],
|
||||
'Empresas' => [
|
||||
'view companies' => 'Ver empresas',
|
||||
'create companies' => 'Crear empresas',
|
||||
'edit companies' => 'Editar empresas',
|
||||
'delete companies' => 'Eliminar empresas',
|
||||
],
|
||||
'Usuarios' => [
|
||||
'view users' => 'Ver usuarios',
|
||||
'create users' => 'Crear usuarios',
|
||||
'edit users' => 'Editar usuarios',
|
||||
'delete users' => 'Eliminar usuarios',
|
||||
'assign users' => 'Asignar usuarios/roles a proyectos',
|
||||
],
|
||||
'Roles' => [
|
||||
'manage roles' => 'Crear/editar/borrar roles y asignar permisos',
|
||||
],
|
||||
'Informes' => [
|
||||
'view reports' => 'Ver panel de informes',
|
||||
'export reports' => 'Exportar informes',
|
||||
],
|
||||
'Archivos' => [
|
||||
'view media' => 'Ver archivos/galería',
|
||||
'upload media' => 'Subir archivos',
|
||||
'delete media' => 'Eliminar archivos',
|
||||
],
|
||||
'General' => [
|
||||
'manage all' => 'Súper-admin: acceso total al sistema',
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($catalog as $group => $permissions) {
|
||||
foreach ($permissions as $name => $description) {
|
||||
Permission::updateOrCreate(
|
||||
['name' => $name, 'guard_name' => $guard],
|
||||
['group' => $group, 'description' => $description]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
}
|
||||
}
|
||||
+1
-3
@@ -395,7 +395,5 @@
|
||||
"My Projects": "My Projects",
|
||||
"Editable": "Editable",
|
||||
"Name of responsible": "Name of responsible",
|
||||
"Select template...": "Select template...",
|
||||
"View all": "View all",
|
||||
"View on map": "View on map"
|
||||
"Select template...": "Select template..."
|
||||
}
|
||||
|
||||
+1
-3
@@ -395,7 +395,5 @@
|
||||
"My Projects": "Mis proyectos",
|
||||
"Editable": "Editable",
|
||||
"Name of responsible": "Nombre del responsable",
|
||||
"Select template...": "Seleccionar plantilla...",
|
||||
"View all": "Ver todos",
|
||||
"View on map": "Ver en mapa"
|
||||
"Select template...": "Seleccionar plantilla..."
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<x-app-layout>
|
||||
<x-slot name="header">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
|
||||
{{ __('Roles & permissions') }}
|
||||
</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
<a href="{{ route('admin.permissions') }}" class="btn btn-outline btn-sm gap-1" wire:navigate>
|
||||
<x-heroicon-o-table-cells class="w-4 h-4" /> {{ __('Matrix view') }}
|
||||
</a>
|
||||
<a href="{{ route('admin.roles.create') }}" class="btn btn-primary btn-sm gap-1" wire:navigate>
|
||||
<x-heroicon-o-plus class="w-4 h-4" /> {{ __('New role') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</x-slot>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<livewire:role-table />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -1,21 +1,26 @@
|
||||
<x-app-layout>
|
||||
<x-slot name="header">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
|
||||
{{ __('Administrator') }} — {{ __('Users') }}
|
||||
{{ __('Users') }}
|
||||
</h2>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<a href="{{ route('admin.roles') }}" class="btn btn-outline btn-sm gap-1" wire:navigate>
|
||||
<x-heroicon-o-shield-check class="w-4 h-4" /> {{ __('Roles & permissions') }}
|
||||
</a>
|
||||
<a href="{{ route('admin.users.create') }}" class="btn btn-primary btn-sm gap-1" wire:navigate>
|
||||
<x-heroicon-o-plus class="w-4 h-4" /> {{ __('New user') }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</x-slot>
|
||||
|
||||
<div class="py-4">
|
||||
<div class="max-w-5xl mx-auto sm:px-6 lg:px-8 flex flex-wrap items-center gap-4">
|
||||
<a href="{{ route('projects.list') }}" class="btn btn-outline btn-primary">📁 {{ __('Projects') }}</a>
|
||||
<a href="{{ route('admin.users') }}" class="btn btn-outline btn-secondary">👥 {{ __('User Management') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-5xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
@livewire('user-table')
|
||||
<livewire:user-table />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<div>
|
||||
<x-slot name="header">
|
||||
<x-slot name="header">
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="{{ route('companies.manage') }}" class="btn btn-ghost btn-sm px-2" wire:navigate>
|
||||
<x-heroicon-o-arrow-left class="w-4 h-4" />
|
||||
@@ -8,10 +8,10 @@
|
||||
{{ $company ? 'Editar empresa: ' . $company->name : 'Nueva empresa' }}
|
||||
</h2>
|
||||
</div>
|
||||
</x-slot>
|
||||
</x-slot>
|
||||
|
||||
<div class="py-8">
|
||||
<div class="max-w-3xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="py-8">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
|
||||
@if(session('notify'))
|
||||
<div class="alert alert-success mb-4">{{ session('notify') }}</div>
|
||||
@@ -241,6 +241,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
<div>
|
||||
<x-slot name="header">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-gray-800">{{ __('Company Management') }}</h2>
|
||||
<p class="text-sm text-gray-500 mt-1">{{ __('Manage the companies that participate in projects') }}</p>
|
||||
</div>
|
||||
</x-slot>
|
||||
|
||||
<div class="py-12">
|
||||
@if(session('message'))
|
||||
<div class="alert alert-success mb-4">
|
||||
<x-heroicon-o-check-circle class="w-5 h-5" />
|
||||
@@ -6,16 +13,16 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-gray-800">{{ __('Company Management') }}</h2>
|
||||
<p class="text-sm text-gray-500 mt-1">{{ __('Manage the companies that participate in projects') }}</p>
|
||||
</div>
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="flex justify-end mb-4">
|
||||
<a href="{{ route('companies.create') }}" class="btn btn-primary btn-sm gap-1" wire:navigate>
|
||||
<x-heroicon-o-plus class="w-4 h-4" />
|
||||
{{ __('New Company') }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<livewire:company-table />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
<div>
|
||||
{{-- Issue form --}}
|
||||
@if($editing)
|
||||
<div class="card bg-base-100 shadow mb-4">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title text-base">
|
||||
{{ $editingId ? 'Editar Issue' : 'Nuevo Issue' }}
|
||||
</h3>
|
||||
|
||||
<div class="form-control mb-3">
|
||||
<label class="label"><span class="label-text">Título *</span></label>
|
||||
<input type="text" wire:model="title" class="input input-bordered" placeholder="Título del issue" />
|
||||
@error('title') <span class="text-error text-xs mt-1">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
|
||||
<div class="form-control mb-3">
|
||||
<label class="label"><span class="label-text">Descripción</span></label>
|
||||
<textarea wire:model="description" class="textarea textarea-bordered" rows="3" placeholder="Descripción..."></textarea>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3 mb-3">
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Estado</span></label>
|
||||
<select wire:model="status" class="select select-bordered">
|
||||
<option value="open">Abierto</option>
|
||||
<option value="in_review">En revisión</option>
|
||||
<option value="resolved">Resuelto</option>
|
||||
<option value="closed">Cerrado</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Prioridad</span></label>
|
||||
<select wire:model="priority" class="select select-bordered">
|
||||
<option value="low">Baja</option>
|
||||
<option value="medium">Media</option>
|
||||
<option value="high">Alta</option>
|
||||
<option value="critical">Crítica</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 justify-end mt-2">
|
||||
<button wire:click="cancel" class="btn btn-ghost btn-sm">Cancelar</button>
|
||||
<button wire:click="save" class="btn btn-primary btn-sm">Guardar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="flex justify-end mb-3">
|
||||
<button wire:click="create" class="btn btn-primary btn-sm">
|
||||
+ Nuevo Issue
|
||||
</button>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Issue list --}}
|
||||
<div class="space-y-2">
|
||||
@forelse($issues as $issue)
|
||||
<div class="card bg-base-100 shadow-sm border border-base-200">
|
||||
<div class="card-body py-3 px-4">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="flex-1">
|
||||
<p class="font-medium text-sm">{{ $issue->title }}</p>
|
||||
@if($issue->description)
|
||||
<p class="text-xs text-base-content/60 mt-0.5 line-clamp-2">{{ $issue->description }}</p>
|
||||
@endif
|
||||
<div class="flex gap-2 mt-1">
|
||||
<span class="badge badge-xs" style="background-color: {{ $issue->priority_color }}; color: #fff;">
|
||||
{{ ucfirst($issue->priority) }}
|
||||
</span>
|
||||
<span class="badge badge-xs badge-outline">
|
||||
{{ ucfirst(str_replace('_', ' ', $issue->status)) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-1 shrink-0">
|
||||
<button wire:click="edit({{ $issue->id }})" class="btn btn-ghost btn-xs">Editar</button>
|
||||
<button wire:click="delete({{ $issue->id }})"
|
||||
wire:confirm="¿Eliminar este issue?"
|
||||
class="btn btn-ghost btn-xs text-error">Eliminar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<p class="text-center text-sm text-base-content/50 py-6">No hay issues registrados</p>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,57 +0,0 @@
|
||||
<div>
|
||||
@if(session()->has('message'))
|
||||
<div class="alert alert-success mb-2">{{ session('message') }}</div>
|
||||
@endif
|
||||
@if(session()->has('error'))
|
||||
<div class="alert alert-error mb-2">{{ session('error') }}</div>
|
||||
@endif
|
||||
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">{{ __("Upload Layer") }}</h2>
|
||||
|
||||
<form wire:submit.prevent="upload" class="space-y-4">
|
||||
<div class="form-control">
|
||||
<label class="label">{{ __("Project") }}</label>
|
||||
<select wire:model.live="projectId" class="select select-bordered" required>
|
||||
<option value="">{{ __("Select project") }}...</option>
|
||||
@foreach($projects as $p)
|
||||
<option value="{{ $p->id }}">{{ $p->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">{{ __("Phase") }}</label>
|
||||
<select wire:model.live="phaseId" class="select select-bordered" required @if(!$projectId) disabled @endif>
|
||||
<option value="">{{ __("Select phase") }}...</option>
|
||||
@foreach($phases as $ph)
|
||||
<option value="{{ $ph->id }}">{{ $ph->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">{{ __("Layer name") }}</label>
|
||||
<input type="text" wire:model="layerName" class="input input-bordered" placeholder="Ej: Cimentación" required />
|
||||
@error('layerName') <span class="text-error text-sm">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">{{ __("Color") }}</label>
|
||||
<input type="color" wire:model="layerColor" class="input input-bordered w-20" />
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">{{ __("File") }} (GeoJSON, KML, KMZ, Shapefile .zip, DWG)</label>
|
||||
<input type="file" wire:model="uploadFile" class="file-input file-input-bordered" accept=".geojson,.kml,.kmz,.zip,.shp,.dwg" />
|
||||
@error('uploadFile') <span class="text-error text-sm">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary w-full">
|
||||
{{ __("Upload Layer") }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -22,7 +22,7 @@
|
||||
<input type="color" wire:model="layerColor" class="input input-bordered">
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label">Archivo (GeoJSON, KML, Shapefile.zip, DWG)</label></br>
|
||||
<label class="label">{{ __('File') }} (GeoJSON, KML, Shapefile.zip, DWG)</label></br>
|
||||
<input type="file" wire:model="uploadFile" class="file-input file-input-bordered">
|
||||
@error('uploadFile') <span class="text-error">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
@@ -49,13 +49,13 @@
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<button wire:click="selectLayer({{ $layer->id }})" class="btn btn-xs btn-info">✏️ Editar</button>
|
||||
<button wire:click="deleteLayer({{ $layer->id }})" class="btn btn-xs btn-error" onclick="return confirm('¿{{ __("Delete layer") }}?')">🗑️</button>
|
||||
<button wire:click="selectLayer({{ $layer->id }})" class="btn btn-xs btn-info">✏️ {{ __('Edit') }}</button>
|
||||
<button wire:click="deleteLayer({{ $layer->id }})" wire:confirm="{{ __('Delete layer') }}?" class="btn btn-xs btn-error">🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
@if($layers->isEmpty())
|
||||
<p class="text-center">{{ __("No results") }}. Crea una o importa.</p>
|
||||
<p class="text-center">{{ __("No layers. Create or import one.") }}</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@@ -69,8 +69,8 @@
|
||||
<h2 class="card-title">{{ __("Edit") }}</h2>
|
||||
@if($selectedLayer)
|
||||
<div class="mt-3 flex gap-2">
|
||||
<button id="saveDrawingBtn" class="btn btn-primary">💾 Guardar cambios</button>
|
||||
<button wire:click="cancelEditing" class="btn btn-outline">Cancelar edición</button>
|
||||
<button id="saveDrawingBtn" class="btn btn-primary">💾 {{ __('Save changes') }}</button>
|
||||
<button wire:click="cancelEditing" class="btn btn-outline">{{ __('Cancel') }}</button>
|
||||
</div>
|
||||
@endif
|
||||
<div id="permanentMap" style="flex: 1; min-height: 500px; width: 100%; background: #e2e8f0;" wire:ignore></div>
|
||||
@@ -88,17 +88,6 @@
|
||||
let allLayersData = {}; // id -> {geojson, color}
|
||||
let visibleLayerIds = [];
|
||||
|
||||
// XSS-safe HTML escaping for user-supplied data rendered in Leaflet popups
|
||||
function escapeHtml(text) {
|
||||
if (text === null || text === undefined) return '';
|
||||
return String(text)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// Inicialización del mapa
|
||||
function initMap() {
|
||||
if (map) return;
|
||||
@@ -148,9 +137,9 @@
|
||||
style: { color: data.color, weight: 3, opacity: 0.8, fillOpacity: 0.3 },
|
||||
onEachFeature: (feature, layer) => {
|
||||
const props = feature.properties;
|
||||
const content = `<b>${escapeHtml(props.name) || 'Elemento'}</b><br>
|
||||
Progreso: ${escapeHtml(props.progress) || 0}%<br>
|
||||
Responsable: ${escapeHtml(props.responsible) || '-'}`;
|
||||
const content = `<b>${props.name || 'Elemento'}</b><br>
|
||||
Progreso: ${props.progress || 0}%<br>
|
||||
Responsable: ${props.responsible || '-'}`;
|
||||
layer.bindPopup(content);
|
||||
}
|
||||
}).addTo(displayGroup);
|
||||
@@ -169,10 +158,10 @@
|
||||
onEachFeature: (f, l) => {
|
||||
l.feature = f;
|
||||
const props = f.properties;
|
||||
const content = `<b>${escapeHtml(props.name) || 'Elemento'}</b><br>
|
||||
Progreso: ${escapeHtml(props.progress) || 0}%<br>
|
||||
Responsable: ${escapeHtml(props.responsible) || '-'}<br>
|
||||
<em>Editable</em>`;
|
||||
const content = `<b>${props.name || @js(__('Feature'))}</b><br>
|
||||
@js(__('Progress')): ${props.progress || 0}%<br>
|
||||
@js(__('Responsible')): ${props.responsible || '-'}<br>
|
||||
<em>@js(__('Editable'))</em>`;
|
||||
l.bindPopup(content);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
<div>
|
||||
<!-- Tabs -->
|
||||
<div class="tab-toggle">
|
||||
<input type="radio" name="tabs-project-edit-{{ $project->id }}" id="tab-project-data-{{ $project->id }}"
|
||||
{{ $activeTab === 'project-data' ? 'checked' : '' }} class="tab-toggle" />
|
||||
<label for="tab-project-data-{{ $project->id }}" class="tab {{ $activeTab === 'project-data' ? 'tab-active' : '' }}">
|
||||
{{ __('Project Data') }}
|
||||
</label>
|
||||
|
||||
<input type="radio" name="tabs-project-edit-{{ $project->id }}" id="tab-phases-{{ $project->id }}"
|
||||
{{ $activeTab === 'phases' ? 'checked' : '' }} class="tab-toggle" />
|
||||
<label for="tab-phases-{{ $project->id }}" class="tab {{ $activeTab === 'phases' ? 'tab-active' : '' }}">
|
||||
{{ __('Phases') }}
|
||||
</label>
|
||||
|
||||
<input type="radio" name="tabs-project-edit-{{ $project->id }}" id="tab-users-{{ $project->id }}"
|
||||
{{ $activeTab === 'users' ? 'checked' : '' }} class="tab-toggle" />
|
||||
<label for="tab-users-{{ $project->id }}" class="tab {{ $activeTab === 'users' ? 'tab-active' : '' }}">
|
||||
{{ __('Users') }}
|
||||
</label>
|
||||
|
||||
<input type="radio" name="tabs-project-edit-{{ $project->id }}" id="tab-companies-{{ $project->id }}"
|
||||
{{ $activeTab === 'companies' ? 'checked' : '' }} class="tab-toggle" />
|
||||
<label for="tab-companies-{{ $project->id }}" class="tab {{ $activeTab === 'companies' ? 'tab-active' : '' }}">
|
||||
{{ __('Companies') }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<div class="tab-content">
|
||||
<!-- Project Data Tab -->
|
||||
<div id="tab-project-data-{{ $project->id }}"
|
||||
class="tab-content-base p-4 {{ $activeTab === 'project-data' ? '' : 'hidden' }}">
|
||||
<form wire:submit.prevent="updateProject" class="space-y-4">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
<div>
|
||||
<label class="label">{{ __('Name') }}</label>
|
||||
<input type="text" name="name"
|
||||
wire:model.debounce.500ms="project.name"
|
||||
class="input input-bordered w-full" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">{{ __('Address') }}</label>
|
||||
<input type="text" name="address"
|
||||
wire:model.debounce.500ms="project.address"
|
||||
class="input input-bordered w-full" required>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="label">{{ __('Latitude') }}</label>
|
||||
<input type="number" step="any" name="lat"
|
||||
wire:model.debounce.500ms="project.lat"
|
||||
class="input input-bordered w-full" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">{{ __('Longitude') }}</label>
|
||||
<input type="number" step="any" name="lng"
|
||||
wire:model.debounce.500ms="project.lng"
|
||||
class="input input-bordered w-full" required>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">{{ __('Status') }}</label>
|
||||
<select name="status" wire:model="project.status"
|
||||
class="select select-bordered w-full">
|
||||
<option value="planning">{{ __('Planning') }}</option>
|
||||
<option value="in_progress">{{ __('In progress') }}</option>
|
||||
<option value="paused">{{ __('Paused') }}</option>
|
||||
<option value="completed">{{ __('Completed') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="label">{{ __('Start date') }}</label>
|
||||
<input type="date" name="start_date"
|
||||
wire:model.debounce.500ms="project.start_date"
|
||||
class="input input-bordered w-full" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">{{ __('Estimated end date') }}</label>
|
||||
<input type="date" name="end_date_estimated"
|
||||
wire:model.debounce.500ms="project.end_date_estimated"
|
||||
class="input input-bordered w-full">
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-full">
|
||||
{{ __('Update') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Phases Tab -->
|
||||
<div id="tab-phases-{{ $project->id }}"
|
||||
class="tab-content-base p-4 {{ $activeTab === 'phases' ? '' : 'hidden' }}">
|
||||
<h2 class="text-xl font-bold mb-2">{{ __('Phases') }}</h2>
|
||||
<livewire:phase-list :project="$project" />
|
||||
</div>
|
||||
|
||||
<!-- Users Tab -->
|
||||
<div id="tab-users-{{ $project->id }}"
|
||||
class="tab-content-base p-4 {{ $activeTab === 'users' ? '' : 'hidden' }}">
|
||||
<h2 class="text-xl font-bold mb-2">{{ __('Users') }}</h2>
|
||||
<livewire:project-users :project="$project" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Alpine.js for tab switching --}}
|
||||
<script>
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('projectTabs', () => ({
|
||||
activeTab: '{{ $activeTab }}',
|
||||
projectId: {{ $project->id }},
|
||||
|
||||
setTab(tab) {
|
||||
this.activeTab = tab;
|
||||
// Update the Livewire component
|
||||
this.$dispatch('tabChanged', {
|
||||
tab: tab,
|
||||
projectId: this.projectId
|
||||
});
|
||||
}
|
||||
}));
|
||||
});
|
||||
</script>
|
||||
@@ -1,4 +1,4 @@
|
||||
<div>
|
||||
<div>
|
||||
<x-slot name="header">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
@@ -25,19 +25,15 @@
|
||||
<div class="flex gap-2">
|
||||
<a href="{{ route('projects.map', $project) }}" class="btn btn-outline btn-sm gap-1">
|
||||
<x-heroicon-o-map class="w-4 h-4" />
|
||||
{{ __('Map') }}
|
||||
Mapa
|
||||
</a>
|
||||
<a href="{{ route('projects.gantt', $project) }}" class="btn btn-outline btn-sm gap-1">
|
||||
<x-heroicon-o-calendar-days class="w-4 h-4" />
|
||||
{{ __('Gantt') }}
|
||||
</a>
|
||||
<a href="{{ route('projects.report', $project) }}" class="btn btn-outline btn-sm gap-1">
|
||||
<x-heroicon-o-document-chart-bar class="w-4 h-4" />
|
||||
{{ __('Report') }}
|
||||
Gantt
|
||||
</a>
|
||||
<a href="{{ route('projects.issues', $project) }}" class="btn btn-outline btn-sm gap-1">
|
||||
<x-heroicon-o-exclamation-triangle class="w-4 h-4" />
|
||||
{{ __('Issues') }}
|
||||
Issues
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -307,7 +303,7 @@
|
||||
<x-heroicon-o-exclamation-triangle class="w-4 h-4 text-orange-500" />
|
||||
Issues abiertos
|
||||
</h3>
|
||||
<a href="{{ route('projects.issues', $project) }}" class="btn btn-xs btn-outline">{{ __('View all') }}</a>
|
||||
<a href="{{ route('projects.issues', $project) }}" class="btn btn-xs btn-outline">Ver todos</a>
|
||||
</div>
|
||||
@if($recentIssues->isEmpty())
|
||||
<div class="text-center py-4 text-gray-400">
|
||||
@@ -350,7 +346,7 @@
|
||||
<x-heroicon-o-clipboard-document-list class="w-4 h-4 text-yellow-500" />
|
||||
Inspecciones recientes
|
||||
</h3>
|
||||
<a href="{{ route('projects.map', $project) }}?tab=inspections" class="btn btn-xs btn-outline">{{ __('View on map') }}</a>
|
||||
<a href="{{ route('projects.map', $project) }}?tab=inspections" class="btn btn-xs btn-outline">Ver en mapa</a>
|
||||
</div>
|
||||
@if($recentInspections->isEmpty())
|
||||
<div class="text-center py-4 text-gray-400">
|
||||
|
||||
@@ -1,187 +1,138 @@
|
||||
<div>
|
||||
<div class="max-w-2xl mx-auto p-4">
|
||||
<h1 class="text-2xl font-bold mb-6">{{ $projectId ? __('Edit Project') : __('New Project') }}</h1>
|
||||
|
||||
@if($errors->any())
|
||||
<div class="alert alert-error text-sm mb-4">
|
||||
<x-heroicon-o-exclamation-circle class="w-5 h-5 shrink-0" />
|
||||
<ul class="list-disc pl-3 space-y-0.5">
|
||||
@foreach($errors->all() as $e) <li>{{ $e }}</li> @endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<form wire:submit.prevent="save" class="space-y-6">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2">{{ __('Name') }}</label>
|
||||
<input type="text" wire:model="name" class="input input-bordered w-full {{ $errors->has('name') ? 'input-error' : '' }}" placeholder="{{ __('Project name') }}" required>
|
||||
@error('name') <span class="text-error text-xs mt-1">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2">{{ __('Address') }}</label>
|
||||
<input type="text" wire:model="address" class="input input-bordered w-full" placeholder="{{ __('Street address, city, etc.') }}">
|
||||
@error('address') <span class="text-error text-xs mt-1">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2">{{ __('Country') }}</label>
|
||||
<input type="text" wire:model="country" class="input input-bordered w-full" placeholder="{{ __('Country (auto-filled)') }}" readonly>
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium mb-2">{{ __('Start Date') }}</label>
|
||||
<input type="date" wire:model="start_date" class="input input-bordered w-full {{ $errors->has('start_date') ? 'input-error' : '' }}" required>
|
||||
@error('start_date') <span class="text-error text-xs mt-1">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium mb-2">{{ __('End Date (estimated)') }}</label>
|
||||
<input type="date" wire:model="end_date_estimated" class="input input-bordered w-full {{ $errors->has('end_date_estimated') ? 'input-error' : '' }}">
|
||||
@error('end_date_estimated') <span class="text-error text-xs mt-1">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium mb-2">{{ __('Status') }}</label>
|
||||
<select wire:model="status" class="select select-bordered w-full">
|
||||
<option value="planning">{{ __('Planning') }}</option>
|
||||
<option value="in_progress">{{ __('In Progress') }}</option>
|
||||
<option value="paused">{{ __('Paused') }}</option>
|
||||
<option value="completed">{{ __('Completed') }}</option>
|
||||
</select>
|
||||
@error('status') <span class="text-error text-xs mt-1">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
<div class="max-w-7xl mx-auto p-4">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h1 class="text-2xl font-bold">{{ $project ? __('Edit Project') : __('New Project') }}</h1>
|
||||
<a href="{{ route('projects.index') }}" class="btn btn-ghost btn-sm gap-1" wire:navigate>
|
||||
<x-heroicon-o-arrow-left class="w-4 h-4" /> {{ __('Back') }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="border rounded-lg p-4">
|
||||
<h2 class="text-xl font-bold mb-4">{{ __('Project Location') }}</h2>
|
||||
<p class="text-sm text-gray-500 mb-2">
|
||||
{{ __('Click on the map to set the project location. The address and country will be filled automatically.') }}
|
||||
</p>
|
||||
<div id="projectMap" style="height: 400px; width: 100%; background: #e2e8f0; border-radius: 0.5rem;"></div>
|
||||
<input type="hidden" wire:model="lat">
|
||||
<input type="hidden" wire:model="lng">
|
||||
@if($project)
|
||||
{{-- Editor con pestañas para el resto de parámetros del proyecto --}}
|
||||
<div x-data="{ tab: 'data' }">
|
||||
<div class="flex flex-wrap gap-1 mb-4">
|
||||
<button type="button" @click="tab='data'" :class="tab==='data' ? 'btn-primary' : 'btn-ghost'" class="btn btn-sm">{{ __('Project Data') }}</button>
|
||||
<button type="button" @click="tab='phases'" :class="tab==='phases' ? 'btn-primary' : 'btn-ghost'" class="btn btn-sm">{{ __('Phases') }}</button>
|
||||
<button type="button" @click="tab='users'" :class="tab==='users' ? 'btn-primary' : 'btn-ghost'" class="btn btn-sm">{{ __('Users') }}</button>
|
||||
<button type="button" @click="tab='companies'" :class="tab==='companies' ? 'btn-primary' : 'btn-ghost'" class="btn btn-sm">{{ __('Companies') }}</button>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-4">
|
||||
<button type="button" wire:click="resetForm" class="btn btn-outline">
|
||||
{{ __('Reset') }}
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
{{ $projectId ? __('Update') : __('Create') }}
|
||||
</button>
|
||||
<div x-show="tab==='data'">
|
||||
@include('livewire.projects.partials.project-data-form')
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@if(session()->has('message'))
|
||||
<div class="mt-4 p-4 bg-green-50 border-l-4 border-green-400 text-green-700">
|
||||
{{ session('message') }}
|
||||
<div x-show="tab==='phases'" x-cloak>
|
||||
<livewire:phase-list :project="$project" :key="'phases-'.$project->id" />
|
||||
</div>
|
||||
<div x-show="tab==='users'" x-cloak>
|
||||
<livewire:project-users :project="$project" :key="'users-'.$project->id" />
|
||||
</div>
|
||||
<div x-show="tab==='companies'" x-cloak>
|
||||
<livewire:project-companies :project="$project" :key="'companies-'.$project->id" />
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
{{-- Alta de proyecto: solo el formulario de datos --}}
|
||||
@include('livewire.projects.partials.project-data-form')
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
let map;
|
||||
let marker;
|
||||
@push('scripts')
|
||||
<script>
|
||||
(function () {
|
||||
let pmap = null, pmarker = null;
|
||||
|
||||
// Initialize Leaflet map
|
||||
function initMap() {
|
||||
if (map) return;
|
||||
function setStatus(msg) {
|
||||
const s = document.getElementById('geocode-status');
|
||||
if (s) s.textContent = msg || '';
|
||||
}
|
||||
|
||||
// Default coordinates (can be overridden)
|
||||
const defaultLat = @json($lat ?? 0);
|
||||
const defaultLng = @json($lng ?? 0);
|
||||
function placeMarker(lat, lng) {
|
||||
if (!pmap) return;
|
||||
if (pmarker) {
|
||||
pmarker.setLatLng([lat, lng]);
|
||||
} else {
|
||||
pmarker = L.marker([lat, lng], { draggable: true }).addTo(pmap);
|
||||
pmarker.on('dragend', () => {
|
||||
const p = pmarker.getLatLng();
|
||||
pickLocation(p.lat, p.lng);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const center = defaultLat && defaultLng ? [defaultLat, defaultLng] : [0, 0];
|
||||
map = L.map('projectMap').setView(center, 13);
|
||||
async function pickLocation(lat, lng) {
|
||||
setStatus('{{ __('Loading...') }}');
|
||||
let address = '', country = '';
|
||||
try {
|
||||
const r = await fetch(`https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lng}&zoom=18&addressdetails=1`);
|
||||
if (r.ok) {
|
||||
const d = await r.json();
|
||||
address = d.display_name || '';
|
||||
country = (d.address && d.address.country_code) ? d.address.country_code : '';
|
||||
}
|
||||
} catch (e) { /* geocoding optional */ }
|
||||
@this.setLocation(String(lat), String(lng), address, country);
|
||||
setStatus('');
|
||||
}
|
||||
|
||||
async function searchLocation(q) {
|
||||
if (!q || !q.trim() || !pmap) return;
|
||||
setStatus('{{ __('Searching...') }}');
|
||||
try {
|
||||
const r = await fetch(`https://nominatim.openstreetmap.org/search?format=json&limit=1&addressdetails=1&q=${encodeURIComponent(q)}`);
|
||||
const arr = await r.json();
|
||||
if (arr && arr.length) {
|
||||
const lat = parseFloat(arr[0].lat), lng = parseFloat(arr[0].lon);
|
||||
pmap.setView([lat, lng], 16);
|
||||
placeMarker(lat, lng);
|
||||
const address = arr[0].display_name || '';
|
||||
const country = (arr[0].address && arr[0].address.country_code) ? arr[0].address.country_code : '';
|
||||
@this.setLocation(String(lat), String(lng), address, country);
|
||||
setStatus('');
|
||||
} else {
|
||||
setStatus('{{ __('No results') }}');
|
||||
}
|
||||
} catch (e) {
|
||||
setStatus('{{ __('No results') }}');
|
||||
}
|
||||
}
|
||||
|
||||
function initProjectLocationMap() {
|
||||
const el = document.getElementById('project-location-map');
|
||||
if (!el || el._leafletInit) return;
|
||||
el._leafletInit = true;
|
||||
|
||||
const dLat = parseFloat(el.dataset.lat);
|
||||
const dLng = parseFloat(el.dataset.lng);
|
||||
const hasCoords = !isNaN(dLat) && !isNaN(dLng) && (dLat !== 0 || dLng !== 0);
|
||||
const lat = hasCoords ? dLat : 40.4168;
|
||||
const lng = hasCoords ? dLng : -3.7038;
|
||||
|
||||
pmap = L.map('project-location-map').setView([lat, lng], hasCoords ? 16 : 5);
|
||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> & CartoDB'
|
||||
}).addTo(map);
|
||||
}).addTo(pmap);
|
||||
|
||||
// Add marker if we have coordinates
|
||||
if (defaultLat && defaultLng) {
|
||||
marker = L.marker([defaultLat, defaultLng], {
|
||||
draggable: true
|
||||
}).addTo(map);
|
||||
if (hasCoords) placeMarker(lat, lng);
|
||||
|
||||
marker.on('dragend', function(e) {
|
||||
const pos = marker.getLatLng();
|
||||
updateCoordinates(pos.lat, pos.lng);
|
||||
});
|
||||
}
|
||||
|
||||
// Handle map clicks to place marker
|
||||
map.on('click', function(e) {
|
||||
const pos = e.latlng;
|
||||
if (marker) {
|
||||
marker.setLatLng(pos);
|
||||
} else {
|
||||
marker = L.marker(pos, {
|
||||
draggable: true
|
||||
}).addTo(map);
|
||||
|
||||
marker.on('dragend', function(e) {
|
||||
const pos = marker.getLatLng();
|
||||
updateCoordinates(pos.lat, pos.lng);
|
||||
});
|
||||
}
|
||||
updateCoordinates(pos.lat, pos.lng);
|
||||
});
|
||||
}
|
||||
|
||||
// Update coordinates and trigger reverse geocoding
|
||||
function updateCoordinates(lat, lng) {
|
||||
// Update hidden inputs
|
||||
document.querySelector('input[name="lat"]').value = lat;
|
||||
document.querySelector('input[name="lng"]').value = lng;
|
||||
|
||||
// Trigger Livewire event to update coordinates
|
||||
@this.setCoordinates(lat, lng);
|
||||
|
||||
// Reverse geocode to get address and country
|
||||
reverseGeocode(lat, lng);
|
||||
}
|
||||
|
||||
// Reverse geocode using Nominatim (OpenStreetMap)
|
||||
async function reverseGeocode(lat, lng) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lng}&zoom=18&addressdetails=1`,
|
||||
{
|
||||
headers: {
|
||||
'User-Agent': 'OpenClaw/1.0 (construprogress)'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) throw new Error('Geocoding request failed');
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Update address field
|
||||
const addressInput = document.querySelector('input[name="address"]');
|
||||
if (data.display_name) {
|
||||
addressInput.value = data.display_name;
|
||||
}
|
||||
|
||||
// Update country field
|
||||
const countryInput = document.querySelector('input[name="country"]');
|
||||
if (data.address && data.address.country) {
|
||||
countryInput.value = data.address.country;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error reverse geocoding:', error);
|
||||
// Don't fail the UI if geocoding fails
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize map when component is ready
|
||||
document.addEventListener('Livewire:load', function() {
|
||||
initMap();
|
||||
pmap.on('click', (e) => {
|
||||
placeMarker(e.latlng.lat, e.latlng.lng);
|
||||
pickLocation(e.latlng.lat, e.latlng.lng);
|
||||
});
|
||||
|
||||
// Also initialize on DOMContentLoaded as fallback
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
initMap();
|
||||
const input = document.getElementById('map-search-input');
|
||||
const btn = document.getElementById('map-search-btn');
|
||||
if (btn) btn.addEventListener('click', () => searchLocation(input ? input.value : ''));
|
||||
if (input) input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); searchLocation(input.value); }
|
||||
});
|
||||
</script>
|
||||
@endpush
|
||||
|
||||
setTimeout(() => pmap.invalidateSize(), 200);
|
||||
}
|
||||
|
||||
document.addEventListener('livewire:navigated', initProjectLocationMap);
|
||||
document.addEventListener('DOMContentLoaded', initProjectLocationMap);
|
||||
setTimeout(initProjectLocationMap, 300);
|
||||
})();
|
||||
</script>
|
||||
@endpush
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,51 @@
|
||||
<div>
|
||||
<livewire:project-table />
|
||||
<div class="flex justify-between mb-4">
|
||||
<input type="text" wire:model.live="search" placeholder="{{ __('Search') }}..." class="input input-bordered w-64" />
|
||||
<select wire:model.live="statusFilter" class="select select-bordered">
|
||||
<option value="">{{ __('All') }}</option>
|
||||
<option value="planning">{{ __('Planning') }}</option>
|
||||
<option value="in_progress">{{ __('In progress') }}</option>
|
||||
<option value="paused">{{ __('Paused') }}</option>
|
||||
<option value="completed">{{ __('Completed') }}</option>
|
||||
</select>
|
||||
@can('create projects')
|
||||
<a href="{{ route('projects.create') }}" class="btn btn-primary">+ {{ __('New Project') }}</a>
|
||||
@endcan
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr><th>{{ __('Name') }}</th><th>{{ __('Address') }}</th><th>{{ __('Status') }}</th><th>{{ __('Progress') }}</th><th>{{ __('Actions') }}</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach($projects as $project)
|
||||
<tr>
|
||||
<td>{{ $project->name }}</td>
|
||||
<td>{{ $project->address }}</td>
|
||||
<td>{{ __(ucfirst(str_replace('_', ' ', $project->status))) }}</td>
|
||||
<td>
|
||||
<div class="w-full bg-gray-200 rounded-full h-2.5">
|
||||
<div class="bg-primary h-2.5 rounded-full" style="width: {{ $project->phases->avg('progress_percent') }}%"></div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ route('projects.dashboard', $project) }}" class="btn btn-sm btn-outline gap-1">
|
||||
<x-heroicon-o-squares-2x2 class="w-3.5 h-3.5" />
|
||||
Dashboard
|
||||
</a>
|
||||
<a href="{{ route('projects.map', $project) }}" class="btn btn-sm btn-outline gap-1">
|
||||
<x-heroicon-o-map class="w-3.5 h-3.5" />
|
||||
{{ __('Map') }}
|
||||
</a>
|
||||
@can('edit projects')
|
||||
<a href="{{ route('projects.edit', $project) }}" class="btn btn-sm btn-warning">{{ __('Edit') }}</a>
|
||||
@endcan
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{ $projects->links() }}
|
||||
</div>
|
||||
|
||||
@@ -1,33 +1,57 @@
|
||||
<div x-data="{ formFullscreen: $wire.entangle('formFullscreen') }"
|
||||
class="flex flex-col lg:flex-row gap-0 h-screen p-1">
|
||||
<!-- Columna izquierda: Mapa -->
|
||||
<div x-show="!formFullscreen" x-cloak class="w-full lg:w-2/3 flex-1 relative">
|
||||
<div x-show="!formFullscreen" x-cloak x-data="{ showLayers: true }" class="w-full lg:w-2/3 flex-1 relative">
|
||||
<div id="map" style="height: 100%; min-height: 500px; width: 100%;" wire:ignore></div>
|
||||
|
||||
<!-- Botón para reabrir el panel (solo cuando está colapsado) -->
|
||||
<button x-show="!showLayers" x-cloak @click="showLayers = true"
|
||||
class="absolute top-2 right-2 z-[1001] btn btn-sm btn-circle shadow-lg"
|
||||
title="{{ __('Show/hide panel') }}">
|
||||
<x-heroicon-o-bars-3 class="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<!-- Panel lateral de capas -->
|
||||
<div class="absolute top-2 right-2 z-[1000] bg-base-100 rounded-box shadow-xl p-4 w-72 border border-base-300 text-sm max-h-[calc(100vh-4rem)] overflow-y-auto">
|
||||
<h3 class="font-semibold text-base mb-2">{{ __("Fases and layers") }}</h3>
|
||||
<div x-show="showLayers" x-transition
|
||||
class="absolute top-2 right-2 z-[1000] bg-base-100 rounded-box shadow-xl p-4 w-72 border border-base-300 text-sm max-h-[calc(100vh-4rem)] overflow-y-auto">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h3 class="font-semibold text-base">{{ __('Phases and layers') }}</h3>
|
||||
<button @click="showLayers = false" class="btn btn-xs btn-circle btn-ghost" title="{{ __('Show/hide panel') }}">
|
||||
<x-heroicon-o-x-mark class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
@foreach($phases as $phase)
|
||||
<div class="border rounded-lg p-2 {{ in_array($phase->id, $activeLayers) ? 'bg-base-200' : '' }}">
|
||||
@php
|
||||
$phaseLayerIds = $phase->layers->pluck('id')->map(fn($i) => (int) $i)->all();
|
||||
$phaseAllActive = count($phaseLayerIds) > 0 && collect($phaseLayerIds)->every(fn($i) => in_array($i, $activeLayers));
|
||||
@endphp
|
||||
<div class="border rounded-lg p-2 {{ $phaseAllActive ? 'bg-base-200' : '' }}">
|
||||
{{-- Fase: el toggle muestra/oculta TODAS sus capas --}}
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="checkbox"
|
||||
wire:change="toggleLayer({{ $phase->id }})"
|
||||
@if(in_array($phase->id, $activeLayers)) checked @endif
|
||||
class="toggle toggle-xs toggle-primary">
|
||||
wire:change="togglePhase({{ $phase->id }})"
|
||||
@if($phaseAllActive) checked @endif
|
||||
class="toggle toggle-xs toggle-primary"
|
||||
title="{{ __('Show/hide all layers of this phase') }}">
|
||||
<span style="color: {{ $phase->color }};" class="text-lg">⬤</span>
|
||||
<span class="flex-1 font-medium text-sm truncate">{{ $phase->name }}</span>
|
||||
<span class="badge badge-sm {{ $phase->progress_percent >= 100 ? 'badge-success' : 'badge-ghost' }}">{{ $phase->progress_percent }}</span>
|
||||
</div>
|
||||
|
||||
{{-- Capas de esta fase --}}
|
||||
{{-- Capas de esta fase: cada una con su propio toggle independiente --}}
|
||||
@if($phase->layers->isNotEmpty())
|
||||
<div class="ml-7 mt-1 space-y-1">
|
||||
@foreach($phase->layers as $layer)
|
||||
<div class="flex items-center gap-1 text-xs text-gray-600">
|
||||
<span class="w-2 h-2 rounded-full inline-block" style="background: {{ $layer->color ?? '#ccc' }}"></span>
|
||||
<div class="flex items-center gap-2 text-xs text-gray-600">
|
||||
<input type="checkbox"
|
||||
wire:change="toggleLayer({{ $layer->id }})"
|
||||
@if(in_array((int) $layer->id, $activeLayers)) checked @endif
|
||||
class="toggle toggle-xs toggle-primary"
|
||||
title="{{ __('Show/hide layer') }}">
|
||||
<span class="w-2 h-2 rounded-full inline-block shrink-0" style="background: {{ $layer->color ?? '#ccc' }}"></span>
|
||||
<span class="flex-1 truncate">{{ $layer->name }}</span>
|
||||
<span class="badge badge-xs">{{ $layer->features_count ?? $layer->features->count() }} elem.</span>
|
||||
<span class="badge badge-xs">{{ $layer->features_count ?? $layer->features->count() }} {{ __('elem.') }}</span>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@@ -35,11 +59,11 @@
|
||||
|
||||
{{-- Botón para ir a gestión de capas de esta fase --}}
|
||||
<div class="mt-1 ml-7">
|
||||
<a href="{{ route('layers.manage', [$project, $phase]) }}" class="btn btn-xs btn-outline btn-primary">
|
||||
✏️ {{ __("Manage Layers") }}
|
||||
<a href="{{ route('layers.manage', [$project, $phase]) }}" class="btn btn-xs btn-outline btn-primary gap-1">
|
||||
<x-heroicon-o-pencil-square class="w-3.5 h-3.5" /> {{ __('Manage Layers') }}
|
||||
</a>
|
||||
<a href="{{ route('phases.progress', $phase) }}" class="btn btn-xs btn-outline">
|
||||
📊 {{ __("Progress") }}
|
||||
<a href="{{ route('phases.progress', $phase) }}" class="btn btn-xs btn-outline gap-1">
|
||||
<x-heroicon-o-chart-bar class="w-3.5 h-3.5" /> {{ __('Progress') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -50,7 +74,7 @@
|
||||
<div class="mt-3">
|
||||
<label class="flex items-center gap-2 text-xs cursor-pointer">
|
||||
<input type="checkbox" wire:change="toggleFeatureImages" @if($showFeatureImages) checked @endif class="checkbox checkbox-xs checkbox-primary" />
|
||||
🖼️ {{ __("Show images on map") }}
|
||||
<x-heroicon-o-photo class="w-4 h-4" /> {{ __('Show images on map') }}
|
||||
@if($featureImageMarkers)
|
||||
<span class="badge badge-xs">{{ count($featureImageMarkers) }}</span>
|
||||
@endif
|
||||
@@ -59,103 +83,94 @@
|
||||
|
||||
{{-- Botones generales --}}
|
||||
<div class="mt-2 space-y-1">
|
||||
<a href="{{ route('projects.media', $project) }}" class="btn btn-sm btn-outline w-full">
|
||||
📁 {{ __("Project files") }}
|
||||
<a href="{{ route('projects.media', $project) }}" class="btn btn-sm btn-outline w-full gap-1">
|
||||
<x-heroicon-o-folder class="w-4 h-4" /> {{ __('Project files') }}
|
||||
</a>
|
||||
<button wire:click="$dispatch('centerMap')" class="btn btn-sm btn-outline w-full gap-1">
|
||||
<x-heroicon-o-map-pin class="w-4 h-4" /> {{ __('Centered in project') }}
|
||||
</button>
|
||||
<button wire:click="$dispatch('centerMap')" class="btn btn-sm btn-outline w-full">
|
||||
📍 {{ __("Centered in project") }}
|
||||
</button>
|
||||
<button onclick="getUserLocation()" class="btn btn-sm btn-outline w-full">
|
||||
🧭 {{ __("My location") }}
|
||||
<button onclick="getUserLocation()" class="btn btn-sm btn-outline w-full gap-1">
|
||||
<x-heroicon-o-viewfinder-circle class="w-4 h-4" /> {{ __('My location') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Columna derecha: {{ __("Edit") }} de progreso / inspecciones -->
|
||||
<!-- Columna derecha: {{ __('Edit') }} de progreso / inspecciones -->
|
||||
<div class="w-full lg:w-1/3 transition-all duration-300" :class="{'lg:w-full': formFullscreen}">
|
||||
<div class="card bg-base-100 shadow-xl h-full flex flex-col">
|
||||
<div class="card-body overflow-y-auto flex-1">
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<h2 class="card-title">{{ __("Project Map") }}</h2>
|
||||
<button wire:click="toggleFullscreen" class="btn btn-circle btn-sm" title="Pantalla completa">
|
||||
<span x-text="formFullscreen ? '✕' : '⤢'"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Project navigation bar -->
|
||||
<div class="flex flex-wrap gap-1 mb-3">
|
||||
<a href="{{ route('projects.dashboard', $project) }}"
|
||||
class="btn btn-xs {{ request()->routeIs('projects.dashboard') ? 'btn-primary' : 'btn-outline' }}">
|
||||
📊 {{ __('Dashboard') }}
|
||||
</a>
|
||||
<a href="{{ route('projects.map', $project) }}"
|
||||
class="btn btn-xs {{ request()->routeIs('projects.map') ? 'btn-primary' : 'btn-outline' }}">
|
||||
🗺️ {{ __('Map') }}
|
||||
</a>
|
||||
<a href="{{ route('projects.gantt', $project) }}"
|
||||
class="btn btn-xs {{ request()->routeIs('projects.gantt') ? 'btn-primary' : 'btn-outline' }}">
|
||||
📅 {{ __('Gantt') }}
|
||||
</a>
|
||||
<a href="{{ route('projects.report', $project) }}"
|
||||
class="btn btn-xs {{ request()->routeIs('projects.report') ? 'btn-primary' : 'btn-outline' }}">
|
||||
📄 {{ __('Report') }}
|
||||
</a>
|
||||
<a href="{{ route('projects.issues', $project) }}"
|
||||
class="btn btn-xs {{ request()->routeIs('projects.issues') ? 'btn-error' : 'btn-outline' }} gap-1">
|
||||
⚠️ {{ __('Issues') }}
|
||||
@if($openIssuesCount > 0)
|
||||
<span class="badge badge-error badge-xs">{{ $openIssuesCount }}</span>
|
||||
@endif
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center gap-2 mb-4">
|
||||
<!-- Tabs -->
|
||||
<div class="tabs box mb-4">
|
||||
<button wire:click="setActiveTab('edit')" class="tab {{ $activeTab === 'edit' ? 'tab-active' : '' }}">{{ __("Edit") }}</button>
|
||||
<button wire:click="setActiveTab('features')" class="tab {{ $activeTab === 'features' ? 'tab-active' : '' }}">{{ __("Features") }}</button>
|
||||
<button wire:click="setActiveTab('inspections')" class="tab {{ $activeTab === 'inspections' ? 'tab-active' : '' }}">{{ __("Inspections") }}</button>
|
||||
<button wire:click="setActiveTab('issues')" class="tab {{ $activeTab === 'issues' ? 'tab-active' : '' }} gap-1">
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<button wire:click="setActiveTab('edit')" class="btn btn-sm {{ $activeTab === 'edit' ? 'btn-primary' : 'btn-ghost' }}">{{ __('Edit') }}</button>
|
||||
<button wire:click="setActiveTab('features')" class="btn btn-sm {{ $activeTab === 'features' ? 'btn-primary' : 'btn-ghost' }}">{{ __('Features') }}</button>
|
||||
<button wire:click="setActiveTab('inspections')" class="btn btn-sm {{ $activeTab === 'inspections' ? 'btn-primary' : 'btn-ghost' }}">{{ __('Inspections') }}</button>
|
||||
<button wire:click="setActiveTab('issues')" class="btn btn-sm gap-1 {{ $activeTab === 'issues' ? 'btn-primary' : 'btn-ghost' }}">
|
||||
{{ __('Issues') }}
|
||||
@if($openIssuesCount > 0)
|
||||
<span class="badge badge-error badge-xs">{{ $openIssuesCount }}</span>
|
||||
@endif
|
||||
</button>
|
||||
</div>
|
||||
<button wire:click="toggleFullscreen" class="btn btn-circle btn-sm shrink-0" title="{{ __('Fullscreen') }}">
|
||||
<span x-text="formFullscreen ? '✕' : '⤢'"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<div class="tab-content">
|
||||
<!-- Project navigation bar (hidden for now, kept for later) -->
|
||||
<div class="hidden flex-wrap gap-1 mb-3">
|
||||
<a href="{{ route('projects.dashboard', $project) }}"
|
||||
class="btn btn-xs gap-1 {{ request()->routeIs('projects.dashboard') ? 'btn-primary' : 'btn-outline' }}">
|
||||
<x-heroicon-o-squares-2x2 class="w-3.5 h-3.5" /> {{ __('Dashboard') }}
|
||||
</a>
|
||||
<a href="{{ route('projects.map', $project) }}"
|
||||
class="btn btn-xs gap-1 {{ request()->routeIs('projects.map') ? 'btn-primary' : 'btn-outline' }}">
|
||||
<x-heroicon-o-map class="w-3.5 h-3.5" /> {{ __('Map') }}
|
||||
</a>
|
||||
<a href="{{ route('projects.gantt', $project) }}"
|
||||
class="btn btn-xs gap-1 {{ request()->routeIs('projects.gantt') ? 'btn-primary' : 'btn-outline' }}">
|
||||
<x-heroicon-o-calendar-days class="w-3.5 h-3.5" /> {{ __('Gantt') }}
|
||||
</a>
|
||||
<a href="{{ route('projects.report', $project) }}"
|
||||
class="btn btn-xs gap-1 {{ request()->routeIs('projects.report') ? 'btn-primary' : 'btn-outline' }}">
|
||||
<x-heroicon-o-document-text class="w-3.5 h-3.5" /> {{ __('Report') }}
|
||||
</a>
|
||||
<a href="{{ route('projects.issues', $project) }}"
|
||||
class="btn btn-xs gap-1 {{ request()->routeIs('projects.issues') ? 'btn-error' : 'btn-outline' }}">
|
||||
<x-heroicon-o-exclamation-triangle class="w-3.5 h-3.5" /> {{ __('Issues') }}
|
||||
@if($openIssuesCount > 0)
|
||||
<span class="badge badge-error badge-xs">{{ $openIssuesCount }}</span>
|
||||
@endif
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content: visibility controlled by Livewire conditionals, not DaisyUI -->
|
||||
<div class="mt-2">
|
||||
@if($activeTab === 'edit')
|
||||
@if($selectedFeature)
|
||||
<!-- Feature seleccionado -->
|
||||
<div class="border rounded-lg p-3 mb-3 bg-base-200">
|
||||
<h3 class="font-bold text-sm">{{ $selectedFeature->name ?? 'Elemento' }}</h3>
|
||||
<p class="text-xs text-gray-500">Fase: {{ $selectedFeature->layer?->phase?->name ?? '—' }}</p>
|
||||
<p class="text-xs text-gray-500">Capa: {{ $selectedFeature->layer?->name ?? '—' }}</p>
|
||||
{{-- Título a todo el ancho: progreso (solo número) a la izquierda + nombre --}}
|
||||
<div class="flex items-center gap-3 mb-4 pb-2 border-b border-base-300">
|
||||
<span class="badge badge-lg shrink-0 {{ $editProgress >= 100 ? 'badge-success' : ($editProgress > 0 ? 'badge-warning' : 'badge-ghost') }}">{{ $editProgress }}%</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="font-bold text-base truncate">{{ $selectedFeature->name ?? __('Feature') }}</h3>
|
||||
<p class="text-xs text-gray-500 truncate">{{ __('Phase') }}: {{ $selectedFeature->layer?->phase?->name ?? '—' }} · {{ __('Layer') }}: {{ $selectedFeature->layer?->name ?? '—' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- {{ __("Progress") }} --}}
|
||||
{{-- En pantalla completa el contenido se reparte en columnas --}}
|
||||
<div :class="formFullscreen ? 'grid grid-cols-1 lg:grid-cols-2 gap-x-8 items-start' : ''">
|
||||
<div>
|
||||
{{-- Responsable (se guarda al salir del campo) --}}
|
||||
<div class="form-control mb-3">
|
||||
<label class="label-text">{{ __("Progress") }}: {{ $editProgress }}%</label>
|
||||
<input type="range" min="0" max="100" wire:model.live="editProgress" class="range range-primary range-sm" />
|
||||
<div class="flex justify-between text-xs">
|
||||
<span>0%</span><span>50%</span><span>100%</span>
|
||||
<label class="label-text">{{ __('Responsible') }}</label>
|
||||
<input type="text" wire:model="editResponsible" wire:blur="saveFeatureProgress" class="input input-bordered input-sm" placeholder="{{ __('Name of responsible') }}" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-control mb-3">
|
||||
<label class="label-text">{{ __("Responsible") }}</label>
|
||||
<input type="text" wire:model="editResponsible" class="input input-bordered input-sm" placeholder="Nombre" />
|
||||
</div>
|
||||
|
||||
<button wire:click="saveFeatureProgress" class="btn btn-primary btn-sm w-full mb-3">
|
||||
💾 {{ __("Save progress") }}
|
||||
</button>
|
||||
|
||||
{{-- Gestor de archivos del feature --}}
|
||||
<details class="mb-3 border rounded-lg">
|
||||
<summary class="text-xs font-semibold cursor-pointer p-2 bg-base-200 rounded-t-lg">
|
||||
📎 {{ __("Files of element") }}
|
||||
<x-heroicon-o-paper-clip class="w-4 h-4 inline" /> {{ __('Files of element') }}
|
||||
</summary>
|
||||
<div class="p-2">
|
||||
@livewire('media-manager', [
|
||||
@@ -164,14 +179,16 @@
|
||||
], key('media-feature-' . $selectedFeature->id))
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{{-- Templates / Inspecciones --}}
|
||||
@if($templates->isNotEmpty())
|
||||
<div class="divider text-xs">{{ __("Inspection") }}</div>
|
||||
<div class="divider text-xs">{{ __('Inspection') }}</div>
|
||||
<div class="form-control mb-2">
|
||||
<label class="label-text">Plantilla</label>
|
||||
<label class="label-text">{{ __('Template') }}</label>
|
||||
<select wire:model.live="selectedTemplateId" wire:change="onTemplateChange" class="select select-bordered select-sm">
|
||||
<option value="">Seleccionar plantilla...</option>
|
||||
<option value="">{{ __('Select template...') }}</option>
|
||||
@foreach($templates as $t)
|
||||
<option value="{{ $t->id }}">{{ $t->name }}</option>
|
||||
@endforeach
|
||||
@@ -197,7 +214,7 @@
|
||||
@break
|
||||
@case('select')
|
||||
<select wire:model="inspectionFormData.{{ $field['name'] }}" class="select select-bordered select-sm w-full">
|
||||
<option value="">Seleccionar</option>
|
||||
<option value="">{{ __('Select') }}</option>
|
||||
@foreach(explode(',', $field['options'] ?? '') as $opt)
|
||||
<option value="{{ trim($opt) }}">{{ trim($opt) }}</option>
|
||||
@endforeach
|
||||
@@ -211,21 +228,21 @@
|
||||
@endswitch
|
||||
</div>
|
||||
@endforeach
|
||||
<button wire:click="saveInspection" class="btn btn-primary btn-xs w-full mt-1">{{ __("Register inspection") }}</button>
|
||||
<button wire:click="saveInspection" class="btn btn-primary btn-xs w-full mt-1">{{ __('Register inspection') }}</button>
|
||||
@endif
|
||||
@endif
|
||||
|
||||
{{-- {{ __("History") }} de inspecciones --}}
|
||||
{{-- Historial de inspecciones --}}
|
||||
@if($inspectionHistory->isNotEmpty())
|
||||
<div class="divider text-xs">{{ __("History") }}</div>
|
||||
<div class="divider text-xs">{{ __('History') }}</div>
|
||||
<div class="space-y-1 max-h-40 overflow-y-auto">
|
||||
@foreach($inspectionHistory as $ins)
|
||||
<div class="border rounded p-2 text-xs">
|
||||
<div class="border rounded p-2 text-xs" wire:key="hist-{{ $ins->id }}">
|
||||
<div class="flex justify-between">
|
||||
<span class="font-medium">{{ $ins->template?->name ?? __("Inspection") }}</span>
|
||||
<span class="font-medium">{{ $ins->template?->name ?? __('Inspection') }}</span>
|
||||
<span class="text-gray-400">{{ $ins->created_at->diffForHumans() }}</span>
|
||||
</div>
|
||||
@if($ins->user)<span class="text-gray-500">por {{ $ins->user->name }}</span>@endif
|
||||
@if($ins->user)<span class="text-gray-500">{{ __('by') }} {{ $ins->user->name }}</span>@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@@ -236,45 +253,45 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
<div>
|
||||
<h3 class="font-bold">{{ __("No templates yet") }}</h3>
|
||||
<div class="text-xs">{{ __("Create an inspection template") }}.</div>
|
||||
<h3 class="font-bold">{{ __('No templates yet') }}</h3>
|
||||
<div class="text-xs">{{ __('Create an inspection template') }}.</div>
|
||||
</div>
|
||||
<a href="{{ route('projects.templates', $project) }}" class="btn btn-primary btn-sm">{{ __("Create") }}</a>
|
||||
<a href="{{ route('projects.templates', $project) }}" class="btn btn-primary btn-sm">{{ __('Create') }}</a>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="text-center text-gray-400 py-8">
|
||||
<p class="text-lg">👆</p>
|
||||
<p>Haz clic en un elemento del mapa para editarlo</p>
|
||||
<x-heroicon-o-cursor-arrow-rays class="w-10 h-10 mx-auto opacity-30" />
|
||||
<p>{{ __('Click on a map element or search above to edit it') }}</p>
|
||||
</div>
|
||||
@endif
|
||||
@elseif($activeTab === 'features')
|
||||
<!-- Features Table -->
|
||||
@if($allFeatures->isNotEmpty())
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-sm table-compact">
|
||||
<div class="overflow-x-auto rounded-lg border border-base-300">
|
||||
<table class="table table-sm table-zebra table-pin-rows">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ __("Feature") }}</th>
|
||||
<th>{{ __("Layer") }}</th>
|
||||
<th>{{ __("Phase") }}</th>
|
||||
<th>{{ __("Progress") }}</th>
|
||||
<th>{{ __("Responsible") }}</th>
|
||||
<th>{{ __("Template") }}</th>
|
||||
<th class="w-16"></th>
|
||||
<th>{{ __('Feature') }}</th>
|
||||
<th>{{ __('Layer') }}</th>
|
||||
<th>{{ __('Phase') }}</th>
|
||||
<th class="text-center">{{ __('Progress') }}</th>
|
||||
<th class="w-10"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach($allFeatures as $feature)
|
||||
<tr class="hover" wire:click="selectFeature({{ $feature->id }})">
|
||||
<td>{{ $feature->name }}</td>
|
||||
<td>{{ $feature->layer->name }}</td>
|
||||
<td>{{ $feature->layer->phase->name }}</td>
|
||||
<td>{{ $feature->progress }}%</td>
|
||||
<td>{{ $feature->responsible ?? '-' }}</td>
|
||||
<td>{{ $feature->template?->name ?? '-' }}</td>
|
||||
<td class="justify-end">
|
||||
<button class="btn btn-xs btn-outline btn-primary">✏️</button>
|
||||
<tr class="hover cursor-pointer" wire:click="selectFeature({{ $feature->id }})" wire:key="feat-{{ $feature->id }}">
|
||||
<td class="font-medium">{{ $feature->name }}</td>
|
||||
<td>{{ $feature->layer?->name ?? '—' }}</td>
|
||||
<td>{{ $feature->layer?->phase?->name ?? '—' }}</td>
|
||||
<td class="text-center">
|
||||
<span class="badge badge-sm {{ $feature->progress >= 100 ? 'badge-success' : ($feature->progress > 0 ? 'badge-warning' : 'badge-ghost') }}">{{ $feature->progress }}%</span>
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<x-heroicon-o-chevron-right class="w-4 h-4 opacity-40" />
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
@@ -283,33 +300,35 @@
|
||||
</div>
|
||||
@else
|
||||
<div class="text-center text-gray-400 py-8">
|
||||
<p class="text-lg">📋</p>
|
||||
<p>{{ __("No features found") }}</p>
|
||||
<x-heroicon-o-clipboard-document-list class="w-10 h-10 mx-auto opacity-30" />
|
||||
<p>{{ __('No elements in this project') }}</p>
|
||||
</div>
|
||||
@endif
|
||||
@elseif($activeTab === 'inspections')
|
||||
<!-- Inspections Table -->
|
||||
@if($allInspections->isNotEmpty())
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-sm table-compact">
|
||||
<div class="overflow-x-auto rounded-lg border border-base-300">
|
||||
<table class="table table-sm table-zebra table-pin-rows">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ __("Date") }}</th>
|
||||
<th>{{ __("Feature") }}</th>
|
||||
<th>{{ __("Template") }}</th>
|
||||
<th>{{ __("User") }}</th>
|
||||
<th class="w-16"></th>
|
||||
<th>{{ __('Date') }}</th>
|
||||
<th>{{ __('Feature') }}</th>
|
||||
<th>{{ __('Template') }}</th>
|
||||
<th>{{ __('User') }}</th>
|
||||
<th class="w-10"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach($allInspections as $inspection)
|
||||
<tr>
|
||||
<td>{{ $inspection->created_at->format('d/m/Y') }}</td>
|
||||
<td>{{ $inspection->feature->name }}</td>
|
||||
<td>{{ $inspection->template->name }}</td>
|
||||
<td>{{ $inspection->user->name }}</td>
|
||||
<td class="justify-end">
|
||||
<button class="btn btn-xs btn-outline btn-info">👁️</button>
|
||||
<tr class="hover" wire:key="insp-{{ $inspection->id }}">
|
||||
<td class="whitespace-nowrap">{{ $inspection->created_at?->format('d/m/Y') ?? '—' }}</td>
|
||||
<td class="font-medium">{{ $inspection->feature?->name ?? '—' }}</td>
|
||||
<td>{{ $inspection->template?->name ?? '—' }}</td>
|
||||
<td>{{ $inspection->user?->name ?? '—' }}</td>
|
||||
<td class="text-right">
|
||||
<button wire:click="viewInspection({{ $inspection->id }})" class="btn btn-xs btn-ghost" title="{{ __('View') }}">
|
||||
<x-heroicon-o-eye class="w-4 h-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
@@ -318,8 +337,8 @@
|
||||
</div>
|
||||
@else
|
||||
<div class="text-center text-gray-400 py-8">
|
||||
<p class="text-lg">📋</p>
|
||||
<p>{{ __("No inspections found") }}</p>
|
||||
<x-heroicon-o-clipboard-document-list class="w-10 h-10 mx-auto opacity-30" />
|
||||
<p>{{ __('No inspections registered') }}</p>
|
||||
</div>
|
||||
@endif
|
||||
@elseif($activeTab === 'issues')
|
||||
@@ -327,8 +346,54 @@
|
||||
@livewire('issue-manager', ['project' => $project], key('issues-tab-' . $project->id))
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Visor de inspección --}}
|
||||
@if($viewingInspection)
|
||||
<div class="modal modal-open z-[2000]" wire:key="ins-viewer-{{ $viewingInspection['id'] }}">
|
||||
<div class="modal-box max-w-lg">
|
||||
<div class="flex justify-between items-start mb-3">
|
||||
<h3 class="font-bold text-lg">{{ __('Inspection') }} #{{ $viewingInspection['id'] }}</h3>
|
||||
<button wire:click="closeViewInspection" class="btn btn-sm btn-circle btn-ghost">
|
||||
<x-heroicon-o-x-mark class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2 text-sm mb-2">
|
||||
<div><span class="text-gray-500">{{ __('Feature') }}:</span> {{ $viewingInspection['feature_name'] }}</div>
|
||||
<div><span class="text-gray-500">{{ __('Template') }}:</span> {{ $viewingInspection['template_name'] }}</div>
|
||||
<div><span class="text-gray-500">{{ __('Phase') }}:</span> {{ $viewingInspection['phase_name'] }}</div>
|
||||
<div><span class="text-gray-500">{{ __('Layer') }}:</span> {{ $viewingInspection['layer_name'] }}</div>
|
||||
<div><span class="text-gray-500">{{ __('User') }}:</span> {{ $viewingInspection['user_name'] }}</div>
|
||||
<div><span class="text-gray-500">{{ __('Date') }}:</span> {{ $viewingInspection['date'] }}</div>
|
||||
</div>
|
||||
|
||||
@if(!empty($viewingInspection['fields']))
|
||||
<div class="divider text-xs">{{ __('Data') }}</div>
|
||||
<div class="space-y-1 text-sm">
|
||||
@foreach($viewingInspection['fields'] as $field)
|
||||
<div class="flex justify-between gap-3 border-b border-base-200 py-1">
|
||||
<span class="text-gray-500">{{ $field['label'] ?? ($field['name'] ?? '') }}</span>
|
||||
<span class="font-medium text-right">{{ $viewingInspection['data'][$field['name']] ?? '—' }}</span>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if(!empty($viewingInspection['notes']))
|
||||
<div class="divider text-xs">{{ __('Notes') }}</div>
|
||||
<p class="text-sm whitespace-pre-line">{{ $viewingInspection['notes'] }}</p>
|
||||
@endif
|
||||
|
||||
<div class="modal-action">
|
||||
<button wire:click="closeViewInspection" class="btn btn-sm">{{ __('Close') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop bg-black/40" wire:click="closeViewInspection"></div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@push('styles')
|
||||
<style>
|
||||
@@ -374,17 +439,28 @@
|
||||
const center = [{{ $project->lat }}, {{ $project->lng }}];
|
||||
map = L.map('map').setView(center, 16);
|
||||
|
||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
|
||||
// Capas base seleccionables (calles / OSM / satélite)
|
||||
const baseLayers = {
|
||||
'{{ __('Streets') }}': L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> & CartoDB'
|
||||
}).addTo(map);
|
||||
}),
|
||||
'OpenStreetMap': L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
}),
|
||||
'{{ __('Satellite') }}': L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
|
||||
attribution: 'Tiles © Esri — Source: Esri, Maxar, Earthstar Geographics'
|
||||
}),
|
||||
};
|
||||
baseLayers['{{ __('Streets') }}'].addTo(map);
|
||||
L.control.layers(baseLayers, null, { position: 'topleft' }).addTo(map);
|
||||
|
||||
// Cargar fases y sus features
|
||||
// Cargar capas y sus features (cada capa = un grupo Leaflet independiente)
|
||||
@foreach($phases as $phase)
|
||||
@foreach($phase->layers as $layer)
|
||||
@php
|
||||
$phaseFeatures = $phase->features()->with('layer.phase')->get();
|
||||
$fc = [
|
||||
'type' => 'FeatureCollection',
|
||||
'features' => $phaseFeatures->map(function($f) {
|
||||
'features' => $layer->features->map(function($f) {
|
||||
return [
|
||||
'type' => 'Feature',
|
||||
'id' => $f->id,
|
||||
@@ -403,30 +479,30 @@
|
||||
(function() {
|
||||
const data = @json($fc);
|
||||
if (data && data.features && data.features.length > 0) {
|
||||
const phaseLayer = L.geoJSON(data, {
|
||||
style: { color: '{{ $phase->color }}', weight: 3, opacity: 0.8, fillOpacity: 0.3 },
|
||||
onEachFeature: function(feature, layer) {
|
||||
const layerGroup = L.geoJSON(data, {
|
||||
style: { color: '{{ $layer->color ?? $phase->color }}', weight: 3, opacity: 0.8, fillOpacity: 0.3 },
|
||||
onEachFeature: function(feature, lyr) {
|
||||
const props = feature.properties || {};
|
||||
const featId = props._feature_id || feature.id;
|
||||
// Escape all user-generated content for HTML context
|
||||
const safeName = escapeHtml(props.name || 'Elemento');
|
||||
const safeName = escapeHtml(props.name || '{{ __('Feature') }}');
|
||||
const safeProgress = escapeHtml(props.progress || 0);
|
||||
const safeResponsible = escapeHtml(props.responsible || '-');
|
||||
let content = `<b>${safeName}</b><br>
|
||||
{{ __("Progress") }}: ${safeProgress}%<br>
|
||||
{{ __("Responsible") }}: ${safeResponsible}<br>
|
||||
<button class="btn btn-xs btn-primary mt-1" onclick="selectFeature('${featId}')">✏️ Editar</button>`;
|
||||
layer.bindPopup(content);
|
||||
layer.on('click', function() { selectFeature(featId); });
|
||||
{{ __('Progress') }}: ${safeProgress}%<br>
|
||||
{{ __('Responsible') }}: ${safeResponsible}<br>
|
||||
<button class="btn btn-xs btn-primary mt-1" onclick="selectFeature('${featId}')">{{ __('Edit') }}</button>`;
|
||||
lyr.bindPopup(content);
|
||||
lyr.on('click', function() { selectFeature(featId); });
|
||||
}
|
||||
});
|
||||
layers[{{ $phase->id }}] = phaseLayer;
|
||||
@if(in_array($phase->id, $activeLayers))
|
||||
phaseLayer.addTo(map);
|
||||
layers[{{ $layer->id }}] = layerGroup;
|
||||
@if(in_array((int) $layer->id, $activeLayers))
|
||||
layerGroup.addTo(map);
|
||||
@endif
|
||||
}
|
||||
})()
|
||||
@endforeach
|
||||
@endforeach
|
||||
|
||||
// Initialize combined bounds
|
||||
updateCombinedBounds();
|
||||
@@ -434,7 +510,7 @@
|
||||
setTimeout(() => {
|
||||
map.invalidateSize();
|
||||
zoomToAllFeatures();
|
||||
}, 100); // Reduced from 200ms to 100ms
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function updateCombinedBounds() {
|
||||
@@ -456,10 +532,7 @@
|
||||
|
||||
function zoomToAllFeatures() {
|
||||
if (!map) return;
|
||||
|
||||
// Update combined bounds if needed
|
||||
updateCombinedBounds();
|
||||
|
||||
if (combinedBounds && combinedBounds.isValid()) {
|
||||
map.fitBounds(combinedBounds, { padding: [20, 20] });
|
||||
} else {
|
||||
@@ -475,32 +548,29 @@
|
||||
if (navigator.geolocation) {
|
||||
navigator.geolocation.getCurrentPosition((pos) => {
|
||||
const latlng = [pos.coords.latitude, pos.coords.longitude];
|
||||
L.marker(latlng).addTo(map).bindPopup('Tu ubicación').openPopup();
|
||||
L.marker(latlng).addTo(map).bindPopup('{{ __('My location') }}').openPopup();
|
||||
map.setView(latlng, 16);
|
||||
}, () => alert('No se pudo obtener la ubicación'));
|
||||
}, () => alert('{{ __('No results') }}'));
|
||||
} else {
|
||||
alert('Geolocalización no soportada');
|
||||
alert('{{ __('No results') }}');
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('livewire:init', function () {
|
||||
setTimeout(initMap, 50); // Reduced from 100ms to 50ms
|
||||
setTimeout(initMap, 50);
|
||||
|
||||
Livewire.on('layersUpdated', (activeIds) => {
|
||||
// Livewire wraps single parameters in an array, so we need to extract the actual data
|
||||
const ids = Array.isArray(activeIds) ? activeIds[0] : activeIds;
|
||||
for (let id in layers) {
|
||||
const lid = parseInt(id);
|
||||
if (ids.includes(lid)) {
|
||||
if (!map.hasLayer(layers[id])) {
|
||||
layers[id].addTo(map);
|
||||
// Update combined bounds when adding a layer
|
||||
updateCombinedBounds();
|
||||
}
|
||||
} else {
|
||||
if (map.hasLayer(layers[id])) {
|
||||
map.removeLayer(layers[id]);
|
||||
// Update combined bounds when removing a layer
|
||||
updateCombinedBounds();
|
||||
}
|
||||
}
|
||||
@@ -511,7 +581,6 @@
|
||||
Livewire.on('centerMap', zoomToAllFeatures);
|
||||
Livewire.on('mapResize', () => {
|
||||
if (map) {
|
||||
// Throttle resize events to prevent excessive calls
|
||||
if (!this.resizeTimeout) {
|
||||
this.resizeTimeout = setTimeout(() => {
|
||||
map.invalidateSize();
|
||||
@@ -521,14 +590,12 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Toggle imágenes en mapa
|
||||
Livewire.on('featureImagesToggled', (show, markers) => {
|
||||
const m = Array.isArray(markers) ? markers : markers[1];
|
||||
const s = Array.isArray(show) ? show[0] : show;
|
||||
if (imageMarkersLayer) {
|
||||
map.removeLayer(imageMarkersLayer);
|
||||
imageMarkersLayer = null;
|
||||
// Update bounds when removing image markers layer
|
||||
updateCombinedBounds();
|
||||
}
|
||||
if (s && m && m.length > 0) {
|
||||
@@ -540,10 +607,9 @@
|
||||
iconAnchor: [10, 10]
|
||||
});
|
||||
m.forEach(marker => {
|
||||
// Validate URL and sanitize name for security
|
||||
const safeUrl = isValidUrl(marker.image_url) ? marker.image_url : '';
|
||||
const safeName = escapeHtml(marker.image_name || '');
|
||||
if (safeUrl) { // Only add marker if URL is valid
|
||||
if (safeUrl) {
|
||||
const popupContent = `<b>${safeName}</b><br>
|
||||
<img src="${safeUrl}" class="max-w-[200px] max-h-[150px] rounded cursor-pointer"
|
||||
onclick="window.openViewer('${safeUrl}', '${safeName}')" />`;
|
||||
@@ -552,21 +618,16 @@
|
||||
.addTo(imageMarkersLayer);
|
||||
}
|
||||
});
|
||||
// Update bounds when adding image markers layer
|
||||
updateCombinedBounds();
|
||||
}
|
||||
});
|
||||
|
||||
// Modal para ver imagen al hacer clic
|
||||
window.openViewer = function(url, name) {
|
||||
// Validate URL and sanitize name for security
|
||||
if (!isValidUrl(url)) {
|
||||
console.error('Invalid URL provided to openViewer:', url);
|
||||
return;
|
||||
}
|
||||
|
||||
const safeName = escapeHtml(name);
|
||||
|
||||
if (imageViewerModal) imageViewerModal.remove();
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'imageViewerModal';
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<div class="py-8 max-w-6xl mx-auto sm:px-6 lg:px-8">
|
||||
|
||||
<div class="mb-6">
|
||||
<h2 class="text-2xl font-bold text-gray-800">{{ __('Permission management') }}</h2>
|
||||
<p class="text-sm text-gray-500 mt-1">{{ __('Tick which permissions each role has. Changes are saved instantly.') }}</p>
|
||||
</div>
|
||||
|
||||
{{-- Crear rol / permiso --}}
|
||||
<div class="flex flex-wrap items-start gap-6 mb-6">
|
||||
<form wire:submit.prevent="addRole" class="flex flex-col gap-1">
|
||||
<div class="flex gap-2">
|
||||
<input wire:model="newRole" class="input input-bordered input-sm w-48" placeholder="{{ __('New role') }}" />
|
||||
<button class="btn btn-sm btn-primary gap-1">
|
||||
<x-heroicon-o-plus class="w-4 h-4" /> {{ __('Role') }}
|
||||
</button>
|
||||
</div>
|
||||
@error('newRole') <span class="text-error text-xs">{{ $message }}</span> @enderror
|
||||
</form>
|
||||
|
||||
<form wire:submit.prevent="addPermission" class="flex flex-col gap-1">
|
||||
<div class="flex gap-2">
|
||||
<input wire:model="newPermission" class="input input-bordered input-sm w-48" placeholder="{{ __('New permission') }}" />
|
||||
<button class="btn btn-sm btn-outline gap-1">
|
||||
<x-heroicon-o-plus class="w-4 h-4" /> {{ __('Permission') }}
|
||||
</button>
|
||||
</div>
|
||||
@error('newPermission') <span class="text-error text-xs">{{ $message }}</span> @enderror
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{{-- Matriz Roles × Permisos --}}
|
||||
<div class="overflow-x-auto border border-base-300 rounded-lg bg-white">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="bg-base-200">{{ __('Permission') }}</th>
|
||||
@foreach($roles as $role)
|
||||
<th class="bg-base-200 text-center align-bottom">
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<span class="font-semibold">{{ $role->name }}</span>
|
||||
@unless(in_array($role->name, ['Admin'], true))
|
||||
<button wire:click="deleteRole({{ $role->id }})"
|
||||
wire:confirm="{{ __('Delete role') }} '{{ $role->name }}'?"
|
||||
class="btn btn-ghost btn-xs text-error" title="{{ __('Delete role') }}">
|
||||
<x-heroicon-o-trash class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
@endunless
|
||||
</div>
|
||||
</th>
|
||||
@endforeach
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse($permissions as $perm)
|
||||
<tr wire:key="perm-row-{{ $perm->id }}" class="hover">
|
||||
<td class="font-medium whitespace-nowrap">
|
||||
<div class="flex items-center gap-2">
|
||||
<span>{{ $perm->name }}</span>
|
||||
@if($perm->name !== 'manage all')
|
||||
<button wire:click="deletePermission({{ $perm->id }})"
|
||||
wire:confirm="{{ __('Delete permission') }} '{{ $perm->name }}'?"
|
||||
class="btn btn-ghost btn-xs text-error opacity-40 hover:opacity-100" title="{{ __('Delete permission') }}">
|
||||
<x-heroicon-o-trash class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
</td>
|
||||
@foreach($roles as $role)
|
||||
<td class="text-center" wire:key="cell-{{ $perm->id }}-{{ $role->id }}">
|
||||
<input type="checkbox"
|
||||
class="checkbox checkbox-sm checkbox-primary"
|
||||
@checked($role->permissions->contains('id', $perm->id))
|
||||
wire:click="togglePermission({{ $role->id }}, '{{ $perm->name }}')" />
|
||||
</td>
|
||||
@endforeach
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="{{ $roles->count() + 1 }}" class="text-center text-gray-400 py-6">{{ __('No permissions') }}</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-gray-400 mt-3">
|
||||
{{ __('The Admin role and the "manage all" permission are protected and cannot be removed.') }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -0,0 +1,59 @@
|
||||
<div class="py-8 max-w-3xl mx-auto sm:px-6 lg:px-8">
|
||||
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-2xl font-bold text-gray-800">
|
||||
{{ $role ? __('Edit role') : __('New role') }}
|
||||
</h2>
|
||||
<a href="{{ route('admin.roles') }}" class="btn btn-ghost btn-sm gap-1" wire:navigate>
|
||||
<x-heroicon-o-arrow-left class="w-4 h-4" /> {{ __('Back') }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<form wire:submit.prevent="save" class="space-y-5">
|
||||
|
||||
{{-- Nombre --}}
|
||||
<div class="flex items-start gap-4">
|
||||
<label class="w-40 shrink-0 pt-2 text-sm font-medium text-gray-700">
|
||||
{{ __('Name') }} <span class="text-error">*</span>
|
||||
</label>
|
||||
<div class="flex-1">
|
||||
<input type="text" wire:model="name"
|
||||
class="input input-bordered w-full @error('name') input-error @enderror"
|
||||
placeholder="{{ __('e.g. Site Supervisor') }}"
|
||||
@if($isProtected) readonly @endif />
|
||||
@error('name') <p class="text-error text-xs mt-1">{{ $message }}</p> @enderror
|
||||
@if($isProtected)
|
||||
<p class="text-xs text-gray-400 mt-1">{{ __('This role is protected and cannot be renamed.') }}</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Descripción --}}
|
||||
<div class="flex items-start gap-4">
|
||||
<label class="w-40 shrink-0 pt-2 text-sm font-medium text-gray-700">
|
||||
{{ __('Description') }}
|
||||
</label>
|
||||
<div class="flex-1">
|
||||
<textarea wire:model="description" rows="2"
|
||||
class="textarea textarea-bordered w-full @error('description') textarea-error @enderror"
|
||||
placeholder="{{ __('What is this role for?') }}"></textarea>
|
||||
@error('description') <p class="text-error text-xs mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-gray-400 pl-44">
|
||||
{{ __('Permissions are assigned from the role view, in the "Permissions" tab.') }}
|
||||
</p>
|
||||
|
||||
<div class="flex items-center justify-end gap-3 pt-2 border-t border-base-200">
|
||||
<a href="{{ route('admin.roles') }}" class="btn btn-ghost" wire:navigate>{{ __('Cancel') }}</a>
|
||||
<button type="submit" class="btn btn-primary gap-2" wire:loading.attr="disabled" wire:target="save">
|
||||
<span wire:loading wire:target="save" class="loading loading-spinner loading-sm"></span>
|
||||
<x-heroicon-o-check class="w-4 h-4" />
|
||||
{{ $role ? __('Update role') : __('Create role') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,133 @@
|
||||
<div class="py-8 max-w-4xl mx-auto sm:px-6 lg:px-8">
|
||||
|
||||
{{-- Cabecera: nombre del rol + botón Volver --}}
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-2xl font-bold text-gray-800 flex items-center gap-2">
|
||||
<x-heroicon-o-shield-check class="w-6 h-6 text-primary" />
|
||||
{{ $role->name }}
|
||||
@if($isProtected)
|
||||
<span class="badge badge-ghost badge-sm">{{ __('protected') }}</span>
|
||||
@endif
|
||||
</h2>
|
||||
<a href="{{ route('admin.roles') }}" class="btn btn-ghost btn-sm gap-1" wire:navigate>
|
||||
<x-heroicon-o-arrow-left class="w-4 h-4" /> {{ __('Back') }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{{-- Tabs --}}
|
||||
<div class="flex flex-wrap gap-1 mb-4">
|
||||
<button wire:click="setTab('ficha')" class="btn btn-sm {{ $tab === 'ficha' ? 'btn-primary' : 'btn-ghost' }}">
|
||||
{{ __('Details') }}
|
||||
</button>
|
||||
<button wire:click="setTab('permisos')" class="btn btn-sm {{ $tab === 'permisos' ? 'btn-primary' : 'btn-ghost' }}">
|
||||
{{ __('Permissions') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- ═══════════════ TAB FICHA ═══════════════ --}}
|
||||
@if($tab === 'ficha')
|
||||
<div class="bg-white rounded-lg shadow p-6 mb-6">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex-1">
|
||||
<h3 class="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-1">{{ __('Description') }}</h3>
|
||||
<p class="text-gray-700">{{ $role->description ?: __('No description') }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<a href="{{ route('admin.roles.edit', $role->id) }}" class="btn btn-sm btn-info gap-1" wire:navigate>
|
||||
<x-heroicon-o-pencil class="w-4 h-4" /> {{ __('Edit') }}
|
||||
</a>
|
||||
@unless($isProtected)
|
||||
<button wire:click="delete"
|
||||
wire:confirm="{{ __('Delete role') }} '{{ $role->name }}'?"
|
||||
class="btn btn-sm btn-error btn-outline gap-1">
|
||||
<x-heroicon-o-trash class="w-4 h-4" /> {{ __('Delete') }}
|
||||
</button>
|
||||
@endunless
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Usuarios con este rol --}}
|
||||
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||
<div class="px-6 py-3 border-b border-base-200">
|
||||
<h3 class="font-semibold text-gray-700">{{ __('Users with this role') }} ({{ $users->count() }})</h3>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ __('Name') }}</th>
|
||||
<th>{{ __('Last name') }}</th>
|
||||
<th>{{ __('Status') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse($users as $u)
|
||||
<tr wire:key="ru-{{ $u->id }}" class="hover">
|
||||
<td>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="avatar placeholder">
|
||||
<div class="bg-neutral text-neutral-content rounded-full w-8">
|
||||
<span class="text-xs">{{ strtoupper(mb_substr($u->first_name ?: $u->name, 0, 1)) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-sm leading-tight">{{ $u->first_name ?: $u->name }}</p>
|
||||
<p class="text-xs text-gray-500">{{ $u->email }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-sm">{{ $u->last_name ?: '—' }}</td>
|
||||
<td>
|
||||
@php
|
||||
[$cls, $label] = match($u->status) {
|
||||
'active' => ['badge-success', __('Active')],
|
||||
'inactive' => ['badge-ghost', __('Inactive')],
|
||||
'suspended' => ['badge-error', __('Suspended')],
|
||||
default => ['badge-ghost', ucfirst((string) $u->status)],
|
||||
};
|
||||
@endphp
|
||||
<span class="badge badge-sm {{ $cls }}">{{ $label }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="3" class="text-center text-gray-400 py-8">{{ __('No users with this role') }}</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- ═══════════════ TAB PERMISOS ═══════════════ --}}
|
||||
@if($tab === 'permisos')
|
||||
<div class="bg-white rounded-lg shadow p-6 space-y-6">
|
||||
@forelse($grouped as $section => $perms)
|
||||
<div>
|
||||
<h3 class="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-3 border-b border-base-200 pb-1">
|
||||
{{ $section }}
|
||||
</h3>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-2">
|
||||
@foreach($perms as $perm)
|
||||
<label class="flex items-center justify-between gap-3 cursor-pointer py-1">
|
||||
<div class="min-w-0">
|
||||
<span class="text-sm">{{ $perm->name }}</span>
|
||||
@if($perm->description)
|
||||
<p class="text-xs text-gray-400 leading-tight">{{ $perm->description }}</p>
|
||||
@endif
|
||||
</div>
|
||||
<input type="checkbox"
|
||||
class="toggle toggle-primary toggle-sm shrink-0"
|
||||
wire:key="perm-{{ $role->id }}-{{ $perm->id }}"
|
||||
@checked(in_array($perm->name, $rolePerms, true))
|
||||
wire:click="togglePermission('{{ $perm->name }}')" />
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<p class="text-gray-400 text-sm">{{ __('No permissions') }}</p>
|
||||
@endforelse
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@@ -1,10 +1,10 @@
|
||||
<div>
|
||||
<div class="bg-base-100 p-4 rounded shadow">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-xl font-bold">📋 Templates de inspección</h2>
|
||||
<h2 class="text-xl font-bold">📋 {{ __('Inspection templates') }}</h2>
|
||||
<div>
|
||||
<button wire:click="newTemplate" class="btn btn-primary btn-sm">
|
||||
Nuevo template
|
||||
{{ __('New template') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -21,20 +21,19 @@
|
||||
{{-- Nombre del template --}}
|
||||
<tr>
|
||||
<td class="w-1/4 py-3 pr-4 align-top">
|
||||
{{__('Template name')}}
|
||||
{{ __('Template name') }}
|
||||
</td>
|
||||
<td class="py-3">
|
||||
<input type="text" wire:model="form.name"
|
||||
class="input w-full {{ $errors->has('form.name') ? 'input-error' : '' }}"
|
||||
class="input w-full"
|
||||
required>
|
||||
@error('form.name') <span class="text-error text-xs mt-1">{{ $message }}</span> @enderror
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{{-- Descripción --}}
|
||||
<tr>
|
||||
<td class="w-1/4 py-3 pr-4 align-top">
|
||||
{{__('Descripción')}}
|
||||
{{ __('Description') }}
|
||||
</td>
|
||||
<td class="py-3">
|
||||
<textarea wire:model="form.description" class="textarea textarea-bordered w-full" rows="2"></textarea>
|
||||
@@ -44,11 +43,11 @@
|
||||
{{-- Fase asociada (opcional) --}}
|
||||
<tr>
|
||||
<td class="w-1/4 py-3 pr-4 align-top">
|
||||
{{__('Fase asociada (opcional)')}}
|
||||
{{ __('Associated phase (optional)') }}
|
||||
</td>
|
||||
<td class="py-3">
|
||||
<select wire:model="form.phase_id" class="select select-bordered w-full">
|
||||
<option value="">Ninguna (global para el proyecto)</option>
|
||||
<option value="">{{ __('Global project') }}</option>
|
||||
@foreach($phases as $phase)
|
||||
<option value="{{ $phase->id }}" {{ old('form.phase_id') == $phase->id ? 'selected' : '' }}>
|
||||
{{ $phase->name }}
|
||||
@@ -62,22 +61,22 @@
|
||||
|
||||
{{-- Campos dinámicos --}}
|
||||
<div class="border-t pt-4 mt-2">
|
||||
<h3 class="font-bold mb-3">Campos del formulario</h3>
|
||||
<h3 class="font-bold mb-3">{{ __('Form fields') }}</h3>
|
||||
@foreach($form['fields'] as $index => $field)
|
||||
<div class="border p-3 rounded mb-3 bg-base-100">
|
||||
{{-- Fila: nombre interno --}}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-2">
|
||||
<div class="font-medium">Nombre interno</div>
|
||||
<div class="font-medium">{{ __('Internal name') }}</div>
|
||||
<div><input type="text" wire:model="form.fields.{{ $index }}.name" placeholder="ej: altura_medida" class="input input-sm w-full"></div>
|
||||
</div>
|
||||
{{-- Fila: etiqueta --}}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-2">
|
||||
<div class="font-medium">Etiqueta visible</div>
|
||||
<div class="font-medium">{{ __('Visible label') }}</div>
|
||||
<div><input type="text" wire:model="form.fields.{{ $index }}.label" placeholder="ej: Altura medida (m)" class="input input-sm w-full"></div>
|
||||
</div>
|
||||
{{-- Fila: tipo --}}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-2">
|
||||
<div class="font-medium">Tipo de campo</div>
|
||||
<div class="font-medium">{{ __('Field type') }}</div>
|
||||
<div>
|
||||
<select wire:model="form.fields.{{ $index }}.type" class="select select-sm w-full">
|
||||
@foreach($fieldTypes as $typeValue => $typeLabel)
|
||||
@@ -88,43 +87,36 @@
|
||||
</div>
|
||||
{{-- Fila: requerido y botón eliminar --}}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-2">
|
||||
<div class="font-medium">Requerido</div>
|
||||
<div class="font-medium">{{ __('Required') }}</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<input type="checkbox" wire:model="form.fields.{{ $index }}.required" class="checkbox checkbox-sm">
|
||||
<button type="button" wire:click="removeField({{ $index }})" class="btn btn-xs btn-error">Eliminar campo</button>
|
||||
<button type="button" wire:click="removeField({{ $index }})" class="btn btn-xs btn-error">{{ __('Remove field') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Campos adicionales según tipo --}}
|
||||
@if(in_array($field['type'], ['integer', 'decimal', 'percentage']))
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-2">
|
||||
<div class="font-medium">Mínimo / Máximo / Paso</div>
|
||||
<div class="font-medium">{{ __('Min') }} / {{ __('Max') }} / {{ __('Step') }}</div>
|
||||
<div class="flex gap-2">
|
||||
<input type="number" wire:model="form.fields.{{ $index }}.min" placeholder="Mín" class="input input-xs w-20">
|
||||
<input type="number" wire:model="form.fields.{{ $index }}.max" placeholder="Máx" class="input input-xs w-20">
|
||||
<input type="number" step="any" wire:model="form.fields.{{ $index }}.step" placeholder="Paso" class="input input-xs w-20">
|
||||
<input type="number" wire:model="form.fields.{{ $index }}.min" placeholder="{{ __('Min') }}" class="input input-xs w-20">
|
||||
<input type="number" wire:model="form.fields.{{ $index }}.max" placeholder="{{ __('Max') }}" class="input input-xs w-20">
|
||||
<input type="number" step="any" wire:model="form.fields.{{ $index }}.step" placeholder="{{ __('Step') }}" class="input input-xs w-20">
|
||||
</div>
|
||||
</div>
|
||||
@elseif($field['type'] === 'select')
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-2">
|
||||
<div class="font-medium">Opciones (separadas por coma)</div>
|
||||
<div class="font-medium">{{ __('Options (comma separated)') }}</div>
|
||||
<div><input type="text" wire:model="form.fields.{{ $index }}.options" placeholder="ej: Bueno,Regular,Malo" class="input input-sm w-full"></div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
<button type="button" wire:click="addField" class="btn btn-sm btn-secondary mt-2">+ Agregar campo</button>
|
||||
<button type="button" wire:click="addField" class="btn btn-sm btn-secondary mt-2">+ {{ __('Add field') }}</button>
|
||||
</div>
|
||||
|
||||
@if($errors->any())
|
||||
<div class="alert alert-error text-sm mt-3">
|
||||
<ul class="list-disc pl-3 space-y-0.5">
|
||||
@foreach($errors->all() as $e) <li>{{ $e }}</li> @endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
<div class="flex gap-2 mt-4">
|
||||
<button type="submit" class="btn btn-primary">{{ __('Save template') }}</button>
|
||||
<button type="submit" class="btn btn-primary">{{ $editingTemplate ? __('Update') : __('Save template') }}</button>
|
||||
<button type="button" wire:click="cancelForm" class="btn">{{ __('Cancel') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -135,11 +127,11 @@
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nombre</th>
|
||||
<th>Descripción</th>
|
||||
<th>Fase</th>
|
||||
<th>Campos</th>
|
||||
<th>Acciones</th>
|
||||
<th>{{ __('Name') }}</th>
|
||||
<th>{{ __('Description') }}</th>
|
||||
<th>{{ __('Phase') }}</th>
|
||||
<th>{{ __('Fields') }}</th>
|
||||
<th>{{ __('Actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -147,18 +139,20 @@
|
||||
<tr>
|
||||
<td>{{ $template->name }}</td>
|
||||
<td>{{ $template->description ?? '-' }}</td>
|
||||
<td>{{ $template->phase ? $template->phase->name : 'Global' }}</td>
|
||||
<td>{{ $template->phase ? $template->phase->name : __('Global project') }}</td>
|
||||
<td>{{ count($template->fields) }}</td>
|
||||
<td>
|
||||
<button wire:click="editTemplate({{ $template->id }})" class="btn btn-xs btn-warning">
|
||||
Editar
|
||||
{{ __('Edit') }}
|
||||
</button>
|
||||
<button wire:click="deleteTemplate({{ $template->id }})" class="btn btn-xs btn-error" onclick="return confirm('¿Eliminar template?')">Eliminar</button>
|
||||
<button wire:click="deleteTemplate({{ $template->id }})"
|
||||
wire:confirm="{{ __('Delete template confirmation') }}"
|
||||
class="btn btn-xs btn-error">{{ __('Delete') }}</button>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="5" class="text-center">No hay templates creados. Presiona "Nuevo template" para comenzar.</td>
|
||||
<td colspan="5" class="text-center">{{ __('No templates yet (table)') }}</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
</x-slot>
|
||||
|
||||
<div class="py-8">
|
||||
<div class="max-w-3xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
|
||||
@if(session('notify'))
|
||||
<div class="alert alert-success mb-4">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<div>
|
||||
<x-slot name="header">
|
||||
<x-slot name="header">
|
||||
|
||||
{{-- ── Header del usuario ───────────────────────────────────────────── --}}
|
||||
<div class="flex items-start justify-between gap-4 flex-wrap">
|
||||
@@ -124,11 +124,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</x-slot>
|
||||
</x-slot>
|
||||
|
||||
{{-- ── Tabs ──────────────────────────────────────────────────────────────── --}}
|
||||
<div class="py-6">
|
||||
<div class="max-w-5xl mx-auto sm:px-6 lg:px-8 space-y-4">
|
||||
{{-- ── Tabs ──────────────────────────────────────────────────────────────── --}}
|
||||
<div class="py-6">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-4">
|
||||
|
||||
<div role="tablist" class="tabs tabs-bordered">
|
||||
<button role="tab" wire:click="setTab('permissions')"
|
||||
@@ -547,6 +547,6 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
<x-app-layout>
|
||||
<div class="max-w-2xl mx-auto p-4">
|
||||
<h1 class="text-2xl font-bold mb-4">{{ __('Create Project') }}</h1>
|
||||
<form action="{{ route('projects.store') }}" method="POST" class="space-y-4">
|
||||
@csrf
|
||||
<div>
|
||||
<label class="label">{{ __('Project name') }}</label>
|
||||
<input type="text" name="name" class="input input-bordered w-full" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">{{ __('Address') }}</label>
|
||||
<input type="text" name="address" class="input input-bordered w-full" required>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="label">{{ __('Latitude') }}</label>
|
||||
<input type="number" step="any" name="lat" class="input input-bordered w-full" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">{{ __('Longitude') }}</label>
|
||||
<input type="number" step="any" name="lng" class="input input-bordered w-full" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="label">{{ __('Start date') }}</label>
|
||||
<input type="date" name="start_date" class="input input-bordered w-full" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">{{ __('Estimated end date') }}</label>
|
||||
<input type="date" name="end_date_estimated" class="input input-bordered w-full">
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-full">{{ __('Create') }} {{ __('Project') }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -1,157 +0,0 @@
|
||||
<x-app-layout>
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<div x-data="{ tabActivo: 1 }">
|
||||
<div role="tablist" class="tabs tabs-border">
|
||||
<a role="tab" class="tab" :class="{ 'tab-active': tabActivo === 1 }" @click.prevent="tabActivo = 1">{{ __("Project Data") }}</a>
|
||||
<a role="tab" class="tab" :class="{ 'tab-active': tabActivo === 2 }" @click.prevent="tabActivo = 2">{{ __("Phases") }}</a>
|
||||
<a role="tab" class="tab" :class="{ 'tab-active': tabActivo === 3 }" @click.prevent="tabActivo = 3">{{ __("Users") }}</a>
|
||||
<a role="tab" class="tab" :class="{ 'tab-active': tabActivo === 4 }" @click.prevent="tabActivo = 4">{{ __("Companies") }}</a>
|
||||
</div>
|
||||
|
||||
<!-- Los contenedores del contenido -->
|
||||
<div class="py-4" x-show="tabActivo === 1">
|
||||
<h1 class="text-2xl font-bold mb-4">{{ __('Edit Project') }}: {{ $project->name }}</h1>
|
||||
<form action="{{ route('projects.update', $project) }}" method="POST" class="space-y-4">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
|
||||
<table class="w-full mb-8">
|
||||
<tbody>
|
||||
{{-- Nombre --}}
|
||||
<tr>
|
||||
<td class="w-1/4 py-3 pr-4 align-bootom font-bold">
|
||||
{{ __('Reference') }}
|
||||
</td>
|
||||
<td class="py-3">
|
||||
<input type="text"
|
||||
name="reference"
|
||||
value="{{ old('reference', $project->reference) }}"
|
||||
class="input w-64"
|
||||
required
|
||||
autofocus>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{{-- Nombre --}}
|
||||
<tr>
|
||||
<td class="w-1/4 py-3 pr-4 align-bootom font-bold">
|
||||
{{ __('Name') }}
|
||||
</td>
|
||||
<td class="py-3">
|
||||
<input type="text"
|
||||
name="name"
|
||||
value="{{ old('name', $project->name) }}"
|
||||
class="input w-64"
|
||||
required
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{{-- Dirección --}}
|
||||
<tr>
|
||||
<td class="w-1/4 py-3 pr-4 align-bottom font-bold">
|
||||
{{ __('Address') }}
|
||||
</td>
|
||||
<td class="py-3">
|
||||
<input type="text"
|
||||
name="address"
|
||||
value="{{ old('address', $project->address) }}"
|
||||
class="input w-1/2"
|
||||
required>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{{-- Coordenadas --}}
|
||||
<tr>
|
||||
<td class="w-1/4 py-3 pr-4 align-bottom font-bold">
|
||||
{{ __('Coordinates') }}
|
||||
</td>
|
||||
<td class="py-3">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="label">{{ __('Latitude') }}</label>
|
||||
<input type="number"
|
||||
step="any"
|
||||
name="lat"
|
||||
value="{{ old('lat', $project->lat) }}"
|
||||
class="input w-64"
|
||||
required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">{{ __('Longitude') }}</label>
|
||||
<input type="number"
|
||||
step="any"
|
||||
name="lng"
|
||||
value="{{ old('lng', $project->lng) }}"
|
||||
class="input w-64"
|
||||
required>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{{-- Estatus --}}
|
||||
<tr>
|
||||
<td class="w-1/4 py-3 pr-4 align-bottom ">
|
||||
{{ __('Status') }}
|
||||
</td>
|
||||
<td class="py-3">
|
||||
<select name="status" class="select select-bordered w-full">
|
||||
<option value="planning" @selected($project->status == 'planning')>{{ __('Planning') }}</option>
|
||||
<option value="in_progress" @selected($project->status == 'in_progress')>{{ __('In progress') }}</option>
|
||||
<option value="paused" @selected($project->status == 'paused')>{{ __('Paused') }}</option>
|
||||
<option value="completed" @selected($project->status == 'completed')>{{ __('Completed') }}</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{{-- Fecha de Inicio --}}
|
||||
<tr>
|
||||
<td class="w-1/4 py-3 pr-4 align-bottom font-bold">
|
||||
{{ __('Start date') }}
|
||||
</td>
|
||||
<td class="py-3">
|
||||
<input type="date"
|
||||
name="start_date"
|
||||
value="{{ old('start_date', $project->start_date->format('Y-m-d')) }}"
|
||||
class="input w-64"
|
||||
required>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{{-- Fechas de finalización --}}
|
||||
<tr>
|
||||
<td class="w-1/4 py-3 pr-4 align-bottom">
|
||||
{{ __('Estimated end date') }}
|
||||
</td>
|
||||
<td class="py-3">
|
||||
<input type="date"
|
||||
name="end_date_estimated"
|
||||
value="{{ old('end_date_estimated', $project->end_date_estimated?->format('Y-m-d')) }}"
|
||||
class="input w-64">
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<button type="submit" class="btn btn-primary">{{ __('Update') }}</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="py-4" x-show="tabActivo === 2">
|
||||
<h2 class="text-xl font-bold mb-2">{{ __('Phases') }}</h2>
|
||||
<livewire:phase-list :project="$project" />
|
||||
</div>
|
||||
<div class="py-4" x-show="tabActivo === 3">
|
||||
|
||||
<h2 class="text-xl font-bold mb-2">{{ __('Users') }}</h2>
|
||||
<livewire:project-users :project="$project" />
|
||||
</div>
|
||||
<div class="py-4" x-show="tabActivo === 4">
|
||||
<h2 class="text-xl font-bold mb-2">{{ __('Companies') }}</h2>
|
||||
<livewire:project-companies :project="$project" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -13,7 +13,7 @@
|
||||
</div>
|
||||
</x-slot>
|
||||
|
||||
<div class="py-8">
|
||||
<div class="py-12">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
@if(session('success'))
|
||||
<div class="alert alert-success mb-4 shadow">
|
||||
@@ -21,7 +21,7 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="bg-white rounded-xl shadow">
|
||||
<div class="bg-white rounded-xl shadow p-6">
|
||||
<livewire:project-table />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
<title>Laravel</title>
|
||||
|
||||
<!-- Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||
<link href="https://fonts.bunny.net/css?family=figtree:400,600&display=swap" rel="stylesheet" />
|
||||
|
||||
<!-- Styles -->
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
</head>
|
||||
<body class="antialiased font-sans">
|
||||
<div class="bg-gray-50 text-black/50 dark:bg-black dark:text-white/50">
|
||||
<img id="background" class="absolute -left-20 top-0 max-w-[877px]" src="https://laravel.com/assets/img/welcome/background.svg" />
|
||||
<div class="relative min-h-screen flex flex-col items-center justify-center selection:bg-[#FF2D20] selection:text-white">
|
||||
<div class="relative w-full max-w-2xl px-6 lg:max-w-7xl">
|
||||
<header class="grid grid-cols-2 items-center gap-2 py-10 lg:grid-cols-3">
|
||||
<div class="flex lg:justify-center lg:col-start-2">
|
||||
<svg class="h-12 w-auto text-white lg:h-16 lg:text-[#FF2D20]" viewBox="0 0 62 65" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M61.8548 14.6253C61.8778 14.7102 61.8895 14.7978 61.8897 14.8858V28.5615C61.8898 28.737 61.8434 28.9095 61.7554 29.0614C61.6675 29.2132 61.5409 29.3392 61.3887 29.4265L49.9104 36.0351V49.1337C49.9104 49.4902 49.7209 49.8192 49.4118 49.9987L25.4519 63.7916C25.3971 63.8227 25.3372 63.8427 25.2774 63.8639C25.255 63.8714 25.2338 63.8851 25.2101 63.8913C25.0426 63.9354 24.8666 63.9354 24.6991 63.8913C24.6716 63.8838 24.6467 63.8689 24.6205 63.8589C24.5657 63.8389 24.5084 63.8215 24.456 63.7916L0.501061 49.9987C0.348882 49.9113 0.222437 49.7853 0.134469 49.6334C0.0465019 49.4816 0.000120578 49.3092 0 49.1337L0 8.10652C0 8.01678 0.0124642 7.92953 0.0348998 7.84477C0.0423783 7.8161 0.0598282 7.78993 0.0697995 7.76126C0.0884958 7.70891 0.105946 7.65531 0.133367 7.6067C0.152063 7.5743 0.179485 7.54812 0.20192 7.51821C0.230588 7.47832 0.256763 7.43719 0.290416 7.40229C0.319084 7.37362 0.356476 7.35243 0.388883 7.32751C0.425029 7.29759 0.457436 7.26518 0.498568 7.2415L12.4779 0.345059C12.6296 0.257786 12.8015 0.211853 12.9765 0.211853C13.1515 0.211853 13.3234 0.257786 13.475 0.345059L25.4531 7.2415H25.4556C25.4955 7.26643 25.5292 7.29759 25.5653 7.32626C25.5977 7.35119 25.6339 7.37362 25.6625 7.40104C25.6974 7.43719 25.7224 7.47832 25.7523 7.51821C25.7735 7.54812 25.8021 7.5743 25.8196 7.6067C25.8483 7.65656 25.8645 7.70891 25.8844 7.76126C25.8944 7.78993 25.9118 7.8161 25.9193 7.84602C25.9423 7.93096 25.954 8.01853 25.9542 8.10652V33.7317L35.9355 27.9844V14.8846C35.9355 14.7973 35.948 14.7088 35.9704 14.6253C35.9792 14.5954 35.9954 14.5692 36.0053 14.5405C36.0253 14.4882 36.0427 14.4346 36.0702 14.386C36.0888 14.3536 36.1163 14.3274 36.1375 14.2975C36.1674 14.2576 36.1923 14.2165 36.2272 14.1816C36.2559 14.1529 36.292 14.1317 36.3244 14.1068C36.3618 14.0769 36.3942 14.0445 36.4341 14.0208L48.4147 7.12434C48.5663 7.03694 48.7383 6.99094 48.9133 6.99094C49.0883 6.99094 49.2602 7.03694 49.4118 7.12434L61.3899 14.0208C61.4323 14.0457 61.4647 14.0769 61.5021 14.1055C61.5333 14.1305 61.5694 14.1529 61.5981 14.1803C61.633 14.2165 61.6579 14.2576 61.6878 14.2975C61.7103 14.3274 61.7377 14.3536 61.7551 14.386C61.7838 14.4346 61.8 14.4882 61.8199 14.5405C61.8312 14.5692 61.8474 14.5954 61.8548 14.6253ZM59.893 27.9844V16.6121L55.7013 19.0252L49.9104 22.3593V33.7317L59.8942 27.9844H59.893ZM47.9149 48.5566V37.1768L42.2187 40.4299L25.953 49.7133V61.2003L47.9149 48.5566ZM1.99677 9.83281V48.5566L23.9562 61.199V49.7145L12.4841 43.2219L12.4804 43.2194L12.4754 43.2169C12.4368 43.1945 12.4044 43.1621 12.3682 43.1347C12.3371 43.1097 12.3009 43.0898 12.2735 43.0624L12.271 43.0586C12.2386 43.0275 12.2162 42.9888 12.1887 42.9539C12.1638 42.9203 12.1339 42.8916 12.114 42.8567L12.1127 42.853C12.0903 42.8156 12.0766 42.7707 12.0604 42.7283C12.0442 42.6909 12.023 42.656 12.013 42.6161C12.0005 42.5688 11.998 42.5177 11.9931 42.4691C11.9881 42.4317 11.9781 42.3943 11.9781 42.3569V15.5801L6.18848 12.2446L1.99677 9.83281ZM12.9777 2.36177L2.99764 8.10652L12.9752 13.8513L22.9541 8.10527L12.9752 2.36177H12.9777ZM18.1678 38.2138L23.9574 34.8809V9.83281L19.7657 12.2459L13.9749 15.5801V40.6281L18.1678 38.2138ZM48.9133 9.14105L38.9344 14.8858L48.9133 20.6305L58.8909 14.8846L48.9133 9.14105ZM47.9149 22.3593L42.124 19.0252L37.9323 16.6121V27.9844L43.7219 31.3174L47.9149 33.7317V22.3593ZM24.9533 47.987L39.59 39.631L46.9065 35.4555L36.9352 29.7145L25.4544 36.3242L14.9907 42.3482L24.9533 47.987Z" fill="currentColor"/></svg>
|
||||
</div>
|
||||
@if (Route::has('login'))
|
||||
<livewire:welcome.navigation />
|
||||
@endif
|
||||
</header>
|
||||
|
||||
<main class="mt-6">
|
||||
<div class="grid gap-6 lg:grid-cols-2 lg:gap-8">
|
||||
<a
|
||||
href="https://laravel.com/docs"
|
||||
id="docs-card"
|
||||
class="flex flex-col items-start gap-6 overflow-hidden rounded-lg bg-white p-6 shadow-[0px_14px_34px_0px_rgba(0,0,0,0.08)] ring-1 ring-white/[0.05] transition duration-300 hover:text-black/70 hover:ring-black/20 focus:outline-none focus-visible:ring-[#FF2D20] md:row-span-3 lg:p-10 lg:pb-10 dark:bg-zinc-900 dark:ring-zinc-800 dark:hover:text-white/70 dark:hover:ring-zinc-700 dark:focus-visible:ring-[#FF2D20]"
|
||||
>
|
||||
<div id="screenshot-container" class="relative flex w-full flex-1 items-stretch">
|
||||
<img
|
||||
src="https://laravel.com/assets/img/welcome/docs-light.svg"
|
||||
alt="Laravel documentation screenshot"
|
||||
class="aspect-video h-full w-full flex-1 rounded-[10px] object-top object-cover drop-shadow-[0px_4px_34px_rgba(0,0,0,0.06)] dark:hidden"
|
||||
onerror="
|
||||
document.getElementById('screenshot-container').classList.add('!hidden');
|
||||
document.getElementById('docs-card').classList.add('!row-span-1');
|
||||
document.getElementById('docs-card-content').classList.add('!flex-row');
|
||||
document.getElementById('background').classList.add('!hidden');
|
||||
"
|
||||
/>
|
||||
<img
|
||||
src="https://laravel.com/assets/img/welcome/docs-dark.svg"
|
||||
alt="Laravel documentation screenshot"
|
||||
class="hidden aspect-video h-full w-full flex-1 rounded-[10px] object-top object-cover drop-shadow-[0px_4px_34px_rgba(0,0,0,0.25)] dark:block"
|
||||
/>
|
||||
<div
|
||||
class="absolute -bottom-16 -left-16 h-40 w-[calc(100%+8rem)] bg-gradient-to-b from-transparent via-white to-white dark:via-zinc-900 dark:to-zinc-900"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div class="relative flex items-center gap-6 lg:items-end">
|
||||
<div id="docs-card-content" class="flex items-start gap-6 lg:flex-col">
|
||||
<div class="flex size-12 shrink-0 items-center justify-center rounded-full bg-[#FF2D20]/10 sm:size-16">
|
||||
<svg class="size-5 sm:size-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#FF2D20" d="M23 4a1 1 0 0 0-1.447-.894L12.224 7.77a.5.5 0 0 1-.448 0L2.447 3.106A1 1 0 0 0 1 4v13.382a1.99 1.99 0 0 0 1.105 1.79l9.448 4.728c.14.065.293.1.447.1.154-.005.306-.04.447-.105l9.453-4.724a1.99 1.99 0 0 0 1.1-1.789V4ZM3 6.023a.25.25 0 0 1 .362-.223l7.5 3.75a.251.251 0 0 1 .138.223v11.2a.25.25 0 0 1-.362.224l-7.5-3.75a.25.25 0 0 1-.138-.22V6.023Zm18 11.2a.25.25 0 0 1-.138.224l-7.5 3.75a.249.249 0 0 1-.329-.099.249.249 0 0 1-.033-.12V9.772a.251.251 0 0 1 .138-.224l7.5-3.75a.25.25 0 0 1 .362.224v11.2Z"/><path fill="#FF2D20" d="m3.55 1.893 8 4.048a1.008 1.008 0 0 0 .9 0l8-4.048a1 1 0 0 0-.9-1.785l-7.322 3.706a.506.506 0 0 1-.452 0L4.454.108a1 1 0 0 0-.9 1.785H3.55Z"/></svg>
|
||||
</div>
|
||||
|
||||
<div class="pt-3 sm:pt-5 lg:pt-0">
|
||||
<h2 class="text-xl font-semibold text-black dark:text-white">Documentation</h2>
|
||||
|
||||
<p class="mt-4 text-sm/relaxed">
|
||||
Laravel has wonderful documentation covering every aspect of the framework. Whether you are a newcomer or have prior experience with Laravel, we recommend reading our documentation from beginning to end.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<svg class="size-6 shrink-0 stroke-[#FF2D20]" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12h15m0 0l-6.75-6.75M19.5 12l-6.75 6.75"/></svg>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://laracasts.com"
|
||||
class="flex items-start gap-4 rounded-lg bg-white p-6 shadow-[0px_14px_34px_0px_rgba(0,0,0,0.08)] ring-1 ring-white/[0.05] transition duration-300 hover:text-black/70 hover:ring-black/20 focus:outline-none focus-visible:ring-[#FF2D20] lg:pb-10 dark:bg-zinc-900 dark:ring-zinc-800 dark:hover:text-white/70 dark:hover:ring-zinc-700 dark:focus-visible:ring-[#FF2D20]"
|
||||
>
|
||||
<div class="flex size-12 shrink-0 items-center justify-center rounded-full bg-[#FF2D20]/10 sm:size-16">
|
||||
<svg class="size-5 sm:size-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><g fill="#FF2D20"><path d="M24 8.25a.5.5 0 0 0-.5-.5H.5a.5.5 0 0 0-.5.5v12a2.5 2.5 0 0 0 2.5 2.5h19a2.5 2.5 0 0 0 2.5-2.5v-12Zm-7.765 5.868a1.221 1.221 0 0 1 0 2.264l-6.626 2.776A1.153 1.153 0 0 1 8 18.123v-5.746a1.151 1.151 0 0 1 1.609-1.035l6.626 2.776ZM19.564 1.677a.25.25 0 0 0-.177-.427H15.6a.106.106 0 0 0-.072.03l-4.54 4.543a.25.25 0 0 0 .177.427h3.783c.027 0 .054-.01.073-.03l4.543-4.543ZM22.071 1.318a.047.047 0 0 0-.045.013l-4.492 4.492a.249.249 0 0 0 .038.385.25.25 0 0 0 .14.042h5.784a.5.5 0 0 0 .5-.5v-2a2.5 2.5 0 0 0-1.925-2.432ZM13.014 1.677a.25.25 0 0 0-.178-.427H9.101a.106.106 0 0 0-.073.03l-4.54 4.543a.25.25 0 0 0 .177.427H8.4a.106.106 0 0 0 .073-.03l4.54-4.543ZM6.513 1.677a.25.25 0 0 0-.177-.427H2.5A2.5 2.5 0 0 0 0 3.75v2a.5.5 0 0 0 .5.5h1.4a.106.106 0 0 0 .073-.03l4.54-4.543Z"/></g></svg>
|
||||
</div>
|
||||
|
||||
<div class="pt-3 sm:pt-5">
|
||||
<h2 class="text-xl font-semibold text-black dark:text-white">Laracasts</h2>
|
||||
|
||||
<p class="mt-4 text-sm/relaxed">
|
||||
Laracasts offers thousands of video tutorials on Laravel, PHP, and JavaScript development. Check them out, see for yourself, and massively level up your development skills in the process.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<svg class="size-6 shrink-0 self-center stroke-[#FF2D20]" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12h15m0 0l-6.75-6.75M19.5 12l-6.75 6.75"/></svg>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://laravel-news.com"
|
||||
class="flex items-start gap-4 rounded-lg bg-white p-6 shadow-[0px_14px_34px_0px_rgba(0,0,0,0.08)] ring-1 ring-white/[0.05] transition duration-300 hover:text-black/70 hover:ring-black/20 focus:outline-none focus-visible:ring-[#FF2D20] lg:pb-10 dark:bg-zinc-900 dark:ring-zinc-800 dark:hover:text-white/70 dark:hover:ring-zinc-700 dark:focus-visible:ring-[#FF2D20]"
|
||||
>
|
||||
<div class="flex size-12 shrink-0 items-center justify-center rounded-full bg-[#FF2D20]/10 sm:size-16">
|
||||
<svg class="size-5 sm:size-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><g fill="#FF2D20"><path d="M8.75 4.5H5.5c-.69 0-1.25.56-1.25 1.25v4.75c0 .69.56 1.25 1.25 1.25h3.25c.69 0 1.25-.56 1.25-1.25V5.75c0-.69-.56-1.25-1.25-1.25Z"/><path d="M24 10a3 3 0 0 0-3-3h-2V2.5a2 2 0 0 0-2-2H2a2 2 0 0 0-2 2V20a3.5 3.5 0 0 0 3.5 3.5h17A3.5 3.5 0 0 0 24 20V10ZM3.5 21.5A1.5 1.5 0 0 1 2 20V3a.5.5 0 0 1 .5-.5h14a.5.5 0 0 1 .5.5v17c0 .295.037.588.11.874a.5.5 0 0 1-.484.625L3.5 21.5ZM22 20a1.5 1.5 0 1 1-3 0V9.5a.5.5 0 0 1 .5-.5H21a1 1 0 0 1 1 1v10Z"/><path d="M12.751 6.047h2a.75.75 0 0 1 .75.75v.5a.75.75 0 0 1-.75.75h-2A.75.75 0 0 1 12 7.3v-.5a.75.75 0 0 1 .751-.753ZM12.751 10.047h2a.75.75 0 0 1 .75.75v.5a.75.75 0 0 1-.75.75h-2A.75.75 0 0 1 12 11.3v-.5a.75.75 0 0 1 .751-.753ZM4.751 14.047h10a.75.75 0 0 1 .75.75v.5a.75.75 0 0 1-.75.75h-10A.75.75 0 0 1 4 15.3v-.5a.75.75 0 0 1 .751-.753ZM4.75 18.047h7.5a.75.75 0 0 1 .75.75v.5a.75.75 0 0 1-.75.75h-7.5A.75.75 0 0 1 4 19.3v-.5a.75.75 0 0 1 .75-.753Z"/></g></svg>
|
||||
</div>
|
||||
|
||||
<div class="pt-3 sm:pt-5">
|
||||
<h2 class="text-xl font-semibold text-black dark:text-white">Laravel News</h2>
|
||||
|
||||
<p class="mt-4 text-sm/relaxed">
|
||||
Laravel News is a community driven portal and newsletter aggregating all of the latest and most important news in the Laravel ecosystem, including new package releases and tutorials.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<svg class="size-6 shrink-0 self-center stroke-[#FF2D20]" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12h15m0 0l-6.75-6.75M19.5 12l-6.75 6.75"/></svg>
|
||||
</a>
|
||||
|
||||
<div class="flex items-start gap-4 rounded-lg bg-white p-6 shadow-[0px_14px_34px_0px_rgba(0,0,0,0.08)] ring-1 ring-white/[0.05] lg:pb-10 dark:bg-zinc-900 dark:ring-zinc-800">
|
||||
<div class="flex size-12 shrink-0 items-center justify-center rounded-full bg-[#FF2D20]/10 sm:size-16">
|
||||
<svg class="size-5 sm:size-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<g fill="#FF2D20">
|
||||
<path
|
||||
d="M16.597 12.635a.247.247 0 0 0-.08-.237 2.234 2.234 0 0 1-.769-1.68c.001-.195.03-.39.084-.578a.25.25 0 0 0-.09-.267 8.8 8.8 0 0 0-4.826-1.66.25.25 0 0 0-.268.181 2.5 2.5 0 0 1-2.4 1.824.045.045 0 0 0-.045.037 12.255 12.255 0 0 0-.093 3.86.251.251 0 0 0 .208.214c2.22.366 4.367 1.08 6.362 2.118a.252.252 0 0 0 .32-.079 10.09 10.09 0 0 0 1.597-3.733ZM13.616 17.968a.25.25 0 0 0-.063-.407A19.697 19.697 0 0 0 8.91 15.98a.25.25 0 0 0-.287.325c.151.455.334.898.548 1.328.437.827.981 1.594 1.619 2.28a.249.249 0 0 0 .32.044 29.13 29.13 0 0 0 2.506-1.99ZM6.303 14.105a.25.25 0 0 0 .265-.274 13.048 13.048 0 0 1 .205-4.045.062.062 0 0 0-.022-.07 2.5 2.5 0 0 1-.777-.982.25.25 0 0 0-.271-.149 11 11 0 0 0-5.6 2.815.255.255 0 0 0-.075.163c-.008.135-.02.27-.02.406.002.8.084 1.598.246 2.381a.25.25 0 0 0 .303.193 19.924 19.924 0 0 1 5.746-.438ZM9.228 20.914a.25.25 0 0 0 .1-.393 11.53 11.53 0 0 1-1.5-2.22 12.238 12.238 0 0 1-.91-2.465.248.248 0 0 0-.22-.187 18.876 18.876 0 0 0-5.69.33.249.249 0 0 0-.179.336c.838 2.142 2.272 4 4.132 5.353a.254.254 0 0 0 .15.048c1.41-.01 2.807-.282 4.117-.802ZM18.93 12.957l-.005-.008a.25.25 0 0 0-.268-.082 2.21 2.21 0 0 1-.41.081.25.25 0 0 0-.217.2c-.582 2.66-2.127 5.35-5.75 7.843a.248.248 0 0 0-.09.299.25.25 0 0 0 .065.091 28.703 28.703 0 0 0 2.662 2.12.246.246 0 0 0 .209.037c2.579-.701 4.85-2.242 6.456-4.378a.25.25 0 0 0 .048-.189 13.51 13.51 0 0 0-2.7-6.014ZM5.702 7.058a.254.254 0 0 0 .2-.165A2.488 2.488 0 0 1 7.98 5.245a.093.093 0 0 0 .078-.062 19.734 19.734 0 0 1 3.055-4.74.25.25 0 0 0-.21-.41 12.009 12.009 0 0 0-10.4 8.558.25.25 0 0 0 .373.281 12.912 12.912 0 0 1 4.826-1.814ZM10.773 22.052a.25.25 0 0 0-.28-.046c-.758.356-1.55.635-2.365.833a.25.25 0 0 0-.022.48c1.252.43 2.568.65 3.893.65.1 0 .2 0 .3-.008a.25.25 0 0 0 .147-.444c-.526-.424-1.1-.917-1.673-1.465ZM18.744 8.436a.249.249 0 0 0 .15.228 2.246 2.246 0 0 1 1.352 2.054c0 .337-.08.67-.23.972a.25.25 0 0 0 .042.28l.007.009a15.016 15.016 0 0 1 2.52 4.6.25.25 0 0 0 .37.132.25.25 0 0 0 .096-.114c.623-1.464.944-3.039.945-4.63a12.005 12.005 0 0 0-5.78-10.258.25.25 0 0 0-.373.274c.547 2.109.85 4.274.901 6.453ZM9.61 5.38a.25.25 0 0 0 .08.31c.34.24.616.561.8.935a.25.25 0 0 0 .3.127.631.631 0 0 1 .206-.034c2.054.078 4.036.772 5.69 1.991a.251.251 0 0 0 .267.024c.046-.024.093-.047.141-.067a.25.25 0 0 0 .151-.23A29.98 29.98 0 0 0 15.957.764a.25.25 0 0 0-.16-.164 11.924 11.924 0 0 0-2.21-.518.252.252 0 0 0-.215.076A22.456 22.456 0 0 0 9.61 5.38Z"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="pt-3 sm:pt-5">
|
||||
<h2 class="text-xl font-semibold text-black dark:text-white">Vibrant Ecosystem</h2>
|
||||
|
||||
<p class="mt-4 text-sm/relaxed">
|
||||
Laravel's robust library of first-party tools and libraries, such as <a href="https://forge.laravel.com" class="rounded-sm underline hover:text-black focus:outline-none focus-visible:ring-1 focus-visible:ring-[#FF2D20] dark:hover:text-white dark:focus-visible:ring-[#FF2D20]">Forge</a>, <a href="https://vapor.laravel.com" class="rounded-sm underline hover:text-black focus:outline-none focus-visible:ring-1 focus-visible:ring-[#FF2D20] dark:hover:text-white">Vapor</a>, <a href="https://nova.laravel.com" class="rounded-sm underline hover:text-black focus:outline-none focus-visible:ring-1 focus-visible:ring-[#FF2D20] dark:hover:text-white">Nova</a>, <a href="https://envoyer.io" class="rounded-sm underline hover:text-black focus:outline-none focus-visible:ring-1 focus-visible:ring-[#FF2D20] dark:hover:text-white">Envoyer</a>, and <a href="https://herd.laravel.com" class="rounded-sm underline hover:text-black focus:outline-none focus-visible:ring-1 focus-visible:ring-[#FF2D20] dark:hover:text-white">Herd</a> help you take your projects to the next level. Pair them with powerful open source libraries like <a href="https://laravel.com/docs/billing" class="rounded-sm underline hover:text-black focus:outline-none focus-visible:ring-1 focus-visible:ring-[#FF2D20] dark:hover:text-white">Cashier</a>, <a href="https://laravel.com/docs/dusk" class="rounded-sm underline hover:text-black focus:outline-none focus-visible:ring-1 focus-visible:ring-[#FF2D20] dark:hover:text-white">Dusk</a>, <a href="https://laravel.com/docs/broadcasting" class="rounded-sm underline hover:text-black focus:outline-none focus-visible:ring-1 focus-visible:ring-[#FF2D20] dark:hover:text-white">Echo</a>, <a href="https://laravel.com/docs/horizon" class="rounded-sm underline hover:text-black focus:outline-none focus-visible:ring-1 focus-visible:ring-[#FF2D20] dark:hover:text-white">Horizon</a>, <a href="https://laravel.com/docs/sanctum" class="rounded-sm underline hover:text-black focus:outline-none focus-visible:ring-1 focus-visible:ring-[#FF2D20] dark:hover:text-white">Sanctum</a>, <a href="https://laravel.com/docs/telescope" class="rounded-sm underline hover:text-black focus:outline-none focus-visible:ring-1 focus-visible:ring-[#FF2D20] dark:hover:text-white">Telescope</a>, and more.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="py-16 text-center text-sm text-black dark:text-white/70">
|
||||
Laravel v{{ Illuminate\Foundation\Application::VERSION }} (PHP v{{ PHP_VERSION }})
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
+9
-16
@@ -9,16 +9,6 @@ use App\Livewire\ProjectList;
|
||||
use App\Livewire\PhaseProgress;
|
||||
use App\Livewire\PhaseGantt;
|
||||
use App\Http\Controllers\ProjectReportController;
|
||||
|
||||
use App\Http\Controllers\Auth\AuthenticatedSessionController;
|
||||
use App\Http\Controllers\Auth\ConfirmablePasswordController;
|
||||
use App\Http\Controllers\Auth\EmailVerificationNotificationController;
|
||||
use App\Http\Controllers\Auth\EmailVerificationPromptController;
|
||||
use App\Http\Controllers\Auth\NewPasswordController;
|
||||
use App\Http\Controllers\Auth\PasswordController;
|
||||
use App\Http\Controllers\Auth\PasswordResetLinkController;
|
||||
use App\Http\Controllers\Auth\RegisteredUserController;
|
||||
use App\Http\Controllers\Auth\VerifyEmailController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
/*
|
||||
@@ -96,9 +86,8 @@ Route::middleware(['auth'])->group(function () {
|
||||
]);
|
||||
})->name('dashboard');
|
||||
|
||||
// Reports — Admin only
|
||||
Route::middleware(['can:manage all'])->prefix('reports')->name('reports.')->group(function () {
|
||||
Route::get('/dashboard', ReportsDashboard::class)->name('dashboard');
|
||||
Route::get('/reports/dashboard', ReportsDashboard::class)->name('reports.dashboard');
|
||||
Route::prefix('reports')->name('reports.')->group(function () {
|
||||
Route::get('export/projects', [App\Http\Controllers\Reports\ExportController::class, 'exportProjects'])->name('export.projects');
|
||||
Route::get('export/phases', [App\Http\Controllers\Reports\ExportController::class, 'exportPhases'])->name('export.phases');
|
||||
Route::get('export/inspections', [App\Http\Controllers\Reports\ExportController::class, 'exportInspections'])->name('export.inspections');
|
||||
@@ -147,6 +136,11 @@ Route::middleware(['auth'])->group(function () {
|
||||
Route::get('/users/create', \App\Livewire\UserForm::class)->name('users.create');
|
||||
Route::get('/users/{user}', \App\Livewire\UserView::class)->name('users.show');
|
||||
Route::get('/users/{user}/edit', \App\Livewire\UserForm::class)->name('users.edit');
|
||||
Route::get('/roles', function () { return view('admin.roles'); })->name('roles');
|
||||
Route::get('/roles/create', \App\Livewire\RoleForm::class)->name('roles.create');
|
||||
Route::get('/roles/{role}/edit', \App\Livewire\RoleForm::class)->name('roles.edit');
|
||||
Route::get('/roles/{role}', \App\Livewire\RoleView::class)->name('roles.show');
|
||||
Route::get('/permissions', \App\Livewire\RolePermissionManager::class)->name('permissions');
|
||||
});
|
||||
|
||||
// Gestor de medios
|
||||
@@ -171,9 +165,8 @@ Route::middleware(['auth'])->group(function () {
|
||||
//Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
|
||||
Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');
|
||||
Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
|
||||
Route::post('/logout', [AuthenticatedSessionController::class, 'destroy'])
|
||||
->name('logout');
|
||||
|
||||
// Logout se gestiona vía la acción Volt en el componente de navegación
|
||||
// (App\Livewire\Actions\Logout), por lo que no hace falta una ruta /logout.
|
||||
});
|
||||
|
||||
// Incluir rutas de autenticación (login, registro, recuperación de contraseña, logout)
|
||||
|
||||
Reference in New Issue
Block a user