Compare commits
31 Commits
ee3086c34b
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ba363e7e18 | |||
| 13f36e8ec0 | |||
| 8025fa6d05 | |||
| efccb67635 | |||
| 0120c4bfb8 | |||
| 7f20399337 | |||
| 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,27 +13,15 @@ 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',
|
||||
'action' => 'required|in:progress_update,inspection,feature_create,media_upload,task_complete',
|
||||
'payload' => 'required|array',
|
||||
]);
|
||||
PendingSync::create([
|
||||
'user_id' => Auth::id(),
|
||||
'action' => $payload['action'],
|
||||
$pending = PendingSync::create([
|
||||
'user_id' => Auth::id() ?? 1,
|
||||
'action' => $payload['action'],
|
||||
'payload' => $payload['payload'],
|
||||
]);
|
||||
return response()->json(['queued' => true]);
|
||||
@@ -41,114 +29,68 @@ class OfflineSyncController extends Controller
|
||||
|
||||
public function sync(Request $request)
|
||||
{
|
||||
$user = Auth::user();
|
||||
$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->save();
|
||||
$phase->progressUpdates()->create([
|
||||
'user_id' => $user->id,
|
||||
'progress_percent' => $progress,
|
||||
'comment' => substr($pending->payload['comment'] ?? '', 0, 500),
|
||||
]);
|
||||
$result['success'] = true;
|
||||
}
|
||||
} else {
|
||||
$result['error'] = 'Phase not found.';
|
||||
$phase->progress_percent = $pending->payload['progress'];
|
||||
$phase->save();
|
||||
$phase->progressUpdates()->create([
|
||||
'user_id' => $user->id,
|
||||
'progress_percent' => $pending->payload['progress'],
|
||||
'comment' => $pending->payload['comment'] ?? '',
|
||||
'location' => $pending->payload['location'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
$result['success'] = true;
|
||||
} 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];
|
||||
|
||||
$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];
|
||||
|
||||
$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']);
|
||||
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,
|
||||
]);
|
||||
}
|
||||
$model = new $pending->payload['model_type'];
|
||||
$model = $model->find($pending->payload['model_id']);
|
||||
if ($model) {
|
||||
$model->media()->create([
|
||||
'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.
|
||||
*/
|
||||
|
||||
@@ -10,7 +10,7 @@ class ProjectReportController extends Controller
|
||||
public function show(Project $project)
|
||||
{
|
||||
$user = Auth::user();
|
||||
if (!$user->hasRole('Admin') && !$project->users()->where('user_id', $user->id)->exists()) {
|
||||
if (!$user->can('manage all') && !$project->users()->where('user_id', $user->id)->exists()) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
+15
-34
@@ -9,57 +9,38 @@ 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();
|
||||
abort_unless(Auth::user()->can('view users'), 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()
|
||||
{
|
||||
return view('livewire.admin-users');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,15 +4,19 @@ 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
|
||||
{
|
||||
public $projects = [];
|
||||
public $projects = [];
|
||||
public $selectedProject = null;
|
||||
public $projectDetails = [];
|
||||
public $galleryImages = [];
|
||||
public $changeOrders = [];
|
||||
public $projectDetails = [];
|
||||
public $galleryImages = [];
|
||||
public $changeOrders = [];
|
||||
|
||||
public function mount()
|
||||
{
|
||||
@@ -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) {
|
||||
@@ -72,91 +59,112 @@ class ClientProjects extends Component
|
||||
}
|
||||
|
||||
$this->projectDetails = [
|
||||
'id' => $project->id,
|
||||
'name' => $project->name,
|
||||
'description'=> $project->description ?? '',
|
||||
'id' => $project->id,
|
||||
'name' => $project->name,
|
||||
'description' => $project->description,
|
||||
'start_date' => $project->start_date,
|
||||
'end_date' => $project->end_date_estimated,
|
||||
'status' => $project->status,
|
||||
'progress' => round($project->phases->avg('progress_percent') ?? 0),
|
||||
'end_date' => $project->end_date,
|
||||
'status' => $project->status,
|
||||
'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) => [
|
||||
'url' => $media->url,
|
||||
'title' => $media->name,
|
||||
'date' => $media->created_at->format('d/m/Y'),
|
||||
])
|
||||
->map(function($media) {
|
||||
return [
|
||||
'url' => $media->url,
|
||||
'title' => $media->name,
|
||||
'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) => [
|
||||
'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()
|
||||
->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
|
||||
];
|
||||
})
|
||||
->toArray();
|
||||
}
|
||||
|
||||
public function approveChangeOrder($orderId)
|
||||
{
|
||||
$changeOrder = ChangeOrder::where('id', $orderId)
|
||||
->where('project_id', $this->selectedProject)
|
||||
->first();
|
||||
// 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();
|
||||
|
||||
if (!$changeOrder) {
|
||||
abort(403);
|
||||
// Refresh the change orders list
|
||||
$this->loadProjectDetails();
|
||||
|
||||
// Notify any listeners (optional)
|
||||
$this->dispatch('changeOrderUpdated', ['id' => $orderId, 'status' => 'approved']);
|
||||
}
|
||||
}
|
||||
|
||||
// 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(),
|
||||
]);
|
||||
|
||||
$this->loadProjectDetails();
|
||||
$this->dispatch('changeOrderUpdated', ['id' => $orderId, 'status' => 'approved']);
|
||||
}
|
||||
|
||||
public function rejectChangeOrder($orderId)
|
||||
{
|
||||
$changeOrder = ChangeOrder::where('id', $orderId)
|
||||
->where('project_id', $this->selectedProject)
|
||||
->first();
|
||||
// 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();
|
||||
|
||||
if (!$changeOrder) {
|
||||
abort(403);
|
||||
// Refresh the change orders list
|
||||
$this->loadProjectDetails();
|
||||
|
||||
// Notify any listeners (optional)
|
||||
$this->dispatch('changeOrderUpdated', ['id' => $orderId, 'status' => 'rejected']);
|
||||
}
|
||||
}
|
||||
|
||||
// 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(),
|
||||
]);
|
||||
|
||||
$this->loadProjectDetails();
|
||||
$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 . '%');
|
||||
})
|
||||
->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
|
||||
->orderBy('name')
|
||||
->get();
|
||||
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, 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ class CompanyView extends Component
|
||||
|
||||
public function mount(Company $company): void
|
||||
{
|
||||
if (!Auth::user()->hasRole('Admin')) abort(403);
|
||||
abort_unless(Auth::user()->can('view companies'), 403);
|
||||
|
||||
$this->company = $company->load(['users.roles', 'projects.phases']);
|
||||
$this->notes = $company->notes ?? '';
|
||||
|
||||
+114
-58
@@ -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;
|
||||
$this->title = $issue->title;
|
||||
$this->description = $issue->description ?? '';
|
||||
$this->status = $issue->status;
|
||||
$this->priority = $issue->priority;
|
||||
$this->featureId = $issue->feature_id;
|
||||
$this->inspectionId = $issue->inspection_id;
|
||||
$this->assignedTo = $issue->assigned_to;
|
||||
$this->editing = true;
|
||||
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->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([
|
||||
'title' => $this->title,
|
||||
'description' => $this->description,
|
||||
'status' => $this->status,
|
||||
'priority' => $this->priority,
|
||||
'feature_id' => $this->featureId,
|
||||
'inspection_id' => $this->inspectionId,
|
||||
'assigned_to' => $this->assignedTo,
|
||||
]);
|
||||
$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 ?: 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([
|
||||
'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,
|
||||
]);
|
||||
$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,
|
||||
'reported_by' => Auth::id(),
|
||||
]));
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
+240
-160
@@ -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')]
|
||||
@@ -19,104 +21,109 @@ class LayerManager extends Component
|
||||
use WithFileUploads;
|
||||
|
||||
public Project $project;
|
||||
public Phase $phase;
|
||||
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;
|
||||
public $uploadFile = null;
|
||||
public $layerName = '';
|
||||
public $layerColor = '#3b82f6';
|
||||
|
||||
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;
|
||||
$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()) {
|
||||
if (!$user->can('manage all') && !$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,
|
||||
'visibleLayers' => $this->visibleLayers,
|
||||
'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,186 +134,259 @@ 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.');
|
||||
if (!$user->can('upload layers')) {
|
||||
$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',
|
||||
'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;
|
||||
|
||||
$layer = Layer::create([
|
||||
'project_id' => $this->project->id,
|
||||
'phase_id' => $this->phase->id,
|
||||
'name' => $this->layerName,
|
||||
'color' => $layerColor,
|
||||
'original_file' => $originalPath,
|
||||
'uploaded_by' => $user->id,
|
||||
]);
|
||||
try {
|
||||
DB::transaction(function () use ($geojson, $layerColor, $layerName, $user) {
|
||||
$path = $this->uploadFile->store(
|
||||
"uploads/projects/{$this->project->id}/layers", 'public'
|
||||
);
|
||||
|
||||
// Crear features a partir del GeoJSON
|
||||
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,
|
||||
$layer = Layer::create([
|
||||
'project_id' => $this->project->id,
|
||||
'phase_id' => $this->phase->id,
|
||||
'name' => $layerName,
|
||||
'color' => $layerColor,
|
||||
'original_file' => $path,
|
||||
'uploaded_by' => $user->id,
|
||||
]);
|
||||
}
|
||||
|
||||
$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' => $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')) {
|
||||
$this->dispatch('notify', 'Sin permisos para crear capas');
|
||||
return;
|
||||
}
|
||||
|
||||
$layer = Layer::create([
|
||||
'project_id' => $this->project->id,
|
||||
'phase_id' => $this->phase->id,
|
||||
'name' => $this->layerName ?: 'Nueva capa',
|
||||
'color' => $this->layerColor ?: '#3b82f6',
|
||||
'project_id' => $this->project->id,
|
||||
'phase_id' => $this->phase->id,
|
||||
'name' => $this->layerName ?: 'Nueva capa',
|
||||
'color' => $this->layerColor ?: '#3b82f6',
|
||||
'original_file' => null,
|
||||
'uploaded_by' => $user->id,
|
||||
'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;
|
||||
|
||||
// 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,
|
||||
]);
|
||||
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;
|
||||
|
||||
Feature::create([
|
||||
'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)
|
||||
if (!$user->can('delete layers')) abort(403);
|
||||
|
||||
// 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;
|
||||
@@ -317,4 +397,4 @@ class LayerManager extends Component
|
||||
{
|
||||
return view('livewire.layers.layer-manager');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
public $uploadFiles = [];
|
||||
// Subida
|
||||
public $uploadFiles = [];
|
||||
public $uploadDescription = '';
|
||||
public $uploadCategory = 'image';
|
||||
public $uploadCategory = 'image';
|
||||
|
||||
public $showViewer = false;
|
||||
// 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',
|
||||
'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();
|
||||
}
|
||||
|
||||
@@ -80,7 +65,7 @@ class MediaManager extends Component
|
||||
public function upload()
|
||||
{
|
||||
$user = Auth::user();
|
||||
if (!$user->can('upload layers') && !$user->hasRole('Admin')) {
|
||||
if (!$user->can('upload layers')) {
|
||||
session()->flash('error', 'Sin permisos.');
|
||||
return;
|
||||
}
|
||||
@@ -92,58 +77,37 @@ 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();
|
||||
$ext = $file->getClientOriginalExtension();
|
||||
$size = $file->getSize();
|
||||
$name = $file->getClientOriginalName();
|
||||
|
||||
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);
|
||||
|
||||
// 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";
|
||||
$dir = "uploads/{$entityType}s/{$this->mediableId}/media";
|
||||
$path = $file->store($dir, 'public');
|
||||
|
||||
Media::create([
|
||||
'mediable_type' => $this->mediableType,
|
||||
'mediable_id' => $this->mediableId,
|
||||
'name' => $name,
|
||||
'file_path' => $path,
|
||||
'file_type' => $mime,
|
||||
'mediable_type' => $this->mediableType,
|
||||
'mediable_id' => $this->mediableId,
|
||||
'name' => $name,
|
||||
'file_path' => $path,
|
||||
'file_type' => $mime,
|
||||
'file_extension' => $ext,
|
||||
'file_size' => $size,
|
||||
'category' => $category,
|
||||
'description' => $this->uploadDescription,
|
||||
'uploaded_by' => $user->id,
|
||||
'file_size' => $size,
|
||||
'category' => $category,
|
||||
'description' => $this->uploadDescription,
|
||||
'uploaded_by' => $user->id,
|
||||
]);
|
||||
|
||||
$uploaded++;
|
||||
@@ -152,24 +116,21 @@ class MediaManager extends Component
|
||||
$this->reset(['uploadFiles', 'uploadDescription']);
|
||||
$this->loadMedia();
|
||||
|
||||
// Notificar al mapa si corresponde
|
||||
$this->dispatch('mediaUploaded', [
|
||||
'mediableType' => $this->mediableType,
|
||||
'mediableId' => $this->mediableId,
|
||||
'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) {
|
||||
if (!$user->can('delete media') && $media->uploaded_by !== $user->id) {
|
||||
session()->flash('error', 'No puedes borrar archivos de otro usuario.');
|
||||
return;
|
||||
}
|
||||
@@ -181,31 +142,28 @@ 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;
|
||||
}
|
||||
$this->viewingMedia = $media;
|
||||
$this->showViewer = true;
|
||||
$this->showViewer = true;
|
||||
}
|
||||
|
||||
public function closeViewer()
|
||||
{
|
||||
$this->showViewer = false;
|
||||
$this->viewingMedia = null;
|
||||
$this->showViewer = false;
|
||||
$this->viewingMedia = null;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ class PhaseGantt extends Component
|
||||
public function mount(Project $project)
|
||||
{
|
||||
$user = Auth::user();
|
||||
if (!$user->hasRole('Admin') && !$project->users()->where('user_id', $user->id)->exists()) {
|
||||
if (!$user->can('manage all') && !$project->users()->where('user_id', $user->id)->exists()) {
|
||||
abort(403);
|
||||
}
|
||||
$this->project = $project;
|
||||
|
||||
@@ -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;
|
||||
$this->phases = $project->phases;
|
||||
}
|
||||
|
||||
public function addPhase()
|
||||
{
|
||||
Gate::authorize('edit projects', $this->project);
|
||||
|
||||
$this->project->phases()->create([
|
||||
'name' => 'Nueva fase',
|
||||
'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,20 +30,12 @@ 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()
|
||||
{
|
||||
return view('livewire.phase-list');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -35,7 +31,7 @@ class ProjectCompanies extends Component
|
||||
public function assignCompany()
|
||||
{
|
||||
$user = Auth::user();
|
||||
if (!$user->can('assign users') && !$user->hasRole('Admin')) {
|
||||
if (!$user->can('assign users')) {
|
||||
session()->flash('error', 'No tienes permisos para asignar compañías.');
|
||||
return;
|
||||
}
|
||||
@@ -57,7 +53,7 @@ class ProjectCompanies extends Component
|
||||
public function removeCompany($companyId)
|
||||
{
|
||||
$user = Auth::user();
|
||||
if (!$user->can('assign users') && !$user->hasRole('Admin')) {
|
||||
if (!$user->can('assign users')) {
|
||||
session()->flash('error', 'Sin permisos.');
|
||||
return;
|
||||
}
|
||||
@@ -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, [
|
||||
|
||||
@@ -34,7 +34,7 @@ class ProjectDashboard extends Component
|
||||
private function checkAccess(): void
|
||||
{
|
||||
$user = Auth::user();
|
||||
if ($user->hasRole('Admin')) return;
|
||||
if ($user->can('manage all')) return;
|
||||
if (!$this->project->users()->where('user_id', $user->id)->exists()) abort(403);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,15 +18,11 @@ 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;
|
||||
$project = Project::findOrFail($id);
|
||||
if (Auth::user()->can('delete projects')) {
|
||||
$project->delete();
|
||||
session()->flash('message', 'Proyecto eliminado');
|
||||
}
|
||||
// Scope to accessible projects to prevent IDOR (deleting another user's project by ID)
|
||||
$project = Project::accessibleBy($user)->findOrFail($id);
|
||||
$project->delete();
|
||||
session()->flash('message', 'Proyecto eliminado');
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
||||
+229
-125
@@ -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->phases = $project->phases()->with(['layers' => function ($q) {
|
||||
$q->withCount('features');
|
||||
}, 'layers.features'])->get();
|
||||
$this->activeLayers = $this->phases->pluck('id')->toArray();
|
||||
$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();
|
||||
}
|
||||
|
||||
private function authorizeProjectAccess(): void
|
||||
{
|
||||
$user = Auth::user();
|
||||
if ($user->can('manage all')) return;
|
||||
if (!$this->project->users()->where('user_id', $user->id)->exists()) abort(403);
|
||||
}
|
||||
|
||||
public function loadTemplates()
|
||||
@@ -62,92 +105,129 @@ 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')) {
|
||||
if (!$user->can('update progress')) {
|
||||
$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,
|
||||
'user_id' => $user->id,
|
||||
'progress_percent' => $phase->progress_percent,
|
||||
'comment' => $comment,
|
||||
'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;
|
||||
$this->selectedPhaseId = $feature->layer->phase_id;
|
||||
$this->editProgress = $feature->progress;
|
||||
$this->editResponsible = $feature->responsible ?? '';
|
||||
$this->editPhotos = $feature->properties['photos'] ?? [];
|
||||
$this->selectedFeature = $feature;
|
||||
$this->selectedPhaseId = $feature->layer->phase_id;
|
||||
$this->editProgress = $feature->progress;
|
||||
$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.");
|
||||
@@ -198,38 +275,57 @@ class ProjectMap extends Component
|
||||
}
|
||||
|
||||
$inspection = Inspection::create([
|
||||
'project_id' => $this->project->id,
|
||||
'layer_id' => $this->selectedFeature->layer_id,
|
||||
'feature_id' => $this->selectedFeature->id,
|
||||
'template_id' => $this->selectedTemplateId,
|
||||
'user_id' => auth()->id(),
|
||||
'data' => $this->inspectionFormData,
|
||||
'project_id' => $this->project->id,
|
||||
'layer_id' => $this->selectedFeature->layer_id,
|
||||
'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 (isset($this->inspectionFormData['progress'])) {
|
||||
$this->updateProgress($this->selectedFeature->id, (int)$this->inspectionFormData['progress'], 'Inspección registrada');
|
||||
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->dispatch('notify', 'Inspección guardada correctamente');
|
||||
}
|
||||
|
||||
// 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();
|
||||
$this->dispatch('notify', 'Inspección guardada correctamente');
|
||||
}
|
||||
|
||||
/**
|
||||
* Asignar un template al feature seleccionado.
|
||||
*/
|
||||
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,44 +393,31 @@ 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;
|
||||
$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']) {
|
||||
$markers[] = [
|
||||
'feature_id' => $feature->id,
|
||||
'name' => $feature->name,
|
||||
'lat' => $coords['lat'],
|
||||
'lng' => $coords['lng'],
|
||||
'image_url' => $image->url,
|
||||
'name' => $feature->name,
|
||||
'lat' => $coords['lat'],
|
||||
'lng' => $coords['lng'],
|
||||
'image_url' => $image->url,
|
||||
'image_name' => $image->name,
|
||||
];
|
||||
}
|
||||
@@ -330,16 +431,19 @@ 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()
|
||||
{
|
||||
return view('livewire.projects.project-map', [
|
||||
'project' => $this->project,
|
||||
'phases' => $this->phases,
|
||||
'phases' => $this->phases,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -35,7 +31,7 @@ class ProjectUsers extends Component
|
||||
public function assignUser()
|
||||
{
|
||||
$user = Auth::user();
|
||||
if (!$user->can('assign users') && !$user->hasRole('Admin')) {
|
||||
if (!$user->can('assign users')) {
|
||||
session()->flash('error', 'No tienes permisos para asignar usuarios.');
|
||||
return;
|
||||
}
|
||||
@@ -57,7 +53,7 @@ class ProjectUsers extends Component
|
||||
public function removeUser($userId)
|
||||
{
|
||||
$user = Auth::user();
|
||||
if (!$user->can('assign users') && !$user->hasRole('Admin')) {
|
||||
if (!$user->can('assign users')) {
|
||||
session()->flash('error', 'Sin permisos.');
|
||||
return;
|
||||
}
|
||||
@@ -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('manage roles'), 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('manage roles'), 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,143 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use Livewire\Component;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Str;
|
||||
use App\Models\User;
|
||||
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
|
||||
public $newUserId = '';
|
||||
|
||||
private const PROTECTED_ROLES = ['Admin'];
|
||||
private const CORE_PERMISSION = 'manage all';
|
||||
|
||||
public function mount(Role $role): void
|
||||
{
|
||||
abort_unless(Auth::user()?->can('manage roles'), 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 addUser(): void
|
||||
{
|
||||
$this->validate(['newUserId' => 'required|exists:users,id'], [], ['newUserId' => 'usuario']);
|
||||
|
||||
User::findOrFail($this->newUserId)->assignRole($this->role->name);
|
||||
$this->newUserId = '';
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
$this->dispatch('notify', 'Usuario añadido al rol');
|
||||
}
|
||||
|
||||
public function removeUser(int $userId): void
|
||||
{
|
||||
User::findOrFail($userId)->removeRole($this->role->name);
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
$this->dispatch('notify', 'Usuario quitado del rol');
|
||||
}
|
||||
|
||||
public function setGroup(string $group, bool $enabled): void
|
||||
{
|
||||
$names = Permission::where('group', $group)->pluck('name');
|
||||
|
||||
foreach ($names as $name) {
|
||||
// Admin must always keep the core permission
|
||||
if (! $enabled && $this->role->name === 'Admin' && $name === self::CORE_PERMISSION) {
|
||||
continue;
|
||||
}
|
||||
$enabled ? $this->role->givePermissionTo($name) : $this->role->revokePermissionTo($name);
|
||||
}
|
||||
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
$this->role->load('permissions');
|
||||
$this->dispatch('notify', $enabled ? 'Permisos del grupo activados' : 'Permisos del grupo desactivados');
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
$availableUsers = User::whereDoesntHave('roles', fn ($q) => $q->where('roles.id', $this->role->id))
|
||||
->orderBy('first_name')->orderBy('name')->get();
|
||||
|
||||
return view('livewire.roles.role-view', [
|
||||
'users' => $users,
|
||||
'availableUsers' => $availableUsers,
|
||||
'grouped' => $grouped,
|
||||
'rolePerms' => $this->role->permissions->pluck('name')->toArray(),
|
||||
'isProtected' => in_array($this->role->name, self::PROTECTED_ROLES, true),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -3,43 +3,58 @@
|
||||
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' => '',
|
||||
'name' => '',
|
||||
'description' => '',
|
||||
'phase_id' => null,
|
||||
'fields' => [],
|
||||
];
|
||||
public $fieldTypes = [
|
||||
'text' => 'Texto corto',
|
||||
'textarea' => 'Texto largo',
|
||||
'integer' => 'Número entero',
|
||||
'decimal' => 'Número decimal',
|
||||
'percentage' => 'Porcentaje (0-100)',
|
||||
'boolean' => 'Sí/No (checkbox)',
|
||||
'date' => 'Fecha',
|
||||
'select' => 'Lista desplegable',
|
||||
'phase_id' => null,
|
||||
'fields' => [],
|
||||
];
|
||||
|
||||
protected $listeners = ['showTemplateForm' => 'newTemplate'];
|
||||
// ── 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',
|
||||
'integer' => 'Número entero',
|
||||
'decimal' => 'Número decimal',
|
||||
'percentage' => 'Porcentaje (0-100)',
|
||||
'boolean' => 'Sí/No (checkbox)',
|
||||
'date' => 'Fecha',
|
||||
'select' => 'Lista desplegable',
|
||||
];
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -81,10 +102,10 @@ class TemplateManager extends Component
|
||||
public function resetForm()
|
||||
{
|
||||
$this->form = [
|
||||
'name' => '',
|
||||
'name' => '',
|
||||
'description' => '',
|
||||
'phase_id' => null,
|
||||
'fields' => [],
|
||||
'phase_id' => null,
|
||||
'fields' => [],
|
||||
];
|
||||
$this->editingTemplate = null;
|
||||
}
|
||||
@@ -92,14 +113,14 @@ class TemplateManager extends Component
|
||||
public function addField()
|
||||
{
|
||||
$this->form['fields'][] = [
|
||||
'name' => '',
|
||||
'label' => '',
|
||||
'type' => 'text',
|
||||
'options' => [],
|
||||
'name' => '',
|
||||
'label' => '',
|
||||
'type' => 'text',
|
||||
'options' => '',
|
||||
'required' => false,
|
||||
'min' => null,
|
||||
'max' => null,
|
||||
'step' => null,
|
||||
'min' => null,
|
||||
'max' => null,
|
||||
'step' => null,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -112,31 +133,25 @@ class TemplateManager extends Component
|
||||
public function saveTemplate()
|
||||
{
|
||||
$this->validate([
|
||||
'form.name' => 'required|string|max:255',
|
||||
'form.name' => 'required|string|max:255',
|
||||
'form.phase_id' => 'nullable|exists:phases,id',
|
||||
'form.fields' => 'array',
|
||||
'form.fields' => 'array',
|
||||
]);
|
||||
|
||||
$data = [
|
||||
'name' => $this->form['name'],
|
||||
'description' => $this->form['description'],
|
||||
'project_id' => $this->project->id,
|
||||
'phase_id' => $this->form['phase_id'] ?: null,
|
||||
'fields' => array_values($this->form['fields']),
|
||||
];
|
||||
|
||||
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');
|
||||
InspectionTemplate::findOrFail($this->editingTemplate)->update($data);
|
||||
$this->dispatch('notify', 'Template actualizado correctamente');
|
||||
} else {
|
||||
InspectionTemplate::create([
|
||||
'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');
|
||||
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()
|
||||
|
||||
@@ -45,7 +45,7 @@ class UserForm extends Component
|
||||
|
||||
public function mount(?User $user = null): void
|
||||
{
|
||||
if (!Auth::user()->hasRole('Admin')) abort(403);
|
||||
abort_unless(Auth::user()->can('create users') || Auth::user()->can('edit users'), 403);
|
||||
|
||||
$this->roles = Role::orderBy('name')->get();
|
||||
$this->companies = Company::where('estado', 'activo')->orderBy('name')->get();
|
||||
|
||||
@@ -9,12 +9,14 @@ use App\Models\Project;
|
||||
use App\Models\Inspection;
|
||||
use App\Models\Issue;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
use Spatie\Permission\PermissionRegistrar;
|
||||
|
||||
#[Layout('layouts.app')]
|
||||
class UserView extends Component
|
||||
{
|
||||
public User $user;
|
||||
public string $activeTab = 'permissions';
|
||||
public string $activeTab = 'ficha';
|
||||
|
||||
// Projects tab
|
||||
public ?int $addProjectId = null;
|
||||
@@ -31,7 +33,7 @@ class UserView extends Component
|
||||
|
||||
public function mount(User $user): void
|
||||
{
|
||||
if (!Auth::user()->hasRole('Admin')) abort(403);
|
||||
abort_unless(Auth::user()->can('view users'), 403);
|
||||
|
||||
$this->user = $user->load(['roles', 'company', 'projects.phases']);
|
||||
$this->notes = $user->notes ?? '';
|
||||
@@ -93,6 +95,36 @@ class UserView extends Component
|
||||
$this->dispatch('notify', 'Proyecto desasignado.');
|
||||
}
|
||||
|
||||
// ── Permissions (direct, per user) ─────────────────────────────────────────
|
||||
|
||||
public function togglePermission(string $name): void
|
||||
{
|
||||
if ($this->user->hasDirectPermission($name)) {
|
||||
$this->user->revokePermissionTo($name);
|
||||
} else {
|
||||
$this->user->givePermissionTo($name);
|
||||
}
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
$this->user->load('roles', 'permissions');
|
||||
$this->dispatch('notify', 'Permisos del usuario actualizados');
|
||||
}
|
||||
|
||||
public function setUserGroup(string $group, bool $enabled): void
|
||||
{
|
||||
foreach (Permission::where('group', $group)->pluck('name') as $name) {
|
||||
if ($enabled) {
|
||||
if (! $this->user->hasPermissionTo($name)) {
|
||||
$this->user->givePermissionTo($name);
|
||||
}
|
||||
} elseif ($this->user->hasDirectPermission($name)) {
|
||||
$this->user->revokePermissionTo($name);
|
||||
}
|
||||
}
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
$this->user->load('roles', 'permissions');
|
||||
$this->dispatch('notify', $enabled ? 'Permisos del grupo concedidos' : 'Permisos directos del grupo quitados');
|
||||
}
|
||||
|
||||
// ── Notes ─────────────────────────────────────────────────────────────────
|
||||
|
||||
public function saveNotes(): void
|
||||
@@ -105,6 +137,22 @@ class UserView extends Component
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.user-view');
|
||||
$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 ?: 'General')
|
||||
->sortBy(function ($perms, $section) use ($order) {
|
||||
$i = array_search($section, $order, true);
|
||||
return $i === false ? 999 : $i;
|
||||
});
|
||||
|
||||
return view('livewire.user-view', [
|
||||
'grouped' => $grouped,
|
||||
'directPerms' => $this->user->getDirectPermissions()->pluck('name')->toArray(),
|
||||
'rolePerms' => $this->user->getPermissionsViaRoles()->pluck('name')->toArray(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ class Project extends Model
|
||||
// Scope to filter accessible projects for non-admin users
|
||||
public function scopeAccessibleBy($query, User $user)
|
||||
{
|
||||
if ($user->hasRole('Admin')) {
|
||||
if ($user->can('manage all')) {
|
||||
return $query;
|
||||
}
|
||||
return $query->whereHas('users', function ($q) use ($user) {
|
||||
|
||||
+9
-8
@@ -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',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -46,14 +45,16 @@ class User extends Authenticatable
|
||||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
'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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
# Protocolo de sincronización móvil offline-first
|
||||
|
||||
> Estado: **plan aprobado** (2026-06-17). Auth decidida: **Laravel Sanctum (API tokens)**.
|
||||
> Alcance de este documento: lo necesario **en la webapp** para que una app móvil
|
||||
> descargue plantillas/datos, trabaje sin conexión y sincronice al recuperar red.
|
||||
> No cubre la implementación de la app móvil (la consume este contrato).
|
||||
|
||||
## 1. Modelo general
|
||||
|
||||
Offline-first con **cola en el dispositivo (outbox)** + sync bidireccional:
|
||||
|
||||
- **PULL (descarga):** la app baja un "paquete" del proyecto (estructura + plantillas + registros) para trabajar sin red.
|
||||
- **Trabajo offline:** cada cambio se guarda local con un **UUID generado en el móvil** y se encola.
|
||||
- **PUSH (subida):** al volver la conexión, la app envía la cola; el servidor hace *upsert idempotente* por UUID y responde resultado por ítem.
|
||||
- Sincronización **delta** por `updated_at` (solo lo cambiado desde el último sync).
|
||||
|
||||
## 2. Autenticación — Laravel Sanctum (decidido)
|
||||
|
||||
- Instalar `laravel/sanctum`. Tokens personales por dispositivo (no SPA-cookie; modo **API token**).
|
||||
- Endpoints:
|
||||
- `POST /api/v1/login` — `{ email, password, device_name }` → `{ token, user }`.
|
||||
- `POST /api/v1/logout` — revoca el token actual.
|
||||
- `GET /api/v1/me` — usuario + permisos efectivos.
|
||||
- El móvil envía `Authorization: Bearer <token>`.
|
||||
- Token con **abilities** (p. ej. `mobile-sync`) y **registro de dispositivo** (tabla `devices`) para revocar/caducar.
|
||||
- Caducidad de token configurable + endpoint de refresco o re-login.
|
||||
|
||||
## 3. Cambios de esquema
|
||||
|
||||
Añadir a las tablas sincronizables (`features`, `inspections`, `issues`, `progress_updates`, `media`):
|
||||
|
||||
- `uuid` CHAR(36) único — **lo genera el móvil**; permite crear offline y *upsert* idempotente.
|
||||
- `updated_at` (ya existe) — delta + last-write-wins.
|
||||
- `client_updated_at` TIMESTAMP nullable — marca de tiempo del dispositivo (resolución de conflictos).
|
||||
- Soft-deletes (ya existen) — se exponen como **tombstones** (ids/uuids borrados) en el PULL.
|
||||
|
||||
Tablas nuevas:
|
||||
- `devices` (id, user_id, name, token_id, last_seen_at, …).
|
||||
- `sync_logs` (auditoría: device, operación, entidad, uuid, resultado, timestamp).
|
||||
|
||||
## 4. API (`routes/api.php`, prefijo `/api/v1`, stateless + Sanctum)
|
||||
|
||||
### Descarga / PULL
|
||||
- `GET /api/v1/projects` → proyectos accesibles (reusa `Project::accessibleBy`).
|
||||
- `GET /api/v1/projects/{id}/bundle?since=<ISO8601>` → **paquete offline** (delta si viene `since`).
|
||||
- `GET /api/v1/templates?since=<ISO8601>` → plantillas de inspección con `version`/`hash` (descarga incremental).
|
||||
- `GET /api/v1/media/{id}` o URLs firmadas dentro del bundle → adjuntos existentes.
|
||||
|
||||
Ejemplo de respuesta `bundle`:
|
||||
```json
|
||||
{
|
||||
"server_time": "2026-06-17T20:00:00Z",
|
||||
"project": { "id": 1, "uuid": "…", "name": "…", "updated_at": "…" },
|
||||
"phases": [ { "id": 4, "name": "…", "updated_at": "…" } ],
|
||||
"layers": [ { "id": 4, "phase_id": 4, "name": "…", "updated_at": "…" } ],
|
||||
"features": [ { "id": 5, "uuid": "…", "layer_id": 4, "geometry": {…}, "status": "in_progress", "progress": 40, "updated_at": "…" } ],
|
||||
"templates":[ { "id": 1, "version": 3, "fields": [ … ] } ],
|
||||
"inspections": [ … ],
|
||||
"issues": [ … ],
|
||||
"deleted": { "features": ["uuid…"], "inspections": ["uuid…"] }
|
||||
}
|
||||
```
|
||||
|
||||
### Subida / PUSH
|
||||
- `POST /api/v1/sync` — lote de operaciones (idempotente por `uuid`):
|
||||
```json
|
||||
{ "operations": [
|
||||
{ "entity": "progress_update", "op": "create", "uuid": "…", "client_updated_at": "…", "data": { "phase_id": 4, "progress": 60, "comment": "…", "location": {…} } },
|
||||
{ "entity": "inspection", "op": "create", "uuid": "…", "client_updated_at": "…", "data": { "feature_id": 5, "template_id": 1, "data": {…}, "result": "pass" } },
|
||||
{ "entity": "feature", "op": "update", "uuid": "…", "client_updated_at": "…", "data": { "status": "completed", "progress": 100 } },
|
||||
{ "entity": "issue", "op": "create", "uuid": "…", "client_updated_at": "…", "data": { "feature_id": 5, "title": "…", "priority": "high" } }
|
||||
] }
|
||||
```
|
||||
Respuesta por operación:
|
||||
```json
|
||||
{ "results": [
|
||||
{ "uuid": "…", "status": "applied", "server_id": 123 },
|
||||
{ "uuid": "…", "status": "duplicate", "server_id": 124 },
|
||||
{ "uuid": "…", "status": "conflict", "server": { "status": "verified", "updated_at": "…" } },
|
||||
{ "uuid": "…", "status": "error", "error": "validation: …" }
|
||||
] }
|
||||
```
|
||||
- `POST /api/v1/media` — **subida de fotos por multipart** (no base64), referenciando al padre por `uuid` (`parent_entity`, `parent_uuid`, `file`). Soporta reintento; troceado si el archivo es grande.
|
||||
|
||||
## 5. Idempotencia y conflictos
|
||||
|
||||
- **Idempotencia:** el `uuid` evita duplicados si se reenvía la cola (re-sync seguro).
|
||||
- **Append-only (sin conflicto):** `progress_updates`, `inspections` → siempre insertan.
|
||||
- **Editables (con política):** `feature.status/progress`, `issue` → **last-write-wins** comparando `client_updated_at` vs `updated_at` del servidor. Si el servidor es más nuevo → `conflict` y se devuelve el valor del servidor para que el móvil decida/avise.
|
||||
|
||||
## 6. Seguridad
|
||||
|
||||
- **Nunca** `Model::create($payloadCliente)` crudo. Usar FormRequests/DTO; fijar `project_id`/`user_id` **en el servidor** desde el contexto autorizado; validar que `feature/phase` pertenece a un proyecto del usuario (anti-IDOR).
|
||||
- Autorizar cada operación con permisos Spatie (`update progress`, `create inspections`, …) + pertenencia al proyecto (`accessibleBy`).
|
||||
- Rate limiting, caducidad de token, `sync_logs` para auditoría.
|
||||
|
||||
## 7. Versionado
|
||||
|
||||
- Prefijo `/api/v1`; cabecera `X-App-Version`; el servidor responde versión mínima soportada (forzar update del móvil).
|
||||
- Versión/hash por plantilla (descarga incremental).
|
||||
|
||||
## 8. Qué reutilizar / retirar
|
||||
|
||||
- `OfflineSyncController` + `PendingSync`: el **vocabulario de acciones** (progress_update, inspection, feature_create, media_upload, task_complete) es buena base para las operaciones de `/sync`. Pero hay que: pasar a API+token, añadir uuid/validación/autorización, y **mover la cola al dispositivo** (la `PendingSync` del servidor deja de ser necesaria para el móvil; se puede retirar o reaprovechar como `sync_logs`).
|
||||
|
||||
## 9. Entregables en la webapp (por fases)
|
||||
|
||||
- **Fase A — Auth & esqueleto API:** Sanctum, `routes/api.php`, `login`/`logout`/`me`, tabla `devices`, abilities.
|
||||
- **Fase B — PULL:** `projects`, `bundle` + delta, `templates` versionadas, tombstones.
|
||||
- **Fase C — PUSH:** `/sync` idempotente con validación/autorización/conflictos (recoge y endurece la lógica actual).
|
||||
- **Fase D — Media:** subida multipart + descarga.
|
||||
- **Fase E — Endurecimiento + Docs:** rate-limit, `sync_logs`, OpenAPI/Swagger como contrato para el equipo móvil.
|
||||
+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">
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
|
||||
{{ __('Administrator') }} — {{ __('Users') }}
|
||||
</h2>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
|
||||
{{ __('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,246 +1,246 @@
|
||||
<div>
|
||||
<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" />
|
||||
</a>
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
|
||||
{{ $company ? 'Editar empresa: ' . $company->name : 'Nueva empresa' }}
|
||||
</h2>
|
||||
</div>
|
||||
</x-slot>
|
||||
<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" />
|
||||
</a>
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
|
||||
{{ $company ? 'Editar empresa: ' . $company->name : 'Nueva empresa' }}
|
||||
</h2>
|
||||
</div>
|
||||
</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>
|
||||
@endif
|
||||
@if(session('notify'))
|
||||
<div class="alert alert-success mb-4">{{ session('notify') }}</div>
|
||||
@endif
|
||||
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body p-8">
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body p-8">
|
||||
|
||||
<form wire:submit.prevent="save" class="space-y-0">
|
||||
<form wire:submit.prevent="save" class="space-y-0">
|
||||
|
||||
@if($errors->any())
|
||||
<div class="alert alert-error text-sm mb-6">
|
||||
<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
|
||||
|
||||
{{-- ── Macro para fila de formulario horizontal ──────────── --}}
|
||||
{{-- Patrón: flex row, label w-48 shrink-0, campo flex-1 --}}
|
||||
|
||||
{{-- ── Sección: Identificación ──────────────────────────── --}}
|
||||
<div class="pb-6 mb-6 border-b border-base-200">
|
||||
<h3 class="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-4">
|
||||
Identificación
|
||||
</h3>
|
||||
<div class="space-y-4">
|
||||
|
||||
<div class="flex items-start gap-4">
|
||||
<label class="w-48 shrink-0 pt-2 text-sm font-medium text-gray-700">
|
||||
Nombre registrado <span class="text-error">*</span>
|
||||
</label>
|
||||
<div class="flex-1">
|
||||
<input type="text" wire:model="name"
|
||||
class="input input-bordered w-full"
|
||||
placeholder="Constructora Ejemplo, S.L." />
|
||||
@error('name') <p class="text-error text-xs mt-1">{{ $message }}</p> @enderror
|
||||
@if($errors->any())
|
||||
<div class="alert alert-error text-sm mb-6">
|
||||
<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>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="flex items-start gap-4">
|
||||
<label class="w-48 shrink-0 pt-2 text-sm font-medium text-gray-700">
|
||||
Apodo / comercial
|
||||
</label>
|
||||
<div class="flex-1">
|
||||
<input type="text" wire:model="apodo"
|
||||
class="input input-bordered w-full"
|
||||
placeholder="Ejemplo Constr." />
|
||||
</div>
|
||||
</div>
|
||||
{{-- ── Macro para fila de formulario horizontal ──────────── --}}
|
||||
{{-- Patrón: flex row, label w-48 shrink-0, campo flex-1 --}}
|
||||
|
||||
<div class="flex items-start gap-4">
|
||||
<label class="w-48 shrink-0 pt-2 text-sm font-medium text-gray-700">
|
||||
NIF / CIF / Tax ID
|
||||
</label>
|
||||
<div class="flex-1">
|
||||
<input type="text" wire:model="tax_id"
|
||||
class="input input-bordered w-full"
|
||||
placeholder="B12345678" />
|
||||
@error('tax_id') <p class="text-error text-xs mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
</div>
|
||||
{{-- ── Sección: Identificación ──────────────────────────── --}}
|
||||
<div class="pb-6 mb-6 border-b border-base-200">
|
||||
<h3 class="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-4">
|
||||
Identificación
|
||||
</h3>
|
||||
<div class="space-y-4">
|
||||
|
||||
<div class="flex items-start gap-4">
|
||||
<label class="w-48 shrink-0 pt-2 text-sm font-medium text-gray-700">
|
||||
Tipo de empresa <span class="text-error">*</span>
|
||||
</label>
|
||||
<div class="flex-1">
|
||||
<select wire:model="type" class="select select-bordered w-full">
|
||||
<option value="owner">Promotor / Propietario</option>
|
||||
<option value="constructor">Constructor principal</option>
|
||||
<option value="subcontractor">Subcontratista</option>
|
||||
<option value="consultant">Consultor / Ingeniería</option>
|
||||
<option value="supplier">Proveedor</option>
|
||||
<option value="other">Otro</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start gap-4">
|
||||
<label class="w-48 shrink-0 pt-2 text-sm font-medium text-gray-700">
|
||||
Estado <span class="text-error">*</span>
|
||||
</label>
|
||||
<div class="flex-1">
|
||||
<select wire:model="estado" class="select select-bordered w-full max-w-xs">
|
||||
<option value="activo">Activo</option>
|
||||
<option value="inactivo">Inactivo</option>
|
||||
<option value="suspendido">Suspendido</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ── Sección: Contacto ─────────────────────────────────── --}}
|
||||
<div class="pb-6 mb-6 border-b border-base-200">
|
||||
<h3 class="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-4">
|
||||
Contacto
|
||||
</h3>
|
||||
<div class="space-y-4">
|
||||
|
||||
<div class="flex items-start gap-4">
|
||||
<label class="w-48 shrink-0 pt-2 text-sm font-medium text-gray-700">
|
||||
Dirección
|
||||
</label>
|
||||
<div class="flex-1">
|
||||
<textarea wire:model="address" rows="2"
|
||||
class="textarea textarea-bordered w-full"
|
||||
placeholder="Calle, número, ciudad, CP, país"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start gap-4">
|
||||
<label class="w-48 shrink-0 pt-2 text-sm font-medium text-gray-700">
|
||||
Teléfono
|
||||
</label>
|
||||
<div class="flex-1">
|
||||
<label class="input input-bordered flex items-center gap-2">
|
||||
<x-heroicon-o-phone class="w-4 h-4 opacity-40 shrink-0" />
|
||||
<input type="tel" wire:model="phone" class="grow"
|
||||
placeholder="+34 600 123 456" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start gap-4">
|
||||
<label class="w-48 shrink-0 pt-2 text-sm font-medium text-gray-700">
|
||||
Email
|
||||
</label>
|
||||
<div class="flex-1">
|
||||
<label class="input input-bordered flex items-center gap-2">
|
||||
<x-heroicon-o-envelope class="w-4 h-4 opacity-40 shrink-0" />
|
||||
<input type="email" wire:model="email" class="grow"
|
||||
placeholder="contacto@empresa.com" />
|
||||
</label>
|
||||
@error('email') <p class="text-error text-xs mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start gap-4">
|
||||
<label class="w-48 shrink-0 pt-2 text-sm font-medium text-gray-700">
|
||||
Sitio web
|
||||
</label>
|
||||
<div class="flex-1">
|
||||
<label class="input input-bordered flex items-center gap-2">
|
||||
<x-heroicon-o-globe-alt class="w-4 h-4 opacity-40 shrink-0" />
|
||||
<input type="url" wire:model="website" class="grow"
|
||||
placeholder="https://www.empresa.com" />
|
||||
</label>
|
||||
@error('website') <p class="text-error text-xs mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ── Sección: Logo ─────────────────────────────────────── --}}
|
||||
<div class="pb-6 mb-6 border-b border-base-200">
|
||||
<h3 class="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-4">
|
||||
Logo
|
||||
</h3>
|
||||
<div class="flex items-start gap-4">
|
||||
<label class="w-48 shrink-0 pt-2 text-sm font-medium text-gray-700">
|
||||
{{ $company?->logo_path ? 'Reemplazar logo' : 'Subir logo' }}
|
||||
<p class="text-xs text-gray-400 font-normal mt-0.5">PNG / JPG, máx. 2 MB</p>
|
||||
</label>
|
||||
<div class="flex-1 flex items-start gap-4">
|
||||
{{-- Preview --}}
|
||||
@if($logo)
|
||||
<img src="{{ $logo->temporaryUrl() }}" alt="Preview"
|
||||
class="w-16 h-16 object-contain border border-base-300 rounded-lg shrink-0" />
|
||||
@elseif($company?->logo_path && \Illuminate\Support\Facades\Storage::disk('public')->exists($company->logo_path))
|
||||
<img src="{{ \Illuminate\Support\Facades\Storage::disk('public')->url($company->logo_path) }}"
|
||||
alt="Logo actual"
|
||||
class="w-16 h-16 object-contain border border-base-300 rounded-lg shrink-0" />
|
||||
@else
|
||||
<div class="w-16 h-16 bg-base-200 rounded-lg flex items-center justify-center shrink-0">
|
||||
<x-heroicon-o-building-office class="w-7 h-7 opacity-30" />
|
||||
<div class="flex items-start gap-4">
|
||||
<label class="w-48 shrink-0 pt-2 text-sm font-medium text-gray-700">
|
||||
Nombre registrado <span class="text-error">*</span>
|
||||
</label>
|
||||
<div class="flex-1">
|
||||
<input type="text" wire:model="name"
|
||||
class="input input-bordered w-full"
|
||||
placeholder="Constructora Ejemplo, S.L." />
|
||||
@error('name') <p class="text-error text-xs mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
<div class="flex-1">
|
||||
<input type="file" wire:model="logo" accept="image/*"
|
||||
class="file-input file-input-bordered w-full" />
|
||||
<div wire:loading wire:target="logo" class="text-xs text-info mt-1">Subiendo…</div>
|
||||
@error('logo') <p class="text-error text-xs mt-1">{{ $message }}</p> @enderror
|
||||
|
||||
<div class="flex items-start gap-4">
|
||||
<label class="w-48 shrink-0 pt-2 text-sm font-medium text-gray-700">
|
||||
Apodo / comercial
|
||||
</label>
|
||||
<div class="flex-1">
|
||||
<input type="text" wire:model="apodo"
|
||||
class="input input-bordered w-full"
|
||||
placeholder="Ejemplo Constr." />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start gap-4">
|
||||
<label class="w-48 shrink-0 pt-2 text-sm font-medium text-gray-700">
|
||||
NIF / CIF / Tax ID
|
||||
</label>
|
||||
<div class="flex-1">
|
||||
<input type="text" wire:model="tax_id"
|
||||
class="input input-bordered w-full"
|
||||
placeholder="B12345678" />
|
||||
@error('tax_id') <p class="text-error text-xs mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start gap-4">
|
||||
<label class="w-48 shrink-0 pt-2 text-sm font-medium text-gray-700">
|
||||
Tipo de empresa <span class="text-error">*</span>
|
||||
</label>
|
||||
<div class="flex-1">
|
||||
<select wire:model="type" class="select select-bordered w-full">
|
||||
<option value="owner">Promotor / Propietario</option>
|
||||
<option value="constructor">Constructor principal</option>
|
||||
<option value="subcontractor">Subcontratista</option>
|
||||
<option value="consultant">Consultor / Ingeniería</option>
|
||||
<option value="supplier">Proveedor</option>
|
||||
<option value="other">Otro</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start gap-4">
|
||||
<label class="w-48 shrink-0 pt-2 text-sm font-medium text-gray-700">
|
||||
Estado <span class="text-error">*</span>
|
||||
</label>
|
||||
<div class="flex-1">
|
||||
<select wire:model="estado" class="select select-bordered w-full max-w-xs">
|
||||
<option value="activo">Activo</option>
|
||||
<option value="inactivo">Inactivo</option>
|
||||
<option value="suspendido">Suspendido</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ── Sección: Notas ────────────────────────────────────── --}}
|
||||
<div class="pb-6 mb-6 border-b border-base-200">
|
||||
<h3 class="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-4">
|
||||
Notas internas
|
||||
</h3>
|
||||
<div class="flex items-start gap-4">
|
||||
<label class="w-48 shrink-0 pt-2 text-sm font-medium text-gray-700">
|
||||
Observaciones
|
||||
<p class="text-xs text-gray-400 font-normal mt-0.5">Información interna</p>
|
||||
</label>
|
||||
<div class="flex-1">
|
||||
<textarea wire:model="notes" rows="3"
|
||||
class="textarea textarea-bordered w-full"
|
||||
placeholder="Condiciones especiales, observaciones…"></textarea>
|
||||
{{-- ── Sección: Contacto ─────────────────────────────────── --}}
|
||||
<div class="pb-6 mb-6 border-b border-base-200">
|
||||
<h3 class="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-4">
|
||||
Contacto
|
||||
</h3>
|
||||
<div class="space-y-4">
|
||||
|
||||
<div class="flex items-start gap-4">
|
||||
<label class="w-48 shrink-0 pt-2 text-sm font-medium text-gray-700">
|
||||
Dirección
|
||||
</label>
|
||||
<div class="flex-1">
|
||||
<textarea wire:model="address" rows="2"
|
||||
class="textarea textarea-bordered w-full"
|
||||
placeholder="Calle, número, ciudad, CP, país"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start gap-4">
|
||||
<label class="w-48 shrink-0 pt-2 text-sm font-medium text-gray-700">
|
||||
Teléfono
|
||||
</label>
|
||||
<div class="flex-1">
|
||||
<label class="input input-bordered flex items-center gap-2">
|
||||
<x-heroicon-o-phone class="w-4 h-4 opacity-40 shrink-0" />
|
||||
<input type="tel" wire:model="phone" class="grow"
|
||||
placeholder="+34 600 123 456" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start gap-4">
|
||||
<label class="w-48 shrink-0 pt-2 text-sm font-medium text-gray-700">
|
||||
Email
|
||||
</label>
|
||||
<div class="flex-1">
|
||||
<label class="input input-bordered flex items-center gap-2">
|
||||
<x-heroicon-o-envelope class="w-4 h-4 opacity-40 shrink-0" />
|
||||
<input type="email" wire:model="email" class="grow"
|
||||
placeholder="contacto@empresa.com" />
|
||||
</label>
|
||||
@error('email') <p class="text-error text-xs mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start gap-4">
|
||||
<label class="w-48 shrink-0 pt-2 text-sm font-medium text-gray-700">
|
||||
Sitio web
|
||||
</label>
|
||||
<div class="flex-1">
|
||||
<label class="input input-bordered flex items-center gap-2">
|
||||
<x-heroicon-o-globe-alt class="w-4 h-4 opacity-40 shrink-0" />
|
||||
<input type="url" wire:model="website" class="grow"
|
||||
placeholder="https://www.empresa.com" />
|
||||
</label>
|
||||
@error('website') <p class="text-error text-xs mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ── Botones ───────────────────────────────────────────── --}}
|
||||
<div class="flex items-center justify-between pt-2">
|
||||
<a href="{{ route('companies.manage') }}" class="btn btn-outline gap-1" wire:navigate>
|
||||
<x-heroicon-o-x-mark class="w-4 h-4" />
|
||||
Cancelar
|
||||
</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" />
|
||||
{{ $company ? 'Guardar cambios' : 'Crear empresa' }}
|
||||
</button>
|
||||
</div>
|
||||
{{-- ── Sección: Logo ─────────────────────────────────────── --}}
|
||||
<div class="pb-6 mb-6 border-b border-base-200">
|
||||
<h3 class="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-4">
|
||||
Logo
|
||||
</h3>
|
||||
<div class="flex items-start gap-4">
|
||||
<label class="w-48 shrink-0 pt-2 text-sm font-medium text-gray-700">
|
||||
{{ $company?->logo_path ? 'Reemplazar logo' : 'Subir logo' }}
|
||||
<p class="text-xs text-gray-400 font-normal mt-0.5">PNG / JPG, máx. 2 MB</p>
|
||||
</label>
|
||||
<div class="flex-1 flex items-start gap-4">
|
||||
{{-- Preview --}}
|
||||
@if($logo)
|
||||
<img src="{{ $logo->temporaryUrl() }}" alt="Preview"
|
||||
class="w-16 h-16 object-contain border border-base-300 rounded-lg shrink-0" />
|
||||
@elseif($company?->logo_path && \Illuminate\Support\Facades\Storage::disk('public')->exists($company->logo_path))
|
||||
<img src="{{ \Illuminate\Support\Facades\Storage::disk('public')->url($company->logo_path) }}"
|
||||
alt="Logo actual"
|
||||
class="w-16 h-16 object-contain border border-base-300 rounded-lg shrink-0" />
|
||||
@else
|
||||
<div class="w-16 h-16 bg-base-200 rounded-lg flex items-center justify-center shrink-0">
|
||||
<x-heroicon-o-building-office class="w-7 h-7 opacity-30" />
|
||||
</div>
|
||||
@endif
|
||||
<div class="flex-1">
|
||||
<input type="file" wire:model="logo" accept="image/*"
|
||||
class="file-input file-input-bordered w-full" />
|
||||
<div wire:loading wire:target="logo" class="text-xs text-info mt-1">Subiendo…</div>
|
||||
@error('logo') <p class="text-error text-xs mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
{{-- ── Sección: Notas ────────────────────────────────────── --}}
|
||||
<div class="pb-6 mb-6 border-b border-base-200">
|
||||
<h3 class="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-4">
|
||||
Notas internas
|
||||
</h3>
|
||||
<div class="flex items-start gap-4">
|
||||
<label class="w-48 shrink-0 pt-2 text-sm font-medium text-gray-700">
|
||||
Observaciones
|
||||
<p class="text-xs text-gray-400 font-normal mt-0.5">Información interna</p>
|
||||
</label>
|
||||
<div class="flex-1">
|
||||
<textarea wire:model="notes" rows="3"
|
||||
class="textarea textarea-bordered w-full"
|
||||
placeholder="Condiciones especiales, observaciones…"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ── Botones ───────────────────────────────────────────── --}}
|
||||
<div class="flex items-center justify-between pt-2">
|
||||
<a href="{{ route('companies.manage') }}" class="btn btn-outline gap-1" wire:navigate>
|
||||
<x-heroicon-o-x-mark class="w-4 h-4" />
|
||||
Cancelar
|
||||
</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" />
|
||||
{{ $company ? 'Guardar cambios' : 'Crear empresa' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
</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 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>
|
||||
<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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -49,7 +49,7 @@ new class extends Component
|
||||
</x-nav-link>
|
||||
</div>
|
||||
|
||||
@can('manage all')
|
||||
@can('view users')
|
||||
<div class="hidden space-x-8 sm:-my-px sm:ms-10 sm:flex">
|
||||
<x-nav-link :href="route('admin.users')" :active="request()->routeIs('admin.users')" wire:navigate>
|
||||
{{ __('Administrator') }}
|
||||
|
||||
@@ -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,3 +0,0 @@
|
||||
<div>
|
||||
{{-- Nothing in the world is as soft and yielding as water. --}}
|
||||
</div>
|
||||
@@ -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
|
||||
<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>
|
||||
|
||||
<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
|
||||
@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>
|
||||
<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 x-show="tab==='data'">
|
||||
@include('livewire.projects.partials.project-data-form')
|
||||
</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 x-show="tab==='phases'" x-cloak>
|
||||
<livewire:phase-list :project="$project" :key="'phases-'.$project->id" />
|
||||
</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 x-show="tab==='users'" x-cloak>
|
||||
<livewire:project-users :project="$project" :key="'users-'.$project->id" />
|
||||
</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 x-show="tab==='companies'" x-cloak>
|
||||
<livewire:project-companies :project="$project" :key="'companies-'.$project->id" />
|
||||
</div>
|
||||
</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">
|
||||
</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>
|
||||
</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>
|
||||
@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;
|
||||
|
||||
// Initialize Leaflet map
|
||||
function initMap() {
|
||||
if (map) return;
|
||||
|
||||
// Default coordinates (can be overridden)
|
||||
const defaultLat = @json($lat ?? 0);
|
||||
const defaultLng = @json($lng ?? 0);
|
||||
|
||||
const center = defaultLat && defaultLng ? [defaultLat, defaultLng] : [0, 0];
|
||||
map = L.map('projectMap').setView(center, 13);
|
||||
|
||||
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);
|
||||
|
||||
// Add marker if we have coordinates
|
||||
if (defaultLat && defaultLng) {
|
||||
marker = L.marker([defaultLat, defaultLng], {
|
||||
draggable: true
|
||||
}).addTo(map);
|
||||
|
||||
marker.on('dragend', function(e) {
|
||||
const pos = marker.getLatLng();
|
||||
updateCoordinates(pos.lat, pos.lng);
|
||||
});
|
||||
@push('scripts')
|
||||
<script>
|
||||
(function () {
|
||||
let pmap = null, pmarker = null;
|
||||
|
||||
function setStatus(msg) {
|
||||
const s = document.getElementById('geocode-status');
|
||||
if (s) s.textContent = msg || '';
|
||||
}
|
||||
|
||||
// Handle map clicks to place marker
|
||||
map.on('click', function(e) {
|
||||
const pos = e.latlng;
|
||||
if (marker) {
|
||||
marker.setLatLng(pos);
|
||||
|
||||
function placeMarker(lat, lng) {
|
||||
if (!pmap) return;
|
||||
if (pmarker) {
|
||||
pmarker.setLatLng([lat, lng]);
|
||||
} else {
|
||||
marker = L.marker(pos, {
|
||||
draggable: true
|
||||
}).addTo(map);
|
||||
|
||||
marker.on('dragend', function(e) {
|
||||
const pos = marker.getLatLng();
|
||||
updateCoordinates(pos.lat, pos.lng);
|
||||
pmarker = L.marker([lat, lng], { draggable: true }).addTo(pmap);
|
||||
pmarker.on('dragend', () => {
|
||||
const p = pmarker.getLatLng();
|
||||
pickLocation(p.lat, p.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();
|
||||
});
|
||||
|
||||
// Also initialize on DOMContentLoaded as fallback
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
initMap();
|
||||
});
|
||||
</script>
|
||||
@endpush
|
||||
|
||||
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(pmap);
|
||||
|
||||
if (hasCoords) placeMarker(lat, lng);
|
||||
|
||||
pmap.on('click', (e) => {
|
||||
placeMarker(e.latlng.lat, e.latlng.lng);
|
||||
pickLocation(e.latlng.lat, e.latlng.lng);
|
||||
});
|
||||
|
||||
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); }
|
||||
});
|
||||
|
||||
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') }"
|
||||
<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">
|
||||
<div class="flex justify-between items-center gap-4 mb-4">
|
||||
<!-- Tabs -->
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<button role="tab" wire:click="setActiveTab('edit')" class="btn btn-sm {{ $activeTab === 'edit' ? 'btn-primary' : 'btn-ghost' }}">{{ __('Edit') }}</button>
|
||||
<button role="tab" wire:click="setActiveTab('features')" class="btn btn-sm {{ $activeTab === 'features' ? 'btn-primary' : 'btn-ghost' }}">{{ __('Features') }}</button>
|
||||
<button role="tab" wire:click="setActiveTab('inspections')" class="btn btn-sm {{ $activeTab === 'inspections' ? 'btn-primary' : 'btn-ghost' }}">{{ __('Inspections') }}</button>
|
||||
<button role="tab" 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>
|
||||
|
||||
<!-- Project navigation bar -->
|
||||
<div class="flex flex-wrap gap-1 mb-3">
|
||||
<!-- 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 {{ request()->routeIs('projects.dashboard') ? 'btn-primary' : 'btn-outline' }}">
|
||||
📊 {{ __('Dashboard') }}
|
||||
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 {{ request()->routeIs('projects.map') ? 'btn-primary' : 'btn-outline' }}">
|
||||
🗺️ {{ __('Map') }}
|
||||
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 {{ request()->routeIs('projects.gantt') ? 'btn-primary' : 'btn-outline' }}">
|
||||
📅 {{ __('Gantt') }}
|
||||
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 {{ request()->routeIs('projects.report') ? 'btn-primary' : 'btn-outline' }}">
|
||||
📄 {{ __('Report') }}
|
||||
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 {{ request()->routeIs('projects.issues') ? 'btn-error' : 'btn-outline' }} gap-1">
|
||||
⚠️ {{ __('Issues') }}
|
||||
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>
|
||||
|
||||
<!-- 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">
|
||||
{{ __('Issues') }}
|
||||
@if($openIssuesCount > 0)
|
||||
<span class="badge badge-error badge-xs">{{ $openIssuesCount }}</span>
|
||||
@endif
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<div class="tab-content">
|
||||
<!-- 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>
|
||||
</div>
|
||||
|
||||
{{-- {{ __("Progress") }} --}}
|
||||
<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>
|
||||
{{-- 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>
|
||||
|
||||
<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>
|
||||
{{-- 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">{{ __('Responsible') }}</label>
|
||||
<input type="text" wire:model="editResponsible" wire:blur="saveFeatureProgress" class="input input-bordered input-sm" placeholder="{{ __('Name of responsible') }}" />
|
||||
</div>
|
||||
|
||||
{{-- 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>
|
||||
@@ -370,71 +435,82 @@
|
||||
// Prevent multiple initializations
|
||||
if (mapInitialized || map) return;
|
||||
mapInitialized = true;
|
||||
|
||||
|
||||
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', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> & CartoDB'
|
||||
}).addTo(map);
|
||||
// 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'
|
||||
}),
|
||||
'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)
|
||||
@php
|
||||
$phaseFeatures = $phase->features()->with('layer.phase')->get();
|
||||
$fc = [
|
||||
'type' => 'FeatureCollection',
|
||||
'features' => $phaseFeatures->map(function($f) {
|
||||
return [
|
||||
'type' => 'Feature',
|
||||
'id' => $f->id,
|
||||
'geometry' => $f->geometry,
|
||||
'properties' => array_merge($f->properties ?? [], [
|
||||
'name' => $f->name,
|
||||
'progress' => $f->progress,
|
||||
'responsible' => $f->responsible,
|
||||
'template_id' => $f->template_id,
|
||||
'_feature_id' => $f->id,
|
||||
])
|
||||
];
|
||||
})->values()->toArray()
|
||||
];
|
||||
@endphp
|
||||
(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 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 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); });
|
||||
}
|
||||
});
|
||||
layers[{{ $phase->id }}] = phaseLayer;
|
||||
@if(in_array($phase->id, $activeLayers))
|
||||
phaseLayer.addTo(map);
|
||||
@endif
|
||||
}
|
||||
})()
|
||||
@foreach($phase->layers as $layer)
|
||||
@php
|
||||
$fc = [
|
||||
'type' => 'FeatureCollection',
|
||||
'features' => $layer->features->map(function($f) {
|
||||
return [
|
||||
'type' => 'Feature',
|
||||
'id' => $f->id,
|
||||
'geometry' => $f->geometry,
|
||||
'properties' => array_merge($f->properties ?? [], [
|
||||
'name' => $f->name,
|
||||
'progress' => $f->progress,
|
||||
'responsible' => $f->responsible,
|
||||
'template_id' => $f->template_id,
|
||||
'_feature_id' => $f->id,
|
||||
])
|
||||
];
|
||||
})->values()->toArray()
|
||||
];
|
||||
@endphp
|
||||
(function() {
|
||||
const data = @json($fc);
|
||||
if (data && data.features && data.features.length > 0) {
|
||||
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;
|
||||
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}')">{{ __('Edit') }}</button>`;
|
||||
lyr.bindPopup(content);
|
||||
lyr.on('click', function() { selectFeature(featId); });
|
||||
}
|
||||
});
|
||||
layers[{{ $layer->id }}] = layerGroup;
|
||||
@if(in_array((int) $layer->id, $activeLayers))
|
||||
layerGroup.addTo(map);
|
||||
@endif
|
||||
}
|
||||
})()
|
||||
@endforeach
|
||||
@endforeach
|
||||
|
||||
// Initialize combined bounds
|
||||
updateCombinedBounds();
|
||||
|
||||
|
||||
setTimeout(() => {
|
||||
map.invalidateSize();
|
||||
zoomToAllFeatures();
|
||||
}, 100); // Reduced from 200ms to 100ms
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function updateCombinedBounds() {
|
||||
@@ -445,9 +521,9 @@
|
||||
const layer = layers[id];
|
||||
if (map.hasLayer(layer) && typeof layer.getBounds === 'function') {
|
||||
const b = layer.getBounds();
|
||||
if (b.isValid()) {
|
||||
combinedBounds.extend(b);
|
||||
hasBounds = true;
|
||||
if (b.isValid()) {
|
||||
combinedBounds.extend(b);
|
||||
hasBounds = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -509,9 +579,8 @@
|
||||
});
|
||||
|
||||
Livewire.on('centerMap', zoomToAllFeatures);
|
||||
Livewire.on('mapResize', () => {
|
||||
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
|
||||
if (imageMarkersLayer) {
|
||||
map.removeLayer(imageMarkersLayer);
|
||||
imageMarkersLayer = null;
|
||||
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';
|
||||
@@ -582,4 +643,4 @@
|
||||
};
|
||||
});
|
||||
</script>
|
||||
@endpush
|
||||
@endpush
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<div class="py-8 max-w-7xl 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="toggle toggle-sm toggle-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,175 @@
|
||||
<div class="py-8 max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
|
||||
{{-- Cabecera: nombre del rol + botón Volver --}}
|
||||
<x-slot name="header">
|
||||
<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>
|
||||
</x-slot>
|
||||
|
||||
{{-- 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 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<h3 class="font-semibold text-gray-700">{{ __('Users with this role') }} ({{ $users->count() }})</h3>
|
||||
{{-- Formulario para añadir un usuario al rol --}}
|
||||
<form wire:submit.prevent="addUser" class="flex items-center gap-2">
|
||||
<select wire:model="newUserId" class="select select-bordered select-sm w-56 @error('newUserId') select-error @enderror">
|
||||
<option value="">{{ __('Select a user...') }}</option>
|
||||
@foreach($availableUsers as $au)
|
||||
<option value="{{ $au->id }}">
|
||||
{{ trim(($au->first_name ?? '').' '.($au->last_name ?? '')) ?: $au->name }} — {{ $au->email }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<button class="btn btn-sm btn-primary gap-1">
|
||||
<x-heroicon-o-plus class="w-4 h-4" /> {{ __('Add') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@error('newUserId') <p class="text-error text-xs px-6 pt-2">{{ $message }}</p> @enderror
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ __('Name') }}</th>
|
||||
<th>{{ __('Last name') }}</th>
|
||||
<th>{{ __('Status') }}</th>
|
||||
<th class="w-12"></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>
|
||||
<td class="text-right">
|
||||
<button wire:click="removeUser({{ $u->id }})"
|
||||
wire:confirm="{{ __('Remove this user from the role?') }}"
|
||||
class="btn btn-ghost btn-xs text-error" title="{{ __('Remove') }}">
|
||||
<x-heroicon-o-x-mark class="w-4 h-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="4" 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="space-y-3">
|
||||
@forelse($grouped as $section => $perms)
|
||||
<div x-data="{ open: true }" class="bg-white rounded-lg shadow overflow-hidden">
|
||||
{{-- Cabecera del grupo: colapsable + marcar/desmarcar todo --}}
|
||||
<div class="flex items-center justify-between px-4 py-3 bg-base-200/60">
|
||||
<button type="button" @click="open = !open" class="flex items-center gap-2 flex-1 text-left">
|
||||
<span class="transition-transform duration-200" :class="open ? 'rotate-90' : ''">
|
||||
<x-heroicon-o-chevron-right class="w-4 h-4 text-gray-500" />
|
||||
</span>
|
||||
<span class="font-semibold text-gray-700">{{ $section }}</span>
|
||||
<span class="badge badge-ghost badge-sm">{{ $perms->count() }}</span>
|
||||
</button>
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<button wire:click="setGroup('{{ $section }}', true)" class="btn btn-xs btn-ghost gap-1" title="{{ __('Check all') }}">
|
||||
<x-heroicon-o-check class="w-3.5 h-3.5" /> {{ __('All') }}
|
||||
</button>
|
||||
<button wire:click="setGroup('{{ $section }}', false)" class="btn btn-xs btn-ghost gap-1" title="{{ __('Uncheck all') }}">
|
||||
<x-heroicon-o-x-mark class="w-3.5 h-3.5" /> {{ __('None') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Lista de permisos: una sola columna (permiso izquierda, switch derecha) --}}
|
||||
<div x-show="open" x-transition.opacity class="divide-y divide-base-200">
|
||||
@foreach($perms as $perm)
|
||||
<label class="flex items-center justify-between gap-3 px-4 py-2.5 cursor-pointer hover:bg-base-100">
|
||||
<div class="min-w-0">
|
||||
<span class="text-sm font-medium">{{ $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-sm toggle-primary"
|
||||
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,22 +139,24 @@
|
||||
<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>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
+18
-25
@@ -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;
|
||||
|
||||
/*
|
||||
@@ -95,12 +85,11 @@ Route::middleware(['auth'])->group(function () {
|
||||
'recentIssues' => $recentIssues,
|
||||
]);
|
||||
})->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('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('/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');
|
||||
});
|
||||
|
||||
@@ -141,12 +130,17 @@ Route::middleware(['auth'])->group(function () {
|
||||
})->name('dashboard');
|
||||
});
|
||||
|
||||
// Admin: gestión de usuarios y roles
|
||||
Route::middleware(['can:manage all'])->prefix('admin')->name('admin.')->group(function () {
|
||||
Route::get('/users', function () { return view('admin.users'); })->name('users');
|
||||
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');
|
||||
// Admin: gestión de usuarios y roles (cada ruta protegida por su permiso)
|
||||
Route::prefix('admin')->name('admin.')->group(function () {
|
||||
Route::get('/users', function () { return view('admin.users'); })->middleware('can:view users')->name('users');
|
||||
Route::get('/users/create', \App\Livewire\UserForm::class)->middleware('can:create users')->name('users.create');
|
||||
Route::get('/users/{user}', \App\Livewire\UserView::class)->middleware('can:view users')->name('users.show');
|
||||
Route::get('/users/{user}/edit', \App\Livewire\UserForm::class)->middleware('can:edit users')->name('users.edit');
|
||||
Route::get('/roles', function () { return view('admin.roles'); })->middleware('can:manage roles')->name('roles');
|
||||
Route::get('/roles/create', \App\Livewire\RoleForm::class)->middleware('can:manage roles')->name('roles.create');
|
||||
Route::get('/roles/{role}/edit', \App\Livewire\RoleForm::class)->middleware('can:manage roles')->name('roles.edit');
|
||||
Route::get('/roles/{role}', \App\Livewire\RoleView::class)->middleware('can:manage roles')->name('roles.show');
|
||||
Route::get('/permissions', \App\Livewire\RolePermissionManager::class)->middleware('can:manage roles')->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