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:
@@ -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\\)\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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?
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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}",
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
@@ -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.',
|
||||
|
||||
];
|
||||
@@ -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' => '« Précédent',
|
||||
'next' => 'Suivant »',
|
||||
|
||||
];
|
||||
@@ -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.",
|
||||
|
||||
];
|
||||
@@ -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
@@ -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": "Порядок"
|
||||
}
|
||||
@@ -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 секунд.',
|
||||
|
||||
];
|
||||
@@ -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' => '« Предыдущая',
|
||||
'next' => 'Следующая »',
|
||||
|
||||
];
|
||||
@@ -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' => "Мы не можем найти пользователя с таким адресом электронной почты.",
|
||||
|
||||
];
|
||||
@@ -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' => 'подтверждение нового пароля',
|
||||
],
|
||||
|
||||
];
|
||||
@@ -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 |
@@ -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 |
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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';
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user