feat(tasks): complete task management system with Kanban, Calendar, notifications

- Add Task model with subtasks, priorities, status transitions, dates, hours
- Add Comment polymorphic model for tasks/issues/projects
- Livewire components: TaskManager (list+filters), TaskForm (modal), TaskDetail, TaskKanban (drag&drop), TaskCalendar (FullCalendar)
- TaskPolicy with permissions (view/create/edit/delete/assign/manage all)
- Notifications: assigned, status change, overdue, comment added
- Daily overdue notification job scheduled
- Dashboard widget unifies IssueTask + Task
- i18n: en/es/fr/ru (393 keys each)
- Routes, navigation, offline sync support
- 101 tests passing, Pint compliant on new files
This commit is contained in:
Javier Braña
2026-08-03 13:44:10 +02:00
parent a65d4b12f2
commit ba614bddbc
62 changed files with 5878 additions and 382 deletions
+112
View File
@@ -0,0 +1,112 @@
{
"permissions": {
"allow": [
"Bash(grep -n 'protected $fillable\\\\|protected $guarded' 'C:\\\\xampp\\\\htdocs\\\\construprogress\\\\app\\\\Models\\\\Inspection.php')",
"Bash(findstr /E \"\\\\.php$\")",
"Bash(Get-ChildItem \"C:\\\\xampp\\\\htdocs\\\\construprogress\\\\app\\\\Models\" -Filter \"*.php\")",
"Bash(Select-Object -ExpandProperty Name)",
"mcp__visualize__read_me",
"mcp__visualize__show_widget",
"Bash(php -l C:\\\\xampp\\\\htdocs\\\\construprogress\\\\app\\\\Livewire\\\\ProjectMap.php)",
"Bash(php -l C:\\\\xampp\\\\htdocs\\\\construprogress\\\\app\\\\Livewire\\\\LayerManager.php)",
"Bash(php -l C:\\\\xampp\\\\htdocs\\\\construprogress\\\\app\\\\Models\\\\Project.php)",
"Bash(cd /d C:\\\\xampp\\\\htdocs\\\\construprogress)",
"Bash(php -l app/Livewire/ProjectMap.php)",
"Bash(php -l app/Livewire/LayerManager.php)",
"Bash(php -l app/Models/Project.php)",
"PowerShell(Start-Process \"C:\\\\xampp\\\\xampp-control.exe\" -WindowStyle Hidden)",
"PowerShell(& \"C:\\\\xampp\\\\mysql\\\\bin\\\\mysqladmin.exe\" -u root ping 2>&1)",
"Bash(php artisan *)",
"Bash(php -l app/Models/Issue.php)",
"Bash(php -l app/Livewire/IssueManager.php)",
"Bash(php -l app/Notifications/InspectionCompletedNotification.php)",
"Bash(php -l app/Notifications/IssueReportedNotification.php)",
"Bash(php -l app/Notifications/FeatureCompletedNotification.php)",
"Bash(php -l app/Livewire/NotificationBell.php)",
"Bash(php -l app/Traits/LogsActivity.php)",
"Bash(php -l database/migrations/2026_06_16_100001_create_notifications_table.php)",
"Bash(php -l database/migrations/2026_06_16_100002_create_activity_logs_table.php)",
"Bash(php -l app/Livewire/PhaseGantt.php)",
"Bash(php -l app/Http/Controllers/ProjectReportController.php)",
"Bash(php -l routes/web.php)",
"PowerShell(Set-Location C:\\\\xampp\\\\htdocs\\\\construprogress; php artisan route:list --name=projects 2>&1 | Where-Object { $_ -notmatch \"Warning|openssl\" })",
"Bash(mkdir -p \"C:/Users/JavierBraña/.claude/projects/C--xampp-htdocs-construprogress/memory\")",
"Bash(grep -v \"//\\\\|<!--\")",
"Bash(composer show *)",
"Bash(dir \"C:\\\\xampp\\\\htdocs\\\\construprogress\\\\vendor\\\\blade-ui-kit\\\\blade-heroicons\" /s /b)",
"Bash(findstr /s \"LayerManager\" \"C:\\\\xampp\\\\htdocs\\\\construprogress\\\\tests\")",
"Bash(php -l \"C:\\\\xampp\\\\htdocs\\\\construprogress\\\\app\\\\Livewire\\\\CompanyView.php\")",
"Bash(php -l \"C:\\\\xampp\\\\htdocs\\\\construprogress\\\\app\\\\Models\\\\Company.php\")",
"Bash(New-Item -ItemType Directory -Force \"C:\\\\xampp\\\\htdocs\\\\construprogress\\\\resources\\\\views\\\\livewire\\\\projects\\\\partials\")",
"Bash(Out-Null)",
"Bash(Write-Host \"ok\")",
"Bash(Select-String -Path \"C:\\\\xampp\\\\htdocs\\\\construprogress\\\\resources\\\\views\\\\livewire\\\\projects\\\\project-form.blade.php\" -Pattern \"^<div>$|^</div>$\")",
"Bash(Select-Object LineNumber, Line)",
"Bash(grep -n 'function builder\\\\|function query\\\\|protected $model' vendor/rappasoft/laravel-livewire-tables/src/Traits/WithData.php)",
"Bash(Get-ChildItem -Path \"C:\\\\xampp\\\\htdocs\\\\construprogress\" -Directory -ErrorAction SilentlyContinue)",
"Bash(Select-Object Name)",
"Bash(php -r ' *)",
"Bash(grep -nP \">[^<{@]*[A-ZÁÉÍÓÚ][a-záéíóúñ]{3,}[^<{@]*<\" \"resources/views/livewire/projects/project-map-editor-tab.blade.php\" \"resources/views/dashboard.blade.php\")",
"Bash(grep -nP \">[[:space:]]*[A-ZÁÉÍÓÚ][a-záéíóúñ ]{3,}<\" resources/views/layouts/client.blade.php resources/views/welcome.blade.php resources/views/livewire/layers/layer-manager.blade.php)",
"Bash(xargs grep -l \"UpdateProfileInformation\\\\|UpdatePassword\\\\|DeleteUser\")",
"Bash(xargs grep -l \"class UpdateProfileInformationForm\\\\|class UpdatePasswordForm\\\\|class DeleteUserForm\")",
"Bash(git add *)",
"Bash(git rm *)",
"Bash(echo \".claude/worktrees/\")",
"Bash(git commit -m ' *)",
"Bash(sed -i 's/class=\"btn btn-xs btn-outline\">Ver todos</a>/class=\"btn btn-xs btn-outline\">{{ __\\('\"'\"'View all'\"'\"'\\) }}<\\\\/a>/g' resources/views/livewire/projects/project-dashboard.blade.php)",
"Bash(sed -i 's/class=\"btn btn-xs btn-outline\">Ver en mapa</a>/class=\"btn btn-xs btn-outline\">{{ __\\('\"'\"'View on map'\"'\"'\\) }}<\\\\/a>/g' resources/views/livewire/projects/project-dashboard.blade.php)",
"Bash(git checkout *)",
"Bash(git commit --amend -m '@ *)",
"Bash(git commit --amend -m 'fix: restore Rappasoft tables + fix boot errors from security commit' -m '- Restore UserTable/CompanyTable/ProjectTable usage in users, companies and projects-list pages \\(security commit had replaced them with plain HTML/DaisyUI tables, losing sorting/search/pagination/format\\) *)",
"Bash(git fetch *)",
"Bash(git rebase *)",
"Bash(git merge *)",
"Bash(git push *)",
"Bash(grep -nE \"function |public \\\\\\\\\\\\$|openIssuesCount|setActiveTab\")",
"Bash(grep -nA2 \"projects.media\")",
"Bash(php -l app/Livewire/CompanyManagement.php)",
"Bash(git commit -m 'revert: roll back to 7d854ff \\(pre-security-review state\\)' -m 'Restores all 27 files changed by the security commit \\(f8a1310\\) and later *)",
"Bash(grep -c 'public $activeTab' app/Livewire/ProjectMap.php)",
"Bash(git commit -m 'restore: bring back f8a1310 \\(security review\\) state' -m 'Restores all files to the f8a1310 security-review snapshot as requested, *)",
"Bash(git commit -m 'restore: roll back to 7d854ff \\(stable pre-security state\\)' -m 'Full restore of the 7d854ff snapshot \\(2026-06-16 18:05, before the security *)",
"Bash(grep -rnE \"^\\\\s*end\\(if|foreach|for|while|forelse|switch\\)\\\\s*$\" resources/views/)",
"Bash(git commit -m 'fix: close inline @if in company-management blade \\(bare '\\\\''endif'\\\\'' -> '\\\\''@endif'\\\\''\\)' -m 'Pre-existing bug in 7d854ff: the company type CSS-class @if block at *)",
"Bash(grep -nE \"public \\\\$|function |users|Computed\" app/Livewire/AdminUsers.php)",
"Bash(grep -nE '\\\\$this->users|\\\\$users|forelse|foreach' resources/views/livewire/admin-users.blade.php)",
"Bash(grep -nA6 \"function setActiveTab\\\\|function render\" app/Livewire/ProjectMap.php)",
"Bash(awk -F: '$1 < 340')",
"Bash(awk -F: '$1 < 345')",
"Bash(git commit -m 'feat\\(project-map\\): rework right panel per feedback' -m '1. Hide the project navigation bar \\(kept in DOM via '\\\\''hidden'\\\\'', not deleted\\) *)",
"Bash(git commit -m 'fix\\(project-map\\): null-safe feature/inspection relations in tab tables' -m 'Features and Inspections tab tables read $feature->layer->name and *)",
"Bash(git commit -m 'fix\\(project-map\\): tab content hidden by DaisyUI .tab-content \\(display:none\\)' -m 'The tab panel wrapper used class '\\\\''tab-content'\\\\'', which DaisyUI hides by *)",
"Bash(git commit -m 'fix\\(project-map\\): remove literal @if from HTML comment \\(Blade ParseError\\)' -m 'The tab-content wrapper comment contained the text '\\\\''@if'\\\\'', which Blade *)",
"Bash(git tag -a estable-mapa-ok-2026-06-17 -m 'Punto de control estable \\(2026-06-17\\): base 7d854ff + ProjectMap funcional. *)",
"Bash(git tag *)",
"Bash(grep -nE 'viewingInspection|function viewInspection|function setActiveTab|public \\\\$viewing' app/Livewire/ProjectMap.php)",
"Bash(git commit -m 'feat\\(project-map\\): edit tab redesign, table cleanup, inspection viewer, base layers' -m '1.1 Edit tab: in fullscreen the content splits into 2 columns \\(Alpine :class\\) *)",
"Bash(git commit -m 'fix\\(project-map\\): raise inspection modal z-index above map panel' -m 'The DaisyUI .modal default z-index \\(999\\) was below the phases/layers panel *)",
"Bash(git commit -m 'feat\\(project-map\\): clearer editor tabs + per-phase and per-layer visibility' -m '1. Editor tabs restyled as spaced DaisyUI buttons \\(btn-primary when active, *)",
"Bash(grep -nE \"public \\\\\\\\\\\\$|projectId|project_id|function mount|function render\" app/Livewire/ProjectForm.php)",
"Bash(grep -noE \"\\\\\\\\\\\\$[a-zA-Z_]+\" resources/views/livewire/projects/project-form.blade.php)",
"Bash(git commit -m 'fix\\(project-form\\): use $project instead of undefined $projectId in blade' -m 'project-form.blade.php referenced $projectId, but the ProjectForm component *)",
"Bash(cat resources/views/livewire/project-edit-tabs.blade.php)",
"Bash(sed -n '1,60p' resources/views/livewire/projects/partials/project-data-form.blade.php)",
"Bash(php -l app/Livewire/ProjectForm.php)",
"Bash(git commit -m 'feat\\(project-form\\): wire the rich data form \\(labels-left\\) + edit tabs' -m 'The edit/create project page used a stripped-down inline form. Rewired it *)",
"Bash(grep -nE \"public \\\\\\\\\\\\$|public function\" app/Livewire/IssueManager.php)",
"Bash(grep -noE \"\\\\\\\\\\\\$[a-zA-Z_]+|wire:click=\\\\\"[a-zA-Z_]+|wire:model[^=]*=\\\\\"[a-zA-Z_.]+\" resources/views/livewire/issues/issue-manager.blade.php)",
"Bash(git commit -m 'refactor: migrate users/companies pages to Rappasoft tables; remove unused welcome view' -m 'Point 2 \\(migrate to Rappasoft tables\\): *)",
"Bash(php -l app/Http/Controllers/ProjectController.php)",
"Bash(git commit -m 'chore: remove obsolete duplicate views/components \\(superseded code\\)' -m 'Deleted \\(all superseded, recoverable in git history\\): *)",
"Bash(git commit -m 'feat\\(issues\\): build the rich Issues screen \\(recover yesterday'\\\\''s draft\\)' -m 'Rebuilt IssueManager to match the 403-line draft view that had no companion *)",
"Bash(grep -rn \"route\\('logout'\\)\\\\|route\\(\\\\\"logout\\\\\"\\)\" resources/views app/)",
"Bash(grep -nE \"use App\\\\\\\\\\\\\\\\Http\\\\\\\\\\\\\\\\Controllers\\\\\\\\\\\\\\\\Auth|Controller::class\" routes/web.php)",
"Bash(ls app/Http/Controllers/Auth/)",
"Bash(git commit -m 'fix\\(auth\\): register Spatie role/permission middleware + add missing #[Layout] \\(fixes post-login crash\\)' -m 'Login authenticated fine but the landing page crashed \\(so it looked like *)",
"Bash(git commit -m 'feat\\(permissions\\): admin role/permission matrix + Gate::before super-admin' -m 'Phase 1 \\(additive, doesn'\\\\''t touch existing checks\\): *)",
"Bash(echo \"=== ¿hay App\\\\\\\\Models\\\\\\\\Role custom? ===\")",
"Bash(echo \"no hay \\(usa Spatie\\\\\\\\Permission\\\\\\\\Models\\\\\\\\Role\\)\")"
]
}
}
+346
View File
@@ -0,0 +1,346 @@
# Plan: Task Management Feature (Creación y Asignación de Tareas)
## Análisis del Estado Actual
### Modelos Existentes Relacionados
- **Issue** + **IssueTask**: Sistema de incidencias con checklist de subtareas (ya implementado)
- **User**: Con roles/permisos (Spatie), asignación a proyectos vía pivot `project_user`
- **Project**: Con scope `accessibleBy()` para filtrado por permisos
- **Notifications**: Sistema de notificaciones Laravel nativo
### Diferencia Clave: IssueTask vs Nueva Tarea
| Aspecto | IssueTask (existente) | Nueva Tarea (propuesta) |
|---------|----------------------|------------------------|
| Contexto | Dentro de una Issue (incidencia) | Independiente, a nivel proyecto o global |
| Propósito | Checklist de resolución de incidencia | Gestión de trabajo planificado/operativo |
| Asignación | `assigned_to` en IssueTask | `assigned_to` + posible `assigned_by` |
| Flujo de estado | `is_done` (boolean) | Estados: `pending``in_progress``completed` / `cancelled` |
---
## 1. Modelo de Datos
### Nueva Migración: `create_tasks_table.php`
```php
Schema::create('tasks', function (Blueprint $table) {
$table->id();
$table->foreignId('project_id')->nullable()->constrained()->cascadeOnDelete();
$table->foreignId('phase_id')->nullable()->constrained()->nullOnDelete(); // opcional: vincular a fase
$table->foreignId('parent_task_id')->nullable()->constrained('tasks')->nullOnDelete(); // subtareas
$table->string('title');
$table->text('description')->nullable();
$table->enum('status', ['pending', 'in_progress', 'completed', 'cancelled'])->default('pending');
$table->enum('priority', ['low', 'medium', 'high', 'critical'])->default('medium');
$table->foreignId('created_by')->constrained('users')->cascadeOnDelete();
$table->foreignId('assigned_to')->nullable()->constrained('users')->nullOnDelete();
$table->date('due_date')->nullable();
$table->date('start_date')->nullable();
$table->datetime('completed_at')->nullable();
$table->foreignId('completed_by')->nullable()->constrained('users')->nullOnDelete();
$table->unsignedInteger('estimated_hours')->nullable();
$table->unsignedInteger('actual_hours')->nullable();
$table->integer('order')->default(0); // para ordenar en listas/Kanban
$table->uuid('uuid')->unique(); // para sync offline
$table->datetime('client_updated_at')->nullable(); // para sync offline
$table->softDeletes();
$table->timestamps();
$table->index(['project_id', 'status']);
$table->index(['assigned_to', 'status']);
$table->index(['due_date', 'status']);
$table->index(['parent_task_id']);
});
```
### Relaciones en Modelos
**Task.php** (nuevo)
```php
class Task extends Model {
use HasFactory, SoftDeletes, LogsActivity;
protected $fillable = [/* todos los campos */];
protected $casts = [
'due_date' => 'date',
'start_date' => 'date',
'completed_at' => 'datetime',
'estimated_hours' => 'integer',
'actual_hours' => 'integer',
];
public function project() { return $this->belongsTo(Project::class); }
public function phase() { return $this->belongsTo(Phase::class); }
public function creator() { return $this->belongsTo(User::class, 'created_by'); }
public function assignee() { return $this->belongsTo(User::class, 'assigned_to'); }
public function completer() { return $this->belongsTo(User::class, 'completed_by'); }
public function parent() { return $this->belongsTo(Task::class, 'parent_task_id'); }
public function subtasks() { return $this->hasMany(Task::class, 'parent_task_id')->orderBy('order'); }
public function media() { return $this->morphMany(Media::class, 'mediable'); }
public function comments() { return $this->morphMany(Comment::class, 'commentable'); } // ver punto 6
// Scopes
public function scopePending($q) { return $q->where('status', 'pending'); }
public function scopeInProgress($q) { return $q->where('status', 'in_progress'); }
public function scopeCompleted($q) { return $q->where('status', 'completed'); }
public function scopeOverdue($q) { return $q->where('status', '!=', 'completed')->whereDate('due_date', '<', now()); }
// Accessors
public function getIsOverdueAttribute(): bool { ... }
public function getProgressAttribute(): int { /* si tiene subtareas: done/total */ }
}
```
**Actualizaciones a modelos existentes:**
- `Project.php`: `public function tasks() { return $this->hasMany(Task::class); }`
- `Phase.php`: `public function tasks() { return $this->hasMany(Task::class); }`
- `User.php`:
```php
public function createdTasks() { return $this->hasMany(Task::class, 'created_by'); }
public function assignedTasks() { return $this->hasMany(Task::class, 'assigned_to'); }
public function completedTasks() { return $this->hasMany(Task::class, 'completed_by'); }
```
---
## 2. Permisos (Spatie)
| Permiso | Descripción |
|---------|-------------|
| `view tasks` | Ver tareas en proyectos asignados |
| `create tasks` | Crear tareas en proyectos donde tiene rol |
| `edit tasks` | Editar tareas propias o del proyecto |
| `delete tasks` | Eliminar tareas (soft delete) |
| `assign tasks` | Cambiar `assigned_to` de cualquier tarea |
| `manage all tasks` | Admin: ver/editar todas las tareas |
Se añaden al seeder `RolesAndPermissionsSeeder.php`.
---
## 3. Livewire Components
### Estructura de Archivos
```
app/Livewire/Tasks/
├── TaskManager.php # Lista principal con filtros, tabs (Kanban/List)
├── TaskForm.php # Create/Edit modal o page
├── TaskDetail.php # Vista detalle + comentarios + subtareas
├── TaskKanban.php # Vista Kanban por estado (drag & drop)
├── TaskCalendar.php # Vista calendario (opcional, fase 2)
└── MyTasks.php # Widget dashboard: mis tareas asignadas
```
### TaskManager (Lista Principal)
- **Filtros**: proyecto, fase, asignado, estado, prioridad, vencidas, búsqueda texto
- **Vistas alternables**: Tabla / Kanban / Lista simple
- **Acciones masivas**: asignar, cambiar estado, eliminar, exportar
- **Paginación** + ordenación por columnas
### TaskForm
- Campos: título, descripción (rich text), proyecto, fase, padre (para subtareas), prioridad, fechas, horas estimadas, asignado
- Validación: `due_date >= start_date`, proyecto accesible por usuario
- Modal para crear rápido desde lista
### TaskDetail
- Info completa + barra de progreso (subtareas)
- Timeline de cambios (activity log)
- Comentarios (thread style)
- Adjuntos (media)
- Subtareas inline (crear/editar/marcar done)
- Botones acción: Iniciar, Completar, Reabrir, Cancelar
### TaskKanban
- Columnas: Pendiente | En Progreso | Completado | Cancelado
- Drag & drop para cambiar estado (Livewire `wire:model` + Alpine/SortableJS)
- Tarjetas: título, prioridad (color), asignado (avatar), due date (rojo si vencida)
- Límite WIP configurable por columna (opcional)
### MyTasks (Dashboard Widget)
- Ya existe lógica en `routes/web.php` líneas 37-42 para `IssueTask`
- Extender para incluir `Task::where('assigned_to', $user->id)->where('status', '!=', 'completed')`
- Vista unificada: "Mis Tareas" (IssueTasks + Tasks)
---
## 4. Rutas (routes/web.php)
```php
// Tasks - nivel proyecto
Route::prefix('projects/{project}')->name('projects.')->group(function () {
Route::get('tasks', \App\Livewire\Tasks\TaskManager::class)->name('tasks');
Route::get('tasks/create', \App\Livewire\Tasks\TaskForm::class)->name('tasks.create');
Route::get('tasks/{task}', \App\Livewire\Tasks\TaskDetail::class)->name('tasks.show');
Route::get('tasks/{task}/edit', \App\Livewire\Tasks\TaskForm::class)->name('tasks.edit');
Route::get('tasks/kanban', \App\Livewire\Tasks\TaskKanban::class)->name('tasks.kanban');
});
// Tasks globales (admin/manager)
Route::prefix('tasks')->name('tasks.')->middleware('can:view tasks')->group(function () {
Route::get('/', \App\Livewire\Tasks\TaskManager::class)->name('index');
Route::get('/kanban', \App\Livewire\Tasks\TaskKanban::class)->name('kanban');
Route::get('/calendar', \App\Livewire\Tasks\TaskCalendar::class)->name('calendar');
});
```
---
## 5. Notificaciones y Eventos
### Eventos
- `TaskCreated` → notificar a `assigned_to`
- `TaskAssigned` → notificar al nuevo asignado
- `TaskStatusChanged` → notificar a `created_by` y `assigned_to`
- `TaskOverdue` → job diario que notifica tareas vencidas
- `TaskCommentAdded` → notificar a participantes del hilo
### Notification Classes
- `TaskAssignedNotification` (implements ShouldQueue)
- `TaskStatusChangedNotification`
- `TaskOverdueNotification`
- `TaskCommentNotification`
### Canales
- Database (campana notificaciones)
- Email (opcional, configurable por usuario)
---
## 6. Comentarios y Adjuntos (Polimórficos)
Reutilizar patrón `Media` (ya existe morphMany). Para comentarios, crear:
**Migración:** `create_comments_table.php`
```php
$table->id();
$table->morphs('commentable'); // task, issue, project, etc.
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->text('body');
$table->foreignId('parent_id')->nullable()->constrained('comments')->nullOnDelete(); // respuestas
$table->timestamps();
```
**Modelo:** `Comment.php` con trait `LogsActivity`
**Uso en Task:** `$task->comments()->with('user')->latest()->get()`
---
## 7. Sync Offline (Existente)
Añadir `Task` a `OfflineSyncController`:
- `pending` table: incluir `tasks` en modelos sincronizables
- `uuid` + `client_updated_at` ya previstos en migración
- Reglas de conflicto: `server_updated_at > client_updated_at` gana servidor
---
## 8. Tests (Pest/PHPUnit)
| Test | Descripción |
|------|-------------|
| `TaskModelTest` | CRUD, scopes, accessors, relaciones |
| `TaskPolicyTest` | Permisos por rol (admin, manager, viewer, client) |
| `TaskManagerTest` | Filtros, paginación, vista tabla/kanban |
| `TaskFormTest` | Validaciones, crear/editar, subtareas |
| `TaskKanbanTest` | Drag & drop cambio estado, límites WIP |
| `TaskNotificationTest` | Eventos disparan notificaciones correctas |
| `TaskSyncTest` | Offline sync create/update/delete/conflict |
---
## 9. Internacionalización (i18n)
Añadir keys a `lang/{en,es,fr,ru}.json`:
- `tasks.*` (title, description, status.*, priority.*, due_date, assigned_to, subtasks, etc.)
- Notificaciones: `notifications.task_assigned`, `notifications.task_overdue`, etc.
---
## 10. Fases de Implementación
### Fase 1 - Core (MVP) ⏱️ ~3-4 días
1. Migración + Model `Task` + relaciones
2. Permisos en seeder
3. `TaskManager` (tabla + filtros básicos)
4. `TaskForm` (create/edit modal)
5. `TaskDetail` (vista + acciones estado)
6. Rutas + navegación (sidebar)
7. Tests básicos
### Fase 2 - Kanban + UX ⏱️ ~2 días
1. `TaskKanban` con drag & drop (SortableJS)
2. `MyTasks` widget en dashboard (unificar con IssueTask)
3. Subtareas inline en TaskDetail
4. Comentarios polimórficos
### Fase 3 - Notificaciones + Avanzado ⏱️ ~2 días
1. Eventos + Notifications + Email
2. Job diario overdue
3. Vista calendario (FullCalendar)
4. Exportar CSV/Excel
### Fase 4 - Pulido ⏱️ ~1 día
1. i18n completo
2. Tests E2E (Pest + Browser)
3. Documentación en README
4. Code review + merge
---
## 11. Consideraciones de Diseño
### UI/UX
- **Colores prioridad**: low=gris, medium=ámbar, high=rojo, critical=morado (consistente con Issue)
- **Iconos**: Heroicons (ya usados en proyecto)
- **Responsive**: Tailwind breakpoints, tabla → tarjetas en móvil
- **Accesibilidad**: ARIA en Kanban, focus management en modales
### Rendimiento
- Eager loading: `with(['project', 'assignee', 'phase', 'subtasks'])`
- Índices compuestos en migración
- Paginación 25/50/100
- Lazy loading en Kanban (columnas virtualizadas si >100 tareas)
### Seguridad
- Policy `TaskPolicy` con `viewAny`, `view`, `create`, `update`, `delete`, `assign`, `restore`, `forceDelete`
- Scope `accessibleBy` en Project reutilizable para Task
- Validación de pertenencia a proyecto en FormRequest
---
## 12. Archivos a Crear/Modificar (Resumen)
### Nuevos
- `database/migrations/xxxx_create_tasks_table.php`
- `database/migrations/xxxx_create_comments_table.php`
- `app/Models/Task.php`
- `app/Models/Comment.php`
- `app/Policies/TaskPolicy.php`
- `app/Notifications/TaskAssignedNotification.php`
- `app/Notifications/TaskStatusChangedNotification.php`
- `app/Notifications/TaskOverdueNotification.php`
- `app/Notifications/TaskCommentNotification.php`
- `app/Jobs/NotifyOverdueTasks.php`
- `app/Livewire/Tasks/TaskManager.php` + blade
- `app/Livewire/Tasks/TaskForm.php` + blade
- `app/Livewire/Tasks/TaskDetail.php` + blade
- `app/Livewire/Tasks/TaskKanban.php` + blade
- `app/Livewire/Tasks/MyTasks.php` + blade (widget)
- `tests/Feature/Task*.php` (múltiples)
### Modificados
- `database/seeders/RolesAndPermissionsSeeder.php` (permisos)
- `app/Models/Project.php` (relación tasks)
- `app/Models/Phase.php` (relación tasks)
- `app/Models/User.php` (relaciones tasks)
- `routes/web.php` (rutas tasks)
- `lang/{en,es,fr,ru}.json` (traducciones)
- `resources/views/layouts/navigation.blade.php` (link sidebar)
- `resources/views/dashboard.blade.php` (widget MyTasks)
- `app/Http/Controllers/OfflineSyncController.php` (sync tasks)
---
## Próximo Paso
¿Quieres que inicie la **Fase 1** creando la migración, el modelo Task, los permisos y el TaskManager básico?
+22 -4
View File
@@ -2,11 +2,11 @@
namespace App\Http\Controllers;
use App\Models\Feature;
use App\Models\Inspection;
use App\Models\PendingSync;
use App\Models\Phase;
use App\Models\Inspection;
use App\Models\Feature;
use App\Models\Media;
use App\Models\Task;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Storage;
@@ -16,7 +16,7 @@ class OfflineSyncController extends Controller
public function storePending(Request $request)
{
$payload = $request->validate([
'action' => 'required|in:progress_update,inspection,feature_create,media_upload,task_complete',
'action' => 'required|in:progress_update,inspection,feature_create,media_upload,task_complete,task_create,task_update',
'payload' => 'required|array',
]);
$pending = PendingSync::create([
@@ -24,6 +24,7 @@ class OfflineSyncController extends Controller
'action' => $payload['action'],
'payload' => $payload['payload'],
]);
return response()->json(['queued' => true]);
}
@@ -89,6 +90,22 @@ class OfflineSyncController extends Controller
// For now, just log and mark as success
\Log::info('Task completed offline', $pending->payload);
$result['success'] = true;
} elseif ($pending->action === 'task_create') {
$task = Task::create($pending->payload);
$result['success'] = true;
$result['data'] = ['task_id' => $task->id, 'uuid' => $task->uuid];
} elseif ($pending->action === 'task_update') {
$task = Task::find($pending->payload['id'] ?? null);
if ($task) {
// Remove fields that shouldn't be mass updated
$data = $pending->payload;
unset($data['id'], $data['uuid'], $data['created_at'], $data['updated_at'], $data['deleted_at']);
$task->update($data);
$result['success'] = true;
$result['data'] = ['task_id' => $task->id];
} else {
$result['error'] = 'Task not found';
}
} else {
$result['error'] = 'Unknown action type';
}
@@ -103,6 +120,7 @@ class OfflineSyncController extends Controller
$results[] = $result;
}
return response()->json(['synced' => $results]);
}
}
+5 -4
View File
@@ -16,11 +16,12 @@ class SetLocale
public function handle(Request $request, Closure $next)
{
$locale = null;
$allowedLocales = ['en', 'es', 'fr', 'ru'];
// 1. From authenticated user preference
if (Auth::check()) {
$userLocale = Auth::user()->locale;
if ($userLocale && in_array($userLocale, ['en', 'es'])) {
if ($userLocale && in_array($userLocale, $allowedLocales)) {
$locale = $userLocale;
}
}
@@ -28,7 +29,7 @@ class SetLocale
// 2. From session
if (!$locale && Session::has('locale')) {
$sessionLocale = Session::get('locale');
if (in_array($sessionLocale, ['en', 'es'])) {
if (in_array($sessionLocale, $allowedLocales)) {
$locale = $sessionLocale;
}
}
@@ -36,14 +37,14 @@ class SetLocale
// 3. From browser Accept-Language
if (!$locale) {
$browserLang = substr($request->server('HTTP_ACCEPT_LANGUAGE', 'en'), 0, 2);
if (in_array($browserLang, ['en', 'es'])) {
if (in_array($browserLang, $allowedLocales)) {
$locale = $browserLang;
}
}
// 4. Default to app locale
if (!$locale) {
$locale = config('app.locale', 'es');
$locale = config('app.locale', 'en');
}
App::setLocale($locale);
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace App\Jobs;
use App\Models\Task;
use App\Notifications\TaskOverdueNotification;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class NotifyOverdueTasks implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function handle(): void
{
$overdueTasks = Task::overdue()
->with(['assignee', 'project'])
->get();
foreach ($overdueTasks as $task) {
// Notify assignee
if ($task->assignee) {
$task->assignee->notify(new TaskOverdueNotification($task));
}
// Notify creator (if different from assignee)
if ($task->creator && $task->creator->id !== $task->assignee?->id) {
$task->creator->notify(new TaskOverdueNotification($task));
}
}
}
}
+15 -4
View File
@@ -11,14 +11,22 @@ class LanguageSwitcher extends Component
{
public string $currentLocale;
/** Idiomas disponibles con nombre y bandera. */
public array $languages = [
'en' => ['name' => 'English', 'flag' => 'gb.svg'],
'es' => ['name' => 'Español', 'flag' => 'es.svg'],
'fr' => ['name' => 'Français', 'flag' => 'fr.svg'],
'ru' => ['name' => 'Русский', 'flag' => 'ru.svg'],
];
public function mount(): void
{
$this->currentLocale = App::getLocale();
}
public function switchLanguage(string $locale): void
public function updatedCurrentLocale(string $locale): void
{
if (!in_array($locale, ['en', 'es'])) {
if (!in_array($locale, ['en', 'es', 'fr', 'ru'])) {
return;
}
@@ -31,11 +39,14 @@ class LanguageSwitcher extends Component
}
// Dispatch a browser event — JavaScript reloads the page.
// PHP-side redirects break because $this->redirect() runs inside
// /livewire/update (the AJAX endpoint), not on the real page URL.
$this->dispatch('locale-changed');
}
public function switchLanguage(string $locale): void
{
$this->updatedCurrentLocale($locale);
}
public function render()
{
return view('livewire.common.language-switcher');
+21 -14
View File
@@ -83,20 +83,27 @@ class InspectionTable extends DataTableComponent
->html(),
Column::make('Acciones')
->label(fn ($row) =>
'<div class="flex justify-end gap-1">
<button wire:click="$dispatch(\'map-view-inspection\', { id: ' . $row->id . ' })"
class="btn btn-xs btn-ghost" title="Ver inspección">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
</button>
@can("edit inspections")
<button wire:click="$dispatch(\'edit-inspection\', { id: ' . $row->id . ' })"
class="btn btn-xs btn-ghost" title="Editar inspección">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
</button>
@endcan
</div>')
->html(),
->label(fn ($row) =>
'<div class="flex justify-end gap-1">'
. '<button wire:click="$dispatch(\'map-view-inspection\', { id: ' . $row->id . ' })"'
. 'class="btn btn-xs btn-ghost" title="Ver inspección">'
. '<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>'
. '</button>'
. '@can("edit inspections")'
. '<button wire:click="$dispatch(\'edit-inspection\', { id: ' . $row->id . ' })"'
. 'class="btn btn-xs btn-ghost" title="Editar inspección">'
. '<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>'
. '</button>'
. '@endcan'
. '@can("delete inspections")'
. '<button wire:click="$dispatch(\'delete-inspection\', { id: ' . $row->id . ' })"'
. 'class="btn btn-xs btn-ghost btn-error" title="Eliminar inspección"'
. 'onclick="return confirm(\'¿Eliminar esta inspección? No se puede deshacer.\');">'
. '<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>'
. '</button>'
. '@endcan'
. '</div>')
->html(),
];
}
+62
View File
@@ -6,6 +6,7 @@ use Livewire\Component;
use Livewire\Attributes\On;
use Livewire\WithFileUploads;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
use App\Models\Project;
use App\Models\Phase;
use App\Models\Layer;
@@ -13,6 +14,7 @@ use App\Models\Feature;
use App\Models\Inspection;
use App\Models\InspectionTemplate;
use App\Models\Issue;
use App\Models\Media;
class ProjectMap extends Component
{
@@ -353,6 +355,14 @@ class ProjectMap extends Component
$this->dispatch('notify', 'Inspección guardada correctamente');
}
// Notificar a usuarios del proyecto (excepto creador)
$usersToNotify = $this->project->users()
->where('user_id', '!=', auth()->id())
->get();
foreach ($usersToNotify as $user) {
$user->notify(new \App\Notifications\InspectionCompletedNotification($inspection));
}
// Reload global list
$this->allInspections = Inspection::where('project_id', $this->project->id)
->with(['feature.layer.phase', 'template', 'user'])
@@ -547,6 +557,58 @@ class ProjectMap extends Component
$this->dispatch('notify', 'Inspección actualizada correctamente');
}
// ─── Delete Inspection ───────────────────────────────────────────────────────
#[On('delete-inspection')]
public function deleteInspection($id)
{
\Log::info('deleteInspection: START', ['id' => $id]);
if (!auth()->user()->can('delete inspections')) {
$this->dispatch('notify', 'Sin permisos para eliminar inspecciones.');
\Log::info('deleteInspection: permission denied');
return;
}
$ins = Inspection::where('project_id', $this->project->id)
->with(['feature', 'media'])
->find($id);
if (!$ins) {
\Log::info('deleteInspection: inspection not found', ['id' => $id]);
$this->dispatch('notify', 'Inspección no encontrada');
return;
}
\Log::info('deleteInspection: BEFORE delete', ['id' => $ins->id, 'deleted_at' => $ins->deleted_at, 'project_id' => $ins->project_id]);
// Delete associated media files (booted event deletes physical files)
$ins->media()->get()->each->delete();
$result = $ins->delete();
\Log::info('deleteInspection: delete() returned', ['result' => $result]);
$fresh = $ins->fresh();
\Log::info('deleteInspection: AFTER delete', ['id' => $id, 'fresh_deleted_at' => $fresh ? $fresh->deleted_at : 'null', 'fresh_exists' => $fresh ? 'yes' : 'no']);
// Refresh lists
$this->allInspections = Inspection::where('project_id', $this->project->id)
->with(['feature.layer.phase', 'template', 'user'])
->orderBy('created_at', 'desc')
->get();
$this->loadInspectionHistory();
$this->dispatch('notify', 'Inspección eliminada correctamente');
\Log::info('deleteInspection: dispatched notify SUCCESS');
// Notificar a usuarios del proyecto (excepto eliminador)
$usersToNotify = $this->project->users()
->where('user_id', '!=', auth()->id())
->get();
foreach ($usersToNotify as $user) {
$user->notify(new \App\Notifications\InspectionDeletedNotification($ins));
}
}
// ─── Feature images ──────────────────────────────────────────────────────────
public function toggleFeatureImages()
+167
View File
@@ -0,0 +1,167 @@
<?php
namespace App\Livewire\Tasks;
use App\Models\Project;
use App\Models\Task;
use App\Models\User;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
#[Layout('layouts.app')]
class TaskCalendar extends Component
{
public ?Project $project = null;
public $viewMode = 'dayGridMonth'; // dayGridMonth, timeGridWeek, timeGridDay, listWeek
public $search = '';
public $assigneeFilter = '';
public $phaseFilter = '';
public $priorityFilter = '';
public $statusFilter = '';
protected $queryString = [
'viewMode' => ['except' => 'dayGridMonth'],
'search' => ['except' => ''],
'assigneeFilter' => ['except' => ''],
'phaseFilter' => ['except' => ''],
'priorityFilter' => ['except' => ''],
'statusFilter' => ['except' => ''],
];
public function mount(?Project $project = null): void
{
$this->project = $project;
if ($project) {
abort_unless($this->canAccessProject() && Auth::user()->can('view tasks'), 403);
} else {
abort_unless(Auth::user()->can('view tasks'), 403);
}
}
private function canAccessProject(): bool
{
$user = Auth::user();
return $user->can('manage all')
|| ($this->project && $this->project->users()->where('user_id', $user->id)->exists());
}
#[On('tasksChanged')]
public function refreshCalendar(): void
{
// Trigger re-render
}
public function render()
{
$user = Auth::user();
$query = Task::with(['project', 'phase', 'assignee'])
->where(function ($q) use ($user) {
if (! $user->can('manage all tasks')) {
$q->whereHas('project', function ($sub) use ($user) {
$sub->whereHas('users', function ($sub2) use ($user) {
$sub2->where('user_id', $user->id);
});
});
}
});
if ($this->project) {
$query->where('project_id', $this->project->id);
}
if ($this->search) {
$query->where(function ($q) {
$q->where('title', 'like', '%'.$this->search.'%')
->orWhere('description', 'like', '%'.$this->search.'%');
});
}
if ($this->assigneeFilter) {
$query->where('assigned_to', $this->assigneeFilter);
}
if ($this->phaseFilter) {
$query->where('phase_id', $this->phaseFilter);
}
if ($this->priorityFilter) {
$query->where('priority', $this->priorityFilter);
}
if ($this->statusFilter) {
$query->where('status', $this->statusFilter);
}
$tasks = $query->get();
// Transform tasks to FullCalendar events
$events = $tasks->map(function ($task) {
$start = $task->start_date ?? $task->due_date ?? $task->created_at->toDateString();
$end = $task->due_date ?? $task->start_date;
return [
'id' => $task->id,
'title' => $task->title,
'start' => $start,
'end' => $end ? $end->addDay()->toDateString() : null, // FullCalendar end is exclusive
'url' => route('projects.tasks.show', [$task->project_id, $task->id]),
'backgroundColor' => $task->priority_color,
'borderColor' => $task->priority_color,
'textColor' => '#ffffff',
'extendedProps' => [
'status' => $task->status,
'statusLabel' => $task->status_label,
'priority' => $task->priority,
'priorityLabel' => $task->priority_label,
'project' => $task->project->name ?? '—',
'phase' => $task->phase->name ?? '—',
'assignee' => $task->assignee->name ?? 'Sin asignar',
'dueDate' => $task->due_date?->format('d/m/Y'),
'isOverdue' => $task->is_overdue,
'isDueToday' => $task->is_due_today,
'progress' => $task->progress,
'subtasksCount' => $task->subtasks->count(),
'completedSubtasksCount' => $task->subtasks->where('status', 'completed')->count(),
],
'classNames' => [
'fc-task-'.$task->status,
$task->is_overdue ? 'fc-task-overdue' : '',
$task->is_due_today ? 'fc-task-due-today' : '',
],
];
})->toArray();
// Filter options
$assignees = User::whereHas('assignedTasks', function ($q) use ($user) {
if (! $user->can('manage all tasks')) {
$q->whereHas('project', function ($sub) use ($user) {
$sub->whereHas('users', function ($sub2) use ($user) {
$sub2->where('user_id', $user->id);
});
});
}
})->get(['id', 'name']);
$phases = $this->project
? $this->project->phases()->get(['id', 'name'])
: collect();
return view('livewire.tasks.task-calendar', [
'events' => json_encode($events),
'assignees' => $assignees,
'phases' => $phases,
'statusOptions' => Task::statusOptions(),
'priorityOptions' => Task::priorityOptions(),
]);
}
}
+177
View File
@@ -0,0 +1,177 @@
<?php
namespace App\Livewire\Tasks;
use App\Models\Comment;
use App\Models\Project;
use App\Models\Task;
use App\Models\User;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use Livewire\Attributes\Layout;
use Livewire\Component;
use Livewire\WithFileUploads;
#[Layout('layouts.app')]
class TaskDetail extends Component
{
use WithFileUploads;
public Task $task;
public ?Project $project = null;
// Comment form
public $commentBody = '';
public $editingCommentId = null;
public $editingCommentBody = '';
// Subtask form
public $subtaskTitle = '';
protected $listeners = ['tasksChanged' => '$refresh', 'commentAdded' => '$refresh', 'subtaskAdded' => '$refresh'];
public function mount(Task $task): void
{
$this->task = $task->load(['project', 'phase', 'assignee', 'creator', 'completer', 'subtasks', 'media', 'comments.user', 'comments.replies.user']);
$this->project = $this->task->project;
abort_unless(Auth::user()->can('view', $this->task), 403);
}
public function addComment(): void
{
$this->validate(['commentBody' => 'required|string|max:5000']);
$this->task->comments()->create([
'user_id' => Auth::id(),
'body' => $this->commentBody,
]);
$this->commentBody = '';
$this->dispatch('commentAdded');
$this->dispatch('tasksChanged');
}
public function startEditComment($commentId): void
{
$comment = $this->task->comments()->find($commentId);
if ($comment && $comment->user_id === Auth::id()) {
$this->editingCommentId = $commentId;
$this->editingCommentBody = $comment->body;
}
}
public function saveEditComment(): void
{
$this->validate(['editingCommentBody' => 'required|string|max:5000']);
$comment = Comment::find($this->editingCommentId);
if ($comment && $comment->user_id === Auth::id()) {
$comment->update(['body' => $this->editingCommentBody]);
}
$this->editingCommentId = null;
$this->editingCommentBody = '';
$this->dispatch('commentAdded');
}
public function cancelEditComment(): void
{
$this->editingCommentId = null;
$this->editingCommentBody = '';
}
public function deleteComment($commentId): void
{
$comment = Comment::find($commentId);
if ($comment && ($comment->user_id === Auth::id() || Auth::user()->can('manage all tasks'))) {
$comment->delete();
$this->dispatch('commentAdded');
}
}
public function addSubtask(): void
{
$this->validate(['subtaskTitle' => 'required|string|max:255']);
$maxOrder = $this->task->subtasks()->max('order') ?? 0;
$this->task->subtasks()->create([
'title' => $this->subtaskTitle,
'status' => 'pending',
'priority' => 'medium',
'project_id' => $this->task->project_id,
'phase_id' => $this->task->phase_id,
'parent_task_id' => $this->task->id,
'created_by' => Auth::id(),
'order' => $maxOrder + 1,
'uuid' => Str::uuid(),
]);
$this->subtaskTitle = '';
$this->dispatch('subtaskAdded');
$this->dispatch('tasksChanged');
}
public function updateSubtaskStatus(Task $subtask, string $status): void
{
abort_unless(Auth::user()->can('update', $subtask), 403);
$data = ['status' => $status];
if ($status === 'completed') {
$data['completed_at'] = now();
$data['completed_by'] = Auth::id();
} elseif ($subtask->status === 'completed') {
$data['completed_at'] = null;
$data['completed_by'] = null;
}
$subtask->update($data);
$this->dispatch('tasksChanged');
}
public function deleteSubtask(Task $subtask): void
{
abort_unless(Auth::user()->can('delete', $subtask), 403);
$subtask->delete();
$this->dispatch('tasksChanged');
}
public function changeStatus(string $status): void
{
abort_unless(Auth::user()->can('update', $this->task), 403);
$oldStatus = $this->task->status;
$data = ['status' => $status];
if ($oldStatus !== 'completed' && $status === 'completed') {
$data['completed_at'] = now();
$data['completed_by'] = Auth::id();
} elseif ($oldStatus === 'completed' && $status !== 'completed') {
$data['completed_at'] = null;
$data['completed_by'] = null;
}
$this->task->update($data);
$this->task->refresh();
$this->dispatch('tasksChanged');
}
public function render()
{
$assignees = User::whereHas('assignedTasks', function ($q) {
if ($this->project) {
$q->where('project_id', $this->project->id);
}
})->get(['id', 'name']);
return view('livewire.tasks.task-detail', [
'assignees' => $assignees,
'statusOptions' => Task::statusOptions(),
'priorityOptions' => Task::priorityOptions(),
]);
}
}
+206
View File
@@ -0,0 +1,206 @@
<?php
namespace App\Livewire\Tasks;
use App\Models\Project;
use App\Models\Task;
use App\Models\User;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate;
use Livewire\Component;
use Livewire\WithFileUploads;
#[Layout('layouts.app')]
class TaskForm extends Component
{
use WithFileUploads;
public ?Task $task = null;
public ?Project $project = null;
// Form fields
#[Validate('required|string|max:255')]
public $title = '';
#[Validate('nullable|string')]
public $description = '';
#[Validate('nullable|exists:phases,id')]
public $phase_id = '';
#[Validate('required|in:pending,in_progress,completed,cancelled')]
public $status = 'pending';
#[Validate('required|in:low,medium,high,critical')]
public $priority = 'medium';
#[Validate('nullable|exists:users,id')]
public $assigned_to = '';
#[Validate('nullable|date|after_or_equal:start_date')]
public $due_date = '';
#[Validate('nullable|date|before_or_equal:due_date')]
public $start_date = '';
#[Validate('nullable|integer|min:0|max:10000')]
public $estimated_hours = '';
#[Validate('nullable|integer|min:0|max:10000')]
public $actual_hours = '';
#[Validate('nullable|integer|min:0')]
public $order = 0;
// For subtasks
#[Validate('nullable|exists:tasks,id')]
public $parent_task_id = '';
// UI state
public $showModal = false;
public $mode = 'create'; // 'create' or 'edit'
protected $listeners = ['openTaskModal' => 'openModal'];
public function mount(?Project $project = null, ?Task $task = null): void
{
$this->project = $project;
$this->task = $task;
if ($task) {
$this->mode = 'edit';
$this->fillFromTask($task);
abort_unless(Auth::user()->can('update', $task), 403);
} else {
$this->mode = 'create';
abort_unless(Auth::user()->can('create tasks', $project), 403);
}
}
public function openModal(array $params = []): void
{
$this->resetForm();
$this->mode = $params['mode'] ?? 'create';
$this->project = $params['project'] ?? $this->project;
$this->task = $params['task'] ?? null;
if ($this->task) {
$this->fillFromTask($this->task);
} else {
$this->status = 'pending';
$this->priority = 'medium';
$this->order = 0;
}
$this->showModal = true;
}
private function fillFromTask(Task $task): void
{
$this->title = $task->title;
$this->description = $task->description;
$this->phase_id = $task->phase_id ?? '';
$this->status = $task->status;
$this->priority = $task->priority;
$this->assigned_to = $task->assigned_to ?? '';
$this->due_date = $task->due_date?->format('Y-m-d') ?? '';
$this->start_date = $task->start_date?->format('Y-m-d') ?? '';
$this->estimated_hours = $task->estimated_hours ?? '';
$this->actual_hours = $task->actual_hours ?? '';
$this->order = $task->order ?? 0;
$this->parent_task_id = $task->parent_task_id ?? '';
}
private function resetForm(): void
{
$this->reset(['title', 'description', 'phase_id', 'status', 'priority', 'assigned_to', 'due_date', 'start_date', 'estimated_hours', 'actual_hours', 'order', 'parent_task_id']);
$this->resetValidation();
}
public function save(): void
{
$validated = $this->validate();
$data = [
'title' => $validated['title'],
'description' => $validated['description'] ?? null,
'phase_id' => $validated['phase_id'] ?? null,
'status' => $validated['status'],
'priority' => $validated['priority'],
'assigned_to' => $validated['assigned_to'] ?? null,
'due_date' => $validated['due_date'] ?? null,
'start_date' => $validated['start_date'] ?? null,
'estimated_hours' => $validated['estimated_hours'] ?? null,
'actual_hours' => $validated['actual_hours'] ?? null,
'order' => $validated['order'],
'parent_task_id' => $validated['parent_task_id'] ?? null,
'project_id' => $this->project?->id,
'created_by' => Auth::id(),
];
// Handle status transitions
if ($this->mode === 'edit' && $this->task) {
$oldStatus = $this->task->status;
$newStatus = $data['status'];
if ($oldStatus !== 'completed' && $newStatus === 'completed') {
$data['completed_at'] = now();
$data['completed_by'] = Auth::id();
} elseif ($oldStatus === 'completed' && $newStatus !== 'completed') {
$data['completed_at'] = null;
$data['completed_by'] = null;
}
$this->task->update($data);
$task = $this->task;
session()->flash('message', 'Tarea actualizada correctamente');
} else {
$data['uuid'] = Str::uuid();
$task = Task::create($data);
session()->flash('message', 'Tarea creada correctamente');
}
$this->dispatch('tasksChanged');
$this->dispatch('taskSaved', taskId: $task->id);
$this->closeModal();
}
public function closeModal(): void
{
$this->showModal = false;
$this->resetForm();
$this->mode = 'create';
$this->task = null;
}
public function render()
{
$assignees = User::whereHas('assignedTasks', function ($q) {
if ($this->project) {
$q->where('project_id', $this->project->id);
}
})->get(['id', 'name']);
$phases = $this->project ? $this->project->phases->pluck('name', 'id') : collect();
// For parent task selector (only sibling tasks in same project)
$parentTasks = $this->project
? Task::where('project_id', $this->project->id)
->whereNull('parent_task_id')
->when($this->task, fn ($q) => $q->where('id', '!=', $this->task->id))
->get(['id', 'title'])
: collect();
return view('livewire.tasks.task-form', [
'assignees' => $assignees,
'phases' => $phases,
'parentTasks' => $parentTasks,
'statusOptions' => Task::statusOptions(),
'priorityOptions' => Task::priorityOptions(),
]);
}
}
+194
View File
@@ -0,0 +1,194 @@
<?php
namespace App\Livewire\Tasks;
use App\Models\Project;
use App\Models\Task;
use App\Models\User;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
use Livewire\WithPagination;
#[Layout('layouts.app')]
class TaskKanban extends Component
{
use WithPagination;
public ?Project $project = null;
public $search = '';
public $assigneeFilter = '';
public $phaseFilter = '';
public $priorityFilter = '';
public $showOverdueOnly = false;
public $perPage = 50;
protected $queryString = [
'search' => ['except' => ''],
'assigneeFilter' => ['except' => ''],
'phaseFilter' => ['except' => ''],
'priorityFilter' => ['except' => ''],
'showOverdueOnly' => ['except' => false],
];
public function mount(?Project $project = null): void
{
$this->project = $project;
if ($project) {
abort_unless($this->canAccessProject() && Auth::user()->can('view tasks'), 403);
} else {
abort_unless(Auth::user()->can('view tasks'), 403);
}
}
private function canAccessProject(): bool
{
$user = Auth::user();
return $user->can('manage all')
|| ($this->project && $this->project->users()->where('user_id', $user->id)->exists());
}
#[On('tasksChanged')]
public function refreshTasks(): void
{
// Trigger re-render
}
public function updateTaskStatus(Task $task, string $status): void
{
abort_unless(Auth::user()->can('update', $task), 403);
$oldStatus = $task->status;
$data = ['status' => $status];
if ($oldStatus !== 'completed' && $status === 'completed') {
$data['completed_at'] = now();
$data['completed_by'] = Auth::id();
} elseif ($oldStatus === 'completed' && $status !== 'completed') {
$data['completed_at'] = null;
$data['completed_by'] = null;
}
$task->update($data);
$this->dispatch('tasksChanged');
}
public function updateTaskOrder(int $taskId, int $order): void
{
$task = Task::find($taskId);
if ($task && Auth::user()->can('update', $task)) {
$task->update(['order' => $order]);
}
}
public function resetFilters(): void
{
$this->reset(['search', 'assigneeFilter', 'phaseFilter', 'priorityFilter', 'showOverdueOnly']);
$this->resetPage();
}
public function render()
{
$user = Auth::user();
$baseQuery = Task::with(['project', 'phase', 'assignee', 'creator', 'subtasks'])
->where(function ($q) use ($user) {
if (! $user->can('manage all tasks')) {
$q->whereHas('project', function ($sub) use ($user) {
$sub->whereHas('users', function ($sub2) use ($user) {
$sub2->where('user_id', $user->id);
});
});
}
});
if ($this->project) {
$baseQuery->where('project_id', $this->project->id);
}
if ($this->search) {
$baseQuery->where(function ($q) {
$q->where('title', 'like', '%'.$this->search.'%')
->orWhere('description', 'like', '%'.$this->search.'%');
});
}
if ($this->assigneeFilter) {
$baseQuery->where('assigned_to', $this->assigneeFilter);
}
if ($this->phaseFilter) {
$baseQuery->where('phase_id', $this->phaseFilter);
}
if ($this->priorityFilter) {
$baseQuery->where('priority', $this->priorityFilter);
}
if ($this->showOverdueOnly) {
$baseQuery->overdue();
}
// Get tasks grouped by status
$statuses = ['pending', 'in_progress', 'completed', 'cancelled'];
$columns = [];
foreach ($statuses as $status) {
$query = clone $baseQuery;
$query->where('status', $status)
->orderBy('order')
->orderBy('created_at', 'desc');
$tasks = $query->get();
$columns[$status] = [
'label' => Task::statusOptions()[$status],
'color' => $this->getStatusColor($status),
'tasks' => $tasks,
'count' => $tasks->count(),
];
}
// Filter options
$assignees = User::whereHas('assignedTasks', function ($q) use ($user) {
if (! $user->can('manage all tasks')) {
$q->whereHas('project', function ($sub) use ($user) {
$sub->whereHas('users', function ($sub2) use ($user) {
$sub2->where('user_id', $user->id);
});
});
}
})->get(['id', 'name']);
$phases = $this->project
? $this->project->phases()->get(['id', 'name'])
: collect();
return view('livewire.tasks.task-kanban', [
'columns' => $columns,
'assignees' => $assignees,
'phases' => $phases,
'statusOptions' => Task::statusOptions(),
'priorityOptions' => Task::priorityOptions(),
]);
}
private function getStatusColor(string $status): string
{
return match ($status) {
'pending' => '#6b7280', // gray
'in_progress' => '#3b82f6', // blue
'completed' => '#10b981', // green
'cancelled' => '#ef4444', // red
default => '#6b7280',
};
}
}
+186
View File
@@ -0,0 +1,186 @@
<?php
namespace App\Livewire\Tasks;
use App\Models\Project;
use App\Models\Task;
use App\Models\User;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
use Livewire\WithPagination;
#[Layout('layouts.app')]
class TaskManager extends Component
{
use WithPagination;
public ?Project $project = null;
public $search = '';
public $statusFilter = '';
public $priorityFilter = '';
public $assigneeFilter = '';
public $phaseFilter = '';
public $showOverdueOnly = false;
public $perPage = 25;
public $sortField = 'created_at';
public $sortDirection = 'desc';
public $viewMode = 'table'; // 'table' or 'kanban'
protected $queryString = [
'search' => ['except' => ''],
'statusFilter' => ['except' => ''],
'priorityFilter' => ['except' => ''],
'assigneeFilter' => ['except' => ''],
'phaseFilter' => ['except' => ''],
'showOverdueOnly' => ['except' => false],
'perPage' => ['except' => 25],
'sortField' => ['except' => 'created_at'],
'sortDirection' => ['except' => 'desc'],
'viewMode' => ['except' => 'table'],
];
public function mount(?Project $project = null): void
{
$this->project = $project;
if ($project) {
abort_unless($this->canAccessProject() && Auth::user()->can('view tasks'), 403);
} else {
abort_unless(Auth::user()->can('view tasks'), 403);
}
}
private function canAccessProject(): bool
{
$user = Auth::user();
return $user->can('manage all')
|| ($this->project && $this->project->users()->where('user_id', $user->id)->exists());
}
#[On('tasksChanged')]
public function refreshTasks(): void
{
// Trigger re-render
}
public function sortBy(string $field): void
{
if ($this->sortField === $field) {
$this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc';
} else {
$this->sortField = $field;
$this->sortDirection = 'asc';
}
}
public function resetFilters(): void
{
$this->reset(['search', 'statusFilter', 'priorityFilter', 'assigneeFilter', 'phaseFilter', 'showOverdueOnly']);
$this->resetPage();
}
public function deleteTask(Task $task): void
{
abort_unless(Auth::user()->can('delete', $task), 403);
$task->delete();
session()->flash('message', 'Tarea eliminada');
$this->dispatch('tasksChanged');
}
public function toggleViewMode(): void
{
$this->viewMode = $this->viewMode === 'table' ? 'kanban' : 'table';
}
public function render()
{
$user = Auth::user();
$query = Task::with(['project', 'phase', 'assignee', 'creator', 'subtasks'])
->where(function ($q) use ($user) {
if (! $user->can('manage all tasks')) {
$q->whereHas('project', function ($sub) use ($user) {
$sub->whereHas('users', function ($sub2) use ($user) {
$sub2->where('user_id', $user->id);
});
});
}
});
if ($this->project) {
$query->where('project_id', $this->project->id);
}
if ($this->search) {
$query->where(function ($q) {
$q->where('title', 'like', '%'.$this->search.'%')
->orWhere('description', 'like', '%'.$this->search.'%');
});
}
if ($this->statusFilter) {
$query->where('status', $this->statusFilter);
}
if ($this->priorityFilter) {
$query->where('priority', $this->priorityFilter);
}
if ($this->assigneeFilter) {
$query->where('assigned_to', $this->assigneeFilter);
}
if ($this->phaseFilter) {
$query->where('phase_id', $this->phaseFilter);
}
if ($this->showOverdueOnly) {
$query->overdue();
}
// Sorting
$allowedSorts = ['title', 'status', 'priority', 'due_date', 'created_at', 'order'];
if (in_array($this->sortField, $allowedSorts)) {
$query->orderBy($this->sortField, $this->sortDirection);
} else {
$query->orderBy('order')->orderBy('created_at', 'desc');
}
$tasks = $query->paginate($this->perPage);
// Filter options
$assignees = User::whereHas('assignedTasks', function ($q) use ($user) {
if (! $user->can('manage all tasks')) {
$q->whereHas('project', function ($sub) use ($user) {
$sub->whereHas('users', function ($sub2) use ($user) {
$sub2->where('user_id', $user->id);
});
});
}
})->get(['id', 'name']);
$phases = $this->project
? $this->project->phases()->get(['id', 'name'])
: collect();
return view('livewire.tasks.task-manager', [
'tasks' => $tasks,
'assignees' => $assignees,
'phases' => $phases,
'statusOptions' => Task::statusOptions(),
'priorityOptions' => Task::priorityOptions(),
]);
}
}
+2
View File
@@ -50,6 +50,8 @@ class UserForm extends Component
public array $languages = [
'es' => ['name' => 'Español', 'flag' => 'es.svg'],
'en' => ['name' => 'English', 'flag' => 'gb.svg'],
'fr' => ['name' => 'Français', 'flag' => 'fr.svg'],
'ru' => ['name' => 'Русский', 'flag' => 'ru.svg'],
];
public function mount(?User $user = null): void
+101
View File
@@ -0,0 +1,101 @@
<?php
namespace App\Models;
use App\Notifications\TaskCommentNotification;
use App\Traits\LogsActivity;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class Comment extends Model
{
use HasFactory, LogsActivity;
protected $fillable = [
'commentable_type',
'commentable_id',
'user_id',
'body',
'parent_id',
];
protected $casts = [
'body' => 'string',
];
// ============================================================
// Events / Observers
// ============================================================
protected static function booted(): void
{
static::created(function (Comment $comment) {
// Notify if comment is on a Task
if ($comment->commentable_type === Task::class) {
$task = $comment->commentable;
if ($task) {
// Notify creator and assignee (except the commenter)
$usersToNotify = collect([$task->creator, $task->assignee])
->unique('id')
->filter(fn ($u) => $u && $u->id !== $comment->user_id);
foreach ($usersToNotify as $user) {
$user->notify(new TaskCommentNotification($task, $comment));
}
}
}
});
}
// ============================================================
// Relationships
// ============================================================
public function commentable(): MorphTo
{
return $this->morphTo();
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function parent(): BelongsTo
{
return $this->belongsTo(Comment::class, 'parent_id');
}
public function replies(): HasMany
{
return $this->hasMany(Comment::class, 'parent_id')->orderBy('created_at');
}
// ============================================================
// Scopes
// ============================================================
public function scopeTopLevel($query)
{
return $query->whereNull('parent_id');
}
public function scopeWithReplies($query)
{
return $query->with('replies.user');
}
// ============================================================
// Accessors
// ============================================================
public function getExcerptAttribute(): string
{
return strlen($this->body) > 150
? substr($this->body, 0, 150).'...'
: $this->body;
}
}
+8
View File
@@ -10,6 +10,14 @@ class Inspection extends Model
{
use SoftDeletes, LogsActivity;
protected static function booted(): void
{
static::deleting(function ($inspection) {
// Cascada: borrar media asociado cuando se elimina la inspección
$inspection->media()->get()->each->delete();
});
}
const STATUSES = ['pending', 'in_progress', 'completed', 'approved', 'rejected'];
const RESULTS = ['pass', 'fail', 'conditional'];
+49 -12
View File
@@ -1,5 +1,7 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
@@ -9,28 +11,63 @@ class Phase extends Model
protected $fillable = [
'project_id', 'name', 'description', 'order', 'color', 'progress_percent',
'planned_start', 'planned_end', 'actual_start', 'actual_end'
'planned_start', 'planned_end', 'actual_start', 'actual_end',
];
protected $casts = [
'planned_start' => 'date',
'planned_end' => 'date',
'actual_start' => 'date',
'actual_end' => 'date',
'planned_end' => 'date',
'actual_start' => 'date',
'actual_end' => 'date',
];
public function project() { return $this->belongsTo(Project::class); }
public function layers() { return $this->hasMany(Layer::class); }
public function progressUpdates() { return $this->hasMany(ProgressUpdate::class); }
public function currentLayer() { return $this->hasOne(Layer::class)->latestOfMany(); }
public function features() { return $this->hasManyThrough(Feature::class, Layer::class); }
public function media() { return $this->morphMany(Media::class, 'mediable'); }
public function images() { return $this->morphMany(Media::class, 'mediable')->where('category', 'image'); }
public function project()
{
return $this->belongsTo(Project::class);
}
public function layers()
{
return $this->hasMany(Layer::class);
}
public function progressUpdates()
{
return $this->hasMany(ProgressUpdate::class);
}
public function currentLayer()
{
return $this->hasOne(Layer::class)->latestOfMany();
}
public function features()
{
return $this->hasManyThrough(Feature::class, Layer::class);
}
public function media()
{
return $this->morphMany(Media::class, 'mediable');
}
public function images()
{
return $this->morphMany(Media::class, 'mediable')->where('category', 'image');
}
public function tasks()
{
return $this->hasMany(Task::class);
}
public function getDeviationDaysAttribute(): ?int
{
if (!$this->planned_end) return null;
if (! $this->planned_end) {
return null;
}
$end = $this->actual_end ?? now();
return $this->planned_end->diffInDays($end, false);
}
}
+11 -5
View File
@@ -16,8 +16,8 @@ class Project extends Model
];
protected $casts = [
"start_date" => "date",
"end_date_estimated" => "date",
'start_date' => 'date',
'end_date_estimated' => 'date',
];
public function changeOrders()
@@ -44,8 +44,8 @@ class Project extends Model
public function companies()
{
return $this->belongsToMany(Company::class, 'company_project')
->withPivot('role_in_project')
->withTimestamps();
->withPivot('role_in_project')
->withTimestamps();
}
public function creator()
@@ -69,14 +69,20 @@ class Project extends Model
return $this->morphMany(Media::class, 'mediable')->where('category', 'image');
}
public function tasks()
{
return $this->hasMany(Task::class);
}
// Scope to filter accessible projects for non-admin users
public function scopeAccessibleBy($query, User $user)
{
if ($user->can('manage all')) {
return $query;
}
return $query->whereHas('users', function ($q) use ($user) {
$q->where('user_id', $user->id);
});
}
}
}
+326
View File
@@ -0,0 +1,326 @@
<?php
namespace App\Models;
use App\Notifications\TaskAssignedNotification;
use App\Notifications\TaskStatusChangedNotification;
use App\Traits\LogsActivity;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Task extends Model
{
use HasFactory, LogsActivity, SoftDeletes;
protected $fillable = [
'project_id',
'phase_id',
'parent_task_id',
'title',
'description',
'status',
'priority',
'created_by',
'assigned_to',
'due_date',
'start_date',
'completed_at',
'completed_by',
'estimated_hours',
'actual_hours',
'order',
'uuid',
'client_updated_at',
];
protected $casts = [
'due_date' => 'date',
'start_date' => 'date',
'completed_at' => 'datetime',
'estimated_hours' => 'integer',
'actual_hours' => 'integer',
'order' => 'integer',
];
// ============================================================
// Events / Observers
// ============================================================
protected static function booted(): void
{
static::created(function (Task $task) {
if ($task->assigned_to) {
$assignee = $task->assignee;
if ($assignee && $assignee->id !== auth()->id()) {
$assignee->notify(new TaskAssignedNotification($task));
}
}
});
static::updated(function (Task $task) {
// Notify on assignment change
if ($task->isDirty('assigned_to')) {
$newAssigneeId = $task->assigned_to;
if ($newAssigneeId) {
$assignee = User::find($newAssigneeId);
if ($assignee && $assignee->id !== auth()->id()) {
$assignee->notify(new TaskAssignedNotification($task));
}
}
}
// Notify on status change
if ($task->isDirty('status')) {
$oldStatus = $task->getOriginal('status');
$task->creator?->notify(new TaskStatusChangedNotification($task, $oldStatus));
if ($task->assigned_to && $task->assigned_to !== $task->created_by) {
$task->assignee?->notify(new TaskStatusChangedNotification($task, $oldStatus));
}
}
});
}
// ============================================================
// Relationships
// ============================================================
public function project(): BelongsTo
{
return $this->belongsTo(Project::class);
}
public function phase(): BelongsTo
{
return $this->belongsTo(Phase::class);
}
public function parent(): BelongsTo
{
return $this->belongsTo(Task::class, 'parent_task_id');
}
public function subtasks(): HasMany
{
return $this->hasMany(Task::class, 'parent_task_id')->orderBy('order')->orderBy('id');
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
public function assignee(): BelongsTo
{
return $this->belongsTo(User::class, 'assigned_to');
}
public function completer(): BelongsTo
{
return $this->belongsTo(User::class, 'completed_by');
}
public function media(): MorphMany
{
return $this->morphMany(Media::class, 'mediable');
}
public function comments(): MorphMany
{
return $this->morphMany(Comment::class, 'commentable')->orderBy('created_at');
}
public function activityLogs(): MorphMany
{
return $this->morphMany(ActivityLog::class, 'model');
}
// ============================================================
// Scopes
// ============================================================
public function scopePending($query)
{
return $query->where('status', 'pending');
}
public function scopeInProgress($query)
{
return $query->where('status', 'in_progress');
}
public function scopeCompleted($query)
{
return $query->where('status', 'completed');
}
public function scopeCancelled($query)
{
return $query->where('status', 'cancelled');
}
public function scopeNotCompleted($query)
{
return $query->where('status', '!=', 'completed');
}
public function scopeOverdue($query)
{
return $query->where('status', '!=', 'completed')
->whereNotNull('due_date')
->whereDate('due_date', '<', now()->toDateString());
}
public function scopeDueToday($query)
{
return $query->where('status', '!=', 'completed')
->whereDate('due_date', now()->toDateString());
}
public function scopeAccessibleBy($query, User $user)
{
if ($user->can('manage all tasks')) {
return $query;
}
return $query->whereHas('project', function ($q) use ($user) {
$q->whereHas('users', function ($sub) use ($user) {
$sub->where('user_id', $user->id);
});
});
}
// ============================================================
// Accessors / Helpers
// ============================================================
public function getIsOverdueAttribute(): bool
{
return $this->status !== 'completed'
&& $this->due_date
&& $this->due_date->isPast();
}
public function getIsDueTodayAttribute(): bool
{
return $this->status !== 'completed'
&& $this->due_date
&& $this->due_date->isToday();
}
public function getProgressAttribute(): int
{
$total = $this->subtasks->count();
if ($total === 0) {
return in_array($this->status, ['completed'], true) ? 100 : 0;
}
return (int) round($this->subtasks->where('status', 'completed')->count() / $total * 100);
}
public function getPriorityColorAttribute(): string
{
return match ($this->priority) {
'low' => '#6b7280', // gray
'medium' => '#f59e0b', // amber
'high' => '#ef4444', // red
'critical' => '#7c3aed', // purple
default => '#6b7280',
};
}
public function getStatusColorAttribute(): string
{
return match ($this->status) {
'pending' => '#6b7280', // gray
'in_progress' => '#3b82f6', // blue
'completed' => '#10b981', // green
'cancelled' => '#ef4444', // red
default => '#6b7280',
};
}
public function getStatusLabelAttribute(): string
{
return match ($this->status) {
'pending' => 'Pendiente',
'in_progress' => 'En progreso',
'completed' => 'Completada',
'cancelled' => 'Cancelada',
default => ucfirst((string) $this->status),
};
}
public function getPriorityLabelAttribute(): string
{
return match ($this->priority) {
'low' => 'Baja',
'medium' => 'Media',
'high' => 'Alta',
'critical' => 'Crítica',
default => ucfirst((string) $this->priority),
};
}
// ============================================================
// State Transitions
// ============================================================
public function start(): void
{
$this->update([
'status' => 'in_progress',
'start_date' => $this->start_date ?? now()->toDateString(),
]);
}
public function complete(?User $user = null): void
{
$this->update([
'status' => 'completed',
'completed_at' => now(),
'completed_by' => $user?->id ?? auth()->id(),
]);
}
public function reopen(): void
{
$this->update([
'status' => 'pending',
'completed_at' => null,
'completed_by' => null,
]);
}
public function cancel(): void
{
$this->update(['status' => 'cancelled']);
}
// ============================================================
// Static Helpers
// ============================================================
public static function statusOptions(): array
{
return [
'pending' => 'Pendiente',
'in_progress' => 'En progreso',
'completed' => 'Completada',
'cancelled' => 'Cancelada',
];
}
public static function priorityOptions(): array
{
return [
'low' => 'Baja',
'medium' => 'Media',
'high' => 'Alta',
'critical' => 'Crítica',
];
}
}
+22 -5
View File
@@ -13,7 +13,7 @@ use Spatie\Permission\Traits\HasRoles;
class User extends Authenticatable
{
/** @use HasFactory<UserFactory> */
use HasFactory, Notifiable, HasRoles, HasApiTokens;
use HasApiTokens, HasFactory, HasRoles, Notifiable;
/**
* The attributes that are mass assignable.
@@ -47,16 +47,17 @@ class User extends Authenticatable
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'valid_from' => 'date',
'valid_until' => 'date',
'password' => 'hashed',
'valid_from' => 'date',
'valid_until' => 'date',
];
}
public function company()
{
return $this->belongsTo(\App\Models\Company::class);
return $this->belongsTo(Company::class);
}
// Many-to-many with projects
public function projects()
{
@@ -68,4 +69,20 @@ class User extends Authenticatable
{
return $this->hasMany(ProgressUpdate::class);
}
// Tasks
public function createdTasks()
{
return $this->hasMany(Task::class, 'created_by');
}
public function assignedTasks()
{
return $this->hasMany(Task::class, 'assigned_to');
}
public function completedTasks()
{
return $this->hasMany(Task::class, 'completed_by');
}
}
@@ -0,0 +1,32 @@
<?php
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
use App\Models\Inspection;
class InspectionDeletedNotification extends Notification
{
use Queueable;
public function __construct(public Inspection $inspection, public string $deletedBy) {}
public function via($notifiable): array
{
return ['database'];
}
public function toArray($notifiable): array
{
return [
'type' => 'inspection_deleted',
'inspection_id' => $this->inspection->id,
'project_id' => $this->inspection->project_id,
'feature_name' => $this->inspection->feature?->name ?? '—',
'template_name' => $this->inspection->template?->name ?? '—',
'deleted_by' => $this->deletedBy,
'message' => "Inspección eliminada en '{$this->inspection->feature?->name}' por {$this->deletedBy}",
];
}
}
@@ -0,0 +1,33 @@
<?php
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
use App\Models\Inspection;
class InspectionUpdatedNotification extends Notification
{
use Queueable;
public function __construct(public Inspection $inspection, public array $changes = []) {}
public function via($notifiable): array
{
return ['database'];
}
public function toArray($notifiable): array
{
return [
'type' => 'inspection_updated',
'inspection_id' => $this->inspection->id,
'project_id' => $this->inspection->project_id,
'feature_name' => $this->inspection->feature?->name ?? '—',
'template_name' => $this->inspection->template?->name ?? '—',
'result' => $this->inspection->result,
'changes' => $this->changes,
'message' => "Inspección actualizada en '{$this->inspection->feature?->name}'",
];
}
}
@@ -0,0 +1,30 @@
<?php
namespace App\Notifications;
use App\Models\Task;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
class TaskAssignedNotification extends Notification
{
use Queueable;
public function __construct(public Task $task) {}
public function via($notifiable): array
{
return ['database'];
}
public function toArray($notifiable): array
{
return [
'type' => 'task_assigned',
'task_id' => $this->task->id,
'project_id' => $this->task->project_id,
'priority' => $this->task->priority,
'message' => "Se te ha asignado la tarea '{$this->task->title}'",
];
}
}
@@ -0,0 +1,31 @@
<?php
namespace App\Notifications;
use App\Models\Comment;
use App\Models\Task;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
class TaskCommentNotification extends Notification
{
use Queueable;
public function __construct(public Task $task, public Comment $comment) {}
public function via($notifiable): array
{
return ['database'];
}
public function toArray($notifiable): array
{
return [
'type' => 'task_comment',
'task_id' => $this->task->id,
'project_id' => $this->task->project_id,
'comment_id' => $this->comment->id,
'message' => "Nuevo comentario en '{$this->task->title}' por {$this->comment->user->name}: ".substr($this->comment->body, 0, 100),
];
}
}
@@ -0,0 +1,30 @@
<?php
namespace App\Notifications;
use App\Models\Task;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
class TaskOverdueNotification extends Notification
{
use Queueable;
public function __construct(public Task $task) {}
public function via($notifiable): array
{
return ['database'];
}
public function toArray($notifiable): array
{
return [
'type' => 'task_overdue',
'task_id' => $this->task->id,
'project_id' => $this->task->project_id,
'due_date' => $this->task->due_date?->toDateString(),
'message' => "Tarea vencida: '{$this->task->title}' (venció el {$this->task->due_date?->format('d/m/Y')})",
];
}
}
@@ -0,0 +1,45 @@
<?php
namespace App\Notifications;
use App\Models\Task;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
class TaskStatusChangedNotification extends Notification
{
use Queueable;
public function __construct(public Task $task, public string $oldStatus) {}
public function via($notifiable): array
{
return ['database'];
}
public function toArray($notifiable): array
{
$label = [
'pending' => 'pendiente',
'in_progress' => 'en progreso',
'completed' => 'completada',
'cancelled' => 'cancelada',
][$this->task->status] ?? $this->task->status;
$oldLabel = [
'pending' => 'pendiente',
'in_progress' => 'en progreso',
'completed' => 'completada',
'cancelled' => 'cancelada',
][$this->oldStatus] ?? $this->oldStatus;
return [
'type' => 'task_status_changed',
'task_id' => $this->task->id,
'project_id' => $this->task->project_id,
'old_status' => $this->oldStatus,
'new_status' => $this->task->status,
'message' => "La tarea '{$this->task->title}' cambió de {$oldLabel} a {$label}",
];
}
}
+122
View File
@@ -0,0 +1,122 @@
<?php
namespace App\Policies;
use App\Models\Task;
use App\Models\User;
class TaskPolicy
{
/**
* Determine whether the user can view any tasks.
*/
public function viewAny(User $user): bool
{
return $user->can('view tasks') || $user->can('manage all tasks');
}
/**
* Determine whether the user can view the task.
*/
public function view(User $user, Task $task): bool
{
if ($user->can('manage all tasks')) {
return true;
}
if (! $user->can('view tasks')) {
return false;
}
return $task->project->users()->where('user_id', $user->id)->exists();
}
/**
* Determine whether the user can create tasks.
*/
public function create(User $user, $project = null): bool
{
if ($user->can('manage all tasks')) {
return true;
}
if (! $user->can('create tasks')) {
return false;
}
if ($project) {
return $project->users()->where('user_id', $user->id)->exists();
}
return true;
}
/**
* Determine whether the user can update the task.
*/
public function update(User $user, Task $task): bool
{
if ($user->can('manage all tasks')) {
return true;
}
if (! $user->can('edit tasks')) {
return false;
}
// Own tasks or tasks in own projects
return $task->created_by === $user->id
|| $task->project->users()->where('user_id', $user->id)->exists();
}
/**
* Determine whether the user can delete the task.
*/
public function delete(User $user, Task $task): bool
{
if ($user->can('manage all tasks')) {
return true;
}
if (! $user->can('delete tasks')) {
return false;
}
// Only creator or project admin can delete
return $task->created_by === $user->id
|| $task->project->users()->where('user_id', $user->id)->wherePivot('role_in_project', 'admin')->exists();
}
/**
* Determine whether the user can assign/reassign the task.
*/
public function assign(User $user, Task $task): bool
{
if ($user->can('manage all tasks')) {
return true;
}
if (! $user->can('assign tasks')) {
return false;
}
return $task->project->users()->where('user_id', $user->id)->exists();
}
/**
* Determine whether the user can restore the task.
*/
public function restore(User $user, Task $task): bool
{
if ($user->can('manage all tasks')) {
return true;
}
if (! $user->can('edit tasks')) {
return false;
}
return $task->project->users()->where('user_id', $user->id)->exists();
}
/**
* Determine whether the user can permanently delete the task.
*/
public function forceDelete(User $user, Task $task): bool
{
return $user->can('manage all tasks');
}
}
+6 -1
View File
@@ -2,8 +2,10 @@
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use App\Models\Task;
use App\Policies\TaskPolicy;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
@@ -20,6 +22,9 @@ class AppServiceProvider extends ServiceProvider
*/
public function boot(): void
{
// Register policies
Gate::policy(Task::class, TaskPolicy::class);
// Super-admin bypass: anyone with the "manage all" permission
// (the Admin role has it) passes every authorization check.
// Return true to allow, or null to let normal checks run — never false.
@@ -0,0 +1,51 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('tasks', function (Blueprint $table) {
$table->id();
$table->foreignId('project_id')->nullable()->constrained()->cascadeOnDelete();
$table->foreignId('phase_id')->nullable()->constrained()->nullOnDelete();
$table->foreignId('parent_task_id')->nullable()->constrained('tasks')->nullOnDelete();
$table->string('title');
$table->text('description')->nullable();
$table->enum('status', ['pending', 'in_progress', 'completed', 'cancelled'])->default('pending');
$table->enum('priority', ['low', 'medium', 'high', 'critical'])->default('medium');
$table->foreignId('created_by')->constrained('users')->cascadeOnDelete();
$table->foreignId('assigned_to')->nullable()->constrained('users')->nullOnDelete();
$table->date('due_date')->nullable();
$table->date('start_date')->nullable();
$table->datetime('completed_at')->nullable();
$table->foreignId('completed_by')->nullable()->constrained('users')->nullOnDelete();
$table->unsignedInteger('estimated_hours')->nullable();
$table->unsignedInteger('actual_hours')->nullable();
$table->integer('order')->default(0);
$table->uuid('uuid')->unique();
$table->datetime('client_updated_at')->nullable();
$table->softDeletes();
$table->timestamps();
$table->index(['project_id', 'status']);
$table->index(['assigned_to', 'status']);
$table->index(['due_date', 'status']);
$table->index(['parent_task_id']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('tasks');
}
};
@@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('comments', function (Blueprint $table) {
$table->id();
$table->morphs('commentable'); // commentable_id, commentable_type
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->text('body');
$table->foreignId('parent_id')->nullable()->constrained('comments')->nullOnDelete(); // para respuestas/hilos
$table->timestamps();
$table->index(['commentable_type', 'commentable_id', 'created_at']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('comments');
}
};
+11 -8
View File
@@ -2,23 +2,25 @@
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Spatie\Permission\Models\Role;
use Spatie\Permission\Models\Permission;
use App\Models\User;
use Illuminate\Database\Seeder;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;
class RolesAndPermissionsSeeder extends Seeder
{
public function run()
{
// Reset cached roles and permissions
app()[\Spatie\Permission\PermissionRegistrar::class]->forgetCachedPermissions();
app()[PermissionRegistrar::class]->forgetCachedPermissions();
// Create permissions
$permissions = [
'view projects', 'create projects', 'edit projects', 'delete projects',
'assign users', 'upload layers', 'update progress', 'view reports', 'manage all',
'view inspections', 'create inspections', 'edit inspections', 'delete inspections', 'manage templates',
'view tasks', 'create tasks', 'edit tasks', 'delete tasks', 'assign tasks', 'manage all tasks',
];
foreach ($permissions as $perm) {
Permission::firstOrCreate(['name' => $perm]);
@@ -31,11 +33,12 @@ class RolesAndPermissionsSeeder extends Seeder
$supervisor = Role::firstOrCreate(['name' => 'Supervisor']);
$supervisor->givePermissionTo([
'view projects', 'upload layers', 'update progress',
'view inspections', 'create inspections', 'edit inspections',
'view inspections', 'create inspections', 'edit inspections', 'delete inspections',
'view tasks', 'create tasks', 'edit tasks', 'assign tasks',
]);
$consultor = Role::firstOrCreate(['name' => 'Consultor']);
$consultor->givePermissionTo(['view projects', 'view reports']);
$consultor->givePermissionTo(['view projects', 'view reports', 'view tasks']);
$cliente = Role::firstOrCreate(['name' => 'Cliente']);
$cliente->givePermissionTo(['view projects']);
@@ -45,10 +48,10 @@ class RolesAndPermissionsSeeder extends Seeder
$user = User::firstOrCreate(
['email' => 'admin@email.com'],
[
'name' => 'Admin User',
'name' => 'Admin User',
'password' => bcrypt('password'),
],
);
$user->assignRole('Admin');
}
}
}
+32 -1
View File
@@ -106,6 +106,8 @@
"Language": "Language",
"English": "English",
"Spanish": "Spanish",
"French": "French",
"Russian": "Russian",
"Select language": "Select language",
"Notifications": "Notifications",
"Permissions": "Permissions",
@@ -395,5 +397,34 @@
"My Projects": "My Projects",
"Editable": "Editable",
"Name of responsible": "Name of responsible",
"Select template...": "Select template..."
"Select template...": "Select template...",
"Tasks": "Tasks",
"Task": "Task",
"New Task": "New Task",
"Create Task": "Create Task",
"Edit Task": "Edit Task",
"Delete Task": "Delete Task",
"Task title": "Task title",
"Task description": "Task description",
"Due date": "Due date",
"Start date": "Start date",
"Estimated hours": "Estimated hours",
"Actual hours": "Actual hours",
"Assigned to": "Assigned to",
"Parent task": "Parent task",
"Subtasks": "Subtasks",
"No subtasks": "No subtasks",
"Add subtask": "Add subtask",
"Comments": "Comments",
"Add comment": "Add comment",
"Write a comment...": "Write a comment...",
"No comments yet": "No comments yet",
"Task created": "Task created",
"Task updated": "Task updated",
"Task deleted": "Task deleted",
"Status changed": "Status changed",
"Overdue": "Overdue",
"Due today": "Due today",
"Progress": "Progress",
"Order": "Order"
}
+32 -1
View File
@@ -106,6 +106,8 @@
"Language": "Idioma",
"English": "Inglés",
"Spanish": "Español",
"French": "Francés",
"Russian": "Ruso",
"Select language": "Seleccionar idioma",
"Notifications": "Notificaciones",
"Permissions": "Permisos",
@@ -395,5 +397,34 @@
"My Projects": "Mis proyectos",
"Editable": "Editable",
"Name of responsible": "Nombre del responsable",
"Select template...": "Seleccionar plantilla..."
"Select template...": "Seleccionar plantilla...",
"Tasks": "Tareas",
"Task": "Tarea",
"New Task": "Nueva tarea",
"Create Task": "Crear tarea",
"Edit Task": "Editar tarea",
"Delete Task": "Eliminar tarea",
"Task title": "Título de la tarea",
"Task description": "Descripción de la tarea",
"Due date": "Fecha de vencimiento",
"Start date": "Fecha de inicio",
"Estimated hours": "Horas estimadas",
"Actual hours": "Horas reales",
"Assigned to": "Asignado a",
"Parent task": "Tarea padre",
"Subtasks": "Subtareas",
"No subtasks": "Sin subtareas",
"Add subtask": "Añadir subtarea",
"Comments": "Comentarios",
"Add comment": "Añadir comentario",
"Write a comment...": "Escribe un comentario...",
"No comments yet": "No hay comentarios aún",
"Task created": "Tarea creada",
"Task updated": "Tarea actualizada",
"Task deleted": "Tarea eliminada",
"Status changed": "Estado cambiado",
"Overdue": "Vencida",
"Due today": "Vence hoy",
"Progress": "Progreso",
"Order": "Orden"
}
+430
View File
@@ -0,0 +1,430 @@
{
"Dashboard": "Tableau de bord",
"Projects": "Projets",
"Project": "Projet",
"Create Project": "Créer un projet",
"Edit Project": "Modifier le projet",
"New Project": "Nouveau projet",
"Delete Project": "Supprimer le projet",
"Project name": "Nom du projet",
"Name": "Nom",
"Address": "Adresse",
"Status": "Statut",
"Progress": "Progression",
"Phases": "Phases",
"Phase": "Phase",
"Layer": "Couche",
"Layers": "Couches",
"Features": "Éléments",
"Feature": "Élément",
"Create": "Créer",
"Edit": "Modifier",
"Delete": "Supprimer",
"Save": "Enregistrer",
"Update": "Mettre à jour",
"Cancel": "Annuler",
"Search": "Rechercher",
"Filter": "Filtrer",
"Back": "Retour",
"Actions": "Actions",
"Map": "Carte",
"View Map": "Voir la carte",
"Manage Layers": "Gérer les couches",
"Upload Layer": "Importer une couche",
"Upload file": "Importer un fichier",
"Download": "Télécharger",
"Fullscreen": "Plein écran",
"Close": "Fermer",
"Are you sure?": "Êtes-vous sûr ?",
"No results": "Aucun résultat",
"Loading": "Chargement",
"Active projects": "Projets actifs",
"Total projects": "Total projets",
"Total phases": "Total phases",
"Total features": "Total éléments",
"Global progress": "Progression globale",
"Recent projects": "Projets récents",
"Recent inspections": "Inspections récentes",
"Planning": "Planification",
"In progress": "En cours",
"Paused": "En pause",
"Completed": "Terminé",
"Progress updated": "Progression mise à jour",
"Save progress": "Sauvegarder la progression",
"Responsible": "Responsable",
"Comment": "Commentaire",
"History": "Historique",
"Inspection": "Inspection",
"Template": "Modèle",
"Templates": "Modèles",
"Create Template": "Créer un modèle",
"Inspection template": "Modèle d'inspection",
"Field": "Champ",
"Fields": "Champs",
"Field name": "Nom du champ",
"Field type": "Type de champ",
"Required": "Requis",
"Options": "Options",
"Show images on map": "Afficher les images sur la carte",
"Project files": "Fichiers du projet",
"Media": "Médias",
"Images": "Images",
"Documents": "Documents",
"Upload images": "Importer des images",
"Upload documents": "Importer des documents",
"Upload files": "Importer des fichiers",
"Drop files here": "Déposer les fichiers ici",
"File": "Fichier",
"Files": "Fichiers",
"Category": "Catégorie",
"Description": "Description",
"Size": "Taille",
"Uploaded by": "Importé par",
"Uploaded at": "Importé le",
"No files yet": "Aucun fichier pour l'instant",
"Delete file": "Supprimer le fichier",
"Layer name": "Nom de la couche",
"Layer color": "Couleur de la couche",
"Import file": "Importer un fichier",
"Empty layer": "Couche vide",
"Create empty layer": "Créer une couche vide",
"Select phase": "Sélectionner une phase",
"Select project": "Sélectionner un projet",
"Visible layers": "Couches visibles",
"Edit layer": "Modifier la couche",
"Delete layer": "Supprimer la couche",
"Sign in": "Se connecter",
"Sign out": "Se déconnecter",
"Register": "S'inscrire",
"Email": "E-mail",
"Password": "Mot de passe",
"Confirm password": "Confirmer le mot de passe",
"Forgot password": "Mot de passe oublié",
"Remember me": "Se souvenir de moi",
"Profile": "Profil",
"Update profile": "Mettre à jour le profil",
"Language": "Langue",
"English": "Anglais",
"Spanish": "Espagnol",
"French": "Français",
"Russian": "Russe",
"Select language": "Sélectionner la langue",
"Notifications": "Notifications",
"Permissions": "Permissions",
"Administrator": "Administrateur",
"Users": "Utilisateurs",
"Roles": "Rôles",
"Click on an element to edit": "Cliquez sur un élément pour le modifier",
"No templates yet": "Aucun modèle pour l'instant",
"Create an inspection template": "Créer un modèle d'inspection",
"No phases yet": "Aucune phase pour l'instant",
"Start date": "Date de début",
"Estimated end date": "Date de fin estimée",
"Centered in project": "Centré sur le projet",
"My location": "Ma position",
"File type not allowed": "Type de fichier non autorisé",
"Upload failed": "Échec de l'importation",
"Conversion failed": "Échec de la conversion",
"All": "Tous",
"Latitude": "Latitude",
"Longitude": "Longitude",
"Register inspection": "Enregistrer l'inspection",
"Files of element": "Fichiers de l'élément",
"Phases and layers": "Phases et couches",
"Elements": "Éléments",
"optional": "optionnel",
"each": "chaque",
"Image": "Image",
"Document": "Document",
"Other": "Autre",
"Color": "Couleur",
"Upload": "Importer",
"Assign": "Attribuer",
"Role": "Rôle",
"Supervisor": "Superviseur",
"Consultant": "Consultant",
"Client": "Client",
"Viewer": "Observateur",
"Remove": "Retirer",
"No users assigned yet": "Aucun utilisateur assigné pour l'instant",
"Select": "Sélectionner",
"Log Out": "Déconnexion",
"Company": "Entreprise",
"Companies": "Entreprises",
"Company Management": "Gestion des entreprises",
"New Company": "Nouvelle entreprise",
"Edit Company": "Modifier l'entreprise",
"Delete Company": "Supprimer l'entreprise",
"User Management": "Gestion des utilisateurs",
"New User": "Nouvel utilisateur",
"Edit User": "Modifier l'utilisateur",
"Delete User": "Supprimer l'utilisateur",
"Reference": "Référence",
"Contact": "Contact",
"Verified": "Vérifié",
"Type": "Type",
"Owner": "Propriétaire",
"Constructor": "Constructeur",
"Subcontractor": "Sous-traitant",
"Supplier": "Fournisseur",
"No role": "Aucun rôle",
"Active": "Actif",
"Inactive": "Inactif",
"Suspended": "Suspendu",
"Start Date": "Date de début",
"Est. End": "Fin estimée",
"Issue": "Incident",
"Issues": "Incidents",
"New Issue": "Nouvel incident",
"Open": "Ouvert",
"Resolved": "Résolu",
"Closed": "Fermé",
"Priority": "Priorité",
"High": "Haute",
"Medium": "Moyenne",
"Low": "Basse",
"Gantt": "Gantt",
"Report": "Rapport",
"Reports": "Rapports",
"Created at": "Créé le",
"Updated at": "Mis à jour le",
"Confirm delete": "Confirmer la suppression",
"This action cannot be undone": "Cette action ne peut pas être annulée",
"No data": "Aucune donnée",
"Export CSV": "Exporter CSV",
"Export PDF": "Exporter PDF",
"Planned": "Planifié",
"Started": "Commencé",
"Map filters": "Filtres de la carte",
"Progress: :min% :max%": "Progression : :min% :max%",
"Clear": "Effacer",
"Hide panel": "Masquer le panneau",
"Show phases and layers": "Afficher les phases et couches",
"Show images": "Afficher les images",
"Schedule": "Planning",
"Center map": "Centrer la carte",
"Select element": "Sélectionner un élément",
"Search by name, phase or layer...": "Rechercher par nom, phase ou couche...",
"Element status": "Statut de l'élément",
"Notes": "Notes",
"Result": "Résultat",
"No result": "Aucun résultat",
"Approved": "Approuvé",
"Conditional": "Conditionnel",
"Failed": "Échoué",
"Registered data": "Données enregistrées",
"Inspection #:id": "Inspection n° :id",
"Layer / Phase": "Couche / Phase",
"No templates (info)": "Aucun modèle.",
"Create one": "En créer un",
"Click on a map element or search above to edit it": "Cliquez sur un élément de la carte ou recherchez ci-dessus pour le modifier",
"Date": "Date",
"Inspector": "Inspecteur",
"View detail": "Voir les détails",
"No inspections registered": "Aucune inspection enregistrée",
"No elements in this project": "Aucun élément dans ce projet",
"Inspections": "Inspections",
"Project data": "Données du projet",
"Team": "Équipe",
"Save changes": "Enregistrer les modifications",
"Create project": "Créer un projet",
"Identification": "Identification",
"Location": "Localisation",
"Click on the map or drag the marker to update the location": "Cliquez sur la carte ou déplacez le marqueur pour mettre à jour la localisation",
"Coordinates": "Coordonnées",
"Auto when clicking the map": "Auto au clic sur la carte",
"No country": "Aucun pays",
"Search country...": "Rechercher un pays...",
"Inspection templates": "Modèles d'inspection",
"Import CSV/Excel": "Importer CSV/Excel",
"Copy from project": "Copier depuis un projet",
"New template": "Nouveau modèle",
"Edit template": "Modifier le modèle",
"Template name": "Nom du modèle",
"Associated phase (optional)": "Phase associée (optionnel)",
"Global project": "Projet global",
"Form fields": "Champs du formulaire",
"field(s)": "champ(s)",
"Internal name": "Nom interne",
"Visible label": "Libellé visible",
"Remove field": "Supprimer le champ",
"Min": "Min",
"Max": "Max",
"Step": "Pas",
"Options (comma separated)": "Options (séparées par des virgules)",
"Add field": "Ajouter un champ",
"Save template": "Enregistrer le modèle",
"No templates yet (table)": "Aucun modèle. Utilisez les boutons ci-dessus pour créer ou importer.",
"Delete template confirmation": "Supprimer ce modèle ? Cette action ne peut pas être annulée.",
"Import template from CSV / Excel": "Importer un modèle depuis CSV / Excel",
"File format (one row = one field):": "Format du fichier (une ligne = un champ) :",
"Download example": "Télécharger l'exemple",
"CSV or Excel file": "Fichier CSV ou Excel",
"Loading file...": "Chargement du fichier...",
"Preview": "Aperçu",
"Change file": "Changer de fichier",
"Create template (action)": "Créer le modèle",
"field(s) detected": "champ(s) détecté(s)",
"Copy template from another project": "Copier un modèle depuis un autre projet",
"Source project": "Projet source",
"Select project...": "Sélectionner un projet...",
"This project has no templates.": "Ce projet n'a aucun modèle.",
"Select the templates to copy": "Sélectionner les modèles à copier",
"selected": "sélectionné(s)",
"Select a project to see its templates.": "Sélectionnez un projet pour voir ses modèles.",
"Copy": "Copier",
"Back to map": "Retour à la carte",
"Import": "Importer",
"or": "ou",
"Layers (:count)": "Couches (:count)",
"No layers. Create or import one.": "Aucune couche. Créez-en ou importez-en une.",
"elem.": "élém.",
"Export": "Exporter",
"Bulk assignment": "Attribution en masse",
"Apply template or status to all elements of :layer": "Appliquer le modèle ou le statut à tous les éléments de :layer",
"No change": "Aucun changement",
"Apply to all": "Appliquer à tous",
"Apply changes to all elements of this layer?": "Appliquer les modifications à tous les éléments de cette couche ?",
"Element editor": "Éditeur d'éléments",
"Select a layer to edit": "Sélectionner une couche à modifier",
"Delayed phases": "Phases en retard",
"Needs attention": "Nécessite une attention",
"No delays": "Aucun retard",
"phases": "phases",
"Open issues": "Incidents ouverts",
"critical": "critique",
"Pending inspections": "Inspections en attente",
"To do": "À faire",
"Completed inspections": "Inspections terminées",
"Rejected inspections": "Inspections rejetées",
"Need review": "Nécessitent une révision",
"View all": "Voir tout",
"No projects available": "Aucun projet disponible",
"phase": "phase",
"Recent issues": "Incidents récents",
"No open issues": "Aucun incident ouvert",
"No recent inspections": "Aucune inspection récente",
"User": "Utilisateur",
"No users found": "Aucun utilisateur trouvé",
"No companies assigned yet": "Aucune entreprise assignée pour l'instant",
"Select template...": "Sélectionner un modèle...",
"Observations...": "Observations...",
"by": "par",
"ago": "il y a",
"No inspections yet for this element": "Aucune inspection pour cet élément pour l'instant",
"Inspection History": "Historique des inspections",
"View": "Voir",
"Media for this element": "Médias pour cet élément",
"No media for this element yet": "Aucun média pour cet élément pour l'instant",
"Project Media": "Médias du projet",
"No project media yet": "Aucun média de projet pour l'instant",
"Feature:": "Élément :",
"Inspection:": "Inspection :",
"Project Data": "Données du projet",
"Name of responsible": "Nom du responsable",
"Reports and Analytics": "Rapports et Analyses",
"Time range:": "Période :",
"This week": "Cette semaine",
"This month": "Ce mois",
"This quarter": "Ce trimestre",
"This year": "Cette année",
"Project Progress (last 6 months)": "Progression du projet (6 derniers mois)",
"Inspections by Type": "Inspections par type",
"Projects by Status": "Projets par statut",
"Average Progress by Project": "Progression moyenne par projet",
"Total Active Projects": "Total projets actifs",
"Inspections This Month": "Inspections ce mois",
"Average Progress": "Progression moyenne",
"Completed Projects": "Projets terminés",
"Loading data...": "Chargement des données...",
"Optional": "Optionnel",
"Expand layers": "Déplier les couches",
"New user": "Nouvel utilisateur",
"Search by name or email...": "Rechercher par nom ou e-mail...",
"No users found (table)": "Aucun utilisateur trouvé",
"Select element (label)": "Sélectionner un élément",
"Search by name, layer or phase...": "Rechercher par nom, couche ou phase...",
"No elements found": "Aucun élément trouvé",
"No media yet": "Aucun média pour l'instant",
"Manage the companies that participate in projects": "Gérer les entreprises qui participent aux projets",
"Search companies by name or tax ID...": "Rechercher des entreprises par nom ou NIF...",
"Complete the company information. Fields marked with * are required.": "Complétez les informations de l'entreprise. Les champs marqués d'un * sont obligatoires.",
"Validation errors": "Erreurs de validation",
"Tax ID": "NIF",
"E.g.: B12345678": "Ex. : B12345678",
"Nickname": "Nom court",
"E.g.: Acme Construct": "Ex. : Acme Construct",
"Select a status": "Sélectionner un statut",
"Company Type": "Type d'entreprise",
"Select a type": "Sélectionner un type",
"Phone": "Téléphone",
"Website": "Site web",
"Company Logo": "Logo de l'entreprise",
"Select file...": "Sélectionner un fichier...",
"Logo preview": "Aperçu du logo",
"Additional notes": "Notes additionnelles",
"No companies registered. Create your first company using the button above.": "Aucune entreprise enregistrée. Créez votre première entreprise avec le bouton ci-dessus.",
"Logo of": "Logo de",
"No tax ID": "Pas de NIF",
"Delete company confirmation": "Supprimer cette entreprise ? Cette action ne peut pas être annulée.",
"Company list": "Liste des entreprises",
"Add Phase": "Ajouter une phase",
"Update": "Mettre à jour",
"Delete file confirmation": "Supprimer ce fichier ? Cette action ne peut pas être annulée.",
"Back to map": "Retour à la carte",
"Create generic templates that can be used in any phase of the project": "Créer des modèles génériques utilisables dans n'importe quelle phase du projet",
"In Progress": "En cours",
"Select a project to see its templates.": "Sélectionnez un projet pour voir ses modèles.",
"Select a project to view details": "Sélectionnez un projet pour voir les détails",
"No description available": "Aucune description disponible",
"completed": "terminé",
"Back to projects": "Retour aux projets",
"Not defined": "Non défini",
"Progress overview": "Vue d'ensemble de la progression",
"General progress": "Progression générale",
"Progress by phase": "Progression par phase",
"No phases defined for this project": "Aucune phase définie pour ce projet",
"Progress gallery": "Galerie de progression",
"Change orders": "Ordres de changement",
"Requested": "Demandé",
"Amount": "Montant",
"Approve": "Approuver",
"Reject": "Rejeter",
"No pending change orders": "Aucun ordre de changement en attente",
"Pending": "En attente",
"Total": "Total",
"Inspections": "Inspections",
"My Projects": "Mes projets",
"Editable": "Éditable",
"Name of responsible": "Nom du responsable",
"Select template...": "Sélectionner un modèle...",
"Tasks": "Tâches",
"Task": "Tâche",
"New Task": "Nouvelle tâche",
"Create Task": "Créer une tâche",
"Edit Task": "Modifier la tâche",
"Delete Task": "Supprimer la tâche",
"Task title": "Titre de la tâche",
"Task description": "Description de la tâche",
"Due date": "Date d'échéance",
"Start date": "Date de début",
"Estimated hours": "Heures estimées",
"Actual hours": "Heures réelles",
"Assigned to": "Assigné à",
"Parent task": "Tâche parente",
"Subtasks": "Sous-tâches",
"No subtasks": "Aucune sous-tâche",
"Add subtask": "Ajouter une sous-tâche",
"Comments": "Commentaires",
"Add comment": "Ajouter un commentaire",
"Write a comment...": "Écrire un commentaire...",
"No comments yet": "Aucun commentaire pour l'instant",
"Task created": "Tâche créée",
"Task updated": "Tâche mise à jour",
"Task deleted": "Tâche supprimée",
"Status changed": "Statut changé",
"Overdue": "En retard",
"Due today": "Échéance aujourd'hui",
"Progress": "Progression",
"Order": "Ordre"
}
+20
View File
@@ -0,0 +1,20 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Authentication Language Lines
|--------------------------------------------------------------------------
|
| The following language lines are used during authentication for various
| messages that we need to display to the user. You are free to modify
| these language lines according to your application's requirements.
|
*/
'failed' => 'Ces identifiants ne correspondent pas à nos enregistrements.',
'password' => 'Le mot de passe fourni est incorrect.',
'throttle' => 'Trop de tentatives de connexion. Veuillez réessayer dans :seconds secondes.',
];
+19
View File
@@ -0,0 +1,19 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Pagination Language Lines
|--------------------------------------------------------------------------
|
| The following language lines are used by the paginator library to build
| the simple pagination links. You are free to change them to anything
| you want to customize your views to better match your application.
|
*/
'previous' => '&laquo; Précédent',
'next' => 'Suivant &raquo;',
];
+22
View File
@@ -0,0 +1,22 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Password Reset Language Lines
|--------------------------------------------------------------------------
|
| The following language lines are the default lines which match reasons
| that are given by the password broker for a password update attempt
| outcome such as failure due to an invalid password / reset token.
|
*/
'reset' => 'Votre mot de passe a été réinitialisé.',
'sent' => 'Nous vous avons envoyé par e-mail votre lien de réinitialisation de mot de passe.',
'throttled' => 'Veuillez patienter avant de réessayer.',
'token' => 'Ce jeton de réinitialisation de mot de passe est invalide.',
'user' => "Nous ne trouvons aucun utilisateur avec cette adresse e-mail.",
];
+194
View File
@@ -0,0 +1,194 @@
<?php
return [
'accepted' => 'Le champ :attribute doit être accepté.',
'accepted_if' => 'Le champ :attribute doit être accepté lorsque :other est :value.',
'active_url' => 'Le champ :attribute doit être une URL valide.',
'after' => 'Le champ :attribute doit être une date après :date.',
'after_or_equal' => 'Le champ :attribute doit être une date après ou égale à :date.',
'alpha' => 'Le champ :attribute ne doit contenir que des lettres.',
'alpha_dash' => 'Le champ :attribute ne doit contenir que des lettres, des chiffres, des tirets et des traits de soulignement.',
'alpha_num' => 'Le champ :attribute ne doit contenir que des lettres et des chiffres.',
'any_of' => 'Le champ :attribute est invalide.',
'array' => 'Le champ :attribute doit être un tableau.',
'ascii' => 'Le champ :attribute ne doit contenir que des caractères alphanumériques à un seul octet et des symboles.',
'before' => 'Le champ :attribute doit être une date avant :date.',
'before_or_equal' => 'Le champ :attribute doit être une date avant ou égale à :date.',
'between' => [
'array' => 'Le champ :attribute doit avoir entre :min et :max éléments.',
'file' => 'Le champ :attribute doit être entre :min et :max kilooctets.',
'numeric' => 'Le champ :attribute doit être entre :min et :max.',
'string' => 'Le champ :attribute doit avoir entre :min et :max caractères.',
],
'boolean' => 'Le champ :attribute doit être vrai ou faux.',
'can' => 'Le champ :attribute contient une valeur non autorisée.',
'confirmed' => 'La confirmation du champ :attribute ne correspond pas.',
'contains' => 'Le champ :attribute manque une valeur requise.',
'current_password' => 'Le mot de passe est incorrect.',
'date' => 'Le champ :attribute doit être une date valide.',
'date_equals' => 'Le champ :attribute doit être une date égale à :date.',
'date_format' => 'Le champ :attribute doit correspondre au format :format.',
'decimal' => 'Le champ :attribute doit avoir :decimal décimales.',
'declined' => 'Le champ :attribute doit être refusé.',
'declined_if' => 'Le champ :attribute doit être refusé lorsque :other est :value.',
'different' => 'Le champ :attribute et :other doivent être différents.',
'digits' => 'Le champ :attribute doit avoir :digits chiffres.',
'digits_between' => 'Le champ :attribute doit avoir entre :min et :max chiffres.',
'dimensions' => 'Le champ :attribute a des dimensions d\'image invalides.',
'distinct' => 'Le champ :attribute a une valeur en double.',
'doesnt_contain' => 'Le champ :attribute ne doit contenir aucune des valeurs suivantes : :values.',
'doesnt_end_with' => 'Le champ :attribute ne doit pas se terminer par l\'une des valeurs suivantes : :values.',
'doesnt_start_with' => 'Le champ :attribute ne doit pas commencer par l\'une des valeurs suivantes : :values.',
'email' => 'Le champ :attribute doit être une adresse e-mail valide.',
'encoding' => 'Le champ :attribute doit être encodé en :encoding.',
'ends_with' => 'Le champ :attribute doit se terminer par l\'une des valeurs suivantes : :values.',
'enum' => 'Le :attribute sélectionné est invalide.',
'exists' => 'Le :attribute sélectionné est invalide.',
'extensions' => 'Le champ :attribute doit avoir l\'une des extensions suivantes : :values.',
'file' => 'Le champ :attribute doit être un fichier.',
'filled' => 'Le champ :attribute doit avoir une valeur.',
'gt' => [
'array' => 'Le champ :attribute doit avoir plus de :value éléments.',
'file' => 'Le champ :attribute doit être supérieur à :value kilooctets.',
'numeric' => 'Le champ :attribute doit être supérieur à :value.',
'string' => 'Le champ :attribute doit être supérieur à :value caractères.',
],
'gte' => [
'array' => 'Le champ :attribute doit avoir :value éléments ou plus.',
'file' => 'Le champ :attribute doit être supérieur ou égal à :value kilooctets.',
'numeric' => 'Le champ :attribute doit être supérieur ou égal à :value.',
'string' => 'Le champ :attribute doit avoir :value caractères ou plus.',
],
'hex_color' => 'Le champ :attribute doit être une couleur hexadécimale valide.',
'image' => 'Le champ :attribute doit être une image.',
'in' => 'Le :attribute sélectionné est invalide.',
'in_array' => 'Le champ :attribute doit exister dans :other.',
'in_array_keys' => 'Le champ :attribute doit contenir au moins l\'une des clés suivantes : :values.',
'integer' => 'Le champ :attribute doit être un entier.',
'ip' => 'Le champ :attribute doit être une adresse IP valide.',
'ipv4' => 'Le champ :attribute doit être une adresse IPv4 valide.',
'ipv6' => 'Le champ :attribute doit être une adresse IPv6 valide.',
'json' => 'Le champ :attribute doit être une chaîne JSON valide.',
'list' => 'Le champ :attribute doit être une liste.',
'lowercase' => 'Le champ :attribute doit être en minuscules.',
'lt' => [
'array' => 'Le champ :attribute doit avoir moins de :value éléments.',
'file' => 'Le champ :attribute doit être inférieur à :value kilooctets.',
'numeric' => 'Le champ :attribute doit être inférieur à :value.',
'string' => 'Le champ :attribute doit avoir moins de :value caractères.',
],
'lte' => [
'array' => 'Le champ :attribute ne doit pas avoir plus de :value éléments.',
'file' => 'Le champ :attribute doit être inférieur ou égal à :value kilooctets.',
'numeric' => 'Le champ :attribute doit être inférieur ou égal à :value.',
'string' => 'Le champ :attribute doit avoir :value caractères ou moins.',
],
'mac_address' => 'Le champ :attribute doit être une adresse MAC valide.',
'max' => [
'array' => 'Le champ :attribute ne doit pas avoir plus de :max éléments.',
'file' => 'Le champ :attribute ne doit pas être supérieur à :max kilooctets.',
'numeric' => 'Le champ :attribute ne doit pas être supérieur à :max.',
'string' => 'Le champ :attribute ne doit pas avoir plus de :max caractères.',
],
'max_digits' => 'Le champ :attribute ne doit pas avoir plus de :max chiffres.',
'mimes' => 'Le champ :attribute doit être un fichier de type : :values.',
'mimetypes' => 'Le champ :attribute doit être un fichier de type : :values.',
'min' => [
'array' => 'Le champ :attribute doit avoir au moins :min éléments.',
'file' => 'Le champ :attribute doit être d\'au moins :min kilooctets.',
'numeric' => 'Le champ :attribute doit être d\'au moins :min.',
'string' => 'Le champ :attribute doit avoir au moins :min caractères.',
],
'min_digits' => 'Le champ :attribute doit avoir au moins :min chiffres.',
'missing' => 'Le champ :attribute doit être absent.',
'missing_if' => 'Le champ :attribute doit être absent lorsque :other est :value.',
'missing_unless' => 'Le champ :attribute doit être absent sauf si :other est dans :values.',
'missing_with' => 'Le champ :attribute doit être absent lorsque :values est présent.',
'missing_with_all' => 'Le champ :attribute doit être absent lorsque tous les :values sont présents.',
'multiple_of' => 'Le champ :attribute doit être un multiple de :value.',
'not_in' => 'Le :attribute sélectionné est invalide.',
'not_regex' => 'Le format du champ :attribute est invalide.',
'numeric' => 'Le champ :attribute doit être un nombre.',
'password' => [
'letters' => 'Le champ :attribute doit contenir au moins une lettre.',
'mixed' => 'Le champ :attribute doit contenir au moins une majuscule et une minuscule.',
'numbers' => 'Le champ :attribute doit contenir au moins un chiffre.',
'symbols' => 'Le champ :attribute doit contenir au moins un symbole.',
'uncompromised' => 'Le :attribute fourni a été trouvé dans une fuite de données. Veuillez choisir un autre :attribute.',
],
'present' => 'Le champ :attribute doit être présent.',
'present_if' => 'Le champ :attribute doit être présent lorsque :other est :value.',
'present_unless' => 'Le champ :attribute doit être présent sauf si :other est dans :values.',
'present_with' => 'Le champ :attribute doit être présent lorsque :values est présent.',
'present_with_all' => 'Le champ :attribute doit être présent lorsque tous les :values sont présents.',
'prohibited' => 'Le champ :attribute est interdit.',
'prohibited_if' => 'Le champ :attribute est interdit lorsque :other est :value.',
'prohibited_if_accepted' => 'Le champ :attribute est interdit lorsque :other est accepté.',
'prohibited_if_declined' => 'Le champ :attribute est interdit lorsque :other est refusé.',
'prohibited_unless' => 'Le champ :attribute est interdit sauf si :other est dans :values.',
'prohibits' => 'Le champ :attribute interdit la présence de :other.',
'regex' => 'Le format du champ :attribute est invalide.',
'required' => 'Le champ :attribute est obligatoire.',
'required_array_keys' => 'Le champ :attribute doit contenir des entrées pour : :values.',
'required_if' => 'Le champ :attribute est obligatoire lorsque :other est :value.',
'required_if_accepted' => 'Le champ :attribute est obligatoire lorsque :other est accepté.',
'required_if_declined' => 'Le champ :attribute est obligatoire lorsque :other est refusé.',
'required_unless' => 'Le champ :attribute est obligatoire sauf si :other est dans :values.',
'required_with' => 'Le champ :attribute est obligatoire lorsque :values est présent.',
'required_with_all' => 'Le champ :attribute est obligatoire lorsque tous les :values sont présents.',
'required_without' => 'Le champ :attribute est obligatoire lorsque :values n\'est pas présent.',
'required_without_all' => 'Le champ :attribute est obligatoire lorsque aucun des :values n\'est présent.',
'same' => 'Le champ :attribute doit correspondre à :other.',
'size' => [
'array' => 'Le champ :attribute doit contenir :size éléments.',
'file' => 'Le champ :attribute doit faire :size kilooctets.',
'numeric' => 'Le champ :attribute doit être :size.',
'string' => 'Le champ :attribute doit faire :size caractères.',
],
'starts_with' => 'Le champ :attribute doit commencer par l\'une des valeurs suivantes : :values.',
'string' => 'Le champ :attribute doit être une chaîne de caractères.',
'timezone' => 'Le champ :attribute doit être un fuseau horaire valide.',
'unique' => 'Ce :attribute est déjà utilisé.',
'uploaded' => 'Le téléchargement de :attribute a échoué.',
'uppercase' => 'Le champ :attribute doit être en majuscules.',
'url' => 'Le champ :attribute doit être une URL valide.',
'ulid' => 'Le champ :attribute doit être un ULID valide.',
'uuid' => 'Le champ :attribute doit être un UUID valide.',
'custom' => [
'attribute-name' => [
'rule-name' => 'custom-message',
],
],
'attributes' => [
'name' => 'nom',
'email' => 'e-mail',
'password' => 'mot de passe',
'address' => 'adresse',
'phone' => 'téléphone',
'description' => 'description',
'start_date' => 'date de début',
'end_date' => 'date de fin',
'end_date_estimated' => 'date de fin estimée',
'reference' => 'référence',
'status' => 'statut',
'type' => 'type',
'color' => 'couleur',
'progress_percent' => 'pourcentage de progression',
'tax_id' => 'SIRET',
'country' => 'pays',
'city' => 'ville',
'latitude' => 'latitude',
'longitude' => 'longitude',
'logo' => 'logo',
'avatar' => 'avatar',
'role' => 'rôle',
'company_id' => 'entreprise',
'current_password' => 'mot de passe actuel',
'new_password' => 'nouveau mot de passe',
'new_password_confirmation' => 'confirmation du nouveau mot de passe',
],
];
+430
View File
@@ -0,0 +1,430 @@
{
"Dashboard": "Панель управления",
"Projects": "Проекты",
"Project": "Проект",
"Create Project": "Создать проект",
"Edit Project": "Редактировать проект",
"New Project": "Новый проект",
"Delete Project": "Удалить проект",
"Project name": "Название проекта",
"Name": "Название",
"Address": "Адрес",
"Status": "Статус",
"Progress": "Прогресс",
"Phases": "Этапы",
"Phase": "Этап",
"Layer": "Слой",
"Layers": "Слои",
"Features": "Элементы",
"Feature": "Элемент",
"Create": "Создать",
"Edit": "Редактировать",
"Delete": "Удалить",
"Save": "Сохранить",
"Update": "Обновить",
"Cancel": "Отмена",
"Search": "Поиск",
"Filter": "Фильтр",
"Back": "Назад",
"Actions": "Действия",
"Map": "Карта",
"View Map": "Просмотр карты",
"Manage Layers": "Управление слоями",
"Upload Layer": "Загрузить слой",
"Upload file": "Загрузить файл",
"Download": "Скачать",
"Fullscreen": "Полный экран",
"Close": "Закрыть",
"Are you sure?": "Вы уверены?",
"No results": "Нет результатов",
"Loading": "Загрузка",
"Active projects": "Активные проекты",
"Total projects": "Всего проектов",
"Total phases": "Всего этапов",
"Total features": "Всего элементов",
"Global progress": "Общий прогресс",
"Recent projects": "Недавние проекты",
"Recent inspections": "Недавние осмотры",
"Planning": "Планирование",
"In progress": "В процессе",
"Paused": "Приостановлено",
"Completed": "Завершено",
"Progress updated": "Прогресс обновлен",
"Save progress": "Сохранить прогресс",
"Responsible": "Ответственный",
"Comment": "Комментарий",
"History": "История",
"Inspection": "Осмотр",
"Template": "Шаблон",
"Templates": "Шаблоны",
"Create Template": "Создать шаблон",
"Inspection template": "Шаблон осмотра",
"Field": "Поле",
"Fields": "Поля",
"Field name": "Имя поля",
"Field type": "Тип поля",
"Required": "Обязательно",
"Options": "Опции",
"Show images on map": "Показать изображения на карте",
"Project files": "Файлы проекта",
"Media": "Медиа",
"Images": "Изображения",
"Documents": "Документы",
"Upload images": "Загрузить изображения",
"Upload documents": "Загрузить документы",
"Upload files": "Загрузить файлы",
"Drop files here": "Перетащите файлы сюда",
"File": "Файл",
"Files": "Файлы",
"Category": "Категория",
"Description": "Описание",
"Size": "Размер",
"Uploaded by": "Загрузил",
"Uploaded at": "Загружено",
"No files yet": "Пока нет файлов",
"Delete file": "Удалить файл",
"Layer name": "Имя слоя",
"Layer color": "Цвет слоя",
"Import file": "Импорт файла",
"Empty layer": "Пустой слой",
"Create empty layer": "Создать пустой слой",
"Select phase": "Выбрать этап",
"Select project": "Выбрать проект",
"Visible layers": "Видимые слои",
"Edit layer": "Редактировать слой",
"Delete layer": "Удалить слой",
"Sign in": "Войти",
"Sign out": "Выйти",
"Register": "Регистрация",
"Email": "Эл. почта",
"Password": "Пароль",
"Confirm password": "Подтвердить пароль",
"Forgot password": "Забыли пароль",
"Remember me": "Запомнить меня",
"Profile": "Профиль",
"Update profile": "Обновить профиль",
"Language": "Язык",
"English": "Английский",
"Spanish": "Испанский",
"French": "Французский",
"Russian": "Русский",
"Select language": "Выберите язык",
"Notifications": "Уведомления",
"Permissions": "Разрешения",
"Administrator": "Администратор",
"Users": "Пользователи",
"Roles": "Роли",
"Click on an element to edit": "Нажмите на элемент для редактирования",
"No templates yet": "Пока нет шаблонов",
"Create an inspection template": "Создать шаблон осмотра",
"No phases yet": "Пока нет этапов",
"Start date": "Дата начала",
"Estimated end date": "Предполагаемая дата окончания",
"Centered in project": "Центр в проекте",
"My location": "Моя позиция",
"File type not allowed": "Тип файла не разрешен",
"Upload failed": "Ошибка загрузки",
"Conversion failed": "Ошибка конвертации",
"All": "Все",
"Latitude": "Широта",
"Longitude": "Долгота",
"Register inspection": "Зарегистрировать осмотр",
"Files of element": "Файлы элемента",
"Phases and layers": "Этапы и слои",
"Elements": "Элементы",
"optional": "необязательно",
"each": "каждый",
"Image": "Изображение",
"Document": "Документ",
"Other": "Другое",
"Color": "Цвет",
"Upload": "Загрузить",
"Assign": "Назначить",
"Role": "Роль",
"Supervisor": "Супервизор",
"Consultant": "Консультант",
"Client": "Клиент",
"Viewer": "Наблюдатель",
"Remove": "Удалить",
"No users assigned yet": "Пользователи не назначены",
"Select": "Выбрать",
"Log Out": "Выйти",
"Company": "Компания",
"Companies": "Компании",
"Company Management": "Управление компаниями",
"New Company": "Новая компания",
"Edit Company": "Редактировать компанию",
"Delete Company": "Удалить компанию",
"User Management": "Управление пользователями",
"New User": "Новый пользователь",
"Edit User": "Редактировать пользователя",
"Delete User": "Удалить пользователя",
"Reference": "Ссылка",
"Contact": "Контакт",
"Verified": "Подтвержден",
"Type": "Тип",
"Owner": "Владелец",
"Constructor": "Застройщик",
"Subcontractor": "Подрядчик",
"Supplier": "Поставщик",
"No role": "Нет роли",
"Active": "Активен",
"Inactive": "Неактивен",
"Suspended": "Приостановлен",
"Start Date": "Дата начала",
"Est. End": "Пред. конец",
"Issue": "Проблема",
"Issues": "Проблемы",
"New Issue": "Новая проблема",
"Open": "Открыто",
"Resolved": "Решено",
"Closed": "Закрыто",
"Priority": "Приоритет",
"High": "Высокий",
"Medium": "Средний",
"Low": "Низкий",
"Gantt": "Ганта",
"Report": "Отчет",
"Reports": "Отчеты",
"Created at": "Создано",
"Updated at": "Обновлено",
"Confirm delete": "Подтвердить удаление",
"This action cannot be undone": "Это действие нельзя отменить",
"No data": "Нет данных",
"Export CSV": "Экспорт CSV",
"Export PDF": "Экспорт PDF",
"Planned": "Запланировано",
"Started": "Начато",
"Map filters": "Фильтры карты",
"Progress: :min% :max%": "Прогресс: :min% :max%",
"Clear": "Очистить",
"Hide panel": "Скрыть панель",
"Show phases and layers": "Показать этапы и слои",
"Show images": "Показать изображения",
"Schedule": "Расписание",
"Center map": "Центр карты",
"Select element": "Выбрать элемент",
"Search by name, phase or layer...": "Поиск по имени, этапу или слою...",
"Element status": "Статус элемента",
"Notes": "Заметки",
"Result": "Результат",
"No result": "Нет результата",
"Approved": "Утверждено",
"Conditional": "Условно",
"Failed": "Не прошло",
"Registered data": "Зарегистрированные данные",
"Inspection #:id": "Осмотр №:id",
"Layer / Phase": "Слой / Этап",
"No templates (info)": "Нет шаблонов.",
"Create one": "Создать",
"Click on a map element or search above to edit it": "Нажмите на элемент карты или найдите его выше для редактирования",
"Date": "Дата",
"Inspector": "Инспектор",
"View detail": "Просмотр деталей",
"No inspections registered": "Нет зарегистрированных осмотров",
"No elements in this project": "Нет элементов в этом проекте",
"Inspections": "Осмотры",
"Project data": "Данные проекта",
"Team": "Команда",
"Save changes": "Сохранить изменения",
"Create project": "Создать проект",
"Identification": "Идентификация",
"Location": "Местоположение",
"Click on the map or drag the marker to update the location": "Нажмите на карту или перетащите маркер для обновления местоположения",
"Coordinates": "Координаты",
"Auto when clicking the map": "Авто при клике на карте",
"No country": "Страна не указана",
"Search country...": "Поиск страны...",
"Inspection templates": "Шаблоны осмотров",
"Import CSV/Excel": "Импорт CSV/Excel",
"Copy from project": "Копировать из проекта",
"New template": "Новый шаблон",
"Edit template": "Редактировать шаблон",
"Template name": "Имя шаблона",
"Associated phase (optional)": "Связанный этап (необязательно)",
"Global project": "Глобальный проект",
"Form fields": "Поля формы",
"field(s)": "поле(я)",
"Internal name": "Внутреннее имя",
"Visible label": "Видимая метка",
"Remove field": "Удалить поле",
"Min": "Мин",
"Max": "Макс",
"Step": "Шаг",
"Options (comma separated)": "Опции (через запятую)",
"Add field": "Добавить поле",
"Save template": "Сохранить шаблон",
"No templates yet (table)": "Нет шаблонов. Используйте кнопки выше для создания или импорта.",
"Delete template confirmation": "Удалить этот шаблон? Это действие нельзя отменить.",
"Import template from CSV / Excel": "Импорт шаблона из CSV / Excel",
"File format (one row = one field):": "Формат файла (одна строка = одно поле):",
"Download example": "Скачать пример",
"CSV or Excel file": "Файл CSV или Excel",
"Loading file...": "Загрузка файла...",
"Preview": "Предпросмотр",
"Change file": "Изменить файл",
"Create template (action)": "Создать шаблон",
"field(s) detected": "обнаружено поле(й)",
"Copy template from another project": "Копировать шаблон из другого проекта",
"Source project": "Исходный проект",
"Select project...": "Выбрать проект...",
"This project has no templates.": "У этого проекта нет шаблонов.",
"Select the templates to copy": "Выберите шаблоны для копирования",
"selected": "выбрано",
"Select a project to see its templates.": "Выберите проект, чтобы увидеть его шаблоны.",
"Copy": "Копировать",
"Back to map": "Назад к карте",
"Import": "Импорт",
"or": "или",
"Layers (:count)": "Слои (:count)",
"No layers. Create or import one.": "Нет слоев. Создайте или импортируйте один.",
"elem.": "элем.",
"Export": "Экспорт",
"Bulk assignment": "Массовое назначение",
"Apply template or status to all elements of :layer": "Применить шаблон или статус ко всем элементам :layer",
"No change": "Без изменений",
"Apply to all": "Применить ко всем",
"Apply changes to all elements of this layer?": "Применить изменения ко всем элементам этого слоя?",
"Element editor": "Редактор элементов",
"Select a layer to edit": "Выберите слой для редактирования",
"Delayed phases": "Отложенные этапы",
"Needs attention": "Требует внимания",
"No delays": "Нет задержек",
"phases": "этапы",
"Open issues": "Открытые проблемы",
"critical": "критично",
"Pending inspections": "Ожидающие осмотры",
"To do": "К выполнению",
"Completed inspections": "Завершенные осмотры",
"Rejected inspections": "Отклоненные осмотры",
"Need review": "Требуют проверки",
"View all": "Посмотреть все",
"No projects available": "Нет доступных проектов",
"phase": "этап",
"Recent issues": "Недавние проблемы",
"No open issues": "Нет открытых проблем",
"No recent inspections": "Нет недавних осмотров",
"User": "Пользователь",
"No users found": "Пользователи не найдены",
"No companies assigned yet": "Компании не назначены",
"Select template...": "Выбрать шаблон...",
"Observations...": "Наблюдения...",
"by": "от",
"ago": "назад",
"No inspections yet for this element": "Еще нет осмотров для этого элемента",
"Inspection History": "История осмотров",
"View": "Просмотр",
"Media for this element": "Медиа для этого элемента",
"No media for this element yet": "Нет медиа для этого элемента",
"Project Media": "Медиа проекта",
"No project media yet": "Нет медиа проекта",
"Feature:": "Элемент:",
"Inspection:": "Осмотр:",
"Project Data": "Данные проекта",
"Name of responsible": "Имя ответственного",
"Reports and Analytics": "Отчеты и Аналитика",
"Time range:": "Период:",
"This week": "На этой неделе",
"This month": "В этом месяце",
"This quarter": "В этом квартале",
"This year": "В этом году",
"Project Progress (last 6 months)": "Прогресс проекта (последние 6 месяцев)",
"Inspections by Type": "Осмотры по типу",
"Projects by Status": "Проекты по статусу",
"Average Progress by Project": "Средний прогресс по проекту",
"Total Active Projects": "Всего активных проектов",
"Inspections This Month": "Осмотры в этом месяце",
"Average Progress": "Средний прогресс",
"Completed Projects": "Завершенные проекты",
"Loading data...": "Загрузка данных...",
"Optional": "Необязательно",
"Expand layers": "Развернуть слои",
"New user": "Новый пользователь",
"Search by name or email...": "Поиск по имени или email...",
"No users found (table)": "Пользователи не найдены",
"Select element (label)": "Выбрать элемент",
"Search by name, layer or phase...": "Поиск по имени, слою или этапу...",
"No elements found": "Элементы не найдены",
"No media yet": "Нет медиа",
"Manage the companies that participate in projects": "Управление компаниями, участвующими в проектах",
"Search companies by name or tax ID...": "Поиск компаний по имени или ИНН...",
"Complete the company information. Fields marked with * are required.": "Заполните информацию о компании. Поля, отмеченные *, обязательны.",
"Validation errors": "Ошибки валидации",
"Tax ID": "ИНН",
"E.g.: B12345678": "Напр.: B12345678",
"Nickname": "Краткое имя",
"E.g.: Acme Construct": "Напр.: Acme Construct",
"Select a status": "Выберите статус",
"Company Type": "Тип компании",
"Select a type": "Выберите тип",
"Phone": "Телефон",
"Website": "Веб-сайт",
"Company Logo": "Логотип компании",
"Select file...": "Выберите файл...",
"Logo preview": "Предпросмотр логотипа",
"Additional notes": "Дополнительные заметки",
"No companies registered. Create your first company using the button above.": "Компании не зарегистрированы. Создайте свою первую компанию, используя кнопку выше.",
"Logo of": "Логотип",
"No tax ID": "Нет ИНН",
"Delete company confirmation": "Удалить эту компанию? Это действие нельзя отменить.",
"Company list": "Список компаний",
"Add Phase": "Добавить этап",
"Update": "Обновить",
"Delete file confirmation": "Удалить этот файл? Это действие нельзя отменить.",
"Back to map": "Назад к карте",
"Create generic templates that can be used in any phase of the project": "Создайте универсальные шаблоны, которые можно использовать в любой фазе проекта",
"In Progress": "В процессе",
"Select a project to see its templates.": "Выберите проект, чтобы увидеть его шаблоны.",
"Select a project to view details": "Выберите проект для просмотра деталей",
"No description available": "Нет описания",
"completed": "завершено",
"Back to projects": "Назад к проектам",
"Not defined": "Не определено",
"Progress overview": "Обзор прогресса",
"General progress": "Общий прогресс",
"Progress by phase": "Прогресс по этапам",
"No phases defined for this project": "Этапы не определены для этого проекта",
"Progress gallery": "Галерея прогресса",
"Change orders": "Заказы на изменения",
"Requested": "Запрошено",
"Amount": "Сумма",
"Approve": "Утвердить",
"Reject": "Отклонить",
"No pending change orders": "Нет ожидающих заказов на изменения",
"Pending": "В ожидании",
"Total": "Всего",
"Inspections": "Осмотры",
"My Projects": "Мои проекты",
"Editable": "Редактируемый",
"Name of responsible": "Имя ответственного",
"Select template...": "Выбрать шаблон...",
"Tasks": "Задачи",
"Task": "Задача",
"New Task": "Новая задача",
"Create Task": "Создать задачу",
"Edit Task": "Редактировать задачу",
"Delete Task": "Удалить задачу",
"Task title": "Название задачи",
"Task description": "Описание задачи",
"Due date": "Срок выполнения",
"Start date": "Дата начала",
"Estimated hours": "Оценка часов",
"Actual hours": "Фактические часы",
"Assigned to": "Назначено",
"Parent task": "Родительская задача",
"Subtasks": "Подзадачи",
"No subtasks": "Нет подзадач",
"Add subtask": "Добавить подзадачу",
"Comments": "Комментарии",
"Add comment": "Добавить комментарий",
"Write a comment...": "Написать комментарий...",
"No comments yet": "Пока нет комментариев",
"Task created": "Задача создана",
"Task updated": "Задача обновлена",
"Task deleted": "Задача удалена",
"Status changed": "Статус изменен",
"Overdue": "Просрочено",
"Due today": "Срок сегодня",
"Progress": "Прогресс",
"Order": "Порядок"
}
+20
View File
@@ -0,0 +1,20 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Authentication Language Lines
|--------------------------------------------------------------------------
|
| The following language lines are used during authentication for various
| messages that we need to display to the user. You are free to modify
| these language lines according to your application's requirements.
|
*/
'failed' => 'Эти учетные данные не совпадают с нашими записями.',
'password' => 'Предоставленный пароль неверен.',
'throttle' => 'Слишком много попыток входа. Пожалуйста, попробуйте снова через :seconds секунд.',
];
+19
View File
@@ -0,0 +1,19 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Pagination Language Lines
|--------------------------------------------------------------------------
|
| The following language lines are used by the paginator library to build
| the simple pagination links. You are free to change them to anything
| you want to customize your views to better match your application.
|
*/
'previous' => '&laquo; Предыдущая',
'next' => 'Следующая &raquo;',
];
+22
View File
@@ -0,0 +1,22 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Password Reset Language Lines
|--------------------------------------------------------------------------
|
| The following language lines are the default lines which match reasons
| that are given by the password broker for a password update attempt
| outcome such as failure due to an invalid password / reset token.
|
*/
'reset' => 'Ваш пароль был сброшен.',
'sent' => 'Мы отправили ссылку для сброса пароля на вашу электронную почту.',
'throttled' => 'Пожалуйста, подождите перед повторной попыткой.',
'token' => 'Этот токен сброса пароля недействителен.',
'user' => "Мы не можем найти пользователя с таким адресом электронной почты.",
];
+194
View File
@@ -0,0 +1,194 @@
<?php
return [
'accepted' => 'Поле :attribute должно быть принято.',
'accepted_if' => 'Поле :attribute должно быть принято, когда :other равно :value.',
'active_url' => 'Поле :attribute должно быть действительным URL.',
'after' => 'Поле :attribute должно быть датой после :date.',
'after_or_equal' => 'Поле :attribute должно быть датой после или равно :date.',
'alpha' => 'Поле :attribute должно содержать только буквы.',
'alpha_dash' => 'Поле :attribute должно содержать только буквы, цифры, дефисы и подчеркивания.',
'alpha_num' => 'Поле :attribute должно содержать только буквы и цифры.',
'any_of' => 'Поле :attribute недействительно.',
'array' => 'Поле :attribute должно быть массивом.',
'ascii' => 'Поле :attribute должно содержать только однобайтовые буквенно-цифровые символы и знаки.',
'before' => 'Поле :attribute должно быть датой до :date.',
'before_or_equal' => 'Поле :attribute должно быть датой до или равно :date.',
'between' => [
'array' => 'Поле :attribute должно содержать от :min до :max элементов.',
'file' => 'Поле :attribute должно быть от :min до :max килобайт.',
'numeric' => 'Поле :attribute должно быть от :min до :max.',
'string' => 'Поле :attribute должно быть от :min до :max символов.',
],
'boolean' => 'Поле :attribute должно быть true или false.',
'can' => 'Поле :attribute содержит неавторизованное значение.',
'confirmed' => 'Подтверждение поля :attribute не совпадает.',
'contains' => 'Поле :attribute отсутствует обязательное значение.',
'current_password' => 'Пароль неверен.',
'date' => 'Поле :attribute должно быть действительной датой.',
'date_equals' => 'Поле :attribute должно быть датой, равной :date.',
'date_format' => 'Поле :attribute должно соответствовать формату :format.',
'decimal' => 'Поле :attribute должно иметь :decimal десятичных знаков.',
'declined' => 'Поле :attribute должно быть отклонено.',
'declined_if' => 'Поле :attribute должно быть отклонено, когда :other равно :value.',
'different' => 'Поле :attribute и :other должны быть разными.',
'digits' => 'Поле :attribute должно быть :digits цифр.',
'digits_between' => 'Поле :attribute должно быть от :min до :max цифр.',
'dimensions' => 'Поле :attribute имеет недействительные размеры изображения.',
'distinct' => 'Поле :attribute имеет дублирующееся значение.',
'doesnt_contain' => 'Поле :attribute не должно содержать следующие значения: :values.',
'doesnt_end_with' => 'Поле :attribute не должно заканчиваться на одно из следующих: :values.',
'doesnt_start_with' => 'Поле :attribute не должно начинаться с одного из следующих: :values.',
'email' => 'Поле :attribute должно быть действительным адресом электронной почты.',
'encoding' => 'Поле :attribute должно быть закодировано в :encoding.',
'ends_with' => 'Поле :attribute должно заканчиваться на одно из следующих: :values.',
'enum' => 'Выбранный :attribute недействителен.',
'exists' => 'Выбранный :attribute недействителен.',
'extensions' => 'Поле :attribute должно иметь одно из следующих расширений: :values.',
'file' => 'Поле :attribute должно быть файлом.',
'filled' => 'Поле :attribute должно иметь значение.',
'gt' => [
'array' => 'Поле :attribute должно содержать более :value элементов.',
'file' => 'Поле :attribute должно быть больше :value килобайт.',
'numeric' => 'Поле :attribute должно быть больше :value.',
'string' => 'Поле :attribute должно быть больше :value символов.',
],
'gte' => [
'array' => 'Поле :attribute должно иметь :value элементов или больше.',
'file' => 'Поле :attribute должно быть больше или равно :value килобайт.',
'numeric' => 'Поле :attribute должно быть больше или равно :value.',
'string' => 'Поле :attribute должно иметь :value символов или больше.',
],
'hex_color' => 'Поле :attribute должно быть действительным шестнадцатеричным цветом.',
'image' => 'Поле :attribute должно быть изображением.',
'in' => 'Выбранный :attribute недействителен.',
'in_array' => 'Поле :attribute должно существовать в :other.',
'in_array_keys' => 'Поле :attribute должно содержать хотя бы один из следующих ключей: :values.',
'integer' => 'Поле :attribute должно быть целым числом.',
'ip' => 'Поле :attribute должно быть действительным IP-адресом.',
'ipv4' => 'Поле :attribute должно быть действительным IPv4-адресом.',
'ipv6' => 'Поле :attribute должно быть действительным IPv6-адресом.',
'json' => 'Поле :attribute должно быть действительной JSON-строкой.',
'list' => 'Поле :attribute должно быть списком.',
'lowercase' => 'Поле :attribute должно быть в нижнем регистре.',
'lt' => [
'array' => 'Поле :attribute должно содержать менее :value элементов.',
'file' => 'Поле :attribute должно быть менее :value килобайт.',
'numeric' => 'Поле :attribute должно быть менее :value.',
'string' => 'Поле :attribute должно иметь менее :value символов.',
],
'lte' => [
'array' => 'Поле :attribute не должно иметь более :value элементов.',
'file' => 'Поле :attribute должно быть меньше или равно :value килобайт.',
'numeric' => 'Поле :attribute должно быть меньше или равно :value.',
'string' => 'Поле :attribute должно иметь :value символов или меньше.',
],
'mac_address' => 'Поле :attribute должно быть действительным MAC-адресом.',
'max' => [
'array' => 'Поле :attribute не должно иметь более :max элементов.',
'file' => 'Поле :attribute не должно быть больше :max килобайт.',
'numeric' => 'Поле :attribute не должно быть больше :max.',
'string' => 'Поле :attribute не должно иметь более :max символов.',
],
'max_digits' => 'Поле :attribute не должно иметь более :max цифр.',
'mimes' => 'Поле :attribute должно быть файлом типа: :values.',
'mimetypes' => 'Поле :attribute должно быть файлом типа: :values.',
'min' => [
'array' => 'Поле :attribute должно иметь не менее :min элементов.',
'file' => 'Поле :attribute должно быть не менее :min килобайт.',
'numeric' => 'Поле :attribute должно быть не менее :min.',
'string' => 'Поле :attribute должно иметь не менее :min символов.',
],
'min_digits' => 'Поле :attribute должно иметь не менее :min цифр.',
'missing' => 'Поле :attribute должно отсутствовать.',
'missing_if' => 'Поле :attribute должно отсутствовать, когда :other равно :value.',
'missing_unless' => 'Поле :attribute должно отсутствовать, если :other не в :values.',
'missing_with' => 'Поле :attribute должно отсутствовать, когда :values присутствует.',
'missing_with_all' => 'Поле :attribute должно отсутствовать, когда все :values присутствуют.',
'multiple_of' => 'Поле :attribute должно быть кратным :value.',
'not_in' => 'Выбранный :attribute недействителен.',
'not_regex' => 'Формат поля :attribute недействителен.',
'numeric' => 'Поле :attribute должно быть числом.',
'password' => [
'letters' => 'Поле :attribute должно содержать хотя бы одну букву.',
'mixed' => 'Поле :attribute должно содержать хотя бы одну заглавную и одну строчную букву.',
'numbers' => 'Поле :attribute должно содержать хотя бы одну цифру.',
'symbols' => 'Поле :attribute должно содержать хотя бы один символ.',
'uncompromised' => 'Данный :attribute был найден в утечке данных. Пожалуйста, выберите другой :attribute.',
],
'present' => 'Поле :attribute должно быть присутствовать.',
'present_if' => 'Поле :attribute должно быть присутствовать, когда :other равно :value.',
'present_unless' => 'Поле :attribute должно быть присутствовать, если :other не в :values.',
'present_with' => 'Поле :attribute должно быть присутствовать, когда :values присутствует.',
'present_with_all' => 'Поле :attribute должно быть присутствовать, когда все :values присутствуют.',
'prohibited' => 'Поле :attribute запрещено.',
'prohibited_if' => 'Поле :attribute запрещено, когда :other равно :value.',
'prohibited_if_accepted' => 'Поле :attribute запрещено, когда :other принято.',
'prohibited_if_declined' => 'Поле :attribute запрещено, когда :other отклонено.',
'prohibited_unless' => 'Поле :attribute запрещено, если :other не в :values.',
'prohibits' => 'Поле :attribute запрещает присутствие :other.',
'regex' => 'Формат поля :attribute недействителен.',
'required' => 'Поле :attribute обязательно.',
'required_array_keys' => 'Поле :attribute должно содержать записи для: :values.',
'required_if' => 'Поле :attribute обязательно, когда :other равно :value.',
'required_if_accepted' => 'Поле :attribute обязательно, когда :other принято.',
'required_if_declined' => 'Поле :attribute обязательно, когда :other отклонено.',
'required_unless' => 'Поле :attribute обязательно, если :other не в :values.',
'required_with' => 'Поле :attribute обязательно, когда :values присутствует.',
'required_with_all' => 'Поле :attribute обязательно, когда все :values присутствуют.',
'required_without' => 'Поле :attribute обязательно, когда :values отсутствует.',
'required_without_all' => 'Поле :attribute обязательно, когда ни один из :values не присутствует.',
'same' => 'Поле :attribute должно совпадать с :other.',
'size' => [
'array' => 'Поле :attribute должно содержать :size элементов.',
'file' => 'Поле :attribute должно быть :size килобайт.',
'numeric' => 'Поле :attribute должно быть :size.',
'string' => 'Поле :attribute должно быть :size символов.',
],
'starts_with' => 'Поле :attribute должно начинаться с одного из следующих значений: :values.',
'string' => 'Поле :attribute должно быть строкой.',
'timezone' => 'Поле :attribute должно быть действительным часовым поясом.',
'unique' => 'Этот :attribute уже используется.',
'uploaded' => 'Загрузка :attribute не удалась.',
'uppercase' => 'Поле :attribute должно быть в верхнем регистре.',
'url' => 'Поле :attribute должно быть действительным URL.',
'ulid' => 'Поле :attribute должно быть действительным ULID.',
'uuid' => 'Поле :attribute должно быть действительным UUID.',
'custom' => [
'attribute-name' => [
'rule-name' => 'custom-message',
],
],
'attributes' => [
'name' => 'название',
'email' => 'электронная почта',
'password' => 'пароль',
'address' => 'адрес',
'phone' => 'телефон',
'description' => 'описание',
'start_date' => 'дата начала',
'end_date' => 'дата окончания',
'end_date_estimated' => 'предполагаемая дата окончания',
'reference' => 'ссылка',
'status' => 'статус',
'type' => 'тип',
'color' => 'цвет',
'progress_percent' => 'процент прогресса',
'tax_id' => 'ИНН',
'country' => 'страна',
'city' => 'город',
'latitude' => 'широта',
'longitude' => 'долгота',
'logo' => 'логотип',
'avatar' => 'аватар',
'role' => 'роль',
'company_id' => 'компания',
'current_password' => 'текущий пароль',
'new_password' => 'новый пароль',
'new_password_confirmation' => 'подтверждение нового пароля',
],
];
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 750 500">
<rect width="250" height="500" fill="#002654"/>
<rect x="250" width="250" height="500" fill="#FFFFFF"/>
<rect x="500" width="250" height="500" fill="#ED2939"/>
</svg>

After

Width:  |  Height:  |  Size: 235 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 750 500">
<rect width="750" height="166.67" fill="#FFFFFF"/>
<rect y="166.67" width="750" height="166.66" fill="#0039A6"/>
<rect y="333.33" width="750" height="166.67" fill="#D52B1E"/>
</svg>

After

Width:  |  Height:  |  Size: 250 B

+30 -30
View File
@@ -1,26 +1,26 @@
<x-guest-layout>
<div class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<h1 class="text-2xl font-bold text-gray-900 mb-6">
Bienvenido, {{ auth()->user()->name }}
{{ __('Welcome, :name', ['name' => auth()->user()->name]) }}
</h1>
<div class="grid gap-6 mb-8">
<div class="lg:col-span-3">
<div class="rounded-lg border border-gray-200 bg-white p-6">
<h2 class="text-lg font-medium text-gray-900 mb-4">
Mis Proyectos Activos
{{ __('My Active Projects') }}
</h2>
<livewire:client-projects />
</div>
</div>
<div class="lg:col-span-1">
<div class="rounded-lg border border-gray-200 bg-white p-6">
<h2 class="text-lg font-medium text-gray-900 mb-4">
Notificaciones
{{ __('Notifications') }}
</h2>
<div class="space-y-4">
<div class="flex items-start space-x-3">
<div class="flex-shrink-0">
@@ -30,17 +30,17 @@
</div>
<div class="min-w-0 flex-1">
<h3 class="text-sm font-medium text-gray-900">
Proyecto actualizado
{{ __('Project updated') }}
</h3>
<p class="text-sm text-gray-500">
Se han añadido nuevas fotos al proyecto "Centro Comercial Norte"
{{ __('New photos added to project ":name"', ['name' => 'Centro Comercial Norte']) }}
</p>
<p class="text-xs text-gray-400">
Hace 2 horas
{{ __('2 hours ago') }}
</p>
</div>
</div>
<div class="flex items-start space-x-3">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-green-600" viewBox="0 0 20 20" fill="currentColor">
@@ -49,13 +49,13 @@
</div>
<div class="min-w-0 flex-1">
<h3 class="text-sm font-medium text-gray-900">
Orden de cambio aprobada
{{ __('Change order approved') }}
</h3>
<p class="text-sm text-gray-500">
La orden de cambio #123 ha sido aprobada
{{ __('Change order #:id has been approved', ['id' => '123']) }}
</p>
<p class="text-xs text-gray-400">
Hace 1 día
{{ __('1 day ago') }}
</p>
</div>
</div>
@@ -63,60 +63,60 @@
</div>
</div>
</div>
<div class="grid gap-6">
<div class="lg:col-span-6">
<div class="rounded-lg border border-gray-200 bg-white p-6">
<h2 class="text-lg font-medium text-gray-900 mb-4">
Galería de Progreso
{{ __('Progress Gallery') }}
</h2>
<div class="gallery-grid">
<!-- Placeholder for gallery items -->
<div class="gallery-item bg-gray-100 flex items-center justify-center h-48">
<span class="text-gray-500">Próximamente: Fotos del avance</span>
<span class="text-gray-500">{{ __('Coming soon: Progress photos') }}</span>
</div>
</div>
</div>
</div>
<div class="lg:col-span-6">
<div class="rounded-lg border border-gray-200 bg-white p-6">
<h2 class="text-lg font-medium text-gray-900 mb-4">
Órdenes de Cambio Pendientes
{{ __('Pending Change Orders') }}
</h2>
<div class="space-y-4">
<div class="change-order-card change-order-pending">
<h3 class="text-sm font-medium text-gray-900 mb-2">
Orden de cambio #124
{{ __('Change order #:id', ['id' => '124']) }}
</h3>
<p class="text-sm text-gray-500 mb-2">
Solicitud de ampliación de zona de almacenamiento
{{ __('Storage area expansion request') }}
</p>
<div class="flex items-center space-x-3 mt-2">
<button class="px-3 py-1 bg-green-600 text-white text-sm rounded hover:bg-green-700">
Aprobar
{{ __('Approve') }}
</button>
<button class="px-3 py-1 bg-red-600 text-white text-sm rounded hover:bg-red-700">
Rechazar
{{ __('Reject') }}
</button>
</div>
</div>
<div class="change-order-card change-order-pending">
<h3 class="text-sm font-medium text-gray-900 mb-2">
Orden de cambio #125
{{ __('Change order #:id', ['id' => '125']) }}
</h3>
<p class="text-sm text-gray-500 mb-2">
Cambio de material en acabados interiores
{{ __('Interior finish material change') }}
</p>
<div class="flex items-center space-x-3 mt-2">
<button class="px-3 py-1 bg-green-600 text-white text-sm rounded hover:bg-green-700">
Aprobar
{{ __('Approve') }}
</button>
<button class="px-3 py-1 bg-red-600 text-white text-sm rounded hover:bg-red-700">
Rechazar
{{ __('Reject') }}
</button>
</div>
</div>
+33 -17
View File
@@ -10,8 +10,8 @@
{{-- Saludo --}}
<div>
<h3 class="text-lg font-bold">Hola, {{ $user?->first_name ?? auth()->user()->name }}</h3>
<p class="text-sm text-base-content/60">Un resumen rápido de lo tuyo.</p>
<h3 class="text-lg font-bold">{{ __('Hello, :name', ['name' => $user?->first_name ?? auth()->user()->name]) }}</h3>
<p class="text-sm text-base-content/60">{{ __('A quick summary of your items.') }}</p>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
@@ -21,10 +21,10 @@
<div class="card-body p-4">
<div class="flex items-center justify-between mb-1">
<h3 class="font-bold flex items-center gap-2">
<x-heroicon-o-folder class="w-5 h-5" /> Mis proyectos
<x-heroicon-o-folder class="w-5 h-5" /> {{ __('My Projects') }}
<span class="badge badge-sm">{{ $projectsCount }}</span>
</h3>
<a href="{{ route('projects.index') }}" wire:navigate class="link link-primary text-sm">Ver todos</a>
<a href="{{ route('projects.index') }}" wire:navigate class="link link-primary text-sm">{{ __('View all') }}</a>
</div>
@forelse($projects as $project)
@php $pct = round($project->phases->avg('progress_percent') ?? 0); @endphp
@@ -37,7 +37,7 @@
<span class="text-xs text-base-content/50 w-9 text-right">{{ $pct }}%</span>
</a>
@empty
<p class="text-sm text-base-content/40 py-3">No tienes proyectos asignados.</p>
<p class="text-sm text-base-content/40 py-3">{{ __('You have no assigned projects.') }}</p>
@endforelse
</div>
</div>
@@ -46,7 +46,7 @@
<div class="card bg-base-100 border border-base-300">
<div class="card-body p-4">
<h3 class="font-bold flex items-center gap-2 mb-1">
<x-heroicon-o-clipboard-document-check class="w-5 h-5" /> Mis tareas
<x-heroicon-o-clipboard-document-check class="w-5 h-5" /> {{ __('My Tasks') }}
<span class="badge badge-sm">{{ $myTasksCount }}</span>
</h3>
@forelse($myTasks as $task)
@@ -55,9 +55,7 @@
class="flex items-center gap-2 py-2 border-b border-base-200 last:border-0 hover:bg-base-200/50 rounded px-1">
<div class="flex-1 min-w-0">
<div class="text-sm truncate">{{ $task->title }}</div>
<div class="text-xs text-base-content/50 truncate">
{{ $task->issue?->project?->name ?? '—' }}
</div>
<div class="text-xs text-base-content/50 truncate">{{ $task->issue?->project?->name ?? '—' }}</div>
</div>
@if($task->due_date)
<span class="text-xs whitespace-nowrap {{ $task->is_overdue ? 'text-error font-semibold' : 'text-base-content/50' }}">
@@ -66,7 +64,25 @@
@endif
</a>
@empty
<p class="text-sm text-base-content/40 py-3">No tienes tareas pendientes. 🎉</p>
@if($myTasksFromTasks->isNotEmpty())
@foreach($myTasksFromTasks as $task)
<a href="{{ route('projects.tasks.show', [$task->project_id, $task->id]) }}"
wire:navigate
class="flex items-center gap-2 py-2 border-b border-base-200 last:border-0 hover:bg-base-200/50 rounded px-1">
<div class="flex-1 min-w-0">
<div class="text-sm truncate">{{ $task->title }}</div>
<div class="text-xs text-base-content/50 truncate">{{ $task->project?->name ?? '—' }}</div>
</div>
@if($task->due_date)
<span class="text-xs whitespace-nowrap {{ $task->is_overdue ? 'text-error font-semibold' : 'text-base-content/50' }}">
{{ $task->due_date->format('d/m/Y') }}
</span>
@endif
</a>
@endforeach
@else
<p class="text-sm text-base-content/40 py-3">{{ __('You have no pending tasks. 🎉') }}</p>
@endif
@endforelse
</div>
</div>
@@ -75,12 +91,12 @@
<div class="card bg-base-100 border border-base-300">
<div class="card-body p-4">
<h3 class="font-bold flex items-center gap-2 mb-1">
<x-heroicon-o-exclamation-triangle class="w-5 h-5" /> Incidencias asignadas
<x-heroicon-o-exclamation-triangle class="w-5 h-5" /> {{ __('Assigned Issues') }}
<span class="badge badge-sm">{{ $myIssuesCount }}</span>
</h3>
@forelse($myIssues as $issue)
@php
$sLabel = ['open'=>'Abierto','in_review'=>'En revisión'][$issue->status] ?? $issue->status;
$sLabel = ['open'=>__('Open'),'in_review'=>__('In Review')][$issue->status] ?? $issue->status;
@endphp
<a href="{{ route('projects.issues.show', [$issue->project_id, $issue->id]) }}" wire:navigate
class="flex items-center gap-2 py-2 border-b border-base-200 last:border-0 hover:bg-base-200/50 rounded px-1">
@@ -92,7 +108,7 @@
<span class="badge badge-ghost badge-xs whitespace-nowrap">{{ $sLabel }}</span>
</a>
@empty
<p class="text-sm text-base-content/40 py-3">No tienes incidencias asignadas.</p>
<p class="text-sm text-base-content/40 py-3">{{ __('You have no assigned issues.') }}</p>
@endforelse
</div>
</div>
@@ -101,7 +117,7 @@
<div class="card bg-base-100 border border-base-300">
<div class="card-body p-4">
<h3 class="font-bold flex items-center gap-2 mb-1">
<x-heroicon-o-bell class="w-5 h-5" /> Notificaciones
<x-heroicon-o-bell class="w-5 h-5" /> {{ __('Notifications') }}
@if($unreadCount)<span class="badge badge-error badge-sm">{{ $unreadCount }}</span>@endif
</h3>
@forelse($notifications as $note)
@@ -115,12 +131,12 @@
class="flex items-start gap-2 py-2 border-b border-base-200 last:border-0 hover:bg-base-200/50 rounded px-1 {{ is_null($note->read_at) ? 'font-medium' : '' }}">
<span class="mt-1 w-2 h-2 rounded-full shrink-0 {{ is_null($note->read_at) ? 'bg-error' : 'bg-base-300' }}"></span>
<div class="flex-1 min-w-0">
<div class="text-sm">{{ $d['message'] ?? 'Notificación' }}</div>
<div class="text-sm">{{ $d['message'] ?? __('Notification') }}</div>
<div class="text-xs text-base-content/40">{{ $note->created_at->diffForHumans() }}</div>
</div>
</a>
@empty
<p class="text-sm text-base-content/40 py-3">Sin notificaciones.</p>
<p class="text-sm text-base-content/40 py-3">{{ __('No notifications.') }}</p>
@endforelse
</div>
</div>
@@ -128,4 +144,4 @@
</div>
</div>
</div>
</x-app-layout>
</x-app-layout>
@@ -1,12 +1,33 @@
<div class="flex items-center gap-1"
x-on:locale-changed.window="window.location.reload()">
@foreach(['es' => ['flag' => 'es.svg', 'label' => 'ES'], 'en' => ['flag' => 'gb.svg', 'label' => 'EN']] as $code => $lang)
<button wire:click="switchLanguage('{{ $code }}')"
class="btn btn-xs gap-1.5 {{ $currentLocale === $code ? 'btn-primary' : 'btn-ghost' }}"
title="{{ __('Language') }}: {{ __($code === 'en' ? 'English' : 'Spanish') }}">
<img src="{{ asset('images/flags/' . $lang['flag']) }}"
class="w-4 h-auto rounded-sm border border-base-300" alt="{{ $lang['label'] }}">
{{ $lang['label'] }}
<x-dropdown align="right" width="48">
<x-slot name="trigger">
<button class="btn btn-ghost btn-sm gap-2" type="button">
<img src="{{ asset('images/flags/' . ($languages[$currentLocale]['flag'] ?? 'gb.svg')) }}"
class="w-5 h-4 rounded-sm border border-base-300"
alt="{{ $languages[$currentLocale]['name'] ?? 'English' }}">
<span class="hidden sm:inline">{{ $languages[$currentLocale]['name'] ?? 'English' }}</span>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
@endforeach
</div>
</x-slot>
<x-slot name="content">
<div class="py-1">
@foreach($languages as $code => $lang)
<button wire:click="switchLanguage('{{ $code }}')"
class="flex items-center gap-2 w-full px-3 py-2 text-sm hover:bg-base-200 rounded-md {{ $currentLocale === $code ? 'bg-primary/10 text-primary font-medium' : 'text-base-content' }}"
type="button">
<img src="{{ asset('images/flags/' . $lang['flag']) }}"
class="w-5 h-4 rounded-sm border border-base-300"
alt="{{ $lang['name'] }}">
<span>{{ $lang['name'] }}</span>
@if($currentLocale === $code)
<svg class="ml-auto w-4 h-4 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
@endif
</button>
@endforeach
</div>
</x-slot>
</x-dropdown>
@@ -18,11 +18,11 @@
<div tabindex="0" class="dropdown-content z-[50] menu p-0 shadow-lg bg-base-100 rounded-box w-80 mt-1 border border-base-200">
<!-- Header -->
<div class="flex items-center justify-between px-4 py-3 border-b border-base-200">
<span class="font-semibold text-base-content">Notificaciones</span>
<span class="font-semibold text-base-content">{{ __('Notifications') }}</span>
@if($unreadCount > 0)
<button wire:click="markAllAsRead"
class="text-xs text-primary hover:underline focus:outline-none">
Marcar todas
{{ __('Mark all as read') }}
</button>
@endif
</div>
@@ -48,7 +48,7 @@
<!-- Content -->
<div class="flex-1 min-w-0">
<p class="text-sm text-base-content leading-snug">
{{ $data['message'] ?? 'Notificación' }}
{{ $data['message'] ?? __('Notification') }}
</p>
<p class="text-xs text-base-content/50 mt-1">
{{ $createdAt->diffForHumans() }}
@@ -59,7 +59,7 @@
@if($isUnread)
<button wire:click="markAsRead('{{ $notification['id'] }}')"
class="shrink-0 text-base-content/40 hover:text-primary focus:outline-none"
title="Marcar como leída">
title="{{ __('Mark as read') }}">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
@@ -68,7 +68,7 @@
</li>
@empty
<li class="px-4 py-8 text-center text-sm text-base-content/50">
No hay notificaciones
{{ __('No notifications') }}
</li>
@endforelse
</ul>
@@ -78,7 +78,7 @@
<div class="border-t border-base-200 px-4 py-2 text-center">
<button wire:click="markAllAsRead"
class="btn btn-ghost btn-xs w-full text-primary">
Marcar todas como leídas
{{ __('Mark all as read') }}
</button>
</div>
@endif
@@ -5,7 +5,7 @@
<x-heroicon-o-arrow-left class="w-4 h-4" />
</a>
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
{{ $company ? 'Editar empresa: ' . $company->name : 'Nueva empresa' }}
{{ $company ? __('Edit company: :name', ['name' => $company->name]) : __('New company') }}
</h2>
</div>
</x-slot>
@@ -37,71 +37,74 @@
{{-- ── Sección: Identificación ──────────────────────────── --}}
<div class="pb-6 mb-6 border-b border-base-200">
<h3 class="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-4">
Identificación
{{ __('Identification') }}
</h3>
<div class="space-y-4">
<div class="flex items-start gap-4">
<label class="w-48 shrink-0 pt-2 text-sm font-medium text-gray-700">
Nombre registrado <span class="text-error">*</span>
{{ __('Registered name') }} <span class="text-error">*</span>
</label>
<div class="flex-1">
<input type="text" wire:model="name"
class="input input-bordered w-full"
placeholder="Constructora Ejemplo, S.L." />
placeholder="{{ __('E.g.: Acme Construct, S.L.') }}" />
@error('name') <p class="text-error text-xs mt-1">{{ $message }}</p> @enderror
</div>
</div>
<div class="flex items-start gap-4">
<label class="w-48 shrink-0 pt-2 text-sm font-medium text-gray-700">
Apodo / comercial
{{ __('Nickname') }}
</label>
<div class="flex-1">
<input type="text" wire:model="apodo"
<input type="text" wire:model="nickname"
class="input input-bordered w-full"
placeholder="Ejemplo Constr." />
placeholder="{{ __('E.g.: Acme Construct') }}" />
</div>
</div>
<div class="flex items-start gap-4">
<label class="w-48 shrink-0 pt-2 text-sm font-medium text-gray-700">
NIF / CIF / Tax ID
{{ __('Tax ID / NIF / CIF') }}
</label>
<div class="flex-1">
<input type="text" wire:model="tax_id"
class="input input-bordered w-full"
placeholder="B12345678" />
placeholder="{{ __('E.g.: B12345678') }}" />
@error('tax_id') <p class="text-error text-xs mt-1">{{ $message }}</p> @enderror
</div>
</div>
<div class="flex items-start gap-4">
<label class="w-48 shrink-0 pt-2 text-sm font-medium text-gray-700">
Tipo de empresa <span class="text-error">*</span>
{{ __('Company type') }} <span class="text-error">*</span>
</label>
<div class="flex-1">
<select wire:model="type" class="select select-bordered w-full">
<option value="owner">Promotor / Propietario</option>
<option value="constructor">Constructor principal</option>
<option value="subcontractor">Subcontratista</option>
<option value="consultant">Consultor / Ingeniería</option>
<option value="supplier">Proveedor</option>
<option value="other">Otro</option>
<option value="">{{ __('Select a type') }}</option>
<option value="owner">{{ __('Owner / Developer') }}</option>
<option value="constructor">{{ __('Constructor') }}</option>
<option value="subcontractor">{{ __('Subcontractor') }}</option>
<option value="supplier">{{ __('Supplier') }}</option>
<option value="consultant">{{ __('Consultant / Engineering') }}</option>
</select>
@error('type') <p class="text-error text-xs mt-1">{{ $message }}</p> @enderror
</div>
</div>
<div class="flex items-start gap-4">
<label class="w-48 shrink-0 pt-2 text-sm font-medium text-gray-700">
Estado <span class="text-error">*</span>
{{ __('Status') }} <span class="text-error">*</span>
</label>
<div class="flex-1">
<select wire:model="estado" class="select select-bordered w-full max-w-xs">
<option value="activo">Activo</option>
<option value="inactivo">Inactivo</option>
<option value="suspendido">Suspendido</option>
<select wire:model="status" class="select select-bordered w-full">
<option value="">{{ __('Select a status') }}</option>
<option value="active">{{ __('Active') }}</option>
<option value="inactive">{{ __('Inactive') }}</option>
<option value="suspended">{{ __('Suspended') }}</option>
</select>
@error('status') <p class="text-error text-xs mt-1">{{ $message }}</p> @enderror
</div>
</div>
@@ -111,24 +114,24 @@
{{-- ── Sección: Contacto ─────────────────────────────────── --}}
<div class="pb-6 mb-6 border-b border-base-200">
<h3 class="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-4">
Contacto
{{ __('Contact') }}
</h3>
<div class="space-y-4">
<div class="flex items-start gap-4">
<label class="w-48 shrink-0 pt-2 text-sm font-medium text-gray-700">
Dirección
{{ __('Address') }}
</label>
<div class="flex-1">
<textarea wire:model="address" rows="2"
class="textarea textarea-bordered w-full"
placeholder="Calle, número, ciudad, CP, país"></textarea>
class="textarea textarea-bordered w-full"
placeholder="{{ __('Street, number, city, ZIP, country') }}"></textarea>
</div>
</div>
<div class="flex items-start gap-4">
<label class="w-48 shrink-0 pt-2 text-sm font-medium text-gray-700">
Teléfono
{{ __('Phone') }}
</label>
<div class="flex-1">
<label class="input input-bordered flex items-center gap-2">
@@ -141,7 +144,7 @@
<div class="flex items-start gap-4">
<label class="w-48 shrink-0 pt-2 text-sm font-medium text-gray-700">
Email
{{ __('Email') }}
</label>
<div class="flex-1">
<label class="input input-bordered flex items-center gap-2">
@@ -155,7 +158,7 @@
<div class="flex items-start gap-4">
<label class="w-48 shrink-0 pt-2 text-sm font-medium text-gray-700">
Sitio web
{{ __('Website') }}
</label>
<div class="flex-1">
<label class="input input-bordered flex items-center gap-2">
@@ -173,12 +176,12 @@
{{-- ── Sección: Logo ─────────────────────────────────────── --}}
<div class="pb-6 mb-6 border-b border-base-200">
<h3 class="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-4">
Logo
{{ __('Logo') }}
</h3>
<div class="flex items-start gap-4">
<label class="w-48 shrink-0 pt-2 text-sm font-medium text-gray-700">
{{ $company?->logo_path ? 'Reemplazar logo' : 'Subir logo' }}
<p class="text-xs text-gray-400 font-normal mt-0.5">PNG / JPG, máx. 2 MB</p>
{{ $company?->logo_path ? __('Replace logo') : __('Upload logo') }}
<p class="text-xs text-gray-400 font-normal mt-0.5">{{ __('PNG / JPG, max. 2 MB') }}</p>
</label>
<div class="flex-1 flex items-start gap-4">
{{-- Preview --}}
@@ -187,7 +190,7 @@
class="w-16 h-16 object-contain border border-base-300 rounded-lg shrink-0" />
@elseif($company?->logo_path && \Illuminate\Support\Facades\Storage::disk('public')->exists($company->logo_path))
<img src="{{ \Illuminate\Support\Facades\Storage::disk('public')->url($company->logo_path) }}"
alt="Logo actual"
alt="{{ __('Current logo') }}"
class="w-16 h-16 object-contain border border-base-300 rounded-lg shrink-0" />
@else
<div class="w-16 h-16 bg-base-200 rounded-lg flex items-center justify-center shrink-0">
@@ -197,7 +200,7 @@
<div class="flex-1">
<input type="file" wire:model="logo" accept="image/*"
class="file-input file-input-bordered w-full" />
<div wire:loading wire:target="logo" class="text-xs text-info mt-1">Subiendo…</div>
<div wire:loading wire:target="logo" class="text-xs text-info mt-1">{{ __('Uploading…') }}</div>
@error('logo') <p class="text-error text-xs mt-1">{{ $message }}</p> @enderror
</div>
</div>
@@ -207,17 +210,17 @@
{{-- ── Sección: Notas ────────────────────────────────────── --}}
<div class="pb-6 mb-6 border-b border-base-200">
<h3 class="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-4">
Notas internas
{{ __('Internal notes') }}
</h3>
<div class="flex items-start gap-4">
<label class="w-48 shrink-0 pt-2 text-sm font-medium text-gray-700">
Observaciones
<p class="text-xs text-gray-400 font-normal mt-0.5">Información interna</p>
{{ __('Observations') }}
<p class="text-xs text-gray-400 font-normal mt-0.5">{{ __('Internal information') }}</p>
</label>
<div class="flex-1">
<textarea wire:model="notes" rows="3"
class="textarea textarea-bordered w-full"
placeholder="Condiciones especiales, observaciones…"></textarea>
placeholder="{{ __('Special conditions, observations…') }}"></textarea>
</div>
</div>
</div>
@@ -226,13 +229,13 @@
<div class="flex items-center justify-between pt-2">
<a href="{{ route('companies.manage') }}" class="btn btn-outline gap-1" wire:navigate>
<x-heroicon-o-x-mark class="w-4 h-4" />
Cancelar
{{ __('Cancel') }}
</a>
<button type="submit" class="btn btn-primary gap-2"
wire:loading.attr="disabled" wire:target="save">
<span wire:loading wire:target="save" class="loading loading-spinner loading-sm"></span>
<x-heroicon-o-check class="w-4 h-4" />
{{ $company ? 'Guardar cambios' : 'Crear empresa' }}
{{ $company ? __('Save changes') : __('Create company') }}
</button>
</div>
@@ -243,4 +246,4 @@
</div>
</div>
</div>
</div>
@@ -28,12 +28,12 @@
{{-- Tipo --}}
@php
$typeBadge = match($company->type) {
'owner' => ['badge-success', 'Promotor'],
'constructor' => ['badge-primary', 'Constructor'],
'subcontractor' => ['badge-secondary','Subcontratista'],
'consultant' => ['badge-info', 'Consultor'],
'supplier' => ['badge-warning', 'Proveedor'],
default => ['badge-ghost', 'Otro'],
'owner' => ['badge-success', __('Owner')],
'constructor' => ['badge-primary', __('Constructor')],
'subcontractor' => ['badge-secondary',__('Subcontractor')],
'consultant' => ['badge-info', __('Consultant')],
'supplier' => ['badge-warning', __('Supplier')],
default => ['badge-ghost', __('Other')],
};
@endphp
<span class="badge {{ $typeBadge[0] }}">{{ $typeBadge[1] }}</span>
@@ -80,9 +80,9 @@
{{-- Estado --}}
@php
$estadoBadge = match($company->estado ?? 'activo') {
'activo' => ['badge-success', 'Activo'],
'inactivo' => ['badge-ghost', 'Inactivo'],
'suspendido' => ['badge-error', 'Suspendido'],
'activo' => ['badge-success', __('Active')],
'inactivo' => ['badge-ghost', __('Inactive')],
'suspendido' => ['badge-error', __('Suspended')],
default => ['badge-ghost', ucfirst($company->estado ?? '')],
};
@endphp
@@ -93,12 +93,12 @@
<a href="{{ route('companies.edit', $company) }}"
class="btn btn-outline btn-sm gap-1" wire:navigate>
<x-heroicon-o-pencil class="w-4 h-4" />
Editar
{{ __('Edit') }}
</a>
<a href="{{ route('companies.manage') }}"
class="btn btn-ghost btn-sm gap-1" wire:navigate>
<x-heroicon-o-arrow-left class="w-4 h-4" />
Volver
{{ __('Back') }}
</a>
</div>
</div>
@@ -111,170 +111,170 @@
<div class="py-6">
<div class="max-w-5xl mx-auto sm:px-6 lg:px-8 space-y-4">
{{-- Tabs --}}
<div role="tablist" class="tabs tabs-bordered">
<button role="tab" wire:click="setTab('summary')"
class="tab gap-2 {{ $activeTab === 'summary' ? 'tab-active font-semibold' : '' }}">
<x-heroicon-o-chart-bar class="w-4 h-4" />
Resumen
</button>
<button role="tab" wire:click="setTab('people')"
class="tab gap-2 {{ $activeTab === 'people' ? 'tab-active font-semibold' : '' }}">
<x-heroicon-o-users class="w-4 h-4" />
Personas
<span class="badge badge-sm badge-outline">{{ $usersCount }}</span>
</button>
<button role="tab" wire:click="setTab('projects')"
class="tab gap-2 {{ $activeTab === 'projects' ? 'tab-active font-semibold' : '' }}">
<x-heroicon-o-folder-open class="w-4 h-4" />
Proyectos
<span class="badge badge-sm badge-outline">{{ $projectsCount }}</span>
</button>
<button role="tab" wire:click="setTab('notes')"
class="tab gap-2 {{ $activeTab === 'notes' ? 'tab-active font-semibold' : '' }}">
<x-heroicon-o-document-text class="w-4 h-4" />
Notas
@if($company->notes)
<span class="badge badge-sm badge-primary"></span>
@endif
</button>
</div>
<!-- Tabs --}}
<div role="tablist" class="tabs tabs-bordered">
<button role="tab" wire:click="setTab('summary')"
class="tab gap-2 {{ $activeTab === 'summary' ? 'tab-active font-semibold' : '' }}">
<x-heroicon-o-chart-bar class="w-4 h-4" />
{{ __('Summary') }}
</button>
<button role="tab" wire:click="setTab('people')"
class="tab gap-2 {{ $activeTab === 'people' ? 'tab-active font-semibold' : '' }}">
<x-heroicon-o-users class="w-4 h-4" />
{{ __('People') }}
<span class="badge badge-sm badge-outline">{{ $usersCount }}</span>
</button>
<button role="tab" wire:click="setTab('projects')"
class="tab gap-2 {{ $activeTab === 'projects' ? 'tab-active font-semibold' : '' }}">
<x-heroicon-o-folder-open class="w-4 h-4" />
{{ __('Projects') }}
<span class="badge badge-sm badge-outline">{{ $projectsCount }}</span>
</button>
<button role="tab" wire:click="setTab('notes')"
class="tab gap-2 {{ $activeTab === 'notes' ? 'tab-active font-semibold' : '' }}">
<x-heroicon-o-document-text class="w-4 h-4" />
{{ __('Notes') }}
@if($company->notes)
<span class="badge badge-sm badge-primary"></span>
@endif
</button>
</div>
{{-- ════════════════════════════════════════════════════════════════════
TAB: RESUMEN
TAB: SUMMARY
════════════════════════════════════════════════════════════════════ --}}
@if($activeTab === 'summary')
<div class="space-y-4">
@if($activeTab === 'summary')
<div class="space-y-4">
{{-- KPIs --}}
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
{{-- KPIs --}}
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div class="card bg-base-100 shadow">
<div class="card-body p-4 items-center text-center">
<div class="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center mb-1">
<x-heroicon-o-users class="w-5 h-5 text-blue-600" />
</div>
<p class="text-3xl font-bold">{{ $usersCount }}</p>
<p class="text-xs text-gray-500 mt-0.5">Personas</p>
</div>
</div>
<div class="card bg-base-100 shadow">
<div class="card-body p-4 items-center text-center">
<div class="w-10 h-10 rounded-full bg-indigo-100 flex items-center justify-center mb-1">
<x-heroicon-o-folder-open class="w-5 h-5 text-indigo-600" />
</div>
<p class="text-3xl font-bold">{{ $projectsCount }}</p>
<p class="text-xs text-gray-500 mt-0.5">Proyectos</p>
</div>
</div>
<div class="card bg-base-100 shadow">
<div class="card-body p-4 items-center text-center">
<div class="w-10 h-10 rounded-full bg-green-100 flex items-center justify-center mb-1">
<x-heroicon-o-arrow-trending-up class="w-5 h-5 text-green-600" />
</div>
<p class="text-3xl font-bold">{{ $avgProgress }}<span class="text-lg font-normal text-gray-400">%</span></p>
<p class="text-xs text-gray-500 mt-0.5">Progreso medio</p>
@if($projectsCount > 0)
<progress class="progress progress-success w-full h-1 mt-1"
value="{{ $avgProgress }}" max="100"></progress>
@endif
</div>
</div>
<div class="card bg-base-100 shadow">
<div class="card-body p-4 items-center text-center">
<div class="w-10 h-10 rounded-full bg-orange-100 flex items-center justify-center mb-1">
<x-heroicon-o-exclamation-triangle class="w-5 h-5 text-orange-500" />
</div>
<p class="text-3xl font-bold {{ $openIssues > 0 ? 'text-warning' : '' }}">{{ $openIssues }}</p>
<p class="text-xs text-gray-500 mt-0.5">Issues abiertos</p>
</div>
</div>
</div>
{{-- Proyectos con progreso --}}
@if($company->projects->isNotEmpty())
<div class="card bg-base-100 shadow">
<div class="card-body p-5">
<h3 class="font-semibold text-sm mb-3 flex items-center gap-2">
<x-heroicon-o-chart-bar-square class="w-4 h-4 text-primary" />
Estado de proyectos
</h3>
<div class="space-y-3">
@foreach($company->projects as $p)
@php
$avg = $p->phases->avg('progress_percent') ?? 0;
$pStatusBadge = match($p->status) {
'in_progress' => ['badge-primary', 'En progreso'],
'completed' => ['badge-success', 'Completado'],
'paused' => ['badge-warning', 'Pausado'],
'planning' => ['badge-ghost', 'Planificación'],
default => ['badge-ghost', ucfirst($p->status)],
};
@endphp
<div class="flex items-center gap-3">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-0.5">
<a href="{{ route('projects.dashboard', $p) }}"
class="font-medium text-sm hover:text-primary transition-colors truncate" wire:navigate>
{{ $p->name }}
</a>
<span class="badge badge-xs {{ $pStatusBadge[0] }} shrink-0">{{ $pStatusBadge[1] }}</span>
</div>
<div class="flex items-center gap-2">
<progress class="progress progress-primary flex-1 h-1.5"
value="{{ round($avg) }}" max="100"></progress>
<span class="text-xs text-gray-400 shrink-0 w-8 text-right">{{ round($avg) }}%</span>
</div>
</div>
@if($p->pivot->role_in_project)
<span class="badge badge-sm badge-outline shrink-0">{{ $p->pivot->role_in_project }}</span>
@endif
<div class="card bg-base-100 shadow">
<div class="card-body p-4 items-center text-center">
<div class="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center mb-1">
<x-heroicon-o-users class="w-5 h-5 text-blue-600" />
</div>
@endforeach
<p class="text-3xl font-bold">{{ $usersCount }}</p>
<p class="text-xs text-gray-500 mt-0.5">{{ __('People') }}</p>
</div>
</div>
</div>
</div>
@endif
{{-- Ficha empresa --}}
<div class="card bg-base-100 shadow">
<div class="card-body p-5">
<h3 class="font-semibold text-sm mb-3 flex items-center gap-2">
<x-heroicon-o-identification class="w-4 h-4 text-primary" />
Ficha
</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-2 text-sm">
@foreach([
['NIF/CIF', $company->tax_id],
['Tipo', $typeBadge[1]],
['Estado', $estadoBadge[1]],
['Teléfono', $company->phone],
['Email', $company->email],
['Dirección', $company->address],
['Web', $company->website],
] as [$label, $val])
@if($val)
<div class="flex gap-2 py-1.5 border-b border-base-200">
<span class="text-gray-400 w-24 shrink-0">{{ $label }}</span>
@if($label === 'Web')
<a href="{{ $val }}" target="_blank" rel="noopener"
class="text-primary hover:underline truncate">{{ $val }}</a>
@else
<span class="font-medium truncate">{{ $val }}</span>
<div class="card bg-base-100 shadow">
<div class="card-body p-4 items-center text-center">
<div class="w-10 h-10 rounded-full bg-indigo-100 flex items-center justify-center mb-1">
<x-heroicon-o-folder-open class="w-5 h-5 text-indigo-600" />
</div>
<p class="text-3xl font-bold">{{ $projectsCount }}</p>
<p class="text-xs text-gray-500 mt-0.5">{{ __('Projects') }}</p>
</div>
</div>
<div class="card bg-base-100 shadow">
<div class="card-body p-4 items-center text-center">
<div class="w-10 h-10 rounded-full bg-green-100 flex items-center justify-center mb-1">
<x-heroicon-o-arrow-trending-up class="w-5 h-5 text-green-600" />
</div>
<p class="text-3xl font-bold">{{ $avgProgress }}<span class="text-lg font-normal text-gray-400">%</span></p>
<p class="text-xs text-gray-500 mt-0.5">{{ __('Average progress') }}</p>
@if($projectsCount > 0)
<progress class="progress progress-success w-full h-1 mt-1"
value="{{ $avgProgress }}" max="100"></progress>
@endif
</div>
</div>
<div class="card bg-base-100 shadow">
<div class="card-body p-4 items-center text-center">
<div class="w-10 h-10 rounded-full bg-orange-100 flex items-center justify-center mb-1">
<x-heroicon-o-exclamation-triangle class="w-5 h-5 text-orange-500" />
</div>
<p class="text-3xl font-bold {{ $openIssues > 0 ? 'text-warning' : '' }}">{{ $openIssues }}</p>
<p class="text-xs text-gray-500 mt-0.5">{{ __('Open issues') }}</p>
</div>
</div>
</div>
{{-- Projects with progress --}}
@if($company->projects->isNotEmpty())
<div class="card bg-base-100 shadow">
<div class="card-body p-5">
<h3 class="font-semibold text-sm mb-3 flex items-center gap-2">
<x-heroicon-o-chart-bar-square class="w-4 h-4 text-primary" />
{{ __('Projects status') }}
</h3>
<div class="space-y-3">
@foreach($company->projects as $p)
@php
$avg = $p->phases->avg('progress_percent') ?? 0;
$pStatusBadge = match($p->status) {
'in_progress' => ['badge-primary', __('In progress')],
'completed' => ['badge-success', __('Completed')],
'paused' => ['badge-warning', __('Paused')],
'planning' => ['badge-ghost', __('Planning')],
default => ['badge-ghost', ucfirst($p->status)],
};
@endphp
<div class="flex items-center gap-3">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-0.5">
<a href="{{ route('projects.dashboard', $p) }}"
class="font-medium text-sm hover:text-primary transition-colors truncate" wire:navigate>
{{ $p->name }}
</a>
<span class="badge badge-xs {{ $pStatusBadge[0] }} shrink-0">{{ $pStatusBadge[1] }}</span>
</div>
<div class="flex items-center gap-2">
<progress class="progress progress-primary flex-1 h-1.5"
value="{{ round($avg) }}" max="100"></progress>
<span class="text-xs text-gray-400 shrink-0 w-8 text-right">{{ round($avg) }}%</span>
</div>
</div>
@if($p->pivot->role_in_project)
<span class="badge badge-sm badge-outline shrink-0">{{ $p->pivot->role_in_project }}</span>
@endif
</div>
@endif
@endforeach
@endforeach
</div>
</div>
</div>
</div>
@endif
</div>
@endif
{{-- Company card --}}
<div class="card bg-base-100 shadow">
<div class="card-body p-5">
<h3 class="font-semibold text-sm mb-3 flex items-center gap-2">
<x-heroicon-o-identification class="w-4 h-4 text-primary" />
{{ __('Company details') }}
</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-2 text-sm">
@foreach([
[__('Tax ID'), $company->tax_id],
[__('Type'), $typeBadge[1]],
[__('Status'), $estadoBadge[1]],
[__('Phone'), $company->phone],
[__('Email'), $company->email],
[__('Address'), $company->address],
[__('Website'), $company->website],
] as [$label, $val])
@if($val)
<div class="flex gap-2 py-1.5 border-b border-base-200">
<span class="text-gray-400 w-24 shrink-0">{{ $label }}</span>
@if($label === __('Website'))
<a href="{{ $val }}" target="_blank" rel="noopener"
class="text-primary hover:underline truncate">{{ $val }}</a>
@else
<span class="font-medium truncate">{{ $val }}</span>
@endif
</div>
@endif
@endforeach
</div>
</div>
</div>
</div>
@endif
{{-- ════════════════════════════════════════════════════════════════════
TAB: PERSONAS
@@ -49,6 +49,14 @@ new class extends Component
</x-nav-link>
</div>
@can('view tasks')
<div class="hidden space-x-8 sm:-my-px sm:ms-10 sm:flex">
<x-nav-link :href="route('projects.list')" :active="request()->routeIs('projects.tasks*')" wire:navigate>
{{ __('Tasks') }}
</x-nav-link>
</div>
@endcan
@can('view users')
<div class="hidden space-x-8 sm:-my-px sm:ms-10 sm:flex">
<x-nav-link :href="route('admin.users')" :active="request()->routeIs('admin.users')" wire:navigate>
@@ -90,6 +90,16 @@
<x-heroicon-o-pencil class="w-4 h-4" />
</button>
@endcan
@can('delete inspections')
<button
wire:click="$dispatch('delete-inspection', { id: {{ $inspection->id } }})"
class="btn btn-xs btn-ghost btn-circle btn-error"
title="{{ __('Delete') }}"
onclick="event.stopPropagation(); return confirm('{{ __('Delete this inspection? This cannot be undone.') }}');"
>
<x-heroicon-o-trash class="w-4 h-4" />
</button>
@endcan
</div>
</div>
</div>
@@ -418,6 +418,16 @@
<x-heroicon-o-pencil class="w-4 h-4 mr-1" /> {{ __('Edit') }}
</button>
@endcan
@can('delete inspections')
<button
wire:click="$dispatch('delete-inspection', { id: {{ $viewingInspection['id'] }} })"
wire:loading.attr="disabled"
class="btn btn-error btn-sm"
onclick="return confirm('{{ __('Delete this inspection? This cannot be undone.') }}');"
>
<x-heroicon-o-trash class="w-4 h-4 mr-1" /> {{ __('Delete') }}
</button>
@endcan
</div>
</div>
<div class="modal-backdrop bg-black/40" wire:click="closeViewInspection"></div>
@@ -0,0 +1,229 @@
<div class="space-y-6">
<!-- Header -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
{{ $project ? 'Calendario: ' . $project->name : 'Calendario - Todas las tareas' }}
</h1>
<p class="text-gray-500 dark:text-gray-400 mt-1">
Vista de calendario de tareas
</p>
</div>
<div class="flex flex-wrap gap-2">
@can('create tasks', $project ?? \App\Models\Project::first())
<a href="{{ $project ? route('projects.tasks.create', $project) : route('tasks.create') }}"
class="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
Nueva Tarea
</a>
@endcan
<a href="{{ $project ? route('projects.tasks', $project) : route('tasks.index') }}"
class="inline-flex items-center px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
</svg>
Vista Tabla
</a>
<a href="{{ $project ? route('projects.tasks.kanban', $project) : route('tasks.kanban') }}"
class="inline-flex items-center px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2"/>
</svg>
Kanban
</a>
</div>
</div>
<!-- Filters -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4">
<div class="flex flex-col lg:flex-row gap-4">
<div class="flex-1 min-w-[250px]">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Buscar</label>
<input type="text" wire:model.debounce.300ms="search" placeholder="Título, descripción..."
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white">
</div>
<div class="min-w-[160px]">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Vista</label>
<select wire:model="viewMode" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white">
<option value="dayGridMonth">Mes</option>
<option value="timeGridWeek">Semana</option>
<option value="timeGridDay">Día</option>
<option value="listWeek">Lista</option>
</select>
</div>
<div class="min-w-[180px]">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Asignado</label>
<select wire:model="assigneeFilter" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white">
<option value="">Todos</option>
@foreach($assignees as $assignee)
<option value="{{ $assignee->id }}">{{ $assignee->name }}</option>
@endforeach
</select>
</div>
@if($project)
<div class="min-w-[180px]">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Fase</label>
<select wire:model="phaseFilter" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white">
<option value="">Todas</option>
@foreach($phases as $phase)
<option value="{{ $phase->id }}">{{ $phase->name }}</option>
@endforeach
</select>
</div>
@endif
<div class="min-w-[160px]">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Prioridad</label>
<select wire:model="priorityFilter" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white">
<option value="">Todas</option>
@foreach($priorityOptions as $value => $label)
<option value="{{ $value }}">{{ $label }}</option>
@endforeach
</select>
</div>
<div class="min-w-[160px]">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Estado</label>
<select wire:model="statusFilter" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white">
<option value="">Todos</option>
@foreach($statusOptions as $value => $label)
<option value="{{ $value }}">{{ $label }}</option>
@endforeach
</select>
</div>
</div>
</div>
<!-- Calendar Container -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<div id="calendar" class="min-h-[600px]"></div>
</div>
<!-- Legend -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4">
<h4 class="font-medium text-gray-900 dark:text-white mb-3">Leyenda</h4>
<div class="flex flex-wrap gap-4 text-sm">
@foreach(['low' => '#6b7280', 'medium' => '#f59e0b', 'high' => '#ef4444', 'critical' => '#7c3aed'] as $key => $color)
<span class="flex items-center gap-2" style="color: {{ $color }}">
<span class="w-3 h-3 rounded" style="background-color: {{ $color }}"></span>
{{ \App\Models\Task::priorityOptions()[$key] ?? ucfirst($key) }}
</span>
@endforeach
<span class="flex items-center gap-2">
<span class="w-3 h-3 rounded border-2 border-dashed border-gray-400"></span>
Tareas vencidas
</span>
<span class="flex items-center gap-2">
<span class="w-3 h-3 rounded border-2 border-amber-500 bg-amber-500/20"></span>
Vence hoy
</span>
</div>
</div>
</div>
@push('scripts')
<script>
// FullCalendar initialization
document.addEventListener('livewire:load', () => {
// Load FullCalendar dynamically if not already loaded
if (typeof FullCalendar === 'undefined') {
// Load CSS
const css = document.createElement('link');
css.rel = 'stylesheet';
css.href = 'https://cdn.jsdelivr.net/npm/@fullcalendar/core@6.1.11/index.global.min.css';
document.head.appendChild(css);
// Load JS
const js = document.createElement('script');
js.src = 'https://cdn.jsdelivr.net/npm/@fullcalendar/core@6.1.11/index.global.min.js';
js.onload = () => {
initCalendar();
};
document.head.appendChild(js);
} else {
initCalendar();
}
});
let calendarInstance = null;
function initCalendar() {
const events = @json($events);
const initialView = '{{ $viewMode }}';
const calendarEl = document.getElementById('calendar');
if (!calendarEl) return;
// Destroy existing instance
if (calendarInstance) {
calendarInstance.destroy();
}
calendarInstance = new FullCalendar.Calendar(calendarEl, {
initialView: initialView,
locale: '{{ app()->getLocale() }}',
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: 'dayGridMonth,timeGridWeek,timeGridDay,listWeek'
},
events: events,
eventTimeFormat: {
hour: '2-digit',
minute: '2-digit',
meridiem: false
},
eventDidMount: function(info) {
// Add tooltip
const props = info.event.extendedProps;
let tooltip = `<strong>${info.event.title}</strong>`;
if (props.project) tooltip += `<br>Proyecto: ${props.project}`;
if (props.phase) tooltip += `<br>Fase: ${props.phase}`;
if (props.assignee) tooltip += `<br>Asignado: ${props.assignee}`;
if (props.dueDate) tooltip += `<br>Vence: ${props.dueDate}`;
if (props.statusLabel) tooltip += `<br>Estado: ${props.statusLabel}`;
if (props.priorityLabel) tooltip += `<br>Prioridad: ${props.priorityLabel}`;
if (props.subtasksCount > 0) tooltip += `<br>Subtareas: ${props.completedSubtasksCount}/${props.subtasksCount}`;
tippy(info.el, {
content: tooltip,
allowHTML: true,
theme: 'light',
placement: 'top'
});
},
eventClick: function(info) {
// Navigate to task detail
if (info.event.url) {
window.location.href = info.event.url;
}
},
datesSet: function(info) {
// Optional: notify Livewire of view change
}
});
calendarInstance.render();
}
// Listen for Livewire updates
document.addEventListener('livewire:render', () => {
// Re-initialize calendar when data changes
setTimeout(() => {
if (calendarInstance) {
calendarInstance.destroy();
}
initCalendar();
}, 100);
});
</script>
<!-- Load Tippy.js for tooltips -->
<script src="https://cdn.jsdelivr.net/npm/tippy.js@6.3.7/dist/tippy-bundle.umd.min.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tippy.js@6.3.7/dist/tippy.css">
@endpush
@@ -0,0 +1,319 @@
<div class="space-y-6">
<!-- Header -->
<div class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
<div>
<div class="flex items-center gap-3">
<a href="{{ route('projects.tasks', $project) }}" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
</svg>
</a>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{{ $task->title }}</h1>
</div>
<div class="flex flex-wrap items-center gap-3 mt-2">
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium"
style="background-color: {{ $task->status_color }}20; color: {{ $task->status_color }}">
{{ $task->status_label }}
</span>
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium"
style="background-color: {{ $task->priority_color }}20; color: {{ $task->priority_color }}">
{{ $task->priority_label }}
</span>
@if($task->phase)
<span class="px-3 py-1 text-sm text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 rounded-full">
{{ $task->phase->name }}
</span>
@endif
</div>
</div>
<div class="flex flex-wrap gap-2">
@can('edit tasks', $task)
<a href="{{ route('projects.tasks.edit', [$project, $task]) }}"
class="inline-flex items-center px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
Editar
</a>
@endcan
<div class="flex items-center gap-2" x-data="{ open: false }">
<button @click="open = !open" class="inline-flex items-center px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/>
</svg>
Acciones
</button>
<div x-show="open" x-transition:enter="transition ease-out duration-100" x-transition:enter-start="opacity-0 scale-95" x-transition:enter-end="opacity-100 scale-100" x-transition:leave="transition ease-in duration-75" x-transition:leave-start="opacity-100 scale-100" x-transition:leave-end="opacity-0 scale-95" @click.outside="open = false" class="absolute right-0 mt-2 w-48 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 py-1 z-50">
@foreach($statusOptions as $value => $label)
<button wire:click="changeStatus('{{ $value }}')"
@click="open = false"
class="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2 {{ $task->status === $value ? 'font-medium text-blue-600 dark:text-blue-400' : '' }}">
@if($task->status === $value)
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>
@endif
{{ $label }}
</button>
@endforeach
</div>
</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Main Content -->
<div class="lg:col-span-2 space-y-6">
<!-- Description -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Descripción</h3>
@if($task->description)
<div class="prose dark:prose-invert max-w-none">{{ $task->description }}</div>
@else
<p class="text-gray-500 dark:text-gray-400 italic">Sin descripción</p>
@endif
</div>
<!-- Subtasks -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
<div class="p-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
Subtareas ({{ $task->subtasks->count() }})
</h3>
@can('create tasks', $task)
<div class="flex gap-2">
<input type="text" x-model="newSubtaskTitle" @keydown.enter="addSubtask" placeholder="Añadir subtarea..." class="px-3 py-1.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white text-sm w-64">
<button @click="addSubtask" class="px-3 py-1.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm transition-colors">Añadir</button>
</div>
@endcan
</div>
@if($task->subtasks->isEmpty())
<div class="p-8 text-center text-gray-500 dark:text-gray-400">
No hay subtareas. {{ auth()->user()->can('create tasks', $task) ? 'Crea la primera arriba.' : '' }}
</div>
@else
<div class="divide-y divide-gray-200 dark:divide-gray-700">
@foreach($task->subtasks as $subtask)
<div class="p-4 flex items-center gap-4 hover:bg-gray-50 dark:hover:bg-gray-700/50">
<div class="flex items-center gap-3">
<select wire:model="subtaskStatuses.{{ $subtask->id }}" wire:change="updateSubtaskStatus({{ $subtask->id }}, $event.target.value)" class="text-sm border border-gray-300 dark:border-gray-600 rounded-lg px-2 py-1 dark:bg-gray-700 dark:text-white" style="width: 140px;">
@foreach($statusOptions as $value => $label)
<option value="{{ $value }}" {{ $subtask->status === $value ? 'selected' : '' }}>{{ $label }}</option>
@endforeach
</select>
<span class="font-medium text-gray-900 dark:text-white">{{ $subtask->title }}</span>
</div>
<div class="flex-1"></div>
@can('delete tasks', $subtask)
<button wire:click="deleteSubtask({{ $subtask->id }})" wire:confirm="¿Eliminar subtarea \"{{ $subtask->title }}\"?" class="text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 p-1">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>
@endcan
</div>
@endforeach
</div>
@endif
</div>
<!-- Comments -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Comentarios</h3>
</div>
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
<textarea wire:model="commentBody" rows="2" placeholder="Escribe un comentario..." class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white"></textarea>
<div class="mt-2 flex justify-end">
<button wire:click="addComment" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors" :disabled="!commentBody.trim()">
Comentar
</button>
</div>
</div>
<div class="divide-y divide-gray-200 dark:divide-gray-700">
@forelse($task->comments->where('parent_id', null) as $comment)
<div class="p-4" x-data="{ editing: false }">
<div class="flex gap-3">
<div class="w-8 h-8 rounded-full bg-blue-100 dark:bg-blue-900 flex items-center justify-center text-sm font-medium text-blue-700 dark:text-blue-300 flex-shrink-0">
{{ strtoupper($comment->user->name[0]) }}
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<span class="font-medium text-gray-900 dark:text-white">{{ $comment->user->name }}</span>
<span class="text-sm text-gray-500 dark:text-gray-400">{{ $comment->created_at->diffForHumans() }}</span>
@if($comment->user_id === auth()->id() || auth()->user()->can('manage all tasks'))
<button @click="editing = !editing" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-sm">Editar</button>
<button wire:click="deleteComment({{ $comment->id }})" wire:confirm="¿Eliminar comentario?" class="text-gray-400 hover:text-red-600 dark:hover:text-red-400 text-sm">Eliminar</button>
@endif
</div>
@if($editing)
<textarea wire:model="editingCommentBody" x-model="editingCommentBody" rows="2" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white mb-2"></textarea>
<div class="flex gap-2">
<button wire:click="saveEditComment" class="px-3 py-1.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm transition-colors">Guardar</button>
<button wire:click="cancelEditComment" @click="editing = false" class="px-3 py-1.5 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 text-sm transition-colors">Cancelar</button>
</div>
@else
<div class="prose prose-sm dark:prose-invert max-w-none">{{ $comment->body }}</div>
@endif
<!-- Replies -->
@if($comment->replies->isNotEmpty())
<div class="ml-8 mt-3 border-l-2 border-gray-200 dark:border-gray-700 pl-4 space-y-3">
@foreach($comment->replies as $reply)
<div class="flex gap-3">
<div class="w-6 h-6 rounded-full bg-gray-100 dark:bg-gray-700 flex items-center justify-center text-xs font-medium text-gray-700 dark:text-gray-300 flex-shrink-0">
{{ strtoupper($reply->user->name[0]) }}
</div>
<div class="flex-1">
<div class="flex items-center gap-2 mb-1">
<span class="font-medium text-sm text-gray-900 dark:text-white">{{ $reply->user->name }}</span>
<span class="text-xs text-gray-500 dark:text-gray-400">{{ $reply->created_at->diffForHumans() }}</span>
</div>
<div class="prose prose-sm dark:prose-invert max-w-none">{{ $reply->body }}</div>
</div>
</div>
@endforeach
</div>
@endif
</div>
</div>
</div>
@empty
<div class="p-8 text-center text-gray-500 dark:text-gray-400">No hay comentarios aún</div>
@endforelse
</div>
</div>
</div>
<!-- Sidebar -->
<div class="lg:col-span-1 space-y-6">
<!-- Info Card -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Información</h3>
<dl class="space-y-4 text-sm">
<div>
<dt class="text-gray-500 dark:text-gray-400">Proyecto</dt>
<dd class="font-medium text-gray-900 dark:text-white">{{ $task->project->name }}</dd>
</div>
@if($task->phase)
<div>
<dt class="text-gray-500 dark:text-gray-400">Fase</dt>
<dd class="font-medium text-gray-900 dark:text-white">{{ $task->phase->name }}</dd>
</div>
@endif
@if($task->parent_task_id)
<div>
<dt class="text-gray-500 dark:text-gray-400">Tarea padre</dt>
<dd class="font-medium text-gray-900 dark:text-white">
<a href="{{ route('projects.tasks.show', [$task->project, $task->parent]) }}" class="hover:text-blue-600 dark:hover:text-blue-400">
{{ $task->parent->title }}
</a>
</dd>
</div>
@endif
<div>
<dt class="text-gray-500 dark:text-gray-400">Creada por</dt>
<dd class="font-medium text-gray-900 dark:text-white">{{ $task->creator->name }}</dd>
</div>
<div>
<dt class="text-gray-500 dark:text-gray-400">Fecha creación</dt>
<dd class="font-medium text-gray-900 dark:text-white">{{ $task->created_at->format('d/m/Y H:i') }}</dd>
</div>
@if($task->due_date)
<div>
<dt class="text-gray-500 dark:text-gray-400">Vence</dt>
<dd class="font-medium {{ $task->is_overdue ? 'text-red-600 dark:text-red-400' : ($task->is_due_today ? 'text-amber-600 dark:text-amber-400' : 'text-gray-900 dark:text-white') }}">
{{ $task->due_date->format('d/m/Y') }}
@if($task->is_overdue) <span class="text-xs ml-1">(Vencida)</span> @endif
@if($task->is_due_today) <span class="text-xs ml-1">(Hoy)</span> @endif
</dd>
</div>
@endif
@if($task->start_date)
<div>
<dt class="text-gray-500 dark:text-gray-400">Inicio</dt>
<dd class="font-medium text-gray-900 dark:text-white">{{ $task->start_date->format('d/m/Y') }}</dd>
</div>
@endif
@if($task->completed_at)
<div>
<dt class="text-gray-500 dark:text-gray-400">Completada</dt>
<dd class="font-medium text-gray-900 dark:text-white">{{ $task->completed_at->format('d/m/Y H:i') }}</dd>
</div>
<div>
<dt class="text-gray-500 dark:text-gray-400">Completada por</dt>
<dd class="font-medium text-gray-900 dark:text-white">{{ $task->completer->name ?? '—' }}</dd>
</div>
@endif
@if($task->estimated_hours || $task->actual_hours)
<div>
<dt class="text-gray-500 dark:text-gray-400">Horas</dt>
<dd class="font-medium text-gray-900 dark:text-white">
{{ $task->estimated_hours ? $task->estimated_hours . 'h estimadas' : '' }}
@if($task->estimated_hours && $task->actual_hours) / @endif
{{ $task->actual_hours ? $task->actual_hours . 'h reales' : '' }}
</dd>
</div>
@endif
</dl>
</div>
<!-- Assignee Card -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Asignación</h3>
@if($task->assignee)
<div class="flex items-center gap-3 mb-4">
<div class="w-12 h-12 rounded-full bg-blue-100 dark:bg-blue-900 flex items-center justify-center text-lg font-medium text-blue-700 dark:text-blue-300">
{{ strtoupper($task->assignee->name[0]) }}
</div>
<div>
<p class="font-medium text-gray-900 dark:text-white">{{ $task->assignee->name }}</p>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ $task->assignee->email }}</p>
</div>
</div>
@else
<p class="text-gray-500 dark:text-gray-400 mb-4">Sin asignar</p>
@endif
@can('assign tasks', $task)
<form wire:submit.prevent="updateAssignee">
<select wire:model="newAssigneeId" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white mb-2">
<option value="">Sin asignar</option>
@foreach($assignees as $assignee)
<option value="{{ $assignee->id }}" {{ $task->assigned_to === $assignee->id ? 'selected' : '' }}>{{ $assignee->name }}</option>
@endforeach
</select>
<button type="submit" class="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">Asignar</button>
</form>
@endcan
</div>
<!-- Progress Card -->
@if($task->subtasks->count() > 0)
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Progreso</h3>
<div class="mb-4">
<div class="flex justify-between text-sm mb-1">
<span class="text-gray-500 dark:text-gray-400">Completado</span>
<span class="font-medium text-gray-900 dark:text-white">{{ $task->progress }}%</span>
</div>
<div class="h-3 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div class="h-full bg-blue-500 rounded-full transition-all duration-300" style="width: {{ $task->progress }}%"></div>
</div>
</div>
<div class="text-sm text-gray-500 dark:text-gray-400">
{{ $task->subtasks->where('status', 'completed')->count() }} de {{ $task->subtasks->count() }} subtareas completadas
</div>
</div>
@endif
</div>
</div>
</div>
@push('scripts')
<script>
document.addEventListener('livewire:load', () => {
// Initialize subtask statuses
@js($task->subtasks->pluck('status', 'id')->toArray())
});
</script>
@endpush
@@ -0,0 +1,177 @@
<div x-show="showModal" x-cloak x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0 scale-95" x-transition:enter-end="opacity-100 scale-100" x-transition:leave="transition ease-in duration-150" x-transition:leave-start="opacity-100 scale-100" x-transition:leave-end="opacity-0 scale-95" class="fixed inset-0 z-50 overflow-y-auto" @keydown.escape.window="closeModal">
<div class="flex min-h-full items-center justify-center p-4">
<!-- Backdrop -->
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" @click="closeModal" aria-hidden="true"></div>
<!-- Modal -->
<div class="relative w-full max-w-2xl bg-white dark:bg-gray-800 rounded-xl shadow-xl">
<form wire:submit.prevent="save" class="p-6 space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ $mode === 'create' ? 'Nueva Tarea' : 'Editar Tarea' }}
</h3>
<button type="button" wire:click="closeModal" class="text-gray-400 hover:text-gray-500 dark:hover:text-gray-300">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
@if($errors->any())
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<div class="flex">
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
</svg>
<ul class="ml-3 text-sm text-red-700 dark:text-red-300">
@foreach($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
</div>
@endif
<!-- Title -->
<div>
<label for="title" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Título <span class="text-red-500">*</span></label>
<input type="text" id="title" wire:model="title"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white"
placeholder="Título de la tarea" required autofocus>
</div>
<!-- Description -->
<div>
<label for="description" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Descripción</label>
<textarea id="description" wire:model="description" rows="3"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white"
placeholder="Detalles de la tarea..."></textarea>
</div>
<!-- Row: Project/Phase + Parent Task -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="phase_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Fase</label>
<select id="phase_id" wire:model="phase_id"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white">
<option value="">Sin fase</option>
@foreach($phases as $id => $name)
<option value="{{ $id }}">{{ $name }}</option>
@endforeach
</select>
</div>
<div>
<label for="parent_task_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Tarea Padre (subtarea de)</label>
<select id="parent_task_id" wire:model="parent_task_id"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white">
<option value="">Ninguna (tarea principal)</option>
@foreach($parentTasks as $pt)
<option value="{{ $pt->id }}">{{ $pt->title }}</option>
@endforeach
</select>
</div>
</div>
<!-- Row: Status + Priority -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="status" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Estado <span class="text-red-500">*</span></label>
<select id="status" wire:model="status"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white" required>
@foreach($statusOptions as $value => $label)
<option value="{{ $value }}">{{ $label }}</option>
@endforeach
</select>
</div>
<div>
<label for="priority" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Prioridad <span class="text-red-500">*</span></label>
<select id="priority" wire:model="priority"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white" required>
@foreach($priorityOptions as $value => $label)
<option value="{{ $value }}">{{ $label }}</option>
@endforeach
</select>
</div>
</div>
<!-- Row: Assignee + Order -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="assigned_to" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Asignado a</label>
<select id="assigned_to" wire:model="assigned_to"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white">
<option value="">Sin asignar</option>
@foreach($assignees as $assignee)
<option value="{{ $assignee->id }}">{{ $assignee->name }}</option>
@endforeach
</select>
</div>
<div>
<label for="order" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Orden</label>
<input type="number" id="order" wire:model="order" min="0"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white">
</div>
</div>
<!-- Row: Dates -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="start_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Fecha inicio</label>
<input type="date" id="start_date" wire:model="start_date"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white">
</div>
<div>
<label for="due_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Fecha vencimiento</label>
<input type="date" id="due_date" wire:model="due_date"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white">
</div>
</div>
<!-- Row: Hours -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="estimated_hours" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Horas estimadas</label>
<input type="number" id="estimated_hours" wire:model="estimated_hours" min="0" max="10000"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white"
placeholder="0">
</div>
<div>
<label for="actual_hours" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Horas reales</label>
<input type="number" id="actual_hours" wire:model="actual_hours" min="0" max="10000"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white"
placeholder="0">
</div>
</div>
<!-- Actions -->
<div class="flex justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
<button type="button" wire:click="closeModal"
class="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">
Cancelar
</button>
<button type="submit"
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
{{ $mode === 'create' ? 'Crear' : 'Guardar' }}
</button>
</div>
</form>
</div>
</div>
</div>
@push('scripts')
<script>
// Close modal on Escape key
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
@this.call('closeModal');
}
});
</script>
@endpush
@@ -0,0 +1,243 @@
<div class="space-y-6">
<!-- Header -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
{{ $project ? 'Kanban: ' . $project->name : 'Kanban - Todas las tareas' }}
</h1>
<p class="text-gray-500 dark:text-gray-400 mt-1">
Arrastra y suelta para cambiar estado
</p>
</div>
<div class="flex flex-wrap gap-2">
@can('create tasks', $project ?? \App\Models\Project::first())
<a href="{{ $project ? route('projects.tasks.create', $project) : route('tasks.create') }}"
class="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
Nueva Tarea
</a>
@endcan
<a href="{{ $project ? route('projects.tasks', $project) : route('tasks.index') }}"
class="inline-flex items-center px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
</svg>
Vista Tabla
</a>
</div>
</div>
<!-- Filters -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4">
<div class="flex flex-col lg:flex-row gap-4">
<div class="flex-1 min-w-[250px]">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Buscar</label>
<input type="text" wire:model.debounce.300ms="search" placeholder="Título, descripción..."
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white">
</div>
<div class="min-w-[180px]">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Asignado</label>
<select wire:model="assigneeFilter" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white">
<option value="">Todos</option>
@foreach($assignees as $assignee)
<option value="{{ $assignee->id }}">{{ $assignee->name }}</option>
@endforeach
</select>
</div>
@if($project)
<div class="min-w-[180px]">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Fase</label>
<select wire:model="phaseFilter" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white">
<option value="">Todas</option>
@foreach($phases as $phase)
<option value="{{ $phase->id }}">{{ $phase->name }}</option>
@endforeach
</select>
</div>
@endif
<div class="min-w-[160px]">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Prioridad</label>
<select wire:model="priorityFilter" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white">
<option value="">Todas</option>
@foreach($priorityOptions as $value => $label)
<option value="{{ $value }}">{{ $label }}</option>
@endforeach
</select>
</div>
<div class="flex items-end">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" wire:model="showOverdueOnly" class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500">
<span class="text-sm text-gray-700 dark:text-gray-300">Solo vencidas</span>
</label>
</div>
<div class="flex items-end">
<button wire:click="resetFilters" class="px-4 py-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white text-sm font-medium">
Limpiar
</button>
</div>
</div>
</div>
<!-- Kanban Board -->
<div class="flex gap-4 overflow-x-auto pb-4" id="kanban-board" x-data="kanbanBoard()">
@foreach($columns as $status => $column)
<div class="flex-shrink-0 w-80 min-h-[500px]"
style="background-color: {{ $column['color'] }}15; border: 1px solid {{ $column['color'] }}40;"
x-ref="column-{{ $status }}">
<!-- Column Header -->
<div class="p-3 sticky top-0 z-10 flex items-center justify-between"
style="background-color: {{ $column['color'] }}; color: white; border-radius: 0.5rem 0.5rem 0 0;">
<div class="flex items-center gap-2">
<h3 class="font-semibold text-sm">{{ $column['label'] }}</h3>
<span class="px-2 py-0.5 text-xs font-medium bg-white/20 rounded-full">
{{ $column['count'] }}
</span>
</div>
</div>
<!-- Column Content / Drop Zone -->
<div class="p-3 space-y-3 min-h-[400px] kanban-column"
x-data="{ columnStatus: '{{ $status }}' }"
@dragover.prevent="onDragOver($event)"
@drop="onDrop($event)"
data-status="{{ $status }}"
style="min-height: 400px;">
@if($column['tasks']->isEmpty())
<div class="text-center py-8 text-gray-400 dark:text-gray-500 text-sm border-2 border-dashed border-gray-200 dark:border-gray-700 rounded-lg"
style="border-color: {{ $column['color'] }}80;">
<svg class="mx-auto h-8 w-8 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/>
</svg>
<p>Sin tareas</p>
</div>
@endif
@foreach($column['tasks'] as $task)
<div class="kanban-card bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-3 hover:shadow-md transition-shadow cursor-pointer"
draggable="true"
wire:click="$dispatch('open-task-detail', { taskId: {{ $task->id }}, projectId: {{ $task->project_id }} })"
@dragstart="onDragStart($event, {{ $task->id }}, '{{ $status }}')"
@dragend="onDragEnd($event)"
data-task-id="{{ $task->id }}"
data-status="{{ $status }}">
<!-- Priority indicator -->
<div class="w-full h-1 rounded-t-lg mb-2"
style="background-color: {{ $task->priority_color }};"></div>
<h4 class="font-medium text-gray-900 dark:text-white text-sm mb-1 line-clamp-2">
{{ $task->title }}
</h4>
@if($task->description)
<p class="text-xs text-gray-500 dark:text-gray-400 mb-2 line-clamp-2">{{ $task->description }}</p>
@endif
<div class="flex flex-wrap gap-2 mb-2">
@if($task->phase)
<span class="px-2 py-0.5 text-xs bg-gray-100 dark:bg-gray-700 rounded text-gray-700 dark:text-gray-300">
{{ $task->phase->name }}
</span>
@endif
@if($task->due_date)
<span class="px-2 py-0.5 text-xs rounded
{{ $task->is_overdue ? 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300' : ($task->is_due_today ? 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300' : 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300') }}">
{{ $task->due_date->format('d/m') }}
@if($task->is_overdue)
<svg class="inline w-3 h-3 ml-0.5" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-6a.75.75 0 01.75.75v4.5a.75.75 0 01-1.5 0v-4.5A.75.75 0 0110 4zm0 10a.75.75 0 01.75-.75.75.75 0 01.75.75v.75a.75.75 0 01-1.5 0v-.75z" clip-rule="evenodd"/></svg>
@elseif($task->is_due_today)
<svg class="inline w-3 h-3 ml-0.5" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd"/></svg>
@endif
</span>
@endif
</div>
<div class="flex items-center justify-between pt-2 border-t border-gray-100 dark:border-gray-700">
@if($task->assignee)
<div class="flex items-center gap-1.5">
<div class="w-6 h-6 rounded-full bg-blue-100 dark:bg-blue-900 flex items-center justify-center text-xs font-medium text-blue-700 dark:text-blue-300">
{{ strtoupper($task->assignee->name[0]) }}
</div>
<span class="text-xs text-gray-600 dark:text-gray-400">{{ $task->assignee->name }}</span>
</div>
@else
<span class="text-xs text-gray-400 dark:text-gray-500">Sin asignar</span>
@endif
@if($task->subtasks->count() > 0)
<span class="text-xs text-gray-500 dark:text-gray-400">
{{ $task->subtasks->where('status', 'completed')->count() }}/{{ $task->subtasks->count() }}
</span>
@endif
</div>
</div>
@endforeach
</div>
<!-- Add Task Button -->
@can('create tasks', $project ?? \App\Models\Project::first())
<div class="p-3">
<button wire:click="$dispatch('open-task-modal', { mode: 'create', projectId: {{ $project->id ?? 'null'}}, status: '{{ $status }}' })"
class="w-full py-2 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg transition-colors">
<svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
Añadir tarea
</button>
</div>
@endcan
</div>
@endforeach
</div>
</div>
@push('scripts')
<script>
// Simple Alpine-based Kanban drag & drop
function kanbanBoard() {
return {
draggedTask: null,
draggedFromStatus: null,
onDragStart(event, taskId, status) {
this.draggedTask = taskId;
this.draggedFromStatus = status;
event.target.classList.add('opacity-50', 'rotate-1');
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', taskId);
},
onDragEnd(event) {
event.target.classList.remove('opacity-50', 'rotate-1');
this.draggedTask = null;
this.draggedFromStatus = null;
},
onDragOver(event) {
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
event.currentTarget.classList.add('bg-blue-50', 'dark:bg-blue-900/20');
},
onDrop(event) {
event.preventDefault();
event.currentTarget.classList.remove('bg-blue-50', 'dark:bg-blue-900/20');
const taskId = event.dataTransfer.getData('text/plain');
const targetStatus = event.currentTarget.dataset.status;
if (taskId && this.draggedFromStatus !== targetStatus) {
// Call Livewire to update status
@this.call('updateTaskStatus', taskId, targetStatus);
}
},
};
}
</script>
@endpush
@@ -0,0 +1,258 @@
<div class="space-y-6">
<!-- Header -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
{{ $project ? 'Tareas del proyecto: ' . $project->name : 'Tareas' }}
</h1>
<p class="text-gray-500 dark:text-gray-400 mt-1">
{{ $project ? 'Gestiona las tareas de este proyecto' : 'Todas las tareas accesibles' }}
</p>
</div>
<div class="flex flex-wrap gap-2">
@can('create tasks', $project ?? \App\Models\Project::first())
<a href="{{ $project ? route('projects.tasks.create', $project) : route('tasks.create') }}"
class="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
Nueva Tarea
</a>
@endcan
<button wire:click="toggleViewMode"
class="inline-flex items-center px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"
:class="{'hidden': viewMode === 'table', 'inline': viewMode === 'kanban'}">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/>
</svg>
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"
:class="{'inline': viewMode === 'table', 'hidden': viewMode === 'kanban'}">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
</svg>
{{ $viewMode === 'table' ? 'Vista Kanban' : 'Vista Tabla' }}
</button>
</div>
</div>
<!-- Filters -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4">
<div class="flex flex-col lg:flex-row gap-4">
<div class="flex-1 min-w-[200px]">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Buscar</label>
<input type="text" wire:model.debounce.300ms="search" placeholder="Título, descripción..."
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white">
</div>
<div class="min-w-[160px]">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Estado</label>
<select wire:model="statusFilter" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white">
<option value="">Todos</option>
@foreach($statusOptions as $value => $label)
<option value="{{ $value }}">{{ $label }}</option>
@endforeach
</select>
</div>
<div class="min-w-[160px]">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Prioridad</label>
<select wire:model="priorityFilter" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white">
<option value="">Todas</option>
@foreach($priorityOptions as $value => $label)
<option value="{{ $value }}">{{ $label }}</option>
@endforeach
</select>
</div>
<div class="min-w-[180px]">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Asignado</label>
<select wire:model="assigneeFilter" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white">
<option value="">Todos</option>
@foreach($assignees as $assignee)
<option value="{{ $assignee->id }}">{{ $assignee->name }}</option>
@endforeach
</select>
</div>
@if($project)
<div class="min-w-[180px]">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Fase</label>
<select wire:model="phaseFilter" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white">
<option value="">Todas</option>
@foreach($phases as $phase)
<option value="{{ $phase->id }}">{{ $phase->name }}</option>
@endforeach
</select>
</div>
@endif
<div class="flex items-end">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" wire:model="showOverdueOnly" class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500">
<span class="text-sm text-gray-700 dark:text-gray-300">Solo vencidas</span>
</label>
</div>
<div class="flex items-end">
<button wire:click="resetFilters" class="px-4 py-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white text-sm font-medium">
Limpiar
</button>
</div>
</div>
</div>
<!-- Table View -->
<div x-show="viewMode === 'table'" x-cloak class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
@if($tasks->isEmpty())
<div class="p-12 text-center">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/>
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-white">No hay tareas</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ $project ? 'Comienza creando la primera tarea para este proyecto.' : 'No se encontraron tareas con los filtros actuales.' }}
</p>
</div>
@else
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-700/50">
<tr>
@foreach([
'title' => 'Tarea',
'project.name' => 'Proyecto',
'phase.name' => 'Fase',
'status' => 'Estado',
'priority' => 'Prioridad',
'assignee.name' => 'Asignado',
'due_date' => 'Vence',
'actions' => '',
] as $key => $label)
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700/50"
@if(in_array($key, ['title', 'status', 'priority', 'due_date']))
wire:click="sortBy('{{ $key }}')"
@endif>
<div class="flex items-center gap-1">
{{ $label }}
@if($sortField === $key)
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="{{ $sortDirection === 'asc' ? 'M5 15l7-7 7 7' : 'M19 9l-7 7-7-7' }}"/>
</svg>
@else
<svg class="w-4 h-4 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16l4-4m0 0l4 4m-4-4v12"/>
</svg>
@endif
</div>
</th>
@endforeach
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
@foreach($tasks as $task)
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
<td class="px-4 py-4">
<div>
<a href="{{ route('projects.tasks.show', [$task->project, $task]) }}"
class="font-medium text-gray-900 dark:text-white hover:text-blue-600 dark:hover:text-blue-400">
{{ $task->title }}
</a>
@if($task->description)
<p class="text-sm text-gray-500 dark:text-gray-400 truncate max-w-xs mt-1">{{ $task->description }}</p>
@endif
@if($task->subtasks->count() > 0)
<div class="mt-2 flex items-center gap-2">
<span class="text-xs text-gray-500 dark:text-gray-400">
{{ $task->subtasks->where('status', 'completed')->count() }} / {{ $task->subtasks->count() }} subtareas
</span>
<div class="w-24 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div class="h-full bg-blue-500" style="width: {{ $task->progress }}%"></div>
</div>
</div>
@endif
</div>
</td>
<td class="px-4 py-4 text-sm text-gray-700 dark:text-gray-300">
{{ $task->project->name ?? '—' }}
</td>
<td class="px-4 py-4 text-sm text-gray-700 dark:text-gray-300">
{{ $task->phase->name ?? '—' }}
</td>
<td class="px-4 py-4">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
style="background-color: {{ $task->status_color }}20; color: {{ $task->status_color }}">
{{ $task->status_label }}
</span>
</td>
<td class="px-4 py-4">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
style="background-color: {{ $task->priority_color }}20; color: {{ $task->priority_color }}">
{{ $task->priority_label }}
</span>
</td>
<td class="px-4 py-4 text-sm text-gray-700 dark:text-gray-300">
@if($task->assignee)
<div class="flex items-center gap-2">
<div class="w-6 h-6 rounded-full bg-blue-100 dark:bg-blue-900 flex items-center justify-center text-xs font-medium text-blue-700 dark:text-blue-300">
{{ strtoupper($task->assignee->name[0]) }}
</div>
{{ $task->assignee->name }}
</div>
@else
<span class="text-gray-400 dark:text-gray-500"></span>
@endif
</td>
<td class="px-4 py-4 text-sm">
@if($task->due_date)
<span class="{{ $task->is_overdue ? 'text-red-600 dark:text-red-400 font-medium' : ($task->is_due_today ? 'text-amber-600 dark:text-amber-400 font-medium' : 'text-gray-700 dark:text-gray-300') }}">
{{ $task->due_date->format('d/m/Y') }}
@if($task->is_overdue)
<svg class="inline w-3.5 h-3.5 ml-1" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-6a.75.75 0 01.75.75v4.5a.75.75 0 01-1.5 0v-4.5A.75.75 0 0110 4zm0 10a.75.75 0 01.75-.75.75.75 0 01.75.75v.75a.75.75 0 01-1.5 0v-.75z" clip-rule="evenodd"/></svg>
@elseif($task->is_due_today)
<svg class="inline w-3.5 h-3.5 ml-1" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd"/></svg>
@endif
</span>
@else
<span class="text-gray-400 dark:text-gray-500"></span>
@endif
</td>
<td class="px-4 py-4">
<div class="flex items-center gap-2">
@can('edit tasks', $task)
<a href="{{ route('projects.tasks.edit', [$task->project, $task]) }}"
class="text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300 text-sm font-medium">
Editar
</a>
@endcan
@can('delete tasks', $task)
<button wire:click="deleteTask({{ $task->id }})"
wire:confirm="¿Eliminar la tarea \"{{ $task->title }}\"?"
class="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300 text-sm font-medium">
Eliminar
</button>
@endcan
</div>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="px-4 py-3 border-t border-gray-200 dark:border-gray-700">
{{ $tasks->links() }}
</div>
@endif
</div>
<!-- Kanban View (placeholder) -->
<div x-show="viewMode === 'kanban'" x-cloak class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4">
<div class="text-center py-12">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2"/>
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-white">Vista Kanban</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">En desarrollo - Próximamente</p>
</div>
</div>
</div>
+4
View File
@@ -1,5 +1,6 @@
<?php
use App\Jobs\NotifyOverdueTasks;
use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schedule;
@@ -10,3 +11,6 @@ Artisan::command('inspire', function () {
// Avisar a los asignados de tareas de incidencia vencidas (una vez por tarea).
Schedule::command('issues:notify-overdue')->dailyAt('07:00');
// Avisar de tareas vencidas (tareas independientes)
Schedule::job(new NotifyOverdueTasks)->dailyAt('07:00');
+101 -52
View File
@@ -1,14 +1,43 @@
<?php
use App\Http\Controllers\ProfileController;
use App\Livewire\Reports\ReportsDashboard;
use App\Http\Controllers\ProjectController;
use App\Http\Controllers\OfflineSyncController;
use App\Livewire\Projects\ProjectMap;
use App\Livewire\Projects\ProjectList;
use App\Livewire\Phases\PhaseProgress;
use App\Livewire\Phases\PhaseGantt;
use App\Http\Controllers\ProfileController;
use App\Http\Controllers\ProjectController;
use App\Http\Controllers\ProjectReportController;
use App\Http\Controllers\Reports\ExportController;
use App\Livewire\Admin\RoleForm;
use App\Livewire\Admin\RolePermissionManager;
use App\Livewire\Admin\RoleView;
use App\Livewire\Companies\CompanyForm;
use App\Livewire\Companies\CompanyManagement;
use App\Livewire\Companies\CompanyView;
use App\Livewire\Inspections\GlobalTemplateManager;
use App\Livewire\Issues\IssueChecklistManager;
use App\Livewire\Issues\IssueDetail;
use App\Livewire\Issues\IssueForm;
use App\Livewire\Issues\IssueManager;
use App\Livewire\Layers\LayerManager;
use App\Livewire\Phases\PhaseGantt;
use App\Livewire\Phases\PhaseProgress;
use App\Livewire\Projects\FeatureManager;
use App\Livewire\Projects\FeatureTypeManager;
use App\Livewire\Projects\ProjectDashboard;
use App\Livewire\Projects\ProjectForm;
use App\Livewire\Projects\ProjectList;
use App\Livewire\Projects\ProjectTemplatesPicker;
use App\Livewire\Reports\ReportsDashboard;
use App\Livewire\Tasks\TaskCalendar;
use App\Livewire\Tasks\TaskDetail;
use App\Livewire\Tasks\TaskForm;
use App\Livewire\Tasks\TaskKanban;
use App\Livewire\Tasks\TaskManager;
use App\Livewire\Users\UserForm;
use App\Livewire\Users\UserView;
use App\Models\Issue;
use App\Models\IssueTask;
use App\Models\Project;
use App\Models\Task;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Route;
/*
@@ -27,84 +56,100 @@ Route::middleware(['auth'])->group(function () {
// Home: vista ligera (proyectos, tareas e incidencias del usuario + notificaciones)
Route::get('/dashboard', function () {
$user = \Illuminate\Support\Facades\Auth::user();
$user = Auth::user();
$projects = \App\Models\Project::accessibleBy($user)
$projects = Project::accessibleBy($user)
->with(['phases' => fn ($q) => $q->select('id', 'project_id', 'progress_percent')])
->orderBy('name')->take(8)->get();
$projectsCount = \App\Models\Project::accessibleBy($user)->count();
$projectsCount = Project::accessibleBy($user)->count();
$myTasks = \App\Models\IssueTask::where('assigned_to', $user->id)
$myTasks = IssueTask::where('assigned_to', $user->id)
->where('is_done', false)
->with('issue.project')
->orderByRaw('due_date IS NULL, due_date ASC')
->take(8)->get();
$myTasksCount = \App\Models\IssueTask::where('assigned_to', $user->id)->where('is_done', false)->count();
$myIssues = \App\Models\Issue::where('assigned_to', $user->id)
$myTasksFromTasks = Task::where('assigned_to', $user->id)
->where('status', '!=', 'completed')
->with('project')
->orderByRaw('due_date IS NULL, due_date ASC')
->take(8)->get();
$myTasksCount = IssueTask::where('assigned_to', $user->id)->where('is_done', false)->count()
+ Task::where('assigned_to', $user->id)->where('status', '!=', 'completed')->count();
$myIssues = Issue::where('assigned_to', $user->id)
->whereIn('status', ['open', 'in_review'])
->with('project')
->latest()->take(6)->get();
$myIssuesCount = \App\Models\Issue::where('assigned_to', $user->id)->whereIn('status', ['open', 'in_review'])->count();
$myIssuesCount = Issue::where('assigned_to', $user->id)->whereIn('status', ['open', 'in_review'])->count();
$notifications = $user->notifications()->latest()->take(6)->get();
$unreadCount = $user->unreadNotifications()->count();
$unreadCount = $user->unreadNotifications()->count();
return view('dashboard', compact(
'user', 'projects', 'projectsCount', 'myTasks', 'myTasksCount',
'user', 'projects', 'projectsCount', 'myTasks', 'myTasksFromTasks', 'myTasksCount',
'myIssues', 'myIssuesCount', 'notifications', 'unreadCount'
));
})->name('dashboard');
Route::get('/reports/dashboard', ReportsDashboard::class)->name('reports.dashboard');
Route::get('/reports/dashboard', ReportsDashboard::class)->name('reports.dashboard');
Route::prefix('reports')->name('reports.')->group(function () {
Route::get('export/projects', [App\Http\Controllers\Reports\ExportController::class, 'exportProjects'])->name('export.projects');
Route::get('export/phases', [App\Http\Controllers\Reports\ExportController::class, 'exportPhases'])->name('export.phases');
Route::get('export/inspections', [App\Http\Controllers\Reports\ExportController::class, 'exportInspections'])->name('export.inspections');
Route::get('export/projects', [ExportController::class, 'exportProjects'])->name('export.projects');
Route::get('export/phases', [ExportController::class, 'exportPhases'])->name('export.phases');
Route::get('export/inspections', [ExportController::class, 'exportInspections'])->name('export.inspections');
});
// ------------------------------------------------------------
// Gestión de proyectos
// ------------------------------------------------------------
// Create/Edit handled by unified Livewire component
Route::get('/projects/create', \App\Livewire\Projects\ProjectForm::class)->name('projects.create');
Route::get('/projects/{project}/edit', \App\Livewire\Projects\ProjectForm::class)->name('projects.edit');
Route::get('/projects/create', ProjectForm::class)->name('projects.create');
Route::get('/projects/{project}/edit', ProjectForm::class)->name('projects.edit');
Route::resource('projects', ProjectController::class)->except(['create', 'edit']);
// Ruta personalizada para ver el mapa de un proyecto específico
Route::get('/projects/{project}/map', [ProjectController::class, 'map'])->name('projects.map');
// Ruta para que el componente Livewire muestre/gestione el progreso de una fase
Route::get('/phases/{phase}/progress', PhaseProgress::class)->name('phases.progress');
Route::get('/projects-list', ProjectList::class)->name('projects.list');
// Ruta para templates
// Plantillas globales (catálogo único)
Route::get('/inspection-templates', \App\Livewire\Inspections\GlobalTemplateManager::class)
Route::get('/inspection-templates', GlobalTemplateManager::class)
->middleware('can:manage templates')->name('inspection-templates');
// Plantillas asignadas a un proyecto (picker sobre el catálogo global)
Route::get('/projects/{project}/templates', \App\Livewire\Projects\ProjectTemplatesPicker::class)
Route::get('/projects/{project}/templates', ProjectTemplatesPicker::class)
->middleware('can:edit projects')->name('projects.templates');
// Rutas para el LayerManager:
Route::get('/projects/{project}/phases/{phase}/layers/manage', \App\Livewire\Layers\LayerManager::class)->name('layers.manage');
Route::get('/projects/{project}/phases/{phase}/layers/manage', LayerManager::class)->name('layers.manage');
// Elementos del proyecto y catálogo de tipos de elemento
Route::get('/projects/{project}/features', \App\Livewire\Projects\FeatureManager::class)->middleware('can:edit layers')->name('projects.features');
Route::get('/feature-types', \App\Livewire\Projects\FeatureTypeManager::class)->middleware('can:edit layers')->name('feature-types');
Route::get('/projects/{project}/features', FeatureManager::class)->middleware('can:edit layers')->name('projects.features');
Route::get('/feature-types', FeatureTypeManager::class)->middleware('can:edit layers')->name('feature-types');
// Cronograma Gantt y reporte del proyecto
Route::get('/projects/{project}/gantt', PhaseGantt::class)->name('projects.gantt');
Route::get('/projects/{project}/report', [ProjectReportController::class, 'show'])->name('projects.report');
// Issues del proyecto
Route::get('/projects/{project}/issues', \App\Livewire\Issues\IssueManager::class)->name('projects.issues');
Route::get('/projects/{project}/issues/create', \App\Livewire\Issues\IssueForm::class)->name('projects.issues.create');
Route::get('/projects/{project}/issues/checklists', \App\Livewire\Issues\IssueChecklistManager::class)->name('projects.issues.checklists');
Route::get('/projects/{project}/issues/{issue}', \App\Livewire\Issues\IssueDetail::class)->name('projects.issues.show');
Route::get('/projects/{project}/issues/{issue}/edit', \App\Livewire\Issues\IssueForm::class)->name('projects.issues.edit');
Route::get('/projects/{project}/issues', IssueManager::class)->name('projects.issues');
Route::get('/projects/{project}/issues/create', IssueForm::class)->name('projects.issues.create');
Route::get('/projects/{project}/issues/checklists', IssueChecklistManager::class)->name('projects.issues.checklists');
Route::get('/projects/{project}/issues/{issue}', IssueDetail::class)->name('projects.issues.show');
Route::get('/projects/{project}/issues/{issue}/edit', IssueForm::class)->name('projects.issues.edit');
// Tasks del proyecto
Route::get('/projects/{project}/tasks', TaskManager::class)->name('projects.tasks');
Route::get('/projects/{project}/tasks/create', TaskForm::class)->name('projects.tasks.create');
Route::get('/projects/{project}/tasks/{task}', TaskDetail::class)->name('projects.tasks.show');
Route::get('/projects/{project}/tasks/{task}/edit', TaskForm::class)->name('projects.tasks.edit');
Route::get('/projects/{project}/tasks/kanban', TaskKanban::class)->name('projects.tasks.kanban');
Route::get('/projects/{project}/tasks/calendar', TaskCalendar::class)->name('projects.tasks.calendar');
// Dashboard por proyecto
Route::get('/projects/{project}/dashboard', \App\Livewire\Projects\ProjectDashboard::class)->name('projects.dashboard');
Route::get('/projects/{project}/dashboard', ProjectDashboard::class)->name('projects.dashboard');
// Cliente: portal cliente
Route::middleware(['auth', 'role:client'])->prefix('client')->name('client.')->group(function () {
@@ -115,25 +160,29 @@ Route::get('/reports/dashboard', ReportsDashboard::class)->name('reports.dashboa
// Admin: gestión de usuarios y roles (cada ruta protegida por su permiso)
Route::prefix('admin')->name('admin.')->group(function () {
Route::get('/users', function () { return view('admin.users'); })->middleware('can:view users')->name('users');
Route::get('/users/create', \App\Livewire\Users\UserForm::class)->middleware('can:create users')->name('users.create');
Route::get('/users/{user}', \App\Livewire\Users\UserView::class)->middleware('can:view users')->name('users.show');
Route::get('/users/{user}/edit', \App\Livewire\Users\UserForm::class)->middleware('can:edit users')->name('users.edit');
Route::get('/roles', function () { return view('admin.roles'); })->middleware('can:manage roles')->name('roles');
Route::get('/roles/create', \App\Livewire\Admin\RoleForm::class)->middleware('can:manage roles')->name('roles.create');
Route::get('/roles/{role}/edit', \App\Livewire\Admin\RoleForm::class)->middleware('can:manage roles')->name('roles.edit');
Route::get('/roles/{role}', \App\Livewire\Admin\RoleView::class)->middleware('can:manage roles')->name('roles.show');
Route::get('/permissions', \App\Livewire\Admin\RolePermissionManager::class)->middleware('can:manage roles')->name('permissions');
Route::get('/users', function () {
return view('admin.users');
})->middleware('can:view users')->name('users');
Route::get('/users/create', UserForm::class)->middleware('can:create users')->name('users.create');
Route::get('/users/{user}', UserView::class)->middleware('can:view users')->name('users.show');
Route::get('/users/{user}/edit', UserForm::class)->middleware('can:edit users')->name('users.edit');
Route::get('/roles', function () {
return view('admin.roles');
})->middleware('can:manage roles')->name('roles');
Route::get('/roles/create', RoleForm::class)->middleware('can:manage roles')->name('roles.create');
Route::get('/roles/{role}/edit', RoleForm::class)->middleware('can:manage roles')->name('roles.edit');
Route::get('/roles/{role}', RoleView::class)->middleware('can:manage roles')->name('roles.show');
Route::get('/permissions', RolePermissionManager::class)->middleware('can:manage roles')->name('permissions');
});
// Gestor de medios
Route::get('/projects/{project}/media', function (\App\Models\Project $project) {
Route::get('/projects/{project}/media', function (Project $project) {
return view('projects.media', compact('project'));
})->name('projects.media');
Route::get('/companies', \App\Livewire\Companies\CompanyManagement::class)->name('companies.manage');
Route::get('/companies/create', \App\Livewire\Companies\CompanyForm::class)->name('companies.create');
Route::get('/companies/{company}', \App\Livewire\Companies\CompanyView::class)->name('companies.show');
Route::get('/companies/{company}/edit', \App\Livewire\Companies\CompanyForm::class)->name('companies.edit');
Route::get('/companies', CompanyManagement::class)->name('companies.manage');
Route::get('/companies/create', CompanyForm::class)->name('companies.create');
Route::get('/companies/{company}', CompanyView::class)->name('companies.show');
Route::get('/companies/{company}/edit', CompanyForm::class)->name('companies.edit');
// ------------------------------------------------------------
// Sincronización offline (para trabajadores en campo)
@@ -145,7 +194,7 @@ Route::get('/reports/dashboard', ReportsDashboard::class)->name('reports.dashboa
// Perfil de usuario (proporcionado por Laravel Breeze)
// ------------------------------------------------------------
Route::get('/profile', [ProfileController::class, 'edit'])->name('profile');
//Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
// Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');
Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
// Logout se gestiona vía la acción Volt en el componente de navegación
@@ -153,4 +202,4 @@ Route::get('/reports/dashboard', ReportsDashboard::class)->name('reports.dashboa
});
// Incluir rutas de autenticación (login, registro, recuperación de contraseña, logout)
require __DIR__ . '/auth.php';
require __DIR__.'/auth.php';
+278 -5
View File
@@ -7,6 +7,7 @@ use App\Models\Feature;
use App\Models\Inspection;
use App\Models\InspectionTemplate;
use App\Models\Layer;
use App\Models\Media;
use App\Models\Phase;
use App\Models\Project;
use App\Models\User;
@@ -20,14 +21,16 @@ class InspectionFormTest extends TestCase
{
use RefreshDatabase;
public function test_register_inspection_with_comments_and_photo(): void
protected function setUp(): void
{
parent::setUp();
Storage::fake('public');
// Seed roles and permissions
$this->seed(\Database\Seeders\RolesAndPermissionsSeeder::class);
$this->seed(\Database\Seeders\PermissionCatalogSeeder::class);
}
private function createTestData(): array
{
$user = User::factory()->create();
$user->assignRole('Supervisor');
@@ -50,10 +53,15 @@ class InspectionFormTest extends TestCase
['group' => 'Geometría', 'name' => 'altura', 'label' => 'Altura', 'question' => '¿Cota OK?', 'type' => 'text', 'required' => false, 'help' => 'Medir'],
],
]);
// Attach template to project (pivot required for loadTemplates)
$project->inspectionTemplates()->attach($template->id);
return [$user, $project, $feature, $template];
}
public function test_register_inspection_with_comments_and_photo(): void
{
[$user, $project, $feature, $template] = $this->createTestData();
Livewire::actingAs($user)
->test(ProjectMap::class, ['project' => $project])
->call('selectFeature', $feature->id)
@@ -69,4 +77,269 @@ class InspectionFormTest extends TestCase
$this->assertEquals('5.2', $ins->data['altura']);
$this->assertCount(1, $ins->media);
}
public function test_edit_inspection_updates_data_and_photos(): void
{
[$user, $project, $feature, $template] = $this->createTestData();
// Create initial inspection
$inspection = Inspection::create([
'project_id' => $project->id,
'layer_id' => $feature->layer_id,
'feature_id' => $feature->id,
'template_id' => $template->id,
'user_id' => $user->id,
'inspector_user_id' => $user->id,
'status' => 'completed',
'completed_at' => now(),
'result' => 'pass',
'notes' => 'Original note',
'data' => ['altura' => '5.0'],
]);
$photo1 = Media::create([
'mediable_type' => Inspection::class,
'mediable_id' => $inspection->id,
'name' => 'original.jpg',
'file_path' => 'uploads/inspections/' . $inspection->id . '/original.jpg',
'file_type' => 'image/jpeg',
'file_extension' => 'jpg',
'file_size' => 1000,
'category' => 'image',
'uploaded_by' => $user->id,
]);
Livewire::actingAs($user)
->test(ProjectMap::class, ['project' => $project])
->call('selectFeature', $feature->id)
->call('editInspection', $inspection->id)
->assertSet('editingInspection.id', $inspection->id)
->assertSet('editInspectionFormData.altura', '5.0')
->assertSet('editInspectionResult', 'pass')
->assertSet('editInspectionNotes', 'Original note')
->set('editInspectionFormData.altura', '5.5')
->set('editInspectionResult', 'fail')
->set('editInspectionNotes', 'Updated note')
->set('editInspectionPhotos', [UploadedFile::fake()->image('new.jpg')])
->call('saveEditInspection');
$ins = $inspection->fresh();
$this->assertEquals('5.5', $ins->data['altura']);
$this->assertEquals('fail', $ins->result);
$this->assertEquals('Updated note', $ins->notes);
$this->assertCount(2, $ins->media); // original + new
}
public function test_edit_inspection_delete_existing_photo(): void
{
[$user, $project, $feature, $template] = $this->createTestData();
$inspection = Inspection::create([
'project_id' => $project->id,
'layer_id' => $feature->layer_id,
'feature_id' => $feature->id,
'template_id' => $template->id,
'user_id' => $user->id,
'inspector_user_id' => $user->id,
'status' => 'completed',
'completed_at' => now(),
'result' => 'pass',
'notes' => 'Note',
'data' => ['altura' => '5.0'],
]);
$photo1 = Media::create([
'mediable_type' => Inspection::class,
'mediable_id' => $inspection->id,
'name' => 'to_delete.jpg',
'file_path' => 'uploads/inspections/' . $inspection->id . '/to_delete.jpg',
'file_type' => 'image/jpeg',
'file_extension' => 'jpg',
'file_size' => 1000,
'category' => 'image',
'uploaded_by' => $user->id,
]);
$photo2 = Media::create([
'mediable_type' => Inspection::class,
'mediable_id' => $inspection->id,
'name' => 'keep.jpg',
'file_path' => 'uploads/inspections/' . $inspection->id . '/keep.jpg',
'file_type' => 'image/jpeg',
'file_extension' => 'jpg',
'file_size' => 1000,
'category' => 'image',
'uploaded_by' => $user->id,
]);
Livewire::actingAs($user)
->test(ProjectMap::class, ['project' => $project])
->call('selectFeature', $feature->id)
->call('editInspection', $inspection->id)
->call('deleteEditPhoto', 0) // delete first photo (to_delete.jpg)
->call('saveEditInspection');
$this->assertCount(1, $inspection->fresh()->media);
$this->assertEquals('keep.jpg', $inspection->fresh()->media->first()->name);
$this->assertDatabaseMissing('media', ['id' => $photo1->id]);
}
public function test_edit_inspection_forbidden_without_permission(): void
{
[$user, $project, $feature, $template] = $this->createTestData();
$user->syncRoles([]);
$user->givePermissionTo(['view projects', 'view inspections']); // only view
$inspection = Inspection::create([
'project_id' => $project->id,
'layer_id' => $feature->layer_id,
'feature_id' => $feature->id,
'template_id' => $template->id,
'user_id' => $user->id,
'inspector_user_id' => $user->id,
'status' => 'completed',
'completed_at' => now(),
'result' => 'pass',
'notes' => 'Note',
'data' => ['altura' => '5.0'],
]);
// Modal opens but save is blocked
Livewire::actingAs($user)
->test(ProjectMap::class, ['project' => $project])
->call('selectFeature', $feature->id)
->call('editInspection', $inspection->id)
->set('editInspectionNotes', 'Intento editar')
->call('saveEditInspection')
->assertDispatched('notify', 'Sin permisos para editar inspecciones.');
// Data should not have changed
$this->assertEquals('Note', $inspection->fresh()->notes);
}
public function test_create_inspection_forbidden_without_permission(): void
{
[$user, $project, $feature, $template] = $this->createTestData();
$user->syncRoles([]);
$user->givePermissionTo(['view projects', 'view inspections']);
Livewire::actingAs($user)
->test(ProjectMap::class, ['project' => $project])
->call('selectFeature', $feature->id)
->set('selectedTemplateId', $template->id)
->set('inspectionFormData.altura', '5.2')
->set('inspectionPhotos', [UploadedFile::fake()->image('foto.jpg')])
->call('saveInspection')
->assertDispatched('notify', 'Sin permisos para crear inspecciones.');
$this->assertDatabaseMissing('inspections', ['feature_id' => $feature->id]);
}
public function test_delete_inspection_removes_inspection_and_media(): void
{
[$user, $project, $feature, $template] = $this->createTestData();
$inspection = Inspection::create([
'project_id' => $project->id,
'layer_id' => $feature->layer_id,
'feature_id' => $feature->id,
'template_id' => $template->id,
'user_id' => $user->id,
'inspector_user_id' => $user->id,
'status' => 'completed',
'completed_at' => now(),
'result' => 'pass',
'notes' => 'Note',
'data' => ['altura' => '5.0'],
]);
$photo = Media::create([
'mediable_type' => Inspection::class,
'mediable_id' => $inspection->id,
'name' => 'test.jpg',
'file_path' => 'uploads/inspections/' . $inspection->id . '/test.jpg',
'file_type' => 'image/jpeg',
'file_extension' => 'jpg',
'file_size' => 1000,
'category' => 'image',
'uploaded_by' => $user->id,
]);
// Test direct model delete first
$inspection->delete();
$this->assertSoftDeleted('inspections', ['id' => $inspection->id]);
$this->assertDatabaseMissing('media', ['id' => $photo->id]);
// Restore for the actual test
$inspection->restore();
$this->assertDatabaseHas('inspections', ['id' => $inspection->id, 'deleted_at' => null]);
// Now test through Livewire component
$inspection2 = Inspection::create([
'project_id' => $project->id,
'layer_id' => $feature->layer_id,
'feature_id' => $feature->id,
'template_id' => $template->id,
'user_id' => $user->id,
'inspector_user_id' => $user->id,
'status' => 'completed',
'completed_at' => now(),
'result' => 'pass',
'notes' => 'Note',
'data' => ['altura' => '5.0'],
]);
$photo2 = Media::create([
'mediable_type' => Inspection::class,
'mediable_id' => $inspection2->id,
'name' => 'test2.jpg',
'file_path' => 'uploads/inspections/' . $inspection2->id . '/test2.jpg',
'file_type' => 'image/jpeg',
'file_extension' => 'jpg',
'file_size' => 1000,
'category' => 'image',
'uploaded_by' => $user->id,
]);
$component = Livewire::actingAs($user)
->test(ProjectMap::class, ['project' => $project])
->call('selectFeature', $feature->id);
// Debug: check if component is instantiated
$component->assertSet('project.id', $project->id);
$component->assertSet('selectedFeature.id', $feature->id);
// Call deleteInspection and capture events
$component->call('deleteInspection', $inspection2->id);
// Check if notify was dispatched
$component->assertDispatched('notify', 'Inspección eliminada correctamente');
$this->assertSoftDeleted('inspections', ['id' => $inspection2->id]);
$this->assertDatabaseMissing('media', ['id' => $photo2->id]);
}
public function test_delete_inspection_forbidden_without_permission(): void
{
[$user, $project, $feature, $template] = $this->createTestData();
$user->syncRoles([]);
$user->givePermissionTo(['view projects', 'view inspections']);
$inspection = Inspection::create([
'project_id' => $project->id,
'layer_id' => $feature->layer_id,
'feature_id' => $feature->id,
'template_id' => $template->id,
'user_id' => $user->id,
'inspector_user_id' => $user->id,
'status' => 'completed',
'completed_at' => now(),
'result' => 'pass',
'notes' => 'Note',
'data' => ['altura' => '5.0'],
]);
Livewire::actingAs($user)
->test(ProjectMap::class, ['project' => $project])
->call('selectFeature', $feature->id)
->call('deleteInspection', $inspection->id)
->assertDispatched('notify', 'Sin permisos para eliminar inspecciones.');
$this->assertDatabaseHas('inspections', ['id' => $inspection->id, 'deleted_at' => null]);
}
}