restore: bring back f8a1310 (security review) state
Restores all files to thef8a1310security-review snapshot as requested, plus the 2 boot-critical fixes froma24c8a2(config/session.php env() instead of app()->environment(), and removal of the duplicate $activeTab in ProjectMap.php) so the application actually boots. Forward commit, no history rewrite. The7d854ffstate remains in history. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -13,14 +13,26 @@ use Illuminate\Support\Facades\Storage;
|
|||||||
|
|
||||||
class OfflineSyncController extends Controller
|
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)
|
public function storePending(Request $request)
|
||||||
{
|
{
|
||||||
$payload = $request->validate([
|
$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',
|
'payload' => 'required|array',
|
||||||
]);
|
]);
|
||||||
$pending = PendingSync::create([
|
PendingSync::create([
|
||||||
'user_id' => Auth::id() ?? 1,
|
'user_id' => Auth::id(),
|
||||||
'action' => $payload['action'],
|
'action' => $payload['action'],
|
||||||
'payload' => $payload['payload'],
|
'payload' => $payload['payload'],
|
||||||
]);
|
]);
|
||||||
@@ -32,65 +44,111 @@ class OfflineSyncController extends Controller
|
|||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
$pendings = PendingSync::where('user_id', $user->id)->whereNull('synced_at')->get();
|
$pendings = PendingSync::where('user_id', $user->id)->whereNull('synced_at')->get();
|
||||||
$results = [];
|
$results = [];
|
||||||
|
|
||||||
foreach ($pendings as $pending) {
|
foreach ($pendings as $pending) {
|
||||||
$result = ['id' => $pending->id, 'action' => $pending->action, 'success' => false, 'error' => null];
|
$result = ['id' => $pending->id, 'action' => $pending->action, 'success' => false, 'error' => null];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if ($pending->action === 'progress_update') {
|
if ($pending->action === 'progress_update') {
|
||||||
$phase = Phase::find($pending->payload['phase_id']);
|
$phaseId = (int) ($pending->payload['phase_id'] ?? 0);
|
||||||
|
$progress = (int) ($pending->payload['progress'] ?? 0);
|
||||||
|
$progress = max(0, min(100, $progress));
|
||||||
|
|
||||||
|
$phase = Phase::find($phaseId);
|
||||||
if ($phase) {
|
if ($phase) {
|
||||||
$phase->progress_percent = $pending->payload['progress'];
|
// 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->save();
|
||||||
$phase->progressUpdates()->create([
|
$phase->progressUpdates()->create([
|
||||||
'user_id' => $user->id,
|
'user_id' => $user->id,
|
||||||
'progress_percent' => $pending->payload['progress'],
|
'progress_percent' => $progress,
|
||||||
'comment' => $pending->payload['comment'] ?? '',
|
'comment' => substr($pending->payload['comment'] ?? '', 0, 500),
|
||||||
'location' => $pending->payload['location'] ?? null,
|
|
||||||
]);
|
]);
|
||||||
}
|
|
||||||
$result['success'] = true;
|
$result['success'] = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$result['error'] = 'Phase not found.';
|
||||||
|
}
|
||||||
|
|
||||||
} elseif ($pending->action === 'inspection') {
|
} elseif ($pending->action === 'inspection') {
|
||||||
$inspection = Inspection::create($pending->payload);
|
$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'] : [],
|
||||||
|
]);
|
||||||
$result['success'] = true;
|
$result['success'] = true;
|
||||||
$result['data'] = ['inspection_id' => $inspection->id];
|
$result['data'] = ['inspection_id' => $inspection->id];
|
||||||
|
|
||||||
} elseif ($pending->action === 'feature_create') {
|
} elseif ($pending->action === 'feature_create') {
|
||||||
$feature = Feature::create($pending->payload);
|
$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,
|
||||||
|
]);
|
||||||
$result['success'] = true;
|
$result['success'] = true;
|
||||||
$result['data'] = ['feature_id' => $feature->id];
|
$result['data'] = ['feature_id' => $feature->id];
|
||||||
|
|
||||||
} elseif ($pending->action === 'media_upload') {
|
} 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'])) {
|
if (isset($pending->payload['file'], $pending->payload['path'])) {
|
||||||
$decoded = base64_decode($pending->payload['file']);
|
// Restrict path to safe uploads directory
|
||||||
|
$safePath = 'uploads/' . ltrim(basename($pending->payload['path']), '/');
|
||||||
|
$decoded = base64_decode($pending->payload['file'], true);
|
||||||
|
|
||||||
if ($decoded !== false) {
|
if ($decoded !== false) {
|
||||||
$path = Storage::put($pending->payload['path'], $decoded);
|
Storage::disk('public')->put($safePath, $decoded);
|
||||||
// Attach to model if model_type and model_id are provided
|
|
||||||
|
// Whitelist-based model type resolution (prevents RCE)
|
||||||
if (isset($pending->payload['model_type'], $pending->payload['model_id'])) {
|
if (isset($pending->payload['model_type'], $pending->payload['model_id'])) {
|
||||||
$model = new $pending->payload['model_type'];
|
$typeKey = strtolower(trim($pending->payload['model_type']));
|
||||||
$model = $model->find($pending->payload['model_id']);
|
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) {
|
if ($model) {
|
||||||
$model->media()->create([
|
$model->media()->create([
|
||||||
'name' => $pending->payload['name'] ?? 'unnamed',
|
'name' => substr($pending->payload['name'] ?? 'unnamed', 0, 255),
|
||||||
'path' => $path,
|
'file_path' => $safePath,
|
||||||
'mime_type' => $pending->payload['mime_type'] ?? 'application/octet-stream',
|
'file_type' => substr($pending->payload['mime_type'] ?? 'application/octet-stream', 0, 100),
|
||||||
'disk' => 'public',
|
'file_extension' => pathinfo($safePath, PATHINFO_EXTENSION),
|
||||||
|
'file_size' => strlen($decoded),
|
||||||
|
'category' => 'other',
|
||||||
|
'uploaded_by' => $user->id,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
$result['success'] = true;
|
$result['success'] = true;
|
||||||
$result['data'] = ['path' => $path];
|
$result['data'] = ['path' => $safePath];
|
||||||
} else {
|
} else {
|
||||||
$result['error'] = 'Failed to decode base64 file';
|
$result['error'] = 'Failed to decode base64 file.';
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
$result['error'] = 'Missing file or path in payload';
|
$result['error'] = 'Missing file or path in payload.';
|
||||||
}
|
}
|
||||||
|
|
||||||
} elseif ($pending->action === 'task_complete') {
|
} elseif ($pending->action === 'task_complete') {
|
||||||
// Example: mark a task as complete (you can adjust as needed)
|
// No-op placeholder, just mark as synced
|
||||||
// For now, just log and mark as success
|
|
||||||
\Log::info('Task completed offline', $pending->payload);
|
|
||||||
$result['success'] = true;
|
$result['success'] = true;
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
$result['error'] = 'Unknown action type';
|
$result['error'] = 'Unknown action type.';
|
||||||
}
|
}
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$result['error'] = $e->getMessage();
|
$result['error'] = $e->getMessage();
|
||||||
@@ -103,6 +161,7 @@ class OfflineSyncController extends Controller
|
|||||||
|
|
||||||
$results[] = $result;
|
$results[] = $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
return response()->json(['synced' => $results]);
|
return response()->json(['synced' => $results]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,21 +11,25 @@ use App\Exports\ProjectsExport;
|
|||||||
use App\Exports\PhasesExport;
|
use App\Exports\PhasesExport;
|
||||||
use App\Exports\InspectionsExport;
|
use App\Exports\InspectionsExport;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
|
||||||
class ExportController extends Controller
|
class ExportController extends Controller
|
||||||
{
|
{
|
||||||
public function exportProjects(Request $request)
|
public function exportProjects(Request $request)
|
||||||
{
|
{
|
||||||
|
Gate::authorize('manage all');
|
||||||
return Excel::download(new ProjectsExport, 'projects.xlsx');
|
return Excel::download(new ProjectsExport, 'projects.xlsx');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function exportPhases(Request $request)
|
public function exportPhases(Request $request)
|
||||||
{
|
{
|
||||||
|
Gate::authorize('manage all');
|
||||||
return Excel::download(new PhasesExport, 'phases.xlsx');
|
return Excel::download(new PhasesExport, 'phases.xlsx');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function exportInspections(Request $request)
|
public function exportInspections(Request $request)
|
||||||
{
|
{
|
||||||
|
Gate::authorize('manage all');
|
||||||
return Excel::download(new InspectionsExport, 'inspections.xlsx');
|
return Excel::download(new InspectionsExport, 'inspections.xlsx');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-14
@@ -9,34 +9,53 @@ use Illuminate\Support\Facades\Auth;
|
|||||||
|
|
||||||
class AdminUsers extends Component
|
class AdminUsers extends Component
|
||||||
{
|
{
|
||||||
public string $search = '';
|
public $users;
|
||||||
public $roles;
|
public $roles;
|
||||||
|
|
||||||
public function mount(): void
|
public function mount()
|
||||||
{
|
{
|
||||||
if (!Auth::user()->hasRole('Admin')) abort(403);
|
if (!Auth::user()->hasRole('Admin')) {
|
||||||
$this->roles = Role::orderBy('name')->get();
|
abort(403);
|
||||||
|
}
|
||||||
|
$this->roles = Role::all();
|
||||||
|
$this->loadUsers();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getUsersProperty()
|
public function loadUsers()
|
||||||
{
|
{
|
||||||
return User::with('roles')
|
$this->users = User::with('roles')->orderBy('name')->get();
|
||||||
->when($this->search, fn($q) =>
|
}
|
||||||
$q->where(fn($q2) => $q2
|
|
||||||
->where('name', 'like', '%' . $this->search . '%')
|
public function updateRole($userId, $roleName)
|
||||||
->orWhere('email', 'like', '%' . $this->search . '%')))
|
{
|
||||||
->orderBy('name')
|
$user = Auth::user();
|
||||||
->get();
|
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.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function deleteUser(int $userId): void
|
public function deleteUser(int $userId): void
|
||||||
{
|
{
|
||||||
|
if (!Auth::user()->hasRole('Admin')) abort(403);
|
||||||
|
|
||||||
if ($userId === Auth::id()) {
|
if ($userId === Auth::id()) {
|
||||||
$this->dispatch('notify', 'No puedes eliminarte a ti mismo.');
|
session()->flash('error', 'No puedes eliminarte a ti mismo.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
User::findOrFail($userId)->delete();
|
User::findOrFail($userId)->delete();
|
||||||
$this->dispatch('notify', 'Usuario eliminado.');
|
session()->flash('message', 'Usuario eliminado.');
|
||||||
|
$this->loadUsers();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
|
|||||||
@@ -4,11 +4,7 @@ namespace App\Livewire\Client;
|
|||||||
|
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\Phase;
|
|
||||||
use App\Models\Inspection;
|
|
||||||
use App\Models\Feature;
|
|
||||||
use App\Models\ChangeOrder;
|
use App\Models\ChangeOrder;
|
||||||
use Carbon\Carbon;
|
|
||||||
|
|
||||||
class ClientProjects extends Component
|
class ClientProjects extends Component
|
||||||
{
|
{
|
||||||
@@ -25,7 +21,6 @@ class ClientProjects extends Component
|
|||||||
|
|
||||||
public function loadProjects()
|
public function loadProjects()
|
||||||
{
|
{
|
||||||
// Get projects where the user has the 'client' role
|
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
$this->projects = $user->projects()
|
$this->projects = $user->projects()
|
||||||
->wherePivot('role_in_project', 'client')
|
->wherePivot('role_in_project', 'client')
|
||||||
@@ -36,9 +31,23 @@ class ClientProjects extends Component
|
|||||||
->toArray();
|
->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)
|
public function selectProject($projectId)
|
||||||
{
|
{
|
||||||
$this->selectedProject = $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->loadProjectDetails();
|
$this->loadProjectDetails();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,10 +57,14 @@ class ClientProjects extends Component
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-verify ownership on every load
|
||||||
|
if (!$this->accessibleProjectIds()->contains($this->selectedProject)) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
$project = Project::with([
|
$project = Project::with([
|
||||||
'phases.features',
|
'phases',
|
||||||
'inspections.template',
|
'changeOrders',
|
||||||
'changeOrders' // Load change orders for this project
|
|
||||||
])->find($this->selectedProject);
|
])->find($this->selectedProject);
|
||||||
|
|
||||||
if (!$project) {
|
if (!$project) {
|
||||||
@@ -61,111 +74,90 @@ class ClientProjects extends Component
|
|||||||
$this->projectDetails = [
|
$this->projectDetails = [
|
||||||
'id' => $project->id,
|
'id' => $project->id,
|
||||||
'name' => $project->name,
|
'name' => $project->name,
|
||||||
'description' => $project->description,
|
'description'=> $project->description ?? '',
|
||||||
'start_date' => $project->start_date,
|
'start_date' => $project->start_date,
|
||||||
'end_date' => $project->end_date,
|
'end_date' => $project->end_date_estimated,
|
||||||
'status' => $project->status,
|
'status' => $project->status,
|
||||||
'progress' => $project->phases->avg('progress_percent') ?? 0,
|
'progress' => round($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()
|
$mediaImages = $project->media()
|
||||||
->where('category', 'image')
|
->where('category', 'image')
|
||||||
->latest()
|
->latest()
|
||||||
->take(3)
|
->take(3)
|
||||||
->get()
|
->get()
|
||||||
->map(function($media) {
|
->map(fn ($media) => [
|
||||||
return [
|
|
||||||
'url' => $media->url,
|
'url' => $media->url,
|
||||||
'title' => $media->name,
|
'title' => $media->name,
|
||||||
'date' => $media->created_at->format('d/m/Y')
|
'date' => $media->created_at->format('d/m/Y'),
|
||||||
];
|
])
|
||||||
})
|
|
||||||
->toArray();
|
->toArray();
|
||||||
|
|
||||||
// If we don't have 3 images, we can fallback to placeholders or just use what we have
|
$this->galleryImages = $mediaImages ?: [];
|
||||||
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
|
$this->changeOrders = $project->changeOrders
|
||||||
->orderBy('requested_at', 'desc')
|
->sortByDesc('requested_at')
|
||||||
->get()
|
->map(fn ($order) => [
|
||||||
->map(function($order) {
|
|
||||||
return [
|
|
||||||
'id' => $order->id,
|
'id' => $order->id,
|
||||||
'title' => $order->title,
|
'title' => $order->title,
|
||||||
'description' => $order->description,
|
'description' => $order->description,
|
||||||
'status' => $order->status,
|
'status' => $order->status,
|
||||||
'requested_at' => $order->requested_at->format('d/m/Y'),
|
'requested_at' => $order->requested_at?->format('d/m/Y') ?? '',
|
||||||
'amount' => $order->amount
|
'amount' => $order->amount,
|
||||||
];
|
])
|
||||||
})
|
->values()
|
||||||
->toArray();
|
->toArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function approveChangeOrder($orderId)
|
public function approveChangeOrder($orderId)
|
||||||
{
|
{
|
||||||
// Update the change order in the database
|
$changeOrder = ChangeOrder::where('id', $orderId)
|
||||||
$changeOrder = ChangeOrder::find($orderId);
|
->where('project_id', $this->selectedProject)
|
||||||
if ($changeOrder) {
|
->first();
|
||||||
// Check that the change order belongs to the selected project (security)
|
|
||||||
if ($changeOrder->project_id == $this->selectedProject) {
|
if (!$changeOrder) {
|
||||||
$changeOrder->status = 'approved';
|
abort(403);
|
||||||
$changeOrder->responded_at = now()->toDateString();
|
}
|
||||||
$changeOrder->responded_by = auth()->id();
|
|
||||||
$changeOrder->save();
|
// 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(),
|
||||||
|
]);
|
||||||
|
|
||||||
// Refresh the change orders list
|
|
||||||
$this->loadProjectDetails();
|
$this->loadProjectDetails();
|
||||||
|
|
||||||
// Notify any listeners (optional)
|
|
||||||
$this->dispatch('changeOrderUpdated', ['id' => $orderId, 'status' => 'approved']);
|
$this->dispatch('changeOrderUpdated', ['id' => $orderId, 'status' => 'approved']);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function rejectChangeOrder($orderId)
|
public function rejectChangeOrder($orderId)
|
||||||
{
|
{
|
||||||
// Update the change order in the database
|
$changeOrder = ChangeOrder::where('id', $orderId)
|
||||||
$changeOrder = ChangeOrder::find($orderId);
|
->where('project_id', $this->selectedProject)
|
||||||
if ($changeOrder) {
|
->first();
|
||||||
// Check that the change order belongs to the selected project (security)
|
|
||||||
if ($changeOrder->project_id == $this->selectedProject) {
|
if (!$changeOrder) {
|
||||||
$changeOrder->status = 'rejected';
|
abort(403);
|
||||||
$changeOrder->responded_at = now()->toDateString();
|
}
|
||||||
$changeOrder->responded_by = auth()->id();
|
|
||||||
$changeOrder->save();
|
// 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(),
|
||||||
|
]);
|
||||||
|
|
||||||
// Refresh the change orders list
|
|
||||||
$this->loadProjectDetails();
|
$this->loadProjectDetails();
|
||||||
|
|
||||||
// Notify any listeners (optional)
|
|
||||||
$this->dispatch('changeOrderUpdated', ['id' => $orderId, 'status' => 'rejected']);
|
$this->dispatch('changeOrderUpdated', ['id' => $orderId, 'status' => 'rejected']);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,61 +3,235 @@
|
|||||||
namespace App\Livewire;
|
namespace App\Livewire;
|
||||||
|
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use Livewire\Attributes\Layout;
|
use Livewire\WithFileUploads;
|
||||||
use App\Models\Company;
|
use App\Models\Company;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
|
|
||||||
#[Layout('layouts.app')]
|
|
||||||
class CompanyManagement extends Component
|
class CompanyManagement extends Component
|
||||||
{
|
{
|
||||||
public string $search = '';
|
use WithFileUploads;
|
||||||
public string $filterType = '';
|
|
||||||
public string $filterEstado = '';
|
// 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 function getCompaniesProperty()
|
public function getCompaniesProperty()
|
||||||
{
|
{
|
||||||
return Company::when($this->search, function ($q) {
|
return Company::when($this->search, function ($query) {
|
||||||
$s = '%' . $this->search . '%';
|
$query->where('name', 'like', '%' . $this->search . '%')
|
||||||
$q->where(fn($q2) => $q2
|
->orWhere('apodo', 'like', '%' . $this->search . '%')
|
||||||
->where('name', 'like', $s)
|
->orWhere('tax_id', 'like', '%' . $this->search . '%');
|
||||||
->orWhere('apodo', 'like', $s)
|
|
||||||
->orWhere('tax_id', 'like', $s));
|
|
||||||
})
|
})
|
||||||
->when($this->filterType, fn($q) => $q->where('type', $this->filterType))
|
->when($this->filterType, function ($query) {
|
||||||
->when($this->filterEstado, fn($q) => $q->where('estado', $this->filterEstado))
|
$query->where('type', $this->filterType);
|
||||||
->withCount('projects')
|
})
|
||||||
|
->when($this->filterEstado, function ($query) {
|
||||||
|
$query->where('estado', $this->filterEstado);
|
||||||
|
})
|
||||||
|
->withCount('projects') // Eager load project count
|
||||||
->orderBy('name')
|
->orderBy('name')
|
||||||
->get();
|
->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()
|
public function exportCsv()
|
||||||
{
|
{
|
||||||
$companies = $this->getCompaniesProperty();
|
$companies = $this->getCompaniesProperty();
|
||||||
|
|
||||||
return response()->streamDownload(function () use ($companies) {
|
// 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) {
|
||||||
$handle = fopen('php://output', 'w');
|
$handle = fopen('php://output', 'w');
|
||||||
|
// Add BOM for UTF-8 in Excel
|
||||||
fprintf($handle, chr(0xEF).chr(0xBB).chr(0xBF));
|
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) {
|
// 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) {
|
||||||
fputcsv($handle, [
|
fputcsv($handle, [
|
||||||
$c->name, $c->apodo ?? '', $c->tax_id ?? '',
|
$company->name,
|
||||||
$c->type, $c->estado, $c->address ?? '',
|
$company->apodo ?? '',
|
||||||
$c->phone ?? '', $c->email ?? '', $c->website ?? '',
|
$company->tax_id ?? '',
|
||||||
$c->projects_count ?? 0,
|
$company->type,
|
||||||
$c->created_at?->format('d/m/Y'),
|
$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') : ''
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
fclose($handle);
|
fclose($handle);
|
||||||
}, 'empresas.csv', ['Content-Type' => 'text/csv; charset=UTF-8']);
|
};
|
||||||
|
|
||||||
|
return response()->stream($callback, 200, $headers);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
|
|||||||
+132
-212
@@ -8,11 +8,9 @@ use Livewire\Attributes\Layout;
|
|||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\Phase;
|
use App\Models\Phase;
|
||||||
use App\Models\Layer;
|
use App\Models\Layer;
|
||||||
use App\Models\Feature;
|
|
||||||
use App\Models\InspectionTemplate;
|
|
||||||
use App\Services\SpatialFileConverter;
|
use App\Services\SpatialFileConverter;
|
||||||
|
use App\Models\Feature;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
#[Layout('layouts.app')]
|
#[Layout('layouts.app')]
|
||||||
@@ -24,106 +22,101 @@ class LayerManager extends Component
|
|||||||
public Phase $phase;
|
public Phase $phase;
|
||||||
public $layers;
|
public $layers;
|
||||||
public $selectedLayer = null;
|
public $selectedLayer = null;
|
||||||
public $visibleLayers = [];
|
public $visibleLayers = []; // IDs de capas visibles
|
||||||
|
|
||||||
public $uploadFile = null;
|
public $uploadFile = null;
|
||||||
public $layerName = '';
|
public $layerName = '';
|
||||||
public $layerColor = '#3b82f6';
|
public $layerColor = '#3b82f6';
|
||||||
|
public $manualGeojson = null;
|
||||||
|
public $drawingMode = false;
|
||||||
|
|
||||||
// Batch assign
|
protected $rules = [
|
||||||
public $templates = [];
|
'uploadFile' => 'required|file|mimes:geojson,kmz,kml,shp,dwg,zip|max:51200',
|
||||||
public $batchTemplateId = null;
|
'layerName' => 'required|string|max:255',
|
||||||
public $batchStatus = '';
|
'layerColor' => 'nullable|string|size:7',
|
||||||
|
];
|
||||||
|
|
||||||
public function mount(Project $project, Phase $phase)
|
public function mount(Project $project, Phase $phase)
|
||||||
{
|
{
|
||||||
$this->project = $project;
|
$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();
|
$user = Auth::user();
|
||||||
if (!$user->hasRole('Admin') && !$project->users()->where('user_id', $user->id)->exists()) {
|
if (!$user->hasRole('Admin') && !$project->users()->where('user_id', $user->id)->exists()) {
|
||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->templates = InspectionTemplate::where('project_id', $this->project->id)->get();
|
|
||||||
$this->loadLayers();
|
$this->loadLayers();
|
||||||
|
// Por defecto todas visibles
|
||||||
$this->visibleLayers = $this->layers->pluck('id')->toArray();
|
$this->visibleLayers = $this->layers->pluck('id')->toArray();
|
||||||
$this->emitInitialLayersData();
|
$this->emitInitialLayersData();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Data loaders ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
public function loadLayers()
|
public function loadLayers()
|
||||||
{
|
{
|
||||||
$this->layers = Layer::withCount('features')
|
$this->layers = Layer::with('features')->where('phase_id', $this->phase->id)->latest()->get();
|
||||||
->withAvg('features', 'progress')
|
$this->visibleLayers = array_intersect($this->visibleLayers, $this->layers->pluck('id')->toArray());
|
||||||
->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()
|
private function emitInitialLayersData()
|
||||||
{
|
{
|
||||||
$this->layers->loadMissing('features');
|
$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->dispatch('initialLayersData', [
|
$this->dispatch('initialLayersData', [
|
||||||
'layers' => $this->layers->map(fn($l) => $this->buildLayerPayload($l)),
|
'layers' => $layersData,
|
||||||
'visibleLayers' => $this->visibleLayers,
|
'visibleLayers' => $this->visibleLayers,
|
||||||
'selectedLayerId' => $this->selectedLayer?->id,
|
'selectedLayerId' => $this->selectedLayer?->id,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Visibility ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
public function toggleLayerVisibility($layerId)
|
public function toggleLayerVisibility($layerId)
|
||||||
{
|
{
|
||||||
if ($this->selectedLayer && $this->selectedLayer->id == $layerId) {
|
if ($this->selectedLayer && $this->selectedLayer->id == $layerId) {
|
||||||
$this->dispatch('notify', 'No puedes ocultar la capa que estás editando');
|
session()->flash('info', 'No puedes ocultar la capa que estás editando.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (in_array($layerId, $this->visibleLayers)) {
|
if (in_array($layerId, $this->visibleLayers)) {
|
||||||
$this->visibleLayers = array_values(array_diff($this->visibleLayers, [$layerId]));
|
$this->visibleLayers = array_diff($this->visibleLayers, [$layerId]);
|
||||||
} else {
|
} else {
|
||||||
$this->visibleLayers[] = $layerId;
|
$this->visibleLayers[] = $layerId;
|
||||||
}
|
}
|
||||||
$this->dispatch('visibilityChanged', $this->visibleLayers);
|
$this->dispatch('visibilityChanged', $this->visibleLayers);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Select ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
public function selectLayer($layerId)
|
public function selectLayer($layerId)
|
||||||
{
|
{
|
||||||
$this->selectedLayer = Layer::with('features')->find($layerId);
|
$this->selectedLayer = Layer::with('features')->find($layerId);
|
||||||
@@ -134,105 +127,119 @@ class LayerManager extends Component
|
|||||||
$this->dispatch('visibilityChanged', $this->visibleLayers);
|
$this->dispatch('visibilityChanged', $this->visibleLayers);
|
||||||
}
|
}
|
||||||
|
|
||||||
$payload = $this->buildLayerPayload($this->selectedLayer);
|
// 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]
|
||||||
|
];
|
||||||
|
|
||||||
$this->dispatch('layerSelectedForEdit', [
|
$this->dispatch('layerSelectedForEdit', [
|
||||||
'layerId' => $layerId,
|
'layerId' => $layerId,
|
||||||
'geojson' => $payload['geojson'],
|
'geojson' => $geojson,
|
||||||
'color' => $payload['color'],
|
'color' => $color,
|
||||||
]);
|
]);
|
||||||
$this->dispatch('notify', 'Editando: ' . $this->selectedLayer->name);
|
session()->flash('info', 'Editando capa: ' . $this->selectedLayer->name);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Import file ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
public function importFile()
|
public function importFile()
|
||||||
{
|
{
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
if (!$user->can('upload layers') && !$user->hasRole('Admin')) {
|
if (!$user->can('upload layers') && !$user->hasRole('Admin')) {
|
||||||
$this->dispatch('notify', 'Sin permisos para subir capas');
|
session()->flash('error', 'Sin permisos.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validar campos obligatorios y tamaño máximo
|
||||||
$this->validate([
|
$this->validate([
|
||||||
'uploadFile' => 'required|file|max:51200',
|
'uploadFile' => 'required|file|max:51200',
|
||||||
'layerName' => 'required|string|max:255',
|
'layerName' => 'required|string|max:255',
|
||||||
'layerColor' => 'nullable|string|size:7',
|
'layerColor' => 'nullable|string|size:7',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$ext = strtolower($this->uploadFile->getClientOriginalExtension());
|
$extension = strtolower($this->uploadFile->getClientOriginalExtension());
|
||||||
$allowed = ['geojson', 'json', 'kmz', 'kml', 'shp', 'dwg', 'zip'];
|
$mime = $this->uploadFile->getMimeType();
|
||||||
if (!in_array($ext, $allowed)) {
|
|
||||||
$this->dispatch('notify', 'Extensión no permitida. Válidas: ' . implode(', ', $allowed));
|
$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));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$projectDir = "uploads/projects/{$this->project->id}/layers";
|
||||||
|
$originalPath = $this->uploadFile->store($projectDir, 'public');
|
||||||
$geojson = SpatialFileConverter::convertToGeoJson($this->uploadFile);
|
$geojson = SpatialFileConverter::convertToGeoJson($this->uploadFile);
|
||||||
|
|
||||||
if (!$geojson) {
|
if (!$geojson) {
|
||||||
$this->dispatch('notify', 'No se pudo convertir el archivo. Comprueba que sea GeoJSON, KML o Shapefile válido.');
|
session()->flash('error', 'Conversión fallida. Asegúrate de que el archivo sea válido (KML, GeoJSON, etc.).');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$layerColor = $this->layerColor ?: '#3b82f6';
|
$layerColor = $this->layerColor ?: '#3b82f6';
|
||||||
$layerName = $this->layerName;
|
$geojson['style'] = ['color' => $layerColor];
|
||||||
|
|
||||||
try {
|
|
||||||
DB::transaction(function () use ($geojson, $layerColor, $layerName, $user) {
|
|
||||||
$path = $this->uploadFile->store(
|
|
||||||
"uploads/projects/{$this->project->id}/layers", 'public'
|
|
||||||
);
|
|
||||||
|
|
||||||
$layer = Layer::create([
|
$layer = Layer::create([
|
||||||
'project_id' => $this->project->id,
|
'project_id' => $this->project->id,
|
||||||
'phase_id' => $this->phase->id,
|
'phase_id' => $this->phase->id,
|
||||||
'name' => $layerName,
|
'name' => $this->layerName,
|
||||||
'color' => $layerColor,
|
'color' => $layerColor,
|
||||||
'original_file' => $path,
|
'original_file' => $originalPath,
|
||||||
'uploaded_by' => $user->id,
|
'uploaded_by' => $user->id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$idx = 0;
|
// Crear features a partir del GeoJSON
|
||||||
foreach ($geojson['features'] ?? [] as $fd) {
|
if (isset($geojson['features'])) {
|
||||||
$idx++;
|
foreach ($geojson['features'] as $featureData) {
|
||||||
$name = trim($fd['properties']['name'] ?? '');
|
|
||||||
if ($name === '') $name = $layerName . ' — Elemento ' . $idx;
|
|
||||||
|
|
||||||
Feature::create([
|
Feature::create([
|
||||||
'layer_id' => $layer->id,
|
'layer_id' => $layer->id,
|
||||||
'name' => $name,
|
'name' => $featureData['properties']['name'] ?? null,
|
||||||
'geometry' => $fd['geometry'],
|
'geometry' => $featureData['geometry'],
|
||||||
'properties' => $fd['properties'] ?? [],
|
'properties' => $featureData['properties'] ?? [],
|
||||||
'template_id' => $fd['properties']['template_id'] ?? null,
|
'template_id' => $featureData['properties']['template_id'] ?? null,
|
||||||
'progress' => $fd['properties']['progress'] ?? 0,
|
'progress' => $featureData['properties']['progress'] ?? 0,
|
||||||
'status' => in_array($fd['properties']['status'] ?? '', Feature::STATUSES)
|
'responsible' => $featureData['properties']['responsible'] ?? null,
|
||||||
? $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->loadLayers();
|
||||||
|
$this->visibleLayers[] = $layer->id;
|
||||||
$this->reset(['uploadFile', 'layerName']);
|
$this->reset(['uploadFile', 'layerName']);
|
||||||
$this->emitInitialLayersData();
|
$this->emitInitialLayersData();
|
||||||
$this->dispatch('notify', 'Capa importada correctamente');
|
session()->flash('message', 'Capa importada correctamente.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Create empty layer ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
public function createEmptyLayer()
|
public function createEmptyLayer()
|
||||||
{
|
{
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
if (!$user->can('upload layers') && !$user->hasRole('Admin')) {
|
|
||||||
$this->dispatch('notify', 'Sin permisos para crear capas');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$layer = Layer::create([
|
$layer = Layer::create([
|
||||||
'project_id' => $this->project->id,
|
'project_id' => $this->project->id,
|
||||||
'phase_id' => $this->phase->id,
|
'phase_id' => $this->phase->id,
|
||||||
@@ -241,152 +248,65 @@ class LayerManager extends Component
|
|||||||
'original_file' => null,
|
'original_file' => null,
|
||||||
'uploaded_by' => $user->id,
|
'uploaded_by' => $user->id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->loadLayers();
|
$this->loadLayers();
|
||||||
$this->visibleLayers[] = $layer->id;
|
$this->visibleLayers[] = $layer->id;
|
||||||
$this->selectLayer($layer->id);
|
$this->selectLayer($layer->id);
|
||||||
$this->emitInitialLayersData();
|
$this->emitInitialLayersData();
|
||||||
$this->dispatch('notify', 'Capa vacía creada. Dibuja elementos en el mapa.');
|
session()->flash('message', 'Capa vacía creada. Usa el editor para añadir elementos.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Save drawn GeoJSON ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
public function saveManualGeojson($geojsonString)
|
public function saveManualGeojson($geojsonString)
|
||||||
{
|
{
|
||||||
if (!$this->selectedLayer) {
|
if (!$this->selectedLayer) {
|
||||||
$this->dispatch('notify', 'No hay capa seleccionada');
|
session()->flash('error', 'No hay capa seleccionada.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$geojson = json_decode($geojsonString, true);
|
$geojson = json_decode($geojsonString, true);
|
||||||
if (json_last_error() !== JSON_ERROR_NONE || !isset($geojson['features'])) {
|
if (json_last_error() !== JSON_ERROR_NONE || !isset($geojson['features'])) {
|
||||||
$this->dispatch('notify', 'GeoJSON inválido');
|
session()->flash('error', 'GeoJSON inválido.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$layerId = $this->selectedLayer->id;
|
// Eliminar todos los features existentes de esta capa
|
||||||
$layerName = $this->selectedLayer->name;
|
$this->selectedLayer->features()->delete();
|
||||||
|
|
||||||
try {
|
|
||||||
DB::transaction(function () use ($geojson, $layerId, $layerName) {
|
|
||||||
// forceDelete: reemplazamos completamente los elementos de la capa
|
|
||||||
Feature::where('layer_id', $layerId)->forceDelete();
|
|
||||||
|
|
||||||
$idx = 0;
|
|
||||||
foreach ($geojson['features'] as $fd) {
|
|
||||||
$idx++;
|
|
||||||
$name = trim($fd['properties']['name'] ?? '');
|
|
||||||
if ($name === '') $name = $layerName . ' — Elemento ' . $idx;
|
|
||||||
|
|
||||||
|
// Crear nuevos features a partir del GeoJSON
|
||||||
|
foreach ($geojson['features'] as $featureData) {
|
||||||
Feature::create([
|
Feature::create([
|
||||||
'layer_id' => $layerId,
|
'layer_id' => $this->selectedLayer->id,
|
||||||
'name' => $name,
|
'name' => $featureData['properties']['name'] ?? null,
|
||||||
'geometry' => $fd['geometry'],
|
'geometry' => $featureData['geometry'],
|
||||||
'properties' => $fd['properties'] ?? [],
|
'properties' => $featureData['properties'] ?? [],
|
||||||
'template_id' => $fd['properties']['template_id'] ?? null,
|
'template_id' => $featureData['properties']['template_id'] ?? null,
|
||||||
'progress' => $fd['properties']['progress'] ?? 0,
|
'progress' => $featureData['properties']['progress'] ?? 0,
|
||||||
'status' => in_array($fd['properties']['status'] ?? '', Feature::STATUSES)
|
'responsible' => $featureData['properties']['responsible'] ?? null,
|
||||||
? $fd['properties']['status']
|
|
||||||
: 'planned',
|
|
||||||
'responsible' => $fd['properties']['responsible'] ?? null,
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
$this->dispatch('notify', 'Error al guardar: ' . $e->getMessage());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->loadLayers();
|
$this->loadLayers();
|
||||||
$this->selectLayer($this->selectedLayer->id);
|
$this->selectLayer($this->selectedLayer->id);
|
||||||
$this->emitInitialLayersData();
|
$this->emitInitialLayersData();
|
||||||
$this->dispatch('notify', count($geojson['features']) . ' elementos guardados');
|
session()->flash('message', 'Capa guardada con ' . count($geojson['features']) . ' elementos.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Delete layer ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
public function deleteLayer($layerId)
|
public function deleteLayer($layerId)
|
||||||
{
|
{
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
if (!$user->can('delete layers') && !$user->hasRole('Admin')) abort(403);
|
if (!$user->can('delete layers') && !$user->hasRole('Admin')) abort(403);
|
||||||
|
// Verify layer belongs to this phase (prevents cross-project deletion)
|
||||||
// Verify it belongs to this phase (prevents cross-project deletion)
|
|
||||||
$layer = Layer::where('id', $layerId)->where('phase_id', $this->phase->id)->first();
|
$layer = Layer::where('id', $layerId)->where('phase_id', $this->phase->id)->first();
|
||||||
if (!$layer) return;
|
if (!$layer) return;
|
||||||
|
|
||||||
if ($layer->original_file) Storage::disk('public')->delete($layer->original_file);
|
if ($layer->original_file) Storage::disk('public')->delete($layer->original_file);
|
||||||
$layer->features()->delete();
|
$layer->features()->delete(); // opcional, si no usas cascade
|
||||||
$layer->delete();
|
$layer->delete();
|
||||||
|
|
||||||
$this->loadLayers();
|
$this->loadLayers();
|
||||||
if ($this->selectedLayer && $this->selectedLayer->id == $layerId) {
|
if ($this->selectedLayer && $this->selectedLayer->id == $layerId) {
|
||||||
$this->selectedLayer = null;
|
$this->selectedLayer = null;
|
||||||
$this->dispatch('layerSelectedForEdit', null);
|
|
||||||
}
|
}
|
||||||
$this->emitInitialLayersData();
|
$this->emitInitialLayersData();
|
||||||
$this->dispatch('notify', 'Capa eliminada');
|
session()->flash('message', '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()
|
public function cancelEditing()
|
||||||
{
|
{
|
||||||
$this->selectedLayer = null;
|
$this->selectedLayer = null;
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ namespace App\Livewire;
|
|||||||
|
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use Livewire\WithFileUploads;
|
use Livewire\WithFileUploads;
|
||||||
use Livewire\Attributes\On;
|
|
||||||
use App\Models\Media;
|
use App\Models\Media;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\Phase;
|
use App\Models\Phase;
|
||||||
@@ -12,44 +11,60 @@ use App\Models\Layer;
|
|||||||
use App\Models\Feature;
|
use App\Models\Feature;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Illuminate\Support\Str;
|
|
||||||
|
|
||||||
class MediaManager extends Component
|
class MediaManager extends Component
|
||||||
{
|
{
|
||||||
use WithFileUploads;
|
use WithFileUploads;
|
||||||
|
|
||||||
// Polimórfico: a qué entidad pertenece
|
/**
|
||||||
|
* 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,
|
||||||
|
];
|
||||||
|
|
||||||
public $mediableType;
|
public $mediableType;
|
||||||
public $mediableId;
|
public $mediableId;
|
||||||
|
|
||||||
public $entity; // instancia cargada
|
public $entity;
|
||||||
public $mediaItems = [];
|
public $mediaItems = [];
|
||||||
|
|
||||||
// Subida
|
|
||||||
public $uploadFiles = [];
|
public $uploadFiles = [];
|
||||||
public $uploadDescription = '';
|
public $uploadDescription = '';
|
||||||
public $uploadCategory = 'image';
|
public $uploadCategory = 'image';
|
||||||
|
|
||||||
// Modal visor
|
|
||||||
public $showViewer = false;
|
public $showViewer = false;
|
||||||
public $viewingMedia = null;
|
public $viewingMedia = null;
|
||||||
|
|
||||||
protected $rules = [
|
protected $rules = [
|
||||||
'uploadFiles.*' => 'required|file|max:102400', // 100MB total
|
'uploadFiles.*' => 'required|file|max:51200', // 50 MB per file
|
||||||
'uploadDescription' => 'nullable|string|max:500',
|
'uploadDescription' => 'nullable|string|max:500',
|
||||||
'uploadCategory' => 'required|in:image,document,other',
|
'uploadCategory' => 'required|in:image,document,other',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $messages = [
|
protected $messages = [
|
||||||
'uploadFiles.*.max' => 'Cada archivo debe pesar menos de 100MB.',
|
'uploadFiles.*.max' => 'Cada archivo debe pesar menos de 50 MB.',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function mount($mediableType, $mediableId)
|
public function mount($mediableType, $mediableId)
|
||||||
{
|
{
|
||||||
$this->mediableType = $mediableType;
|
// Validate type against whitelist to prevent RCE via class instantiation
|
||||||
$this->mediableId = $mediableId;
|
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->entity = $mediableType::findOrFail($mediableId);
|
|
||||||
$this->loadMedia();
|
$this->loadMedia();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,22 +92,43 @@ class MediaManager extends Component
|
|||||||
return;
|
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;
|
$uploaded = 0;
|
||||||
foreach ($this->uploadFiles as $file) {
|
foreach ($this->uploadFiles as $file) {
|
||||||
$mime = $file->getMimeType();
|
$mime = $file->getMimeType();
|
||||||
|
|
||||||
|
if (!in_array($mime, $allowedMimes, true)) {
|
||||||
|
session()->flash('error', "Tipo de archivo no permitido: {$mime}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
$ext = $file->getClientOriginalExtension();
|
$ext = $file->getClientOriginalExtension();
|
||||||
$size = $file->getSize();
|
$size = $file->getSize();
|
||||||
$name = $file->getClientOriginalName();
|
$name = substr($file->getClientOriginalName(), 0, 255);
|
||||||
|
|
||||||
// Determinar categoría automática
|
|
||||||
$category = $this->uploadCategory;
|
$category = $this->uploadCategory;
|
||||||
if (str_starts_with($mime, 'image/')) {
|
if (str_starts_with($mime, 'image/')) {
|
||||||
$category = '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'])) {
|
} 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)) {
|
||||||
$category = 'document';
|
$category = 'document';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Guardar en disco
|
|
||||||
$entityType = class_basename($this->entity);
|
$entityType = class_basename($this->entity);
|
||||||
$dir = "uploads/{$entityType}s/{$this->mediableId}/media";
|
$dir = "uploads/{$entityType}s/{$this->mediableId}/media";
|
||||||
$path = $file->store($dir, 'public');
|
$path = $file->store($dir, 'public');
|
||||||
@@ -116,18 +152,21 @@ class MediaManager extends Component
|
|||||||
$this->reset(['uploadFiles', 'uploadDescription']);
|
$this->reset(['uploadFiles', 'uploadDescription']);
|
||||||
$this->loadMedia();
|
$this->loadMedia();
|
||||||
|
|
||||||
// Notificar al mapa si corresponde
|
|
||||||
$this->dispatch('mediaUploaded', [
|
$this->dispatch('mediaUploaded', [
|
||||||
'mediableType' => $this->mediableType,
|
'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)
|
public function deleteMedia($mediaId)
|
||||||
{
|
{
|
||||||
$media = Media::findOrFail($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();
|
||||||
|
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
if (!$user->hasRole('Admin') && $media->uploaded_by !== $user->id) {
|
if (!$user->hasRole('Admin') && $media->uploaded_by !== $user->id) {
|
||||||
@@ -142,9 +181,12 @@ class MediaManager extends Component
|
|||||||
|
|
||||||
public function viewMedia($mediaId)
|
public function viewMedia($mediaId)
|
||||||
{
|
{
|
||||||
$media = Media::findOrFail($mediaId);
|
$media = Media::where('id', $mediaId)
|
||||||
|
->where('mediable_type', $this->mediableType)
|
||||||
|
->where('mediable_id', $this->mediableId)
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
if (!$media->is_image) {
|
if (!$media->is_image) {
|
||||||
// Si no es imagen, abrir en nueva pestaña
|
|
||||||
$this->dispatch('openUrl', $media->url);
|
$this->dispatch('openUrl', $media->url);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ namespace App\Livewire;
|
|||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\Phase;
|
use App\Models\Phase;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
|
||||||
class PhaseList extends Component
|
class PhaseList extends Component
|
||||||
{
|
{
|
||||||
@@ -13,16 +15,19 @@ class PhaseList extends Component
|
|||||||
|
|
||||||
public function mount(Project $project)
|
public function mount(Project $project)
|
||||||
{
|
{
|
||||||
|
Gate::authorize('edit projects', $project);
|
||||||
$this->project = $project;
|
$this->project = $project;
|
||||||
$this->phases = $project->phases;
|
$this->phases = $project->phases;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function addPhase()
|
public function addPhase()
|
||||||
{
|
{
|
||||||
|
Gate::authorize('edit projects', $this->project);
|
||||||
|
|
||||||
$this->project->phases()->create([
|
$this->project->phases()->create([
|
||||||
'name' => 'Nueva fase',
|
'name' => 'Nueva fase',
|
||||||
'order' => $this->phases->count() + 1,
|
'order' => $this->phases->count() + 1,
|
||||||
'color' => '#'.substr(md5(rand()), 0, 6)
|
'color' => '#' . substr(md5(random_int(0, PHP_INT_MAX)), 0, 6),
|
||||||
]);
|
]);
|
||||||
$this->phases = $this->project->phases()->get();
|
$this->phases = $this->project->phases()->get();
|
||||||
session()->flash('message', 'Fase agregada');
|
session()->flash('message', 'Fase agregada');
|
||||||
@@ -30,8 +35,16 @@ class PhaseList extends Component
|
|||||||
|
|
||||||
public function deletePhase($phaseId)
|
public function deletePhase($phaseId)
|
||||||
{
|
{
|
||||||
Phase::find($phaseId)->delete();
|
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();
|
||||||
|
|
||||||
$this->phases = $this->project->phases()->get();
|
$this->phases = $this->project->phases()->get();
|
||||||
|
session()->flash('message', 'Fase eliminada');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace App\Livewire;
|
|||||||
|
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use App\Models\Phase;
|
use App\Models\Phase;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
||||||
class PhaseProgress extends Component
|
class PhaseProgress extends Component
|
||||||
{
|
{
|
||||||
@@ -13,12 +14,21 @@ class PhaseProgress extends Component
|
|||||||
|
|
||||||
public function mount(Phase $phase)
|
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->phase = $phase->load('progressUpdates');
|
||||||
$this->progress = $phase->progress_percent;
|
$this->progress = $phase->progress_percent;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function updateProgressManual()
|
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->validate(['progress' => 'required|integer|min:0|max:100']);
|
||||||
$this->phase->progress_percent = $this->progress;
|
$this->phase->progress_percent = $this->progress;
|
||||||
$this->phase->save();
|
$this->phase->save();
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ class ProjectCompanies extends Component
|
|||||||
|
|
||||||
public function mount(Project $project)
|
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->project = $project;
|
||||||
$this->loadCompanies();
|
$this->loadCompanies();
|
||||||
}
|
}
|
||||||
@@ -65,6 +69,11 @@ class ProjectCompanies extends Component
|
|||||||
|
|
||||||
public function changeRole($companyId, $role)
|
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;
|
if (!in_array($role, ['owner', 'constructor', 'subcontractor', 'consultant', 'supplier', 'other'])) return;
|
||||||
|
|
||||||
$this->project->companies()->updateExistingPivot($companyId, [
|
$this->project->companies()->updateExistingPivot($companyId, [
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace App\Livewire;
|
|||||||
|
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
|
||||||
class ProjectEditTabs extends Component
|
class ProjectEditTabs extends Component
|
||||||
{
|
{
|
||||||
@@ -12,6 +13,7 @@ class ProjectEditTabs extends Component
|
|||||||
|
|
||||||
public function mount(Project $project)
|
public function mount(Project $project)
|
||||||
{
|
{
|
||||||
|
Gate::authorize('edit projects', $project);
|
||||||
$this->project = $project;
|
$this->project = $project;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,6 +31,7 @@ class ProjectEditTabs extends Component
|
|||||||
|
|
||||||
public function updateProject()
|
public function updateProject()
|
||||||
{
|
{
|
||||||
|
Gate::authorize('edit projects', $this->project);
|
||||||
$this->project->save();
|
$this->project->save();
|
||||||
|
|
||||||
session()->flash('message', __('Project updated successfully.'));
|
session()->flash('message', __('Project updated successfully.'));
|
||||||
|
|||||||
@@ -16,12 +16,16 @@ class ProjectList extends Component
|
|||||||
|
|
||||||
public function deleteProject($id)
|
public function deleteProject($id)
|
||||||
{
|
{
|
||||||
$project = Project::findOrFail($id);
|
$user = Auth::user();
|
||||||
if (Auth::user()->can('delete projects')) {
|
if (!$user->can('delete projects')) {
|
||||||
|
session()->flash('error', 'Sin permisos para eliminar proyectos.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Scope to accessible projects to prevent IDOR (deleting another user's project by ID)
|
||||||
|
$project = Project::accessibleBy($user)->findOrFail($id);
|
||||||
$project->delete();
|
$project->delete();
|
||||||
session()->flash('message', 'Proyecto eliminado');
|
session()->flash('message', 'Proyecto eliminado');
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
{
|
{
|
||||||
|
|||||||
+100
-204
@@ -10,28 +10,27 @@ use App\Models\Layer;
|
|||||||
use App\Models\Feature;
|
use App\Models\Feature;
|
||||||
use App\Models\Inspection;
|
use App\Models\Inspection;
|
||||||
use App\Models\InspectionTemplate;
|
use App\Models\InspectionTemplate;
|
||||||
use App\Models\Issue;
|
|
||||||
|
|
||||||
class ProjectMap extends Component
|
class ProjectMap extends Component
|
||||||
{
|
{
|
||||||
public Project $project;
|
public Project $project;
|
||||||
public $phases;
|
public $phases;
|
||||||
public $activeLayers = []; // Now stores Layer IDs (not Phase IDs)
|
public $activeLayers = [];
|
||||||
public $showLayerModal = false;
|
public $showLayerModal = false;
|
||||||
|
|
||||||
// Editor properties
|
// Editor properties
|
||||||
public $selectedFeature = null;
|
public $selectedFeature = null; // será instancia de Feature
|
||||||
public $selectedPhaseId = null;
|
public $selectedPhaseId = null;
|
||||||
public $editProgress = 0;
|
public $editProgress = 0;
|
||||||
public $editComment = '';
|
public $editComment = '';
|
||||||
public $editResponsible = '';
|
public $editResponsible = '';
|
||||||
public $editPhotos = [];
|
public $editPhotos = [];
|
||||||
public $formFullscreen = false;
|
public $formFullscreen = false;
|
||||||
|
|
||||||
// Tab management
|
// Tab management
|
||||||
public $activeTab = 'edit';
|
public $activeTab = 'edit'; // edit, features, inspections
|
||||||
public $allFeatures;
|
public $allFeatures = [];
|
||||||
public $allInspections;
|
public $allInspections = [];
|
||||||
|
|
||||||
|
|
||||||
// Templates e inspecciones
|
// Templates e inspecciones
|
||||||
public $templates = [];
|
public $templates = [];
|
||||||
@@ -43,61 +42,19 @@ class ProjectMap extends Component
|
|||||||
public $showFeatureImages = false;
|
public $showFeatureImages = false;
|
||||||
public $featureImageMarkers = [];
|
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)
|
public function mount(Project $project)
|
||||||
{
|
{
|
||||||
$this->project = $project;
|
$user = Auth::user();
|
||||||
$this->authorizeProjectAccess();
|
if (!$user->hasRole('Admin') && !$project->users()->where('user_id', $user->id)->exists()) {
|
||||||
|
abort(403);
|
||||||
$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
|
$this->project = $project;
|
||||||
{
|
$this->phases = $project->phases()->with(['layers' => function ($q) {
|
||||||
$user = Auth::user();
|
$q->withCount('features');
|
||||||
if ($user->hasRole('Admin')) return;
|
}, 'layers.features'])->get();
|
||||||
if (!$this->project->users()->where('user_id', $user->id)->exists()) abort(403);
|
$this->activeLayers = $this->phases->pluck('id')->toArray();
|
||||||
|
$this->loadTemplates();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function loadTemplates()
|
public function loadTemplates()
|
||||||
@@ -105,113 +62,74 @@ class ProjectMap extends Component
|
|||||||
$this->templates = InspectionTemplate::where('project_id', $this->project->id)->get();
|
$this->templates = InspectionTemplate::where('project_id', $this->project->id)->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Layer / Phase visibility ────────────────────────────────────────────────
|
public function toggleLayer($phaseId)
|
||||||
|
|
||||||
public function toggleLayer($layerId)
|
|
||||||
{
|
{
|
||||||
$layerId = (int) $layerId;
|
if (in_array($phaseId, $this->activeLayers)) {
|
||||||
if (in_array($layerId, $this->activeLayers)) {
|
$this->activeLayers = array_diff($this->activeLayers, [$phaseId]);
|
||||||
$this->activeLayers = array_values(array_diff($this->activeLayers, [$layerId]));
|
|
||||||
} else {
|
} else {
|
||||||
$this->activeLayers[] = $layerId;
|
$this->activeLayers[] = $phaseId;
|
||||||
}
|
}
|
||||||
$this->dispatch('layersUpdated', $this->activeLayers);
|
$this->dispatch('layersUpdated', $this->activeLayers);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function togglePhase($phaseId)
|
public function openLayerModal()
|
||||||
{
|
{
|
||||||
$phase = $this->phases->find($phaseId);
|
$this->showLayerModal = true;
|
||||||
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 openLayerModal() { $this->showLayerModal = true; }
|
public function closeLayerModal()
|
||||||
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()
|
|
||||||
{
|
{
|
||||||
$filtered = $this->allFeatures->filter(function($f) {
|
$this->showLayerModal = false;
|
||||||
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)
|
public function updateProgress($featureId, $newProgress, $comment = null)
|
||||||
{
|
{
|
||||||
$feature = Feature::with('layer.phase')->findOrFail($featureId);
|
$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();
|
$user = Auth::user();
|
||||||
if (!$user->can('update progress') && !$user->hasRole('Admin')) {
|
if (!$user->can('update progress') && !$user->hasRole('Admin')) {
|
||||||
$this->dispatch('notify', 'Sin permisos');
|
$this->dispatch('notify', 'Sin permisos');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if ($feature->layer->phase->project_id !== $this->project->id) abort(403);
|
|
||||||
$feature->progress = min(100, max(0, $newProgress));
|
$feature->progress = min(100, max(0, $newProgress));
|
||||||
$feature->save();
|
$feature->save();
|
||||||
$phase = $feature->layer->phase;
|
|
||||||
|
$phase = Phase::find($feature->layer->phase_id);
|
||||||
$phase->progress_percent = $phase->features()->avg('progress') ?: 0;
|
$phase->progress_percent = $phase->features()->avg('progress') ?: 0;
|
||||||
$phase->save();
|
$phase->save();
|
||||||
|
|
||||||
|
// Registrar la actualización en progress_updates
|
||||||
$phase->progressUpdates()->create([
|
$phase->progressUpdates()->create([
|
||||||
'user_id' => $user->id,
|
'user_id' => $user->id,
|
||||||
'progress_percent' => $phase->progress_percent,
|
'progress_percent' => $phase->progress_percent,
|
||||||
'comment' => $comment,
|
'comment' => $comment,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->dispatch('progressUpdated', $featureId, $feature->progress);
|
$this->dispatch('progressUpdated', $featureId, $feature->progress);
|
||||||
$this->dispatch('notify', 'Progreso actualizado');
|
$this->dispatch('notify', 'Progreso actualizado');
|
||||||
|
|
||||||
|
// Si el feature seleccionado es el mismo, actualizar la propiedad local
|
||||||
if ($this->selectedFeature && $this->selectedFeature->id == $featureId) {
|
if ($this->selectedFeature && $this->selectedFeature->id == $featureId) {
|
||||||
$this->selectedFeature->progress = $feature->progress;
|
$this->selectedFeature->progress = $feature->progress;
|
||||||
$this->editProgress = $feature->progress;
|
$this->editProgress = $feature->progress;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seleccionar un Feature al hacer clic en el mapa.
|
||||||
|
*/
|
||||||
public function selectFeature($featureId)
|
public function selectFeature($featureId)
|
||||||
{
|
{
|
||||||
$this->selectedFeature = null;
|
$this->selectedFeature = null;
|
||||||
$feature = Feature::with(['template', 'layer.phase'])->find($featureId);
|
$feature = Feature::with(['template', 'layer.phase'])->find($featureId);
|
||||||
if (!$feature) return;
|
if (!$feature) return;
|
||||||
|
// Verify feature belongs to this project
|
||||||
if ($feature->layer->phase->project_id !== $this->project->id) abort(403);
|
if ($feature->layer->phase->project_id !== $this->project->id) abort(403);
|
||||||
|
|
||||||
$this->selectedFeature = $feature;
|
$this->selectedFeature = $feature;
|
||||||
@@ -220,14 +138,16 @@ class ProjectMap extends Component
|
|||||||
$this->editResponsible = $feature->responsible ?? '';
|
$this->editResponsible = $feature->responsible ?? '';
|
||||||
$this->editPhotos = $feature->properties['photos'] ?? [];
|
$this->editPhotos = $feature->properties['photos'] ?? [];
|
||||||
$this->selectedTemplateId = $feature->template_id;
|
$this->selectedTemplateId = $feature->template_id;
|
||||||
$this->activeTab = 'edit';
|
|
||||||
|
|
||||||
$this->loadInspectionHistory();
|
$this->loadInspectionHistory();
|
||||||
$this->resetInspectionForm();
|
$this->resetInspectionForm();
|
||||||
|
|
||||||
$this->dispatch('featureSelected', $featureId, $feature->name);
|
$this->dispatch('featureSelected', $featureId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cargar el historial de inspecciones del feature seleccionado.
|
||||||
|
*/
|
||||||
public function loadInspectionHistory()
|
public function loadInspectionHistory()
|
||||||
{
|
{
|
||||||
if (!$this->selectedFeature) {
|
if (!$this->selectedFeature) {
|
||||||
@@ -240,11 +160,12 @@ class ProjectMap extends Component
|
|||||||
->get();
|
->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reiniciar el formulario de inspección según el template seleccionado.
|
||||||
|
*/
|
||||||
public function resetInspectionForm()
|
public function resetInspectionForm()
|
||||||
{
|
{
|
||||||
$this->inspectionFormData = [];
|
$this->inspectionFormData = [];
|
||||||
$this->inspectionResult = '';
|
|
||||||
$this->inspectionNotes = '';
|
|
||||||
if ($this->selectedTemplateId) {
|
if ($this->selectedTemplateId) {
|
||||||
$template = InspectionTemplate::find($this->selectedTemplateId);
|
$template = InspectionTemplate::find($this->selectedTemplateId);
|
||||||
if ($template) {
|
if ($template) {
|
||||||
@@ -255,18 +176,20 @@ class ProjectMap extends Component
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guardar una nueva inspección.
|
||||||
|
*/
|
||||||
public function saveInspection()
|
public function saveInspection()
|
||||||
{
|
{
|
||||||
if (!$this->selectedFeature || !$this->selectedTemplateId) {
|
if (!$this->selectedFeature || !$this->selectedTemplateId) {
|
||||||
$this->dispatch('notify', 'Selecciona un elemento y un template.');
|
$this->dispatch('notify', 'Selecciona un elemento y un template.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$feature = Feature::with('layer.phase')->find($this->selectedFeature->id);
|
|
||||||
if (!$feature || $feature->layer->phase->project_id !== $this->project->id) abort(403);
|
|
||||||
|
|
||||||
$this->validate(['selectedTemplateId' => 'required|exists:inspection_templates,id']);
|
// Verify the template belongs to this project
|
||||||
|
$template = InspectionTemplate::where('id', $this->selectedTemplateId)
|
||||||
$template = InspectionTemplate::find($this->selectedTemplateId);
|
->where('project_id', $this->project->id)
|
||||||
|
->firstOrFail();
|
||||||
foreach ($template->fields as $field) {
|
foreach ($template->fields as $field) {
|
||||||
if (($field['required'] ?? false) && empty($this->inspectionFormData[$field['name']])) {
|
if (($field['required'] ?? false) && empty($this->inspectionFormData[$field['name']])) {
|
||||||
$this->dispatch('notify', "El campo {$field['label']} es obligatorio.");
|
$this->dispatch('notify', "El campo {$field['label']} es obligatorio.");
|
||||||
@@ -280,52 +203,33 @@ class ProjectMap extends Component
|
|||||||
'feature_id' => $this->selectedFeature->id,
|
'feature_id' => $this->selectedFeature->id,
|
||||||
'template_id' => $this->selectedTemplateId,
|
'template_id' => $this->selectedTemplateId,
|
||||||
'user_id' => auth()->id(),
|
'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,
|
'data' => $this->inspectionFormData,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if ($this->inspectionResult === 'fail') {
|
// Si el template tiene un campo llamado 'progress', actualizar el progreso del feature
|
||||||
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'])) {
|
if (isset($this->inspectionFormData['progress'])) {
|
||||||
$this->updateProgress($this->selectedFeature->id, (int)$this->inspectionFormData['progress'], 'Inspección registrada');
|
$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->loadInspectionHistory();
|
||||||
$this->resetInspectionForm();
|
$this->resetInspectionForm();
|
||||||
|
$this->dispatch('notify', 'Inspección guardada correctamente');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asignar un template al feature seleccionado.
|
||||||
|
*/
|
||||||
public function assignTemplateToFeature($templateId)
|
public function assignTemplateToFeature($templateId)
|
||||||
{
|
{
|
||||||
if (!$this->selectedFeature) return;
|
if (!$this->selectedFeature) return;
|
||||||
|
// Verify template belongs to this project (IDOR prevention)
|
||||||
$template = InspectionTemplate::where('id', $templateId)
|
$template = InspectionTemplate::where('id', $templateId)
|
||||||
->where('project_id', $this->project->id)->first();
|
->where('project_id', $this->project->id)
|
||||||
if (!$template) abort(403);
|
->firstOrFail();
|
||||||
$feature = Feature::findOrFail($this->selectedFeature->id);
|
|
||||||
|
$feature = Feature::with('layer.phase')->findOrFail($this->selectedFeature->id);
|
||||||
|
if ($feature->layer->phase->project_id !== $this->project->id) abort(403);
|
||||||
|
|
||||||
$feature->template_id = $templateId;
|
$feature->template_id = $templateId;
|
||||||
$feature->save();
|
$feature->save();
|
||||||
$this->selectedFeature = $feature;
|
$this->selectedFeature = $feature;
|
||||||
@@ -334,58 +238,40 @@ class ProjectMap extends Component
|
|||||||
$this->dispatch('notify', 'Template asignado al elemento');
|
$this->dispatch('notify', 'Template asignado al elemento');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guardar progreso y responsable del feature seleccionado.
|
||||||
|
*/
|
||||||
public function saveFeatureProgress()
|
public function saveFeatureProgress()
|
||||||
{
|
{
|
||||||
if (!$this->selectedFeature) return;
|
if (!$this->selectedFeature) return;
|
||||||
|
|
||||||
$feature = Feature::with('layer.phase')->findOrFail($this->selectedFeature->id);
|
$feature = Feature::with('layer.phase')->findOrFail($this->selectedFeature->id);
|
||||||
if ($feature->layer->phase->project_id !== $this->project->id) abort(403);
|
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->responsible = $this->editResponsible;
|
||||||
$feature->save();
|
$feature->save();
|
||||||
$this->selectedFeature = $feature;
|
$this->selectedFeature = $feature;
|
||||||
$phase = $feature->layer->phase;
|
|
||||||
|
$phase = Phase::find($feature->layer->phase_id);
|
||||||
$phase->progress_percent = $phase->features()->avg('progress') ?: 0;
|
$phase->progress_percent = $phase->features()->avg('progress') ?: 0;
|
||||||
$phase->save();
|
$phase->save();
|
||||||
|
|
||||||
$this->dispatch('progressUpdated', $phase->id, $phase->progress_percent);
|
$this->dispatch('progressUpdated', $phase->id, $phase->progress_percent);
|
||||||
$this->dispatch('notify', 'Progreso guardado');
|
$this->dispatch('notify', 'Progreso guardado');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cuando cambia el template seleccionado, reiniciar el formulario.
|
||||||
|
*/
|
||||||
public function onTemplateChange()
|
public function onTemplateChange()
|
||||||
{
|
{
|
||||||
$this->resetInspectionForm();
|
$this->resetInspectionForm();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Inspection viewer ───────────────────────────────────────────────────────
|
/**
|
||||||
|
* Toggle mostrar imágenes en el mapa.
|
||||||
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()
|
public function toggleFeatureImages()
|
||||||
{
|
{
|
||||||
$this->showFeatureImages = !$this->showFeatureImages;
|
$this->showFeatureImages = !$this->showFeatureImages;
|
||||||
@@ -393,22 +279,35 @@ class ProjectMap extends Component
|
|||||||
$this->dispatch('featureImagesToggled', $this->showFeatureImages, $this->featureImageMarkers);
|
$this->dispatch('featureImagesToggled', $this->showFeatureImages, $this->featureImageMarkers);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cargar marcadores de imágenes para el mapa.
|
||||||
|
*/
|
||||||
public function loadFeatureImageMarkers()
|
public function loadFeatureImageMarkers()
|
||||||
{
|
{
|
||||||
if (!$this->showFeatureImages) { $this->featureImageMarkers = []; return; }
|
if (!$this->showFeatureImages) {
|
||||||
|
$this->featureImageMarkers = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$markers = [];
|
$markers = [];
|
||||||
foreach ($this->phases as $phase) {
|
foreach ($this->phases as $phase) {
|
||||||
foreach ($phase->layers as $layer) {
|
foreach ($phase->layers as $layer) {
|
||||||
foreach ($layer->features as $feature) {
|
foreach ($layer->features as $feature) {
|
||||||
$image = $feature->images->first();
|
$image = $feature->images()->first();
|
||||||
if ($image) {
|
if ($image) {
|
||||||
$geo = $feature->geometry;
|
$geo = $feature->geometry;
|
||||||
$coords = null;
|
$coords = null;
|
||||||
if ($geo && isset($geo['coordinates'])) {
|
if ($geo && isset($geo['coordinates'])) {
|
||||||
if ($geo['type'] === 'Point') {
|
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'])) {
|
} 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']) {
|
if ($coords && $coords['lat'] && $coords['lng']) {
|
||||||
@@ -431,12 +330,9 @@ class ProjectMap extends Component
|
|||||||
public function toggleFullscreen()
|
public function toggleFullscreen()
|
||||||
{
|
{
|
||||||
$this->formFullscreen = !$this->formFullscreen;
|
$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()
|
public function render()
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ class ProjectUsers extends Component
|
|||||||
|
|
||||||
public function mount(Project $project)
|
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->project = $project;
|
||||||
$this->loadUsers();
|
$this->loadUsers();
|
||||||
}
|
}
|
||||||
@@ -65,6 +69,11 @@ class ProjectUsers extends Component
|
|||||||
|
|
||||||
public function changeRole($userId, $role)
|
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;
|
if (!in_array($role, ['supervisor', 'consultant', 'client', 'viewer'])) return;
|
||||||
|
|
||||||
$this->project->users()->updateExistingPivot($userId, [
|
$this->project->users()->updateExistingPivot($userId, [
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use App\Models\Project;
|
|||||||
use App\Models\Phase;
|
use App\Models\Phase;
|
||||||
use App\Models\Inspection;
|
use App\Models\Inspection;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
||||||
class ReportsDashboard extends Component
|
class ReportsDashboard extends Component
|
||||||
{
|
{
|
||||||
@@ -15,6 +16,7 @@ class ReportsDashboard extends Component
|
|||||||
|
|
||||||
public function mount()
|
public function mount()
|
||||||
{
|
{
|
||||||
|
if (!Auth::user()->hasRole('Admin')) abort(403);
|
||||||
$this->loadChartData();
|
$this->loadChartData();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,45 +3,24 @@
|
|||||||
namespace App\Livewire;
|
namespace App\Livewire;
|
||||||
|
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use Livewire\WithFileUploads;
|
|
||||||
use App\Models\InspectionTemplate;
|
use App\Models\InspectionTemplate;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\Phase;
|
use App\Models\Phase;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
|
||||||
|
|
||||||
class TemplateManager extends Component
|
class TemplateManager extends Component
|
||||||
{
|
{
|
||||||
use WithFileUploads;
|
|
||||||
|
|
||||||
public $project;
|
public $project;
|
||||||
public $templates;
|
public $templates;
|
||||||
public $phases;
|
public $phases;
|
||||||
|
|
||||||
// ── Formulario principal ───────────────────────────────────────────────
|
|
||||||
public $editingTemplate = null;
|
public $editingTemplate = null;
|
||||||
public $showForm = false;
|
public $showForm = false; // Controla si mostrar el formulario
|
||||||
public $form = [
|
public $form = [
|
||||||
'name' => '',
|
'name' => '',
|
||||||
'description' => '',
|
'description' => '',
|
||||||
'phase_id' => null,
|
'phase_id' => null,
|
||||||
'fields' => [],
|
'fields' => [],
|
||||||
];
|
];
|
||||||
|
|
||||||
// ── Importar desde CSV/Excel ───────────────────────────────────────────
|
|
||||||
public $showImportFileModal = false;
|
|
||||||
public $importFile = null;
|
|
||||||
public $importPreviewFields = [];
|
|
||||||
public $importTemplateName = '';
|
|
||||||
public $importError = '';
|
|
||||||
|
|
||||||
// ── Importar desde otro proyecto ──────────────────────────────────────
|
|
||||||
public $showImportProjectModal = false;
|
|
||||||
public $availableProjects = [];
|
|
||||||
public $importProjectId = null;
|
|
||||||
public $importableTemplates = [];
|
|
||||||
public $selectedImportTemplateIds = [];
|
|
||||||
|
|
||||||
public $fieldTypes = [
|
public $fieldTypes = [
|
||||||
'text' => 'Texto corto',
|
'text' => 'Texto corto',
|
||||||
'textarea' => 'Texto largo',
|
'textarea' => 'Texto largo',
|
||||||
@@ -53,8 +32,14 @@ class TemplateManager extends Component
|
|||||||
'select' => 'Lista desplegable',
|
'select' => 'Lista desplegable',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
protected $listeners = ['showTemplateForm' => 'newTemplate'];
|
||||||
|
|
||||||
public function mount(Project $project)
|
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->project = $project;
|
||||||
$this->loadPhases();
|
$this->loadPhases();
|
||||||
$this->loadTemplates();
|
$this->loadTemplates();
|
||||||
@@ -67,28 +52,22 @@ class TemplateManager extends Component
|
|||||||
|
|
||||||
public function loadTemplates()
|
public function loadTemplates()
|
||||||
{
|
{
|
||||||
$this->templates = InspectionTemplate::where('project_id', $this->project->id)
|
$this->templates = InspectionTemplate::where('project_id', $this->project->id)->get();
|
||||||
->with('phase')
|
|
||||||
->get();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Formulario manual ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
public function newTemplate()
|
public function newTemplate()
|
||||||
{
|
{
|
||||||
$this->resetForm();
|
$this->resetForm();
|
||||||
|
$this->editingTemplate = null;
|
||||||
$this->showForm = true;
|
$this->showForm = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function editTemplate($id)
|
public function editTemplate($id)
|
||||||
{
|
{
|
||||||
$template = InspectionTemplate::findOrFail($id);
|
$template = InspectionTemplate::where('id', $id)
|
||||||
$this->form = [
|
->where('project_id', $this->project->id)
|
||||||
'name' => $template->name,
|
->firstOrFail();
|
||||||
'description' => $template->description ?? '',
|
$this->form = $template->only(['name', 'description', 'phase_id', 'fields']);
|
||||||
'phase_id' => $template->phase_id,
|
|
||||||
'fields' => $template->fields ?? [],
|
|
||||||
];
|
|
||||||
$this->editingTemplate = $id;
|
$this->editingTemplate = $id;
|
||||||
$this->showForm = true;
|
$this->showForm = true;
|
||||||
}
|
}
|
||||||
@@ -116,7 +95,7 @@ class TemplateManager extends Component
|
|||||||
'name' => '',
|
'name' => '',
|
||||||
'label' => '',
|
'label' => '',
|
||||||
'type' => 'text',
|
'type' => 'text',
|
||||||
'options' => '',
|
'options' => [],
|
||||||
'required' => false,
|
'required' => false,
|
||||||
'min' => null,
|
'min' => null,
|
||||||
'max' => null,
|
'max' => null,
|
||||||
@@ -138,20 +117,26 @@ class TemplateManager extends Component
|
|||||||
'form.fields' => 'array',
|
'form.fields' => 'array',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$data = [
|
if ($this->editingTemplate) {
|
||||||
|
$template = InspectionTemplate::where('id', $this->editingTemplate)
|
||||||
|
->where('project_id', $this->project->id)
|
||||||
|
->firstOrFail();
|
||||||
|
$template->update([
|
||||||
|
'name' => $this->form['name'],
|
||||||
|
'description' => $this->form['description'],
|
||||||
|
'phase_id' => $this->form['phase_id'],
|
||||||
|
'fields' => $this->form['fields'],
|
||||||
|
]);
|
||||||
|
session()->flash('message', 'Template actualizado');
|
||||||
|
} else {
|
||||||
|
InspectionTemplate::create([
|
||||||
'name' => $this->form['name'],
|
'name' => $this->form['name'],
|
||||||
'description' => $this->form['description'],
|
'description' => $this->form['description'],
|
||||||
'project_id' => $this->project->id,
|
'project_id' => $this->project->id,
|
||||||
'phase_id' => $this->form['phase_id'] ?: null,
|
'phase_id' => $this->form['phase_id'],
|
||||||
'fields' => array_values($this->form['fields']),
|
'fields' => $this->form['fields'],
|
||||||
];
|
]);
|
||||||
|
session()->flash('message', 'Template creado');
|
||||||
if ($this->editingTemplate) {
|
|
||||||
InspectionTemplate::findOrFail($this->editingTemplate)->update($data);
|
|
||||||
$this->dispatch('notify', 'Template actualizado correctamente');
|
|
||||||
} else {
|
|
||||||
InspectionTemplate::create($data);
|
|
||||||
$this->dispatch('notify', 'Template creado correctamente');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->cancelForm();
|
$this->cancelForm();
|
||||||
@@ -160,272 +145,12 @@ class TemplateManager extends Component
|
|||||||
|
|
||||||
public function deleteTemplate($id)
|
public function deleteTemplate($id)
|
||||||
{
|
{
|
||||||
InspectionTemplate::findOrFail($id)->delete();
|
InspectionTemplate::where('id', $id)
|
||||||
|
->where('project_id', $this->project->id)
|
||||||
|
->firstOrFail()
|
||||||
|
->delete();
|
||||||
$this->loadTemplates();
|
$this->loadTemplates();
|
||||||
$this->dispatch('notify', 'Template eliminado');
|
session()->flash('message', '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()
|
public function render()
|
||||||
|
|||||||
+5
-11
@@ -20,10 +20,11 @@ class User extends Authenticatable
|
|||||||
* @var list<string>
|
* @var list<string>
|
||||||
*/
|
*/
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'name', 'title', 'first_name', 'last_name',
|
'name',
|
||||||
'email', 'password',
|
'email',
|
||||||
'status', 'valid_from', 'valid_until',
|
'password', // Intentionally kept: required for registration factory and seeding.
|
||||||
'company_id', 'phone', 'address', 'notes',
|
// Sensitive — never pass unvalidated user input directly.
|
||||||
|
// email_verified_at and remember_token are intentionally excluded.
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -46,15 +47,8 @@ class User extends Authenticatable
|
|||||||
return [
|
return [
|
||||||
'email_verified_at' => 'datetime',
|
'email_verified_at' => 'datetime',
|
||||||
'password' => 'hashed',
|
'password' => 'hashed',
|
||||||
'valid_from' => 'date',
|
|
||||||
'valid_until' => 'date',
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function company()
|
|
||||||
{
|
|
||||||
return $this->belongsTo(\App\Models\Company::class);
|
|
||||||
}
|
|
||||||
// Many-to-many with projects
|
// Many-to-many with projects
|
||||||
public function projects()
|
public function projects()
|
||||||
{
|
{
|
||||||
|
|||||||
+1
-1
@@ -169,7 +169,7 @@ return [
|
|||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'secure' => env('SESSION_SECURE_COOKIE'),
|
'secure' => env('SESSION_SECURE_COOKIE', env('APP_ENV', 'production') === 'production'),
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
|
|||||||
+3
-1
@@ -395,5 +395,7 @@
|
|||||||
"My Projects": "My Projects",
|
"My Projects": "My Projects",
|
||||||
"Editable": "Editable",
|
"Editable": "Editable",
|
||||||
"Name of responsible": "Name of responsible",
|
"Name of responsible": "Name of responsible",
|
||||||
"Select template...": "Select template..."
|
"Select template...": "Select template...",
|
||||||
|
"View all": "View all",
|
||||||
|
"View on map": "View on map"
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -395,5 +395,7 @@
|
|||||||
"My Projects": "Mis proyectos",
|
"My Projects": "Mis proyectos",
|
||||||
"Editable": "Editable",
|
"Editable": "Editable",
|
||||||
"Name of responsible": "Nombre del responsable",
|
"Name of responsible": "Nombre del responsable",
|
||||||
"Select template...": "Seleccionar plantilla..."
|
"Select template...": "Seleccionar plantilla...",
|
||||||
|
"View all": "Ver todos",
|
||||||
|
"View on map": "Ver en mapa"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
<input type="color" wire:model="layerColor" class="input input-bordered">
|
<input type="color" wire:model="layerColor" class="input input-bordered">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label">{{ __('File') }} (GeoJSON, KML, Shapefile.zip, DWG)</label></br>
|
<label class="label">Archivo (GeoJSON, KML, Shapefile.zip, DWG)</label></br>
|
||||||
<input type="file" wire:model="uploadFile" class="file-input file-input-bordered">
|
<input type="file" wire:model="uploadFile" class="file-input file-input-bordered">
|
||||||
@error('uploadFile') <span class="text-error">{{ $message }}</span> @enderror
|
@error('uploadFile') <span class="text-error">{{ $message }}</span> @enderror
|
||||||
</div>
|
</div>
|
||||||
@@ -49,13 +49,13 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<button wire:click="selectLayer({{ $layer->id }})" class="btn btn-xs btn-info">✏️ {{ __('Edit') }}</button>
|
<button wire:click="selectLayer({{ $layer->id }})" class="btn btn-xs btn-info">✏️ Editar</button>
|
||||||
<button wire:click="deleteLayer({{ $layer->id }})" wire:confirm="{{ __('Delete layer') }}?" class="btn btn-xs btn-error">🗑️</button>
|
<button wire:click="deleteLayer({{ $layer->id }})" class="btn btn-xs btn-error" onclick="return confirm('¿{{ __("Delete layer") }}?')">🗑️</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@endforeach
|
@endforeach
|
||||||
@if($layers->isEmpty())
|
@if($layers->isEmpty())
|
||||||
<p class="text-center">{{ __("No layers. Create or import one.") }}</p>
|
<p class="text-center">{{ __("No results") }}. Crea una o importa.</p>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -69,8 +69,8 @@
|
|||||||
<h2 class="card-title">{{ __("Edit") }}</h2>
|
<h2 class="card-title">{{ __("Edit") }}</h2>
|
||||||
@if($selectedLayer)
|
@if($selectedLayer)
|
||||||
<div class="mt-3 flex gap-2">
|
<div class="mt-3 flex gap-2">
|
||||||
<button id="saveDrawingBtn" class="btn btn-primary">💾 {{ __('Save changes') }}</button>
|
<button id="saveDrawingBtn" class="btn btn-primary">💾 Guardar cambios</button>
|
||||||
<button wire:click="cancelEditing" class="btn btn-outline">{{ __('Cancel') }}</button>
|
<button wire:click="cancelEditing" class="btn btn-outline">Cancelar edición</button>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
<div id="permanentMap" style="flex: 1; min-height: 500px; width: 100%; background: #e2e8f0;" wire:ignore></div>
|
<div id="permanentMap" style="flex: 1; min-height: 500px; width: 100%; background: #e2e8f0;" wire:ignore></div>
|
||||||
@@ -88,6 +88,17 @@
|
|||||||
let allLayersData = {}; // id -> {geojson, color}
|
let allLayersData = {}; // id -> {geojson, color}
|
||||||
let visibleLayerIds = [];
|
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
|
// Inicialización del mapa
|
||||||
function initMap() {
|
function initMap() {
|
||||||
if (map) return;
|
if (map) return;
|
||||||
@@ -137,9 +148,9 @@
|
|||||||
style: { color: data.color, weight: 3, opacity: 0.8, fillOpacity: 0.3 },
|
style: { color: data.color, weight: 3, opacity: 0.8, fillOpacity: 0.3 },
|
||||||
onEachFeature: (feature, layer) => {
|
onEachFeature: (feature, layer) => {
|
||||||
const props = feature.properties;
|
const props = feature.properties;
|
||||||
const content = `<b>${props.name || 'Elemento'}</b><br>
|
const content = `<b>${escapeHtml(props.name) || 'Elemento'}</b><br>
|
||||||
Progreso: ${props.progress || 0}%<br>
|
Progreso: ${escapeHtml(props.progress) || 0}%<br>
|
||||||
Responsable: ${props.responsible || '-'}`;
|
Responsable: ${escapeHtml(props.responsible) || '-'}`;
|
||||||
layer.bindPopup(content);
|
layer.bindPopup(content);
|
||||||
}
|
}
|
||||||
}).addTo(displayGroup);
|
}).addTo(displayGroup);
|
||||||
@@ -158,10 +169,10 @@
|
|||||||
onEachFeature: (f, l) => {
|
onEachFeature: (f, l) => {
|
||||||
l.feature = f;
|
l.feature = f;
|
||||||
const props = f.properties;
|
const props = f.properties;
|
||||||
const content = `<b>${props.name || @js(__('Feature'))}</b><br>
|
const content = `<b>${escapeHtml(props.name) || 'Elemento'}</b><br>
|
||||||
@js(__('Progress')): ${props.progress || 0}%<br>
|
Progreso: ${escapeHtml(props.progress) || 0}%<br>
|
||||||
@js(__('Responsible')): ${props.responsible || '-'}<br>
|
Responsable: ${escapeHtml(props.responsible) || '-'}<br>
|
||||||
<em>@js(__('Editable'))</em>`;
|
<em>Editable</em>`;
|
||||||
l.bindPopup(content);
|
l.bindPopup(content);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<div>
|
<div>
|
||||||
<x-slot name="header">
|
<x-slot name="header">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
@@ -25,15 +25,19 @@
|
|||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<a href="{{ route('projects.map', $project) }}" class="btn btn-outline btn-sm gap-1">
|
<a href="{{ route('projects.map', $project) }}" class="btn btn-outline btn-sm gap-1">
|
||||||
<x-heroicon-o-map class="w-4 h-4" />
|
<x-heroicon-o-map class="w-4 h-4" />
|
||||||
Mapa
|
{{ __('Map') }}
|
||||||
</a>
|
</a>
|
||||||
<a href="{{ route('projects.gantt', $project) }}" class="btn btn-outline btn-sm gap-1">
|
<a href="{{ route('projects.gantt', $project) }}" class="btn btn-outline btn-sm gap-1">
|
||||||
<x-heroicon-o-calendar-days class="w-4 h-4" />
|
<x-heroicon-o-calendar-days class="w-4 h-4" />
|
||||||
Gantt
|
{{ __('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') }}
|
||||||
</a>
|
</a>
|
||||||
<a href="{{ route('projects.issues', $project) }}" class="btn btn-outline btn-sm gap-1">
|
<a href="{{ route('projects.issues', $project) }}" class="btn btn-outline btn-sm gap-1">
|
||||||
<x-heroicon-o-exclamation-triangle class="w-4 h-4" />
|
<x-heroicon-o-exclamation-triangle class="w-4 h-4" />
|
||||||
Issues
|
{{ __('Issues') }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -303,7 +307,7 @@
|
|||||||
<x-heroicon-o-exclamation-triangle class="w-4 h-4 text-orange-500" />
|
<x-heroicon-o-exclamation-triangle class="w-4 h-4 text-orange-500" />
|
||||||
Issues abiertos
|
Issues abiertos
|
||||||
</h3>
|
</h3>
|
||||||
<a href="{{ route('projects.issues', $project) }}" class="btn btn-xs btn-outline">Ver todos</a>
|
<a href="{{ route('projects.issues', $project) }}" class="btn btn-xs btn-outline">{{ __('View all') }}</a>
|
||||||
</div>
|
</div>
|
||||||
@if($recentIssues->isEmpty())
|
@if($recentIssues->isEmpty())
|
||||||
<div class="text-center py-4 text-gray-400">
|
<div class="text-center py-4 text-gray-400">
|
||||||
@@ -346,7 +350,7 @@
|
|||||||
<x-heroicon-o-clipboard-document-list class="w-4 h-4 text-yellow-500" />
|
<x-heroicon-o-clipboard-document-list class="w-4 h-4 text-yellow-500" />
|
||||||
Inspecciones recientes
|
Inspecciones recientes
|
||||||
</h3>
|
</h3>
|
||||||
<a href="{{ route('projects.map', $project) }}?tab=inspections" class="btn btn-xs btn-outline">Ver en mapa</a>
|
<a href="{{ route('projects.map', $project) }}?tab=inspections" class="btn btn-xs btn-outline">{{ __('View on map') }}</a>
|
||||||
</div>
|
</div>
|
||||||
@if($recentInspections->isEmpty())
|
@if($recentInspections->isEmpty())
|
||||||
<div class="text-center py-4 text-gray-400">
|
<div class="text-center py-4 text-gray-400">
|
||||||
|
|||||||
@@ -2,27 +2,40 @@
|
|||||||
<div class="max-w-2xl mx-auto p-4">
|
<div class="max-w-2xl mx-auto p-4">
|
||||||
<h1 class="text-2xl font-bold mb-6">{{ $projectId ? __('Edit Project') : __('New Project') }}</h1>
|
<h1 class="text-2xl font-bold mb-6">{{ $projectId ? __('Edit Project') : __('New Project') }}</h1>
|
||||||
|
|
||||||
|
@if($errors->any())
|
||||||
|
<div class="alert alert-error text-sm mb-4">
|
||||||
|
<x-heroicon-o-exclamation-circle class="w-5 h-5 shrink-0" />
|
||||||
|
<ul class="list-disc pl-3 space-y-0.5">
|
||||||
|
@foreach($errors->all() as $e) <li>{{ $e }}</li> @endforeach
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
<form wire:submit.prevent="save" class="space-y-6">
|
<form wire:submit.prevent="save" class="space-y-6">
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-2">{{ __('Name') }}</label>
|
<label class="block text-sm font-medium mb-2">{{ __('Name') }}</label>
|
||||||
<input type="text" wire:model="name" class="input input-bordered w-full" placeholder="{{ __('Project name') }}" required>
|
<input type="text" wire:model="name" class="input input-bordered w-full {{ $errors->has('name') ? 'input-error' : '' }}" placeholder="{{ __('Project name') }}" required>
|
||||||
|
@error('name') <span class="text-error text-xs mt-1">{{ $message }}</span> @enderror
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-2">{{ __('Address') }}</label>
|
<label class="block text-sm font-medium mb-2">{{ __('Address') }}</label>
|
||||||
<input type="text" wire:model="address" class="input input-bordered w-full" placeholder="{{ __('Address') }}">
|
<input type="text" wire:model="address" class="input input-bordered w-full" placeholder="{{ __('Street address, city, etc.') }}">
|
||||||
|
@error('address') <span class="text-error text-xs mt-1">{{ $message }}</span> @enderror
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-2">{{ __('Coordinates') }}</label>
|
<label class="block text-sm font-medium mb-2">{{ __('Country') }}</label>
|
||||||
<input type="text" wire:model="country" class="input input-bordered w-full" placeholder="{{ __('No country') }}" readonly>
|
<input type="text" wire:model="country" class="input input-bordered w-full" placeholder="{{ __('Country (auto-filled)') }}" readonly>
|
||||||
</div>
|
</div>
|
||||||
<div class="md:col-span-2">
|
<div class="md:col-span-2">
|
||||||
<label class="block text-sm font-medium mb-2">{{ __('Start date') }}</label>
|
<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" required>
|
<input type="date" wire:model="start_date" class="input input-bordered w-full {{ $errors->has('start_date') ? 'input-error' : '' }}" required>
|
||||||
|
@error('start_date') <span class="text-error text-xs mt-1">{{ $message }}</span> @enderror
|
||||||
</div>
|
</div>
|
||||||
<div class="md:col-span-2">
|
<div class="md:col-span-2">
|
||||||
<label class="block text-sm font-medium mb-2">{{ __('Estimated end date') }}</label>
|
<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">
|
<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>
|
||||||
<div class="md:col-span-2">
|
<div class="md:col-span-2">
|
||||||
<label class="block text-sm font-medium mb-2">{{ __('Status') }}</label>
|
<label class="block text-sm font-medium mb-2">{{ __('Status') }}</label>
|
||||||
@@ -32,13 +45,14 @@
|
|||||||
<option value="paused">{{ __('Paused') }}</option>
|
<option value="paused">{{ __('Paused') }}</option>
|
||||||
<option value="completed">{{ __('Completed') }}</option>
|
<option value="completed">{{ __('Completed') }}</option>
|
||||||
</select>
|
</select>
|
||||||
|
@error('status') <span class="text-error text-xs mt-1">{{ $message }}</span> @enderror
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="border rounded-lg p-4">
|
<div class="border rounded-lg p-4">
|
||||||
<h2 class="text-xl font-bold mb-4">{{ __('Location') }}</h2>
|
<h2 class="text-xl font-bold mb-4">{{ __('Project Location') }}</h2>
|
||||||
<p class="text-sm text-gray-500 mb-2">
|
<p class="text-sm text-gray-500 mb-2">
|
||||||
{{ __('Click on the map or drag the marker to update the location') }}
|
{{ __('Click on the map to set the project location. The address and country will be filled automatically.') }}
|
||||||
</p>
|
</p>
|
||||||
<div id="projectMap" style="height: 400px; width: 100%; background: #e2e8f0; border-radius: 0.5rem;"></div>
|
<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="lat">
|
||||||
@@ -47,7 +61,7 @@
|
|||||||
|
|
||||||
<div class="flex justify-end space-x-4">
|
<div class="flex justify-end space-x-4">
|
||||||
<button type="button" wire:click="resetForm" class="btn btn-outline">
|
<button type="button" wire:click="resetForm" class="btn btn-outline">
|
||||||
{{ __('Cancel') }}
|
{{ __('Reset') }}
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" class="btn btn-primary">
|
<button type="submit" class="btn btn-primary">
|
||||||
{{ $projectId ? __('Update') : __('Create') }}
|
{{ $projectId ? __('Update') : __('Create') }}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
<!-- Panel lateral de capas -->
|
<!-- 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">
|
<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">{{ __('Phases and layers') }}</h3>
|
<h3 class="font-semibold text-base mb-2">{{ __("Fases and layers") }}</h3>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
@foreach($phases as $phase)
|
@foreach($phases as $phase)
|
||||||
<div class="border rounded-lg p-2 {{ in_array($phase->id, $activeLayers) ? 'bg-base-200' : '' }}">
|
<div class="border rounded-lg p-2 {{ in_array($phase->id, $activeLayers) ? 'bg-base-200' : '' }}">
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
<div class="flex items-center gap-1 text-xs text-gray-600">
|
<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>
|
<span class="w-2 h-2 rounded-full inline-block" style="background: {{ $layer->color ?? '#ccc' }}"></span>
|
||||||
<span class="flex-1 truncate">{{ $layer->name }}</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>
|
</div>
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
@@ -36,10 +36,10 @@
|
|||||||
{{-- Botón para ir a gestión de capas de esta fase --}}
|
{{-- Botón para ir a gestión de capas de esta fase --}}
|
||||||
<div class="mt-1 ml-7">
|
<div class="mt-1 ml-7">
|
||||||
<a href="{{ route('layers.manage', [$project, $phase]) }}" class="btn btn-xs btn-outline btn-primary">
|
<a href="{{ route('layers.manage', [$project, $phase]) }}" class="btn btn-xs btn-outline btn-primary">
|
||||||
✏️ {{ __('Manage Layers') }}
|
✏️ {{ __("Manage Layers") }}
|
||||||
</a>
|
</a>
|
||||||
<a href="{{ route('phases.progress', $phase) }}" class="btn btn-xs btn-outline">
|
<a href="{{ route('phases.progress', $phase) }}" class="btn btn-xs btn-outline">
|
||||||
📊 {{ __('Progress') }}
|
📊 {{ __("Progress") }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -50,7 +50,7 @@
|
|||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
<label class="flex items-center gap-2 text-xs cursor-pointer">
|
<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" />
|
<input type="checkbox" wire:change="toggleFeatureImages" @if($showFeatureImages) checked @endif class="checkbox checkbox-xs checkbox-primary" />
|
||||||
🖼️ {{ __('Show images on map') }}
|
🖼️ {{ __("Show images on map") }}
|
||||||
@if($featureImageMarkers)
|
@if($featureImageMarkers)
|
||||||
<span class="badge badge-xs">{{ count($featureImageMarkers) }}</span>
|
<span class="badge badge-xs">{{ count($featureImageMarkers) }}</span>
|
||||||
@endif
|
@endif
|
||||||
@@ -60,34 +60,67 @@
|
|||||||
{{-- Botones generales --}}
|
{{-- Botones generales --}}
|
||||||
<div class="mt-2 space-y-1">
|
<div class="mt-2 space-y-1">
|
||||||
<a href="{{ route('projects.media', $project) }}" class="btn btn-sm btn-outline w-full">
|
<a href="{{ route('projects.media', $project) }}" class="btn btn-sm btn-outline w-full">
|
||||||
📁 {{ __('Project files') }}
|
📁 {{ __("Project files") }}
|
||||||
</a>
|
</button>
|
||||||
<button wire:click="$dispatch('centerMap')" class="btn btn-sm btn-outline w-full">
|
<button wire:click="$dispatch('centerMap')" class="btn btn-sm btn-outline w-full">
|
||||||
📍 {{ __('Centered in project') }}
|
📍 {{ __("Centered in project") }}
|
||||||
</button>
|
</button>
|
||||||
<button onclick="getUserLocation()" class="btn btn-sm btn-outline w-full">
|
<button onclick="getUserLocation()" class="btn btn-sm btn-outline w-full">
|
||||||
🧭 {{ __('My location') }}
|
🧭 {{ __("My location") }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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="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 bg-base-100 shadow-xl h-full flex flex-col">
|
||||||
<div class="card-body overflow-y-auto flex-1">
|
<div class="card-body overflow-y-auto flex-1">
|
||||||
<div class="flex justify-between items-center mb-2">
|
<div class="flex justify-between items-center mb-2">
|
||||||
<h2 class="card-title">{{ __('Map') }}</h2>
|
<h2 class="card-title">{{ __("Project Map") }}</h2>
|
||||||
<button wire:click="toggleFullscreen" class="btn btn-circle btn-sm" title="{{ __('Fullscreen') }}">
|
<button wire:click="toggleFullscreen" class="btn btn-circle btn-sm" title="Pantalla completa">
|
||||||
<span x-text="formFullscreen ? '✕' : '⤢'"></span>
|
<span x-text="formFullscreen ? '✕' : '⤢'"></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Project navigation bar -->
|
||||||
|
<div class="flex flex-wrap gap-1 mb-3">
|
||||||
|
<a href="{{ route('projects.dashboard', $project) }}"
|
||||||
|
class="btn btn-xs {{ request()->routeIs('projects.dashboard') ? 'btn-primary' : 'btn-outline' }}">
|
||||||
|
📊 {{ __('Dashboard') }}
|
||||||
|
</a>
|
||||||
|
<a href="{{ route('projects.map', $project) }}"
|
||||||
|
class="btn btn-xs {{ request()->routeIs('projects.map') ? 'btn-primary' : 'btn-outline' }}">
|
||||||
|
🗺️ {{ __('Map') }}
|
||||||
|
</a>
|
||||||
|
<a href="{{ route('projects.gantt', $project) }}"
|
||||||
|
class="btn btn-xs {{ request()->routeIs('projects.gantt') ? 'btn-primary' : 'btn-outline' }}">
|
||||||
|
📅 {{ __('Gantt') }}
|
||||||
|
</a>
|
||||||
|
<a href="{{ route('projects.report', $project) }}"
|
||||||
|
class="btn btn-xs {{ request()->routeIs('projects.report') ? 'btn-primary' : 'btn-outline' }}">
|
||||||
|
📄 {{ __('Report') }}
|
||||||
|
</a>
|
||||||
|
<a href="{{ route('projects.issues', $project) }}"
|
||||||
|
class="btn btn-xs {{ request()->routeIs('projects.issues') ? 'btn-error' : 'btn-outline' }} gap-1">
|
||||||
|
⚠️ {{ __('Issues') }}
|
||||||
|
@if($openIssuesCount > 0)
|
||||||
|
<span class="badge badge-error badge-xs">{{ $openIssuesCount }}</span>
|
||||||
|
@endif
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Tabs -->
|
<!-- Tabs -->
|
||||||
<div class="tabs box mb-4">
|
<div class="tabs box mb-4">
|
||||||
<button wire:click="setActiveTab('edit')" class="tab {{ $activeTab === 'edit' ? 'tab-active' : '' }}">{{ __('Edit') }}</button>
|
<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('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('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>
|
</div>
|
||||||
|
|
||||||
<!-- Tab Content -->
|
<!-- Tab Content -->
|
||||||
@@ -96,14 +129,14 @@
|
|||||||
@if($selectedFeature)
|
@if($selectedFeature)
|
||||||
<!-- Feature seleccionado -->
|
<!-- Feature seleccionado -->
|
||||||
<div class="border rounded-lg p-3 mb-3 bg-base-200">
|
<div class="border rounded-lg p-3 mb-3 bg-base-200">
|
||||||
<h3 class="font-bold text-sm">{{ $selectedFeature->name ?? __('Feature') }}</h3>
|
<h3 class="font-bold text-sm">{{ $selectedFeature->name ?? 'Elemento' }}</h3>
|
||||||
<p class="text-xs text-gray-500">{{ __('Phase') }}: {{ $selectedFeature->layer?->phase?->name ?? '—' }}</p>
|
<p class="text-xs text-gray-500">Fase: {{ $selectedFeature->layer?->phase?->name ?? '—' }}</p>
|
||||||
<p class="text-xs text-gray-500">{{ __('Layer') }}: {{ $selectedFeature->layer?->name ?? '—' }}</p>
|
<p class="text-xs text-gray-500">Capa: {{ $selectedFeature->layer?->name ?? '—' }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- Progreso --}}
|
{{-- {{ __("Progress") }} --}}
|
||||||
<div class="form-control mb-3">
|
<div class="form-control mb-3">
|
||||||
<label class="label-text">{{ __('Progress') }}: {{ $editProgress }}%</label>
|
<label class="label-text">{{ __("Progress") }}: {{ $editProgress }}%</label>
|
||||||
<input type="range" min="0" max="100" wire:model.live="editProgress" class="range range-primary range-sm" />
|
<input type="range" min="0" max="100" wire:model.live="editProgress" class="range range-primary range-sm" />
|
||||||
<div class="flex justify-between text-xs">
|
<div class="flex justify-between text-xs">
|
||||||
<span>0%</span><span>50%</span><span>100%</span>
|
<span>0%</span><span>50%</span><span>100%</span>
|
||||||
@@ -111,18 +144,18 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-control mb-3">
|
<div class="form-control mb-3">
|
||||||
<label class="label-text">{{ __('Responsible') }}</label>
|
<label class="label-text">{{ __("Responsible") }}</label>
|
||||||
<input type="text" wire:model="editResponsible" class="input input-bordered input-sm" placeholder="{{ __('Name of responsible') }}" />
|
<input type="text" wire:model="editResponsible" class="input input-bordered input-sm" placeholder="Nombre" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button wire:click="saveFeatureProgress" class="btn btn-primary btn-sm w-full mb-3">
|
<button wire:click="saveFeatureProgress" class="btn btn-primary btn-sm w-full mb-3">
|
||||||
💾 {{ __('Save progress') }}
|
💾 {{ __("Save progress") }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{{-- Gestor de archivos del feature --}}
|
{{-- Gestor de archivos del feature --}}
|
||||||
<details class="mb-3 border rounded-lg">
|
<details class="mb-3 border rounded-lg">
|
||||||
<summary class="text-xs font-semibold cursor-pointer p-2 bg-base-200 rounded-t-lg">
|
<summary class="text-xs font-semibold cursor-pointer p-2 bg-base-200 rounded-t-lg">
|
||||||
📎 {{ __('Files of element') }}
|
📎 {{ __("Files of element") }}
|
||||||
</summary>
|
</summary>
|
||||||
<div class="p-2">
|
<div class="p-2">
|
||||||
@livewire('media-manager', [
|
@livewire('media-manager', [
|
||||||
@@ -134,11 +167,11 @@
|
|||||||
|
|
||||||
{{-- Templates / Inspecciones --}}
|
{{-- Templates / Inspecciones --}}
|
||||||
@if($templates->isNotEmpty())
|
@if($templates->isNotEmpty())
|
||||||
<div class="divider text-xs">{{ __('Inspection') }}</div>
|
<div class="divider text-xs">{{ __("Inspection") }}</div>
|
||||||
<div class="form-control mb-2">
|
<div class="form-control mb-2">
|
||||||
<label class="label-text">{{ __('Template') }}</label>
|
<label class="label-text">Plantilla</label>
|
||||||
<select wire:model.live="selectedTemplateId" wire:change="onTemplateChange" class="select select-bordered select-sm">
|
<select wire:model.live="selectedTemplateId" wire:change="onTemplateChange" class="select select-bordered select-sm">
|
||||||
<option value="">{{ __('Select template...') }}</option>
|
<option value="">Seleccionar plantilla...</option>
|
||||||
@foreach($templates as $t)
|
@foreach($templates as $t)
|
||||||
<option value="{{ $t->id }}">{{ $t->name }}</option>
|
<option value="{{ $t->id }}">{{ $t->name }}</option>
|
||||||
@endforeach
|
@endforeach
|
||||||
@@ -164,7 +197,7 @@
|
|||||||
@break
|
@break
|
||||||
@case('select')
|
@case('select')
|
||||||
<select wire:model="inspectionFormData.{{ $field['name'] }}" class="select select-bordered select-sm w-full">
|
<select wire:model="inspectionFormData.{{ $field['name'] }}" class="select select-bordered select-sm w-full">
|
||||||
<option value="">{{ __('Select') }}</option>
|
<option value="">Seleccionar</option>
|
||||||
@foreach(explode(',', $field['options'] ?? '') as $opt)
|
@foreach(explode(',', $field['options'] ?? '') as $opt)
|
||||||
<option value="{{ trim($opt) }}">{{ trim($opt) }}</option>
|
<option value="{{ trim($opt) }}">{{ trim($opt) }}</option>
|
||||||
@endforeach
|
@endforeach
|
||||||
@@ -178,21 +211,21 @@
|
|||||||
@endswitch
|
@endswitch
|
||||||
</div>
|
</div>
|
||||||
@endforeach
|
@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
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
{{-- Historial de inspecciones --}}
|
{{-- {{ __("History") }} de inspecciones --}}
|
||||||
@if($inspectionHistory->isNotEmpty())
|
@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">
|
<div class="space-y-1 max-h-40 overflow-y-auto">
|
||||||
@foreach($inspectionHistory as $ins)
|
@foreach($inspectionHistory as $ins)
|
||||||
<div class="border rounded p-2 text-xs" wire:key="hist-{{ $ins->id }}">
|
<div class="border rounded p-2 text-xs">
|
||||||
<div class="flex justify-between">
|
<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>
|
<span class="text-gray-400">{{ $ins->created_at->diffForHumans() }}</span>
|
||||||
</div>
|
</div>
|
||||||
@if($ins->user)<span class="text-gray-500">{{ __('by') }} {{ $ins->user->name }}</span>@endif
|
@if($ins->user)<span class="text-gray-500">por {{ $ins->user->name }}</span>@endif
|
||||||
</div>
|
</div>
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
@@ -203,16 +236,16 @@
|
|||||||
<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>
|
<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>
|
</svg>
|
||||||
<div>
|
<div>
|
||||||
<h3 class="font-bold">{{ __('No templates yet') }}</h3>
|
<h3 class="font-bold">{{ __("No templates yet") }}</h3>
|
||||||
<div class="text-xs">{{ __('Create an inspection template') }}.</div>
|
<div class="text-xs">{{ __("Create an inspection template") }}.</div>
|
||||||
</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>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
@else
|
@else
|
||||||
<div class="text-center text-gray-400 py-8">
|
<div class="text-center text-gray-400 py-8">
|
||||||
<p class="text-lg">👆</p>
|
<p class="text-lg">👆</p>
|
||||||
<p>{{ __('Click on a map element or search above to edit it') }}</p>
|
<p>Haz clic en un elemento del mapa para editarlo</p>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
@elseif($activeTab === 'features')
|
@elseif($activeTab === 'features')
|
||||||
@@ -222,12 +255,12 @@
|
|||||||
<table class="table table-sm table-compact">
|
<table class="table table-sm table-compact">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>{{ __('Feature') }}</th>
|
<th>{{ __("Feature") }}</th>
|
||||||
<th>{{ __('Layer') }}</th>
|
<th>{{ __("Layer") }}</th>
|
||||||
<th>{{ __('Phase') }}</th>
|
<th>{{ __("Phase") }}</th>
|
||||||
<th>{{ __('Progress') }}</th>
|
<th>{{ __("Progress") }}</th>
|
||||||
<th>{{ __('Responsible') }}</th>
|
<th>{{ __("Responsible") }}</th>
|
||||||
<th>{{ __('Template') }}</th>
|
<th>{{ __("Template") }}</th>
|
||||||
<th class="w-16"></th>
|
<th class="w-16"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -251,7 +284,7 @@
|
|||||||
@else
|
@else
|
||||||
<div class="text-center text-gray-400 py-8">
|
<div class="text-center text-gray-400 py-8">
|
||||||
<p class="text-lg">📋</p>
|
<p class="text-lg">📋</p>
|
||||||
<p>{{ __('No elements in this project') }}</p>
|
<p>{{ __("No features found") }}</p>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
@elseif($activeTab === 'inspections')
|
@elseif($activeTab === 'inspections')
|
||||||
@@ -261,10 +294,10 @@
|
|||||||
<table class="table table-sm table-compact">
|
<table class="table table-sm table-compact">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>{{ __('Date') }}</th>
|
<th>{{ __("Date") }}</th>
|
||||||
<th>{{ __('Feature') }}</th>
|
<th>{{ __("Feature") }}</th>
|
||||||
<th>{{ __('Template') }}</th>
|
<th>{{ __("Template") }}</th>
|
||||||
<th>{{ __('User') }}</th>
|
<th>{{ __("User") }}</th>
|
||||||
<th class="w-16"></th>
|
<th class="w-16"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -286,14 +319,16 @@
|
|||||||
@else
|
@else
|
||||||
<div class="text-center text-gray-400 py-8">
|
<div class="text-center text-gray-400 py-8">
|
||||||
<p class="text-lg">📋</p>
|
<p class="text-lg">📋</p>
|
||||||
<p>{{ __('No inspections registered') }}</p>
|
<p>{{ __("No inspections found") }}</p>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
@elseif($activeTab === 'issues')
|
||||||
|
<!-- Issues tab: render embedded IssueManager component -->
|
||||||
|
@livewire('issue-manager', ['project' => $project], key('issues-tab-' . $project->id))
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
@push('styles')
|
@push('styles')
|
||||||
<style>
|
<style>
|
||||||
@@ -373,13 +408,14 @@
|
|||||||
onEachFeature: function(feature, layer) {
|
onEachFeature: function(feature, layer) {
|
||||||
const props = feature.properties || {};
|
const props = feature.properties || {};
|
||||||
const featId = props._feature_id || feature.id;
|
const featId = props._feature_id || feature.id;
|
||||||
const safeName = escapeHtml(props.name || '{{ __('Feature') }}');
|
// Escape all user-generated content for HTML context
|
||||||
|
const safeName = escapeHtml(props.name || 'Elemento');
|
||||||
const safeProgress = escapeHtml(props.progress || 0);
|
const safeProgress = escapeHtml(props.progress || 0);
|
||||||
const safeResponsible = escapeHtml(props.responsible || '-');
|
const safeResponsible = escapeHtml(props.responsible || '-');
|
||||||
let content = `<b>${safeName}</b><br>
|
let content = `<b>${safeName}</b><br>
|
||||||
{{ __('Progress') }}: ${safeProgress}%<br>
|
{{ __("Progress") }}: ${safeProgress}%<br>
|
||||||
{{ __('Responsible') }}: ${safeResponsible}<br>
|
{{ __("Responsible") }}: ${safeResponsible}<br>
|
||||||
<button class="btn btn-xs btn-primary mt-1" onclick="selectFeature('${featId}')">✏️ {{ __('Edit') }}</button>`;
|
<button class="btn btn-xs btn-primary mt-1" onclick="selectFeature('${featId}')">✏️ Editar</button>`;
|
||||||
layer.bindPopup(content);
|
layer.bindPopup(content);
|
||||||
layer.on('click', function() { selectFeature(featId); });
|
layer.on('click', function() { selectFeature(featId); });
|
||||||
}
|
}
|
||||||
@@ -398,7 +434,7 @@
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
map.invalidateSize();
|
map.invalidateSize();
|
||||||
zoomToAllFeatures();
|
zoomToAllFeatures();
|
||||||
}, 100);
|
}, 100); // Reduced from 200ms to 100ms
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateCombinedBounds() {
|
function updateCombinedBounds() {
|
||||||
@@ -420,7 +456,10 @@
|
|||||||
|
|
||||||
function zoomToAllFeatures() {
|
function zoomToAllFeatures() {
|
||||||
if (!map) return;
|
if (!map) return;
|
||||||
|
|
||||||
|
// Update combined bounds if needed
|
||||||
updateCombinedBounds();
|
updateCombinedBounds();
|
||||||
|
|
||||||
if (combinedBounds && combinedBounds.isValid()) {
|
if (combinedBounds && combinedBounds.isValid()) {
|
||||||
map.fitBounds(combinedBounds, { padding: [20, 20] });
|
map.fitBounds(combinedBounds, { padding: [20, 20] });
|
||||||
} else {
|
} else {
|
||||||
@@ -436,29 +475,32 @@
|
|||||||
if (navigator.geolocation) {
|
if (navigator.geolocation) {
|
||||||
navigator.geolocation.getCurrentPosition((pos) => {
|
navigator.geolocation.getCurrentPosition((pos) => {
|
||||||
const latlng = [pos.coords.latitude, pos.coords.longitude];
|
const latlng = [pos.coords.latitude, pos.coords.longitude];
|
||||||
L.marker(latlng).addTo(map).bindPopup('{{ __('My location') }}').openPopup();
|
L.marker(latlng).addTo(map).bindPopup('Tu ubicación').openPopup();
|
||||||
map.setView(latlng, 16);
|
map.setView(latlng, 16);
|
||||||
}, () => alert('{{ __('No results') }}'));
|
}, () => alert('No se pudo obtener la ubicación'));
|
||||||
} else {
|
} else {
|
||||||
alert('{{ __('No results') }}');
|
alert('Geolocalización no soportada');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('livewire:init', function () {
|
document.addEventListener('livewire:init', function () {
|
||||||
setTimeout(initMap, 50);
|
setTimeout(initMap, 50); // Reduced from 100ms to 50ms
|
||||||
|
|
||||||
Livewire.on('layersUpdated', (activeIds) => {
|
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;
|
const ids = Array.isArray(activeIds) ? activeIds[0] : activeIds;
|
||||||
for (let id in layers) {
|
for (let id in layers) {
|
||||||
const lid = parseInt(id);
|
const lid = parseInt(id);
|
||||||
if (ids.includes(lid)) {
|
if (ids.includes(lid)) {
|
||||||
if (!map.hasLayer(layers[id])) {
|
if (!map.hasLayer(layers[id])) {
|
||||||
layers[id].addTo(map);
|
layers[id].addTo(map);
|
||||||
|
// Update combined bounds when adding a layer
|
||||||
updateCombinedBounds();
|
updateCombinedBounds();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (map.hasLayer(layers[id])) {
|
if (map.hasLayer(layers[id])) {
|
||||||
map.removeLayer(layers[id]);
|
map.removeLayer(layers[id]);
|
||||||
|
// Update combined bounds when removing a layer
|
||||||
updateCombinedBounds();
|
updateCombinedBounds();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -469,6 +511,7 @@
|
|||||||
Livewire.on('centerMap', zoomToAllFeatures);
|
Livewire.on('centerMap', zoomToAllFeatures);
|
||||||
Livewire.on('mapResize', () => {
|
Livewire.on('mapResize', () => {
|
||||||
if (map) {
|
if (map) {
|
||||||
|
// Throttle resize events to prevent excessive calls
|
||||||
if (!this.resizeTimeout) {
|
if (!this.resizeTimeout) {
|
||||||
this.resizeTimeout = setTimeout(() => {
|
this.resizeTimeout = setTimeout(() => {
|
||||||
map.invalidateSize();
|
map.invalidateSize();
|
||||||
@@ -478,12 +521,14 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Toggle imágenes en mapa
|
||||||
Livewire.on('featureImagesToggled', (show, markers) => {
|
Livewire.on('featureImagesToggled', (show, markers) => {
|
||||||
const m = Array.isArray(markers) ? markers : markers[1];
|
const m = Array.isArray(markers) ? markers : markers[1];
|
||||||
const s = Array.isArray(show) ? show[0] : show;
|
const s = Array.isArray(show) ? show[0] : show;
|
||||||
if (imageMarkersLayer) {
|
if (imageMarkersLayer) {
|
||||||
map.removeLayer(imageMarkersLayer);
|
map.removeLayer(imageMarkersLayer);
|
||||||
imageMarkersLayer = null;
|
imageMarkersLayer = null;
|
||||||
|
// Update bounds when removing image markers layer
|
||||||
updateCombinedBounds();
|
updateCombinedBounds();
|
||||||
}
|
}
|
||||||
if (s && m && m.length > 0) {
|
if (s && m && m.length > 0) {
|
||||||
@@ -495,9 +540,10 @@
|
|||||||
iconAnchor: [10, 10]
|
iconAnchor: [10, 10]
|
||||||
});
|
});
|
||||||
m.forEach(marker => {
|
m.forEach(marker => {
|
||||||
|
// Validate URL and sanitize name for security
|
||||||
const safeUrl = isValidUrl(marker.image_url) ? marker.image_url : '';
|
const safeUrl = isValidUrl(marker.image_url) ? marker.image_url : '';
|
||||||
const safeName = escapeHtml(marker.image_name || '');
|
const safeName = escapeHtml(marker.image_name || '');
|
||||||
if (safeUrl) {
|
if (safeUrl) { // Only add marker if URL is valid
|
||||||
const popupContent = `<b>${safeName}</b><br>
|
const popupContent = `<b>${safeName}</b><br>
|
||||||
<img src="${safeUrl}" class="max-w-[200px] max-h-[150px] rounded cursor-pointer"
|
<img src="${safeUrl}" class="max-w-[200px] max-h-[150px] rounded cursor-pointer"
|
||||||
onclick="window.openViewer('${safeUrl}', '${safeName}')" />`;
|
onclick="window.openViewer('${safeUrl}', '${safeName}')" />`;
|
||||||
@@ -506,16 +552,21 @@
|
|||||||
.addTo(imageMarkersLayer);
|
.addTo(imageMarkersLayer);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
// Update bounds when adding image markers layer
|
||||||
updateCombinedBounds();
|
updateCombinedBounds();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Modal para ver imagen al hacer clic
|
||||||
window.openViewer = function(url, name) {
|
window.openViewer = function(url, name) {
|
||||||
|
// Validate URL and sanitize name for security
|
||||||
if (!isValidUrl(url)) {
|
if (!isValidUrl(url)) {
|
||||||
console.error('Invalid URL provided to openViewer:', url);
|
console.error('Invalid URL provided to openViewer:', url);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const safeName = escapeHtml(name);
|
const safeName = escapeHtml(name);
|
||||||
|
|
||||||
if (imageViewerModal) imageViewerModal.remove();
|
if (imageViewerModal) imageViewerModal.remove();
|
||||||
const overlay = document.createElement('div');
|
const overlay = document.createElement('div');
|
||||||
overlay.id = 'imageViewerModal';
|
overlay.id = 'imageViewerModal';
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="bg-base-100 p-4 rounded shadow">
|
<div class="bg-base-100 p-4 rounded shadow">
|
||||||
<div class="flex justify-between items-center mb-4">
|
<div class="flex justify-between items-center mb-4">
|
||||||
<h2 class="text-xl font-bold">📋 {{ __('Inspection templates') }}</h2>
|
<h2 class="text-xl font-bold">📋 Templates de inspección</h2>
|
||||||
<div>
|
<div>
|
||||||
<button wire:click="newTemplate" class="btn btn-primary btn-sm">
|
<button wire:click="newTemplate" class="btn btn-primary btn-sm">
|
||||||
{{ __('New template') }}
|
Nuevo template
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -25,15 +25,16 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="py-3">
|
<td class="py-3">
|
||||||
<input type="text" wire:model="form.name"
|
<input type="text" wire:model="form.name"
|
||||||
class="input w-full"
|
class="input w-full {{ $errors->has('form.name') ? 'input-error' : '' }}"
|
||||||
required>
|
required>
|
||||||
|
@error('form.name') <span class="text-error text-xs mt-1">{{ $message }}</span> @enderror
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
{{-- Descripción --}}
|
{{-- Descripción --}}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="w-1/4 py-3 pr-4 align-top">
|
<td class="w-1/4 py-3 pr-4 align-top">
|
||||||
{{ __('Description') }}
|
{{__('Descripción')}}
|
||||||
</td>
|
</td>
|
||||||
<td class="py-3">
|
<td class="py-3">
|
||||||
<textarea wire:model="form.description" class="textarea textarea-bordered w-full" rows="2"></textarea>
|
<textarea wire:model="form.description" class="textarea textarea-bordered w-full" rows="2"></textarea>
|
||||||
@@ -43,11 +44,11 @@
|
|||||||
{{-- Fase asociada (opcional) --}}
|
{{-- Fase asociada (opcional) --}}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="w-1/4 py-3 pr-4 align-top">
|
<td class="w-1/4 py-3 pr-4 align-top">
|
||||||
{{ __('Associated phase (optional)') }}
|
{{__('Fase asociada (opcional)')}}
|
||||||
</td>
|
</td>
|
||||||
<td class="py-3">
|
<td class="py-3">
|
||||||
<select wire:model="form.phase_id" class="select select-bordered w-full">
|
<select wire:model="form.phase_id" class="select select-bordered w-full">
|
||||||
<option value="">{{ __('Global project') }}</option>
|
<option value="">Ninguna (global para el proyecto)</option>
|
||||||
@foreach($phases as $phase)
|
@foreach($phases as $phase)
|
||||||
<option value="{{ $phase->id }}" {{ old('form.phase_id') == $phase->id ? 'selected' : '' }}>
|
<option value="{{ $phase->id }}" {{ old('form.phase_id') == $phase->id ? 'selected' : '' }}>
|
||||||
{{ $phase->name }}
|
{{ $phase->name }}
|
||||||
@@ -61,22 +62,22 @@
|
|||||||
|
|
||||||
{{-- Campos dinámicos --}}
|
{{-- Campos dinámicos --}}
|
||||||
<div class="border-t pt-4 mt-2">
|
<div class="border-t pt-4 mt-2">
|
||||||
<h3 class="font-bold mb-3">{{ __('Form fields') }}</h3>
|
<h3 class="font-bold mb-3">Campos del formulario</h3>
|
||||||
@foreach($form['fields'] as $index => $field)
|
@foreach($form['fields'] as $index => $field)
|
||||||
<div class="border p-3 rounded mb-3 bg-base-100">
|
<div class="border p-3 rounded mb-3 bg-base-100">
|
||||||
{{-- Fila: nombre interno --}}
|
{{-- Fila: nombre interno --}}
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-2">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-2">
|
||||||
<div class="font-medium">{{ __('Internal name') }}</div>
|
<div class="font-medium">Nombre interno</div>
|
||||||
<div><input type="text" wire:model="form.fields.{{ $index }}.name" placeholder="ej: altura_medida" class="input input-sm w-full"></div>
|
<div><input type="text" wire:model="form.fields.{{ $index }}.name" placeholder="ej: altura_medida" class="input input-sm w-full"></div>
|
||||||
</div>
|
</div>
|
||||||
{{-- Fila: etiqueta --}}
|
{{-- Fila: etiqueta --}}
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-2">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-2">
|
||||||
<div class="font-medium">{{ __('Visible label') }}</div>
|
<div class="font-medium">Etiqueta visible</div>
|
||||||
<div><input type="text" wire:model="form.fields.{{ $index }}.label" placeholder="ej: Altura medida (m)" class="input input-sm w-full"></div>
|
<div><input type="text" wire:model="form.fields.{{ $index }}.label" placeholder="ej: Altura medida (m)" class="input input-sm w-full"></div>
|
||||||
</div>
|
</div>
|
||||||
{{-- Fila: tipo --}}
|
{{-- Fila: tipo --}}
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-2">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-2">
|
||||||
<div class="font-medium">{{ __('Field type') }}</div>
|
<div class="font-medium">Tipo de campo</div>
|
||||||
<div>
|
<div>
|
||||||
<select wire:model="form.fields.{{ $index }}.type" class="select select-sm w-full">
|
<select wire:model="form.fields.{{ $index }}.type" class="select select-sm w-full">
|
||||||
@foreach($fieldTypes as $typeValue => $typeLabel)
|
@foreach($fieldTypes as $typeValue => $typeLabel)
|
||||||
@@ -87,36 +88,43 @@
|
|||||||
</div>
|
</div>
|
||||||
{{-- Fila: requerido y botón eliminar --}}
|
{{-- Fila: requerido y botón eliminar --}}
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-2">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-2">
|
||||||
<div class="font-medium">{{ __('Required') }}</div>
|
<div class="font-medium">Requerido</div>
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
<input type="checkbox" wire:model="form.fields.{{ $index }}.required" class="checkbox checkbox-sm">
|
<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">{{ __('Remove field') }}</button>
|
<button type="button" wire:click="removeField({{ $index }})" class="btn btn-xs btn-error">Eliminar campo</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- Campos adicionales según tipo --}}
|
{{-- Campos adicionales según tipo --}}
|
||||||
@if(in_array($field['type'], ['integer', 'decimal', 'percentage']))
|
@if(in_array($field['type'], ['integer', 'decimal', 'percentage']))
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-2">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-2">
|
||||||
<div class="font-medium">{{ __('Min') }} / {{ __('Max') }} / {{ __('Step') }}</div>
|
<div class="font-medium">Mínimo / Máximo / Paso</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<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 }}.min" placeholder="Mín" 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" 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="{{ __('Step') }}" 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">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@elseif($field['type'] === 'select')
|
@elseif($field['type'] === 'select')
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-2">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-2">
|
||||||
<div class="font-medium">{{ __('Options (comma separated)') }}</div>
|
<div class="font-medium">Opciones (separadas por coma)</div>
|
||||||
<div><input type="text" wire:model="form.fields.{{ $index }}.options" placeholder="ej: Bueno,Regular,Malo" class="input input-sm w-full"></div>
|
<div><input type="text" wire:model="form.fields.{{ $index }}.options" placeholder="ej: Bueno,Regular,Malo" class="input input-sm w-full"></div>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
@endforeach
|
@endforeach
|
||||||
<button type="button" wire:click="addField" class="btn btn-sm btn-secondary mt-2">+ {{ __('Add field') }}</button>
|
<button type="button" wire:click="addField" class="btn btn-sm btn-secondary mt-2">+ Agregar campo</button>
|
||||||
</div>
|
</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">
|
<div class="flex gap-2 mt-4">
|
||||||
<button type="submit" class="btn btn-primary">{{ $editingTemplate ? __('Update') : __('Save template') }}</button>
|
<button type="submit" class="btn btn-primary">{{ __('Save template') }}</button>
|
||||||
<button type="button" wire:click="cancelForm" class="btn">{{ __('Cancel') }}</button>
|
<button type="button" wire:click="cancelForm" class="btn">{{ __('Cancel') }}</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -127,11 +135,11 @@
|
|||||||
<table class="table table-zebra">
|
<table class="table table-zebra">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>{{ __('Name') }}</th>
|
<th>Nombre</th>
|
||||||
<th>{{ __('Description') }}</th>
|
<th>Descripción</th>
|
||||||
<th>{{ __('Phase') }}</th>
|
<th>Fase</th>
|
||||||
<th>{{ __('Fields') }}</th>
|
<th>Campos</th>
|
||||||
<th>{{ __('Actions') }}</th>
|
<th>Acciones</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -139,20 +147,18 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td>{{ $template->name }}</td>
|
<td>{{ $template->name }}</td>
|
||||||
<td>{{ $template->description ?? '-' }}</td>
|
<td>{{ $template->description ?? '-' }}</td>
|
||||||
<td>{{ $template->phase ? $template->phase->name : __('Global project') }}</td>
|
<td>{{ $template->phase ? $template->phase->name : 'Global' }}</td>
|
||||||
<td>{{ count($template->fields) }}</td>
|
<td>{{ count($template->fields) }}</td>
|
||||||
<td>
|
<td>
|
||||||
<button wire:click="editTemplate({{ $template->id }})" class="btn btn-xs btn-warning">
|
<button wire:click="editTemplate({{ $template->id }})" class="btn btn-xs btn-warning">
|
||||||
{{ __('Edit') }}
|
Editar
|
||||||
</button>
|
</button>
|
||||||
<button wire:click="deleteTemplate({{ $template->id }})"
|
<button wire:click="deleteTemplate({{ $template->id }})" class="btn btn-xs btn-error" onclick="return confirm('¿Eliminar template?')">Eliminar</button>
|
||||||
wire:confirm="{{ __('Delete template confirmation') }}"
|
|
||||||
class="btn btn-xs btn-error">{{ __('Delete') }}</button>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@empty
|
@empty
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="5" class="text-center">{{ __('No templates yet (table)') }}</td>
|
<td colspan="5" class="text-center">No hay templates creados. Presiona "Nuevo template" para comenzar.</td>
|
||||||
</tr>
|
</tr>
|
||||||
@endforelse
|
@endforelse
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
+4
-2
@@ -95,8 +95,10 @@ Route::middleware(['auth'])->group(function () {
|
|||||||
'recentIssues' => $recentIssues,
|
'recentIssues' => $recentIssues,
|
||||||
]);
|
]);
|
||||||
})->name('dashboard');
|
})->name('dashboard');
|
||||||
Route::get('/reports/dashboard', ReportsDashboard::class)->name('reports.dashboard');
|
|
||||||
Route::prefix('reports')->name('reports.')->group(function () {
|
// 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/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/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');
|
Route::get('export/inspections', [App\Http\Controllers\Reports\ExportController::class, 'exportInspections'])->name('export.inspections');
|
||||||
|
|||||||
Reference in New Issue
Block a user