Compare commits
52 Commits
cf3d32a6fa
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9c164bb7ef | |||
| 4f66175406 | |||
| f2b2583e62 | |||
| 480dfc657f | |||
| c378ab5884 | |||
| 3d0f4d5cad | |||
| 19e1f57983 | |||
| 8c774d075d | |||
| 3f240e5277 | |||
| 14758136b6 | |||
| 9d2b63c8f4 | |||
| b5deb1c53a | |||
| 17a824f925 | |||
| ba363e7e18 | |||
| 13f36e8ec0 | |||
| 8025fa6d05 | |||
| efccb67635 | |||
| 0120c4bfb8 | |||
| 7f20399337 | |||
| 433c15a183 | |||
| 5587026446 | |||
| 5092896a1e | |||
| 938e704a67 | |||
| 828e70fbe2 | |||
| da0c8bd134 | |||
| 316e0ede39 | |||
| 564b433a62 | |||
| 7df6d208d9 | |||
| 860c502f32 | |||
| 8101f22413 | |||
| fe57388f05 | |||
| 75c07aa0d4 | |||
| 558b1732aa | |||
| 19fef5aa25 | |||
| 238310180f | |||
| 0fca7387e0 | |||
| ffd377cd39 | |||
| 24976e28da | |||
| de68638d7c | |||
| 3fd4d62df1 | |||
| 25f61cdb7d | |||
| 6e66f707d5 | |||
| 941dbd5997 | |||
| c44958ac16 | |||
| ee3086c34b | |||
| a24c8a2c2e | |||
| f8a1310c0f | |||
| 7d854ffb0a | |||
| c832d4f3da | |||
| 2711dcf2f2 | |||
| 052e1397df | |||
| 02e99329eb |
@@ -22,3 +22,4 @@
|
|||||||
Homestead.json
|
Homestead.json
|
||||||
Homestead.yaml
|
Homestead.yaml
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
.claude/worktrees/
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\IssueTask;
|
||||||
|
use App\Notifications\IssueTaskOverdueNotification;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class NotifyOverdueIssueTasks extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'issues:notify-overdue';
|
||||||
|
|
||||||
|
protected $description = 'Notify assignees of issue tasks that are overdue (one notification per task)';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$tasks = IssueTask::overdue()
|
||||||
|
->whereNull('overdue_notified_at')
|
||||||
|
->whereNotNull('assigned_to')
|
||||||
|
->with(['assignee', 'issue'])
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$sent = 0;
|
||||||
|
foreach ($tasks as $task) {
|
||||||
|
if ($task->assignee) {
|
||||||
|
$task->assignee->notify(new IssueTaskOverdueNotification($task));
|
||||||
|
$sent++;
|
||||||
|
}
|
||||||
|
$task->forceFill(['overdue_notified_at' => now()])->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Tareas vencidas notificadas: {$sent}");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Device;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
class AuthController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Issue a long-lived, revocable device token (Sanctum).
|
||||||
|
*/
|
||||||
|
public function login(Request $request)
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'email' => ['required', 'email'],
|
||||||
|
'password' => ['required', 'string'],
|
||||||
|
'device_name' => ['required', 'string', 'max:255'],
|
||||||
|
'app_version' => ['nullable', 'string', 'max:50'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = User::where('email', $data['email'])->first();
|
||||||
|
|
||||||
|
if (! $user || ! Hash::check($data['password'], $user->password)) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'email' => [__('auth.failed')],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// One token per device name: revoke the previous one for this device.
|
||||||
|
$user->tokens()->where('name', $data['device_name'])->delete();
|
||||||
|
|
||||||
|
$token = $user->createToken($data['device_name'], ['mobile-sync']);
|
||||||
|
|
||||||
|
Device::updateOrCreate(
|
||||||
|
['user_id' => $user->id, 'name' => $data['device_name']],
|
||||||
|
[
|
||||||
|
'token_id' => $token->accessToken->id,
|
||||||
|
'app_version' => $data['app_version'] ?? null,
|
||||||
|
'last_seen_at' => now(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'token' => $token->plainTextToken,
|
||||||
|
'user' => $this->userPayload($user),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function me(Request $request)
|
||||||
|
{
|
||||||
|
return response()->json(['user' => $this->userPayload($request->user())]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function logout(Request $request)
|
||||||
|
{
|
||||||
|
$token = $request->user()->currentAccessToken();
|
||||||
|
|
||||||
|
// Clean up the device record bound to this token.
|
||||||
|
Device::where('token_id', $token->id)->delete();
|
||||||
|
$token->delete();
|
||||||
|
|
||||||
|
return response()->json(['message' => 'Logged out']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function userPayload(User $user): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $user->id,
|
||||||
|
'name' => $user->name,
|
||||||
|
'email' => $user->email,
|
||||||
|
'roles' => $user->getRoleNames(),
|
||||||
|
'permissions' => $user->getAllPermissions()->pluck('name')->values(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Feature;
|
||||||
|
use App\Models\Issue;
|
||||||
|
use App\Models\IssueComment;
|
||||||
|
use App\Models\IssueTask;
|
||||||
|
use App\Models\Layer;
|
||||||
|
use App\Models\Media;
|
||||||
|
use App\Models\Phase;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class MediaController extends Controller
|
||||||
|
{
|
||||||
|
private array $map = [
|
||||||
|
'feature' => Feature::class,
|
||||||
|
'issue' => Issue::class,
|
||||||
|
'issue_task' => IssueTask::class,
|
||||||
|
'issue_comment' => IssueComment::class,
|
||||||
|
'project' => Project::class,
|
||||||
|
'phase' => Phase::class,
|
||||||
|
'layer' => Layer::class,
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Upload a file (multipart) and attach it to a parent record. Idempotent by uuid. */
|
||||||
|
public function upload(Request $request)
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'uuid' => ['required', 'uuid'],
|
||||||
|
'parent_entity' => ['required', Rule::in(array_keys($this->map))],
|
||||||
|
'parent_id' => ['required', 'integer'],
|
||||||
|
'file' => ['required', 'file', 'max:20480'], // 20 MB
|
||||||
|
'category' => ['nullable', 'in:image,document,other'],
|
||||||
|
'description' => ['nullable', 'string'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Idempotency: same uuid already uploaded → return it.
|
||||||
|
if ($existing = Media::where('uuid', $data['uuid'])->first()) {
|
||||||
|
return response()->json(['status' => 'duplicate', 'media' => $this->payload($existing)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$parent = $this->map[$data['parent_entity']]::find($data['parent_id']);
|
||||||
|
if (! $parent) {
|
||||||
|
return response()->json(['status' => 'error', 'error' => 'parent not found'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $request->user();
|
||||||
|
$project = $this->projectOf($data['parent_entity'], $parent);
|
||||||
|
abort_unless($this->canAccess($user, $project) && $user->can('upload media'), 403);
|
||||||
|
|
||||||
|
$file = $request->file('file');
|
||||||
|
$path = $file->store("media/{$data['parent_entity']}/{$parent->id}", 'public');
|
||||||
|
$mime = $file->getClientMimeType();
|
||||||
|
|
||||||
|
$media = $parent->media()->create([
|
||||||
|
'uuid' => $data['uuid'],
|
||||||
|
'name' => $file->getClientOriginalName(),
|
||||||
|
'file_path' => $path,
|
||||||
|
'file_type' => $mime,
|
||||||
|
'file_extension' => $file->getClientOriginalExtension(),
|
||||||
|
'file_size' => $file->getSize(),
|
||||||
|
'category' => $data['category'] ?? (Str::startsWith($mime, 'image/') ? 'image' : 'document'),
|
||||||
|
'description' => $data['description'] ?? null,
|
||||||
|
'uploaded_by' => $user->id,
|
||||||
|
'client_updated_at' => $request->input('client_updated_at'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json(['status' => 'applied', 'media' => $this->payload($media)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function projectOf(string $entity, $parent): ?Project
|
||||||
|
{
|
||||||
|
return match ($entity) {
|
||||||
|
'project' => $parent,
|
||||||
|
'phase' => $parent->project,
|
||||||
|
'layer' => $parent->phase?->project,
|
||||||
|
'feature' => $parent->layer?->phase?->project,
|
||||||
|
'issue' => $parent->project,
|
||||||
|
'issue_task' => $parent->issue?->project,
|
||||||
|
'issue_comment' => $parent->issue?->project,
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function canAccess(User $user, ?Project $project): bool
|
||||||
|
{
|
||||||
|
if (! $project) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return $user->can('manage all')
|
||||||
|
|| $project->users()->where('user_id', $user->id)->exists();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function payload(Media $m): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $m->id,
|
||||||
|
'uuid' => $m->uuid,
|
||||||
|
'url' => $m->url,
|
||||||
|
'name' => $m->name,
|
||||||
|
'file_type' => $m->file_type,
|
||||||
|
'category' => $m->category,
|
||||||
|
'updated_at' => $m->updated_at?->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Feature;
|
||||||
|
use App\Models\Inspection;
|
||||||
|
use App\Models\InspectionTemplate;
|
||||||
|
use App\Models\Issue;
|
||||||
|
use App\Models\IssueComment;
|
||||||
|
use App\Models\IssueTask;
|
||||||
|
use App\Models\Layer;
|
||||||
|
use App\Models\Media;
|
||||||
|
use App\Models\Phase;
|
||||||
|
use App\Models\Project;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class ProjectApiController extends Controller
|
||||||
|
{
|
||||||
|
/** Projects the authenticated user can access. */
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$projects = Project::accessibleBy($request->user())
|
||||||
|
->orderBy('name')
|
||||||
|
->get(['id', 'reference', 'name', 'address', 'status', 'lat', 'lng', 'updated_at']);
|
||||||
|
|
||||||
|
return response()->json(['projects' => $projects]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Offline bundle for one project. Full snapshot, or a delta when `?since=` is
|
||||||
|
* given (only records changed after that timestamp + tombstones for deletions).
|
||||||
|
*/
|
||||||
|
public function bundle(Request $request, Project $project)
|
||||||
|
{
|
||||||
|
$this->authorizeProject($request, $project);
|
||||||
|
|
||||||
|
$since = $request->query('since');
|
||||||
|
$since = $since ? Carbon::parse($since) : null;
|
||||||
|
|
||||||
|
$changed = fn ($query) => $since ? $query->where('updated_at', '>', $since) : $query;
|
||||||
|
|
||||||
|
$allPhaseIds = Phase::withTrashed()->where('project_id', $project->id)->pluck('id');
|
||||||
|
$allLayerIds = Layer::withTrashed()->whereIn('phase_id', $allPhaseIds)->pluck('id');
|
||||||
|
|
||||||
|
$phases = $changed(Phase::where('project_id', $project->id))->orderBy('order')->get();
|
||||||
|
$layers = $changed(Layer::whereIn('phase_id', $allPhaseIds))->get();
|
||||||
|
$features = $changed(Feature::whereIn('layer_id', $allLayerIds))->get();
|
||||||
|
$inspections = $changed(Inspection::where('project_id', $project->id))->get();
|
||||||
|
$issues = $changed(Issue::where('project_id', $project->id))->get();
|
||||||
|
$templates = $changed(InspectionTemplate::where('project_id', $project->id))->get();
|
||||||
|
|
||||||
|
$allIssueIds = Issue::withTrashed()->where('project_id', $project->id)->pluck('id');
|
||||||
|
$issueTasks = $changed(IssueTask::whereIn('issue_id', $allIssueIds))->get();
|
||||||
|
$issueComments = $changed(IssueComment::whereIn('issue_id', $allIssueIds))->get();
|
||||||
|
|
||||||
|
$featureIds = Feature::whereIn('layer_id', $allLayerIds)->pluck('id');
|
||||||
|
$issueIds = Issue::where('project_id', $project->id)->pluck('id');
|
||||||
|
$taskIds = IssueTask::whereIn('issue_id', $allIssueIds)->pluck('id');
|
||||||
|
$commentIds = IssueComment::whereIn('issue_id', $allIssueIds)->pluck('id');
|
||||||
|
$media = $changed(Media::where(function ($q) use ($project, $featureIds, $issueIds, $taskIds, $commentIds) {
|
||||||
|
$q->where(fn ($w) => $w->where('mediable_type', Project::class)->where('mediable_id', $project->id))
|
||||||
|
->orWhere(fn ($w) => $w->where('mediable_type', Feature::class)->whereIn('mediable_id', $featureIds))
|
||||||
|
->orWhere(fn ($w) => $w->where('mediable_type', Issue::class)->whereIn('mediable_id', $issueIds))
|
||||||
|
->orWhere(fn ($w) => $w->where('mediable_type', IssueTask::class)->whereIn('mediable_id', $taskIds))
|
||||||
|
->orWhere(fn ($w) => $w->where('mediable_type', IssueComment::class)->whereIn('mediable_id', $commentIds));
|
||||||
|
}))->get();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'server_time' => now()->toIso8601String(),
|
||||||
|
'project' => $this->mapProject($project),
|
||||||
|
'phases' => $phases->map(fn ($p) => $this->mapPhase($p))->values(),
|
||||||
|
'layers' => $layers->map(fn ($l) => $this->mapLayer($l))->values(),
|
||||||
|
'features' => $features->map(fn ($f) => $this->mapFeature($f))->values(),
|
||||||
|
'inspections' => $inspections->map(fn ($i) => $this->mapInspection($i))->values(),
|
||||||
|
'issues' => $issues->map(fn ($i) => $this->mapIssue($i))->values(),
|
||||||
|
'issue_tasks' => $issueTasks->map(fn ($t) => $this->mapIssueTask($t))->values(),
|
||||||
|
'issue_comments' => $issueComments->map(fn ($c) => $this->mapIssueComment($c))->values(),
|
||||||
|
'templates' => $templates->map(fn ($t) => $this->mapTemplate($t))->values(),
|
||||||
|
'media' => $media->map(fn ($m) => $this->mapMedia($m))->values(),
|
||||||
|
'deleted' => $since ? $this->tombstones($since, $project, $allPhaseIds, $allLayerIds, $allIssueIds) : (object) [],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inspection templates for the projects the user can access (with version/hash). */
|
||||||
|
public function templates(Request $request)
|
||||||
|
{
|
||||||
|
$since = $request->query('since');
|
||||||
|
$since = $since ? Carbon::parse($since) : null;
|
||||||
|
|
||||||
|
$projectIds = Project::accessibleBy($request->user())->pluck('id');
|
||||||
|
|
||||||
|
$query = InspectionTemplate::whereIn('project_id', $projectIds);
|
||||||
|
if ($since) {
|
||||||
|
$query->where('updated_at', '>', $since);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'templates' => $query->get()->map(fn ($t) => $this->mapTemplate($t))->values(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private function authorizeProject(Request $request, Project $project): void
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
abort_unless(
|
||||||
|
$user->can('manage all') || $project->users()->where('user_id', $user->id)->exists(),
|
||||||
|
403
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function tombstones(Carbon $since, Project $project, $allPhaseIds, $allLayerIds, $allIssueIds): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'phases' => Phase::onlyTrashed()->where('project_id', $project->id)->where('deleted_at', '>', $since)->pluck('id')->values(),
|
||||||
|
'layers' => Layer::onlyTrashed()->whereIn('phase_id', $allPhaseIds)->where('deleted_at', '>', $since)->pluck('id')->values(),
|
||||||
|
'features' => Feature::onlyTrashed()->whereIn('layer_id', $allLayerIds)->where('deleted_at', '>', $since)->pluck('id')->values(),
|
||||||
|
'inspections' => Inspection::onlyTrashed()->where('project_id', $project->id)->where('deleted_at', '>', $since)->pluck('id')->values(),
|
||||||
|
'issues' => Issue::onlyTrashed()->where('project_id', $project->id)->where('deleted_at', '>', $since)->pluck('id')->values(),
|
||||||
|
'issue_tasks' => IssueTask::onlyTrashed()->whereIn('issue_id', $allIssueIds)->where('deleted_at', '>', $since)->pluck('id')->values(),
|
||||||
|
'issue_comments' => IssueComment::onlyTrashed()->whereIn('issue_id', $allIssueIds)->where('deleted_at', '>', $since)->pluck('id')->values(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function mapProject(Project $p): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $p->id, 'reference' => $p->reference, 'name' => $p->name,
|
||||||
|
'address' => $p->address, 'lat' => $p->lat, 'lng' => $p->lng,
|
||||||
|
'status' => $p->status, 'updated_at' => $p->updated_at?->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function mapPhase(Phase $p): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $p->id, 'name' => $p->name, 'order' => $p->order, 'color' => $p->color,
|
||||||
|
'progress_percent' => $p->progress_percent, 'updated_at' => $p->updated_at?->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function mapLayer(Layer $l): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $l->id, 'phase_id' => $l->phase_id, 'name' => $l->name,
|
||||||
|
'color' => $l->color, 'updated_at' => $l->updated_at?->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function mapFeature(Feature $f): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $f->id, 'layer_id' => $f->layer_id, 'name' => $f->name,
|
||||||
|
'geometry' => $f->geometry, 'status' => $f->status, 'progress' => $f->progress,
|
||||||
|
'responsible' => $f->responsible, 'template_id' => $f->template_id,
|
||||||
|
'updated_at' => $f->updated_at?->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function mapInspection(Inspection $i): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $i->id, 'feature_id' => $i->feature_id, 'layer_id' => $i->layer_id,
|
||||||
|
'template_id' => $i->template_id, 'user_id' => $i->user_id, 'data' => $i->data,
|
||||||
|
'status' => $i->status, 'result' => $i->result, 'notes' => $i->notes,
|
||||||
|
'created_at' => $i->created_at?->toIso8601String(), 'updated_at' => $i->updated_at?->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function mapIssue(Issue $i): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $i->id, 'feature_id' => $i->feature_id, 'title' => $i->title,
|
||||||
|
'description' => $i->description, 'status' => $i->status, 'priority' => $i->priority,
|
||||||
|
'type' => $i->type,
|
||||||
|
'reported_by' => $i->reported_by, 'assigned_to' => $i->assigned_to,
|
||||||
|
'resolved_at' => $i->resolved_at?->toIso8601String(), 'updated_at' => $i->updated_at?->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function mapIssueTask(IssueTask $t): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $t->id, 'issue_id' => $t->issue_id, 'title' => $t->title,
|
||||||
|
'is_done' => $t->is_done, 'done_at' => $t->done_at?->toIso8601String(), 'done_by' => $t->done_by,
|
||||||
|
'assigned_to' => $t->assigned_to, 'due_date' => $t->due_date?->toDateString(),
|
||||||
|
'order' => $t->order, 'updated_at' => $t->updated_at?->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function mapIssueComment(IssueComment $c): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $c->id, 'issue_id' => $c->issue_id, 'user_id' => $c->user_id, 'body' => $c->body,
|
||||||
|
'created_at' => $c->created_at?->toIso8601String(), 'updated_at' => $c->updated_at?->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function mapTemplate(InspectionTemplate $t): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $t->id, 'project_id' => $t->project_id, 'phase_id' => $t->phase_id,
|
||||||
|
'name' => $t->name, 'description' => $t->description, 'fields' => $t->fields,
|
||||||
|
'version' => $t->updated_at?->timestamp,
|
||||||
|
'hash' => md5(json_encode($t->fields) . $t->name),
|
||||||
|
'updated_at' => $t->updated_at?->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function mapMedia(Media $m): array
|
||||||
|
{
|
||||||
|
$entity = [
|
||||||
|
Project::class => 'project',
|
||||||
|
Feature::class => 'feature',
|
||||||
|
Issue::class => 'issue',
|
||||||
|
IssueTask::class => 'issue_task',
|
||||||
|
IssueComment::class => 'issue_comment',
|
||||||
|
][$m->mediable_type] ?? class_basename($m->mediable_type);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $m->id, 'uuid' => $m->uuid,
|
||||||
|
'parent_entity' => $entity, 'parent_id' => $m->mediable_id,
|
||||||
|
'url' => $m->url, 'name' => $m->name, 'file_type' => $m->file_type,
|
||||||
|
'category' => $m->category, 'updated_at' => $m->updated_at?->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,449 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Feature;
|
||||||
|
use App\Models\Inspection;
|
||||||
|
use App\Models\Issue;
|
||||||
|
use App\Models\IssueComment;
|
||||||
|
use App\Models\IssueTask;
|
||||||
|
use App\Models\Phase;
|
||||||
|
use App\Models\ProgressUpdate;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\SyncLog;
|
||||||
|
use App\Models\User;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
|
||||||
|
class SyncController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Push a batch of offline mutations. Returns a per-operation result
|
||||||
|
* (applied | duplicate | conflict | error). Never aborts the whole batch.
|
||||||
|
*
|
||||||
|
* Identity:
|
||||||
|
* - create ops → the new record stores the client-generated `uuid` (idempotent).
|
||||||
|
* - update ops → target identified by `data.id` (server id); last-write-wins.
|
||||||
|
*/
|
||||||
|
public function sync(Request $request)
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'operations' => ['required', 'array'],
|
||||||
|
'operations.*.entity' => ['required', 'string'],
|
||||||
|
'operations.*.op' => ['required', 'string'],
|
||||||
|
'operations.*.uuid' => ['required', 'uuid'],
|
||||||
|
'operations.*.data' => ['required', 'array'],
|
||||||
|
'operations.*.client_updated_at' => ['nullable', 'date'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = $request->user();
|
||||||
|
$results = [];
|
||||||
|
|
||||||
|
foreach ($data['operations'] as $op) {
|
||||||
|
$results[] = $this->handle($user, $op);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(['results' => $results]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function handle(User $user, array $op): array
|
||||||
|
{
|
||||||
|
$uuid = $op['uuid'];
|
||||||
|
|
||||||
|
// Op-level idempotency: if this operation was already applied, replay its result.
|
||||||
|
$prior = SyncLog::where('op_uuid', $uuid)
|
||||||
|
->where('entity', $op['entity'])->where('op', $op['op'])->first();
|
||||||
|
if ($prior) {
|
||||||
|
return ['uuid' => $uuid, 'status' => 'duplicate', 'server_id' => $prior->server_id];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = match ($op['entity'] . '.' . $op['op']) {
|
||||||
|
'progress_update.create' => $this->progressUpdateCreate($user, $uuid, $op),
|
||||||
|
'inspection.create' => $this->inspectionCreate($user, $uuid, $op),
|
||||||
|
'issue.create' => $this->issueCreate($user, $uuid, $op),
|
||||||
|
'issue.update' => $this->issueUpdate($user, $uuid, $op),
|
||||||
|
'issue_task.create' => $this->issueTaskCreate($user, $uuid, $op),
|
||||||
|
'issue_task.update' => $this->issueTaskUpdate($user, $uuid, $op),
|
||||||
|
'issue_comment.create' => $this->issueCommentCreate($user, $uuid, $op),
|
||||||
|
'feature.update' => $this->featureUpdate($user, $uuid, $op),
|
||||||
|
default => $this->error($uuid, 'unsupported entity/op: ' . $op['entity'] . '.' . $op['op']),
|
||||||
|
};
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$result = $this->error($uuid, $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record only terminal successes so conflicts/errors can be safely retried.
|
||||||
|
if ($result['status'] === 'applied') {
|
||||||
|
SyncLog::create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'op_uuid' => $uuid,
|
||||||
|
'entity' => $op['entity'],
|
||||||
|
'op' => $op['op'],
|
||||||
|
'status' => 'applied',
|
||||||
|
'server_id' => $result['server_id'] ?? null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── progress_update.create ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private function progressUpdateCreate(User $user, string $uuid, array $op): array
|
||||||
|
{
|
||||||
|
if ($existing = ProgressUpdate::where('uuid', $uuid)->first()) {
|
||||||
|
return $this->duplicate($uuid, $existing->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
$v = Validator::make($op['data'], [
|
||||||
|
'phase_id' => ['required', 'integer', 'exists:phases,id'],
|
||||||
|
'progress' => ['required', 'integer', 'min:0', 'max:100'],
|
||||||
|
'comment' => ['nullable', 'string'],
|
||||||
|
'location' => ['nullable', 'array'],
|
||||||
|
]);
|
||||||
|
if ($v->fails()) {
|
||||||
|
return $this->error($uuid, 'validation: ' . $v->errors()->first());
|
||||||
|
}
|
||||||
|
$d = $v->validated();
|
||||||
|
|
||||||
|
$phase = Phase::with('project')->findOrFail($d['phase_id']);
|
||||||
|
if (! $this->canAccess($user, $phase->project) || ! $user->can('update progress')) {
|
||||||
|
return $this->error($uuid, 'forbidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
$pu = ProgressUpdate::create([
|
||||||
|
'uuid' => $uuid,
|
||||||
|
'phase_id' => $phase->id,
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'progress_percent' => $d['progress'],
|
||||||
|
'comment' => $d['comment'] ?? null,
|
||||||
|
'location' => $d['location'] ?? null,
|
||||||
|
'client_updated_at' => $op['client_updated_at'] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$phase->progress_percent = $d['progress'];
|
||||||
|
$phase->save();
|
||||||
|
|
||||||
|
return $this->applied($uuid, $pu->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── inspection.create ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private function inspectionCreate(User $user, string $uuid, array $op): array
|
||||||
|
{
|
||||||
|
if ($existing = Inspection::where('uuid', $uuid)->first()) {
|
||||||
|
return $this->duplicate($uuid, $existing->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
$v = Validator::make($op['data'], [
|
||||||
|
'feature_id' => ['required', 'integer', 'exists:features,id'],
|
||||||
|
'template_id' => ['nullable', 'integer', 'exists:inspection_templates,id'],
|
||||||
|
'data' => ['nullable', 'array'],
|
||||||
|
'status' => ['nullable', 'string'],
|
||||||
|
'result' => ['nullable', 'string'],
|
||||||
|
'notes' => ['nullable', 'string'],
|
||||||
|
]);
|
||||||
|
if ($v->fails()) {
|
||||||
|
return $this->error($uuid, 'validation: ' . $v->errors()->first());
|
||||||
|
}
|
||||||
|
$d = $v->validated();
|
||||||
|
|
||||||
|
$feature = Feature::with('layer.phase.project')->findOrFail($d['feature_id']);
|
||||||
|
$project = $feature->layer?->phase?->project;
|
||||||
|
if (! $this->canAccess($user, $project) || ! $user->can('create inspections')) {
|
||||||
|
return $this->error($uuid, 'forbidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
$inspection = Inspection::create([
|
||||||
|
'uuid' => $uuid,
|
||||||
|
'project_id' => $project->id,
|
||||||
|
'layer_id' => $feature->layer_id,
|
||||||
|
'feature_id' => $feature->id,
|
||||||
|
'template_id' => $d['template_id'] ?? null,
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'data' => $d['data'] ?? [],
|
||||||
|
'status' => $d['status'] ?? 'completed',
|
||||||
|
'result' => $d['result'] ?? null,
|
||||||
|
'notes' => $d['notes'] ?? null,
|
||||||
|
'client_updated_at' => $op['client_updated_at'] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $this->applied($uuid, $inspection->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── issue.create / issue.update ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private function issueCreate(User $user, string $uuid, array $op): array
|
||||||
|
{
|
||||||
|
if ($existing = Issue::where('uuid', $uuid)->first()) {
|
||||||
|
return $this->duplicate($uuid, $existing->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
$v = Validator::make($op['data'], [
|
||||||
|
'project_id' => ['required', 'integer', 'exists:projects,id'],
|
||||||
|
'feature_id' => ['nullable', 'integer', 'exists:features,id'],
|
||||||
|
'title' => ['required', 'string', 'max:255'],
|
||||||
|
'description' => ['nullable', 'string'],
|
||||||
|
'priority' => ['nullable', 'in:' . implode(',', Issue::PRIORITIES)],
|
||||||
|
'status' => ['nullable', 'in:' . implode(',', Issue::STATUSES)],
|
||||||
|
'type' => ['nullable', 'in:' . implode(',', Issue::TYPES)],
|
||||||
|
]);
|
||||||
|
if ($v->fails()) {
|
||||||
|
return $this->error($uuid, 'validation: ' . $v->errors()->first());
|
||||||
|
}
|
||||||
|
$d = $v->validated();
|
||||||
|
|
||||||
|
$project = Project::find($d['project_id']);
|
||||||
|
if (! $this->canAccess($user, $project) || ! $user->can('create issues')) {
|
||||||
|
return $this->error($uuid, 'forbidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
$issue = Issue::create([
|
||||||
|
'uuid' => $uuid,
|
||||||
|
'project_id' => $project->id,
|
||||||
|
'feature_id' => $d['feature_id'] ?? null,
|
||||||
|
'title' => $d['title'],
|
||||||
|
'description' => $d['description'] ?? null,
|
||||||
|
'priority' => $d['priority'] ?? 'medium',
|
||||||
|
'status' => $d['status'] ?? 'open',
|
||||||
|
'type' => $d['type'] ?? 'other',
|
||||||
|
'reported_by' => $user->id,
|
||||||
|
'client_updated_at' => $op['client_updated_at'] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $this->applied($uuid, $issue->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function issueUpdate(User $user, string $uuid, array $op): array
|
||||||
|
{
|
||||||
|
$v = Validator::make($op['data'], [
|
||||||
|
'id' => ['required', 'integer', 'exists:issues,id'],
|
||||||
|
'title' => ['nullable', 'string', 'max:255'],
|
||||||
|
'description' => ['nullable', 'string'],
|
||||||
|
'priority' => ['nullable', 'in:' . implode(',', Issue::PRIORITIES)],
|
||||||
|
'status' => ['nullable', 'in:' . implode(',', Issue::STATUSES)],
|
||||||
|
'type' => ['nullable', 'in:' . implode(',', Issue::TYPES)],
|
||||||
|
'assigned_to' => ['nullable', 'integer', 'exists:users,id'],
|
||||||
|
'resolution_notes' => ['nullable', 'string'],
|
||||||
|
]);
|
||||||
|
if ($v->fails()) {
|
||||||
|
return $this->error($uuid, 'validation: ' . $v->errors()->first());
|
||||||
|
}
|
||||||
|
$d = $v->validated();
|
||||||
|
|
||||||
|
$issue = Issue::with('project')->findOrFail($d['id']);
|
||||||
|
if (! $this->canAccess($user, $issue->project) || ! $user->can('edit issues')) {
|
||||||
|
return $this->error($uuid, 'forbidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($conflict = $this->conflict($uuid, $issue, $op)) {
|
||||||
|
return $conflict;
|
||||||
|
}
|
||||||
|
|
||||||
|
$issue->fill(collect($d)->except('id')->toArray());
|
||||||
|
if (in_array($issue->status, ['resolved', 'closed'], true) && ! $issue->resolved_at) {
|
||||||
|
$issue->resolved_at = now();
|
||||||
|
}
|
||||||
|
$issue->client_updated_at = $op['client_updated_at'] ?? null;
|
||||||
|
$issue->save();
|
||||||
|
|
||||||
|
return $this->applied($uuid, $issue->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── issue_task.create / issue_task.update ──────────────────────────────────────
|
||||||
|
|
||||||
|
private function issueTaskCreate(User $user, string $uuid, array $op): array
|
||||||
|
{
|
||||||
|
if ($existing = IssueTask::where('uuid', $uuid)->first()) {
|
||||||
|
return $this->duplicate($uuid, $existing->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
$v = Validator::make($op['data'], [
|
||||||
|
'issue_id' => ['required', 'integer', 'exists:issues,id'],
|
||||||
|
'title' => ['required', 'string', 'max:255'],
|
||||||
|
'assigned_to' => ['nullable', 'integer', 'exists:users,id'],
|
||||||
|
'due_date' => ['nullable', 'date'],
|
||||||
|
'is_done' => ['nullable', 'boolean'],
|
||||||
|
]);
|
||||||
|
if ($v->fails()) {
|
||||||
|
return $this->error($uuid, 'validation: ' . $v->errors()->first());
|
||||||
|
}
|
||||||
|
$d = $v->validated();
|
||||||
|
|
||||||
|
$issue = Issue::with('project')->findOrFail($d['issue_id']);
|
||||||
|
if (! $this->canAccess($user, $issue->project) || ! $user->can('edit issues')) {
|
||||||
|
return $this->error($uuid, 'forbidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
$done = $d['is_done'] ?? false;
|
||||||
|
$task = IssueTask::create([
|
||||||
|
'uuid' => $uuid,
|
||||||
|
'issue_id' => $issue->id,
|
||||||
|
'title' => $d['title'],
|
||||||
|
'assigned_to' => $d['assigned_to'] ?? null,
|
||||||
|
'due_date' => $d['due_date'] ?? null,
|
||||||
|
'is_done' => $done,
|
||||||
|
'done_at' => $done ? now() : null,
|
||||||
|
'done_by' => $done ? $user->id : null,
|
||||||
|
'order' => ((int) $issue->tasks()->max('order')) + 1,
|
||||||
|
'client_updated_at' => $op['client_updated_at'] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $this->applied($uuid, $task->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function issueTaskUpdate(User $user, string $uuid, array $op): array
|
||||||
|
{
|
||||||
|
$v = Validator::make($op['data'], [
|
||||||
|
'id' => ['required', 'integer', 'exists:issue_tasks,id'],
|
||||||
|
'title' => ['nullable', 'string', 'max:255'],
|
||||||
|
'assigned_to' => ['nullable', 'integer', 'exists:users,id'],
|
||||||
|
'due_date' => ['nullable', 'date'],
|
||||||
|
'is_done' => ['nullable', 'boolean'],
|
||||||
|
]);
|
||||||
|
if ($v->fails()) {
|
||||||
|
return $this->error($uuid, 'validation: ' . $v->errors()->first());
|
||||||
|
}
|
||||||
|
$d = $v->validated();
|
||||||
|
|
||||||
|
$task = IssueTask::with('issue.project')->findOrFail($d['id']);
|
||||||
|
if (! $this->canAccess($user, $task->issue?->project) || ! $user->can('edit issues')) {
|
||||||
|
return $this->error($uuid, 'forbidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($conflict = $this->conflict($uuid, $task, $op)) {
|
||||||
|
return $conflict;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('is_done', $d)) {
|
||||||
|
$task->is_done = (bool) $d['is_done'];
|
||||||
|
$task->done_at = $d['is_done'] ? ($task->done_at ?? now()) : null;
|
||||||
|
$task->done_by = $d['is_done'] ? ($task->done_by ?? $user->id) : null;
|
||||||
|
}
|
||||||
|
$task->fill(collect($d)->only('title', 'assigned_to', 'due_date')->toArray());
|
||||||
|
$task->client_updated_at = $op['client_updated_at'] ?? null;
|
||||||
|
$task->save();
|
||||||
|
|
||||||
|
return $this->applied($uuid, $task->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── issue_comment.create ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private function issueCommentCreate(User $user, string $uuid, array $op): array
|
||||||
|
{
|
||||||
|
if ($existing = IssueComment::where('uuid', $uuid)->first()) {
|
||||||
|
return $this->duplicate($uuid, $existing->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
$v = Validator::make($op['data'], [
|
||||||
|
'issue_id' => ['required', 'integer', 'exists:issues,id'],
|
||||||
|
'body' => ['required', 'string', 'max:5000'],
|
||||||
|
]);
|
||||||
|
if ($v->fails()) {
|
||||||
|
return $this->error($uuid, 'validation: ' . $v->errors()->first());
|
||||||
|
}
|
||||||
|
$d = $v->validated();
|
||||||
|
|
||||||
|
$issue = Issue::with('project')->findOrFail($d['issue_id']);
|
||||||
|
if (! $this->canAccess($user, $issue->project) || ! $user->can('view issues')) {
|
||||||
|
return $this->error($uuid, 'forbidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
$comment = IssueComment::create([
|
||||||
|
'uuid' => $uuid,
|
||||||
|
'issue_id' => $issue->id,
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'body' => $d['body'],
|
||||||
|
'client_updated_at' => $op['client_updated_at'] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $this->applied($uuid, $comment->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── feature.update ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private function featureUpdate(User $user, string $uuid, array $op): array
|
||||||
|
{
|
||||||
|
$v = Validator::make($op['data'], [
|
||||||
|
'id' => ['required', 'integer', 'exists:features,id'],
|
||||||
|
'status' => ['nullable', 'string'],
|
||||||
|
'progress' => ['nullable', 'integer', 'min:0', 'max:100'],
|
||||||
|
'responsible' => ['nullable', 'string'],
|
||||||
|
]);
|
||||||
|
if ($v->fails()) {
|
||||||
|
return $this->error($uuid, 'validation: ' . $v->errors()->first());
|
||||||
|
}
|
||||||
|
$d = $v->validated();
|
||||||
|
|
||||||
|
$feature = Feature::with('layer.phase.project')->findOrFail($d['id']);
|
||||||
|
$project = $feature->layer?->phase?->project;
|
||||||
|
if (! $this->canAccess($user, $project) || ! $user->can('update progress')) {
|
||||||
|
return $this->error($uuid, 'forbidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($conflict = $this->conflict($uuid, $feature, $op)) {
|
||||||
|
return $conflict;
|
||||||
|
}
|
||||||
|
|
||||||
|
$feature->fill(collect($d)->except('id')->toArray());
|
||||||
|
$feature->client_updated_at = $op['client_updated_at'] ?? null;
|
||||||
|
$feature->save();
|
||||||
|
|
||||||
|
// Mirror web behaviour: recompute the phase progress from its features.
|
||||||
|
if ($phase = $feature->layer?->phase) {
|
||||||
|
$phase->progress_percent = (int) round($phase->features()->avg('progress') ?: 0);
|
||||||
|
$phase->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->applied($uuid, $feature->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private function canAccess(User $user, ?Project $project): bool
|
||||||
|
{
|
||||||
|
if (! $project) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return $user->can('manage all')
|
||||||
|
|| $project->users()->where('user_id', $user->id)->exists();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Last-write-wins conflict detection: if the server row was updated after the
|
||||||
|
* client's edit, reject with the current server value.
|
||||||
|
*/
|
||||||
|
private function conflict(string $uuid, $model, array $op): ?array
|
||||||
|
{
|
||||||
|
if (empty($op['client_updated_at'])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$clientAt = Carbon::parse($op['client_updated_at']);
|
||||||
|
if ($model->updated_at && $model->updated_at->gt($clientAt)) {
|
||||||
|
return [
|
||||||
|
'uuid' => $uuid,
|
||||||
|
'status' => 'conflict',
|
||||||
|
'server' => $model->fresh()->toArray(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function applied(string $uuid, int $id): array
|
||||||
|
{
|
||||||
|
return ['uuid' => $uuid, 'status' => 'applied', 'server_id' => $id];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function duplicate(string $uuid, int $id): array
|
||||||
|
{
|
||||||
|
return ['uuid' => $uuid, 'status' => 'duplicate', 'server_id' => $id];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function error(string $uuid, string $message): array
|
||||||
|
{
|
||||||
|
return ['uuid' => $uuid, 'status' => 'error', 'error' => $message];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,15 +19,6 @@ class ProjectController extends Controller
|
|||||||
return view('projects.index');
|
return view('projects.index');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Show the form for creating a new resource.
|
|
||||||
*/
|
|
||||||
public function create()
|
|
||||||
{
|
|
||||||
Gate::authorize('create projects');
|
|
||||||
return view('projects.create');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Store a newly created resource in storage.
|
* Store a newly created resource in storage.
|
||||||
*/
|
*/
|
||||||
@@ -58,15 +49,6 @@ class ProjectController extends Controller
|
|||||||
return redirect()->route('projects.map', $project);
|
return redirect()->route('projects.map', $project);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Show the form for editing the specified resource.
|
|
||||||
*/
|
|
||||||
public function edit(Project $project) // <--- ROUTE MODEL BINDING
|
|
||||||
{
|
|
||||||
Gate::authorize('edit projects', $project);
|
|
||||||
return view('projects.edit', compact('project'));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update the specified resource in storage.
|
* Update the specified resource in storage.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Project;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
||||||
|
class ProjectReportController extends Controller
|
||||||
|
{
|
||||||
|
public function show(Project $project)
|
||||||
|
{
|
||||||
|
$user = Auth::user();
|
||||||
|
if (!$user->can('manage all') && !$project->users()->where('user_id', $user->id)->exists()) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$phases = $project->phases()
|
||||||
|
->with(['layers.features.inspections', 'layers.features.issues'])
|
||||||
|
->orderBy('order')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$stats = [
|
||||||
|
'total_features' => $phases->flatMap(fn($p) => $p->layers)->flatMap(fn($l) => $l->features)->count(),
|
||||||
|
'completed_features' => $phases->flatMap(fn($p) => $p->layers)->flatMap(fn($l) => $l->features)->where('status', 'completed')->count(),
|
||||||
|
'total_inspections' => \App\Models\Inspection::where('project_id', $project->id)->count(),
|
||||||
|
'open_issues' => \App\Models\Issue::where('project_id', $project->id)->where('status', 'open')->count(),
|
||||||
|
'avg_progress' => round($phases->avg('progress_percent') ?? 0),
|
||||||
|
];
|
||||||
|
|
||||||
|
$pdf_data = compact('project', 'phases', 'stats');
|
||||||
|
|
||||||
|
// Use Blade to render HTML, then return as "print" view
|
||||||
|
// (barryvdh/laravel-dompdf is not installed, so we render a printable HTML page)
|
||||||
|
return view('reports.project-report', $pdf_data);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,9 +41,9 @@ class SetLocale
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Default to English
|
// 4. Default to app locale
|
||||||
if (!$locale) {
|
if (!$locale) {
|
||||||
$locale = 'en';
|
$locale = config('app.locale', 'es');
|
||||||
}
|
}
|
||||||
|
|
||||||
App::setLocale($locale);
|
App::setLocale($locale);
|
||||||
|
|||||||
+17
-23
@@ -9,40 +9,34 @@ use Illuminate\Support\Facades\Auth;
|
|||||||
|
|
||||||
class AdminUsers extends Component
|
class AdminUsers extends Component
|
||||||
{
|
{
|
||||||
public $users;
|
public string $search = '';
|
||||||
public $roles;
|
public $roles;
|
||||||
|
|
||||||
public function mount()
|
public function mount(): void
|
||||||
{
|
{
|
||||||
if (!Auth::user()->hasRole('Admin')) {
|
abort_unless(Auth::user()->can('view users'), 403);
|
||||||
abort(403);
|
$this->roles = Role::orderBy('name')->get();
|
||||||
}
|
|
||||||
$this->roles = Role::all();
|
|
||||||
$this->loadUsers();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function loadUsers()
|
public function getUsersProperty()
|
||||||
{
|
{
|
||||||
$this->users = User::with('roles')->orderBy('name')->get();
|
return User::with('roles')
|
||||||
|
->when($this->search, fn($q) =>
|
||||||
|
$q->where(fn($q2) => $q2
|
||||||
|
->where('name', 'like', '%' . $this->search . '%')
|
||||||
|
->orWhere('email', 'like', '%' . $this->search . '%')))
|
||||||
|
->orderBy('name')
|
||||||
|
->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function updateRole($userId, $roleName)
|
public function deleteUser(int $userId): void
|
||||||
{
|
{
|
||||||
$user = Auth::user();
|
if ($userId === Auth::id()) {
|
||||||
if (!$user->hasRole('Admin')) {
|
$this->dispatch('notify', 'No puedes eliminarte a ti mismo.');
|
||||||
session()->flash('error', 'Solo administradores.');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
User::findOrFail($userId)->delete();
|
||||||
$targetUser = User::findOrFail($userId);
|
$this->dispatch('notify', 'Usuario eliminado.');
|
||||||
if ($targetUser->id === $user->id && $targetUser->hasRole('Admin')) {
|
|
||||||
session()->flash('error', 'No puedes cambiarte el rol a ti mismo.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$targetUser->syncRoles([$roleName]);
|
|
||||||
$this->loadUsers();
|
|
||||||
$this->dispatch('notify', 'Rol actualizado.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use Livewire\Component;
|
||||||
|
use Livewire\WithFileUploads;
|
||||||
|
use Livewire\Attributes\Layout;
|
||||||
|
use App\Models\Company;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
#[Layout('layouts.app')]
|
||||||
|
class CompanyForm extends Component
|
||||||
|
{
|
||||||
|
use WithFileUploads;
|
||||||
|
|
||||||
|
public ?Company $company = null;
|
||||||
|
|
||||||
|
// Form fields
|
||||||
|
public string $name = '';
|
||||||
|
public string $apodo = '';
|
||||||
|
public string $tax_id = '';
|
||||||
|
public string $estado = 'activo';
|
||||||
|
public string $type = 'other';
|
||||||
|
public string $address = '';
|
||||||
|
public string $phone = '';
|
||||||
|
public string $email = '';
|
||||||
|
public string $website = '';
|
||||||
|
public string $notes = '';
|
||||||
|
public $logo = null;
|
||||||
|
|
||||||
|
public function mount(?Company $company = null): void
|
||||||
|
{
|
||||||
|
if ($company && $company->exists) {
|
||||||
|
$this->company = $company;
|
||||||
|
$this->name = $company->name;
|
||||||
|
$this->apodo = $company->apodo ?? '';
|
||||||
|
$this->tax_id = $company->tax_id ?? '';
|
||||||
|
$this->estado = $company->estado ?? 'activo';
|
||||||
|
$this->type = $company->type ?? 'other';
|
||||||
|
$this->address = $company->address ?? '';
|
||||||
|
$this->phone = $company->phone ?? '';
|
||||||
|
$this->email = $company->email ?? '';
|
||||||
|
$this->website = $company->website ?? '';
|
||||||
|
$this->notes = $company->notes ?? '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function rules(): array
|
||||||
|
{
|
||||||
|
$id = $this->company?->id ?? 'NULL';
|
||||||
|
return [
|
||||||
|
'name' => 'required|string|max:255',
|
||||||
|
'apodo' => 'nullable|string|max:100',
|
||||||
|
'tax_id' => "nullable|string|max:50|unique:companies,tax_id,{$id}",
|
||||||
|
'estado' => 'required|in:activo,inactivo,suspendido',
|
||||||
|
'type' => 'required|in:owner,constructor,subcontractor,consultant,supplier,other',
|
||||||
|
'address' => 'nullable|string',
|
||||||
|
'phone' => 'nullable|string|max:30',
|
||||||
|
'email' => 'nullable|email|max:255',
|
||||||
|
'website' => 'nullable|url|max:255',
|
||||||
|
'notes' => 'nullable|string',
|
||||||
|
'logo' => 'nullable|image|max:2048',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save(): void
|
||||||
|
{
|
||||||
|
$this->validate();
|
||||||
|
|
||||||
|
$data = [
|
||||||
|
'name' => $this->name,
|
||||||
|
'apodo' => $this->apodo ?: null,
|
||||||
|
'tax_id' => $this->tax_id ?: null,
|
||||||
|
'estado' => $this->estado,
|
||||||
|
'type' => $this->type,
|
||||||
|
'address' => $this->address ?: null,
|
||||||
|
'phone' => $this->phone ?: null,
|
||||||
|
'email' => $this->email ?: null,
|
||||||
|
'website' => $this->website ?: null,
|
||||||
|
'notes' => $this->notes ?: null,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($this->logo) {
|
||||||
|
// Delete old logo when replacing
|
||||||
|
if ($this->company?->logo_path) {
|
||||||
|
Storage::disk('public')->delete($this->company->logo_path);
|
||||||
|
}
|
||||||
|
$data['logo_path'] = $this->logo->store('company-logos', 'public');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->company && $this->company->exists) {
|
||||||
|
$this->company->update($data);
|
||||||
|
session()->flash('notify', 'Empresa actualizada correctamente.');
|
||||||
|
} else {
|
||||||
|
Company::create($data);
|
||||||
|
session()->flash('notify', 'Empresa creada correctamente.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->redirect(route('companies.manage'), navigate: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
return view('livewire.company-form');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,230 +3,61 @@
|
|||||||
namespace App\Livewire;
|
namespace App\Livewire;
|
||||||
|
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use Livewire\WithFileUploads;
|
use Livewire\Attributes\Layout;
|
||||||
use App\Models\Company;
|
use App\Models\Company;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Illuminate\Http\Response;
|
|
||||||
|
|
||||||
|
#[Layout('layouts.app')]
|
||||||
class CompanyManagement extends Component
|
class CompanyManagement extends Component
|
||||||
{
|
{
|
||||||
use WithFileUploads;
|
public string $search = '';
|
||||||
|
public string $filterType = '';
|
||||||
// Form state
|
public string $filterEstado = '';
|
||||||
public $name = '';
|
|
||||||
public $tax_id = '';
|
|
||||||
public $address = '';
|
|
||||||
public $email = '';
|
|
||||||
public $website = '';
|
|
||||||
public $type = 'other';
|
|
||||||
public $notes = '';
|
|
||||||
public $apodo = '';
|
|
||||||
public $estado = 'activo';
|
|
||||||
public $logo = null;
|
|
||||||
|
|
||||||
// UI state
|
|
||||||
public $showCreateForm = false;
|
|
||||||
public $showEditForm = false;
|
|
||||||
public $editingCompanyId = null;
|
|
||||||
public $search = '';
|
|
||||||
|
|
||||||
// Filter state
|
|
||||||
public $filterType = '';
|
|
||||||
public $filterEstado = '';
|
|
||||||
|
|
||||||
// Validation rules
|
|
||||||
protected $rules = [
|
|
||||||
'name' => 'required|string|max:255',
|
|
||||||
'apodo' => 'nullable|string|max:100',
|
|
||||||
'tax_id' => 'nullable|string|max:50|unique:companies,tax_id,',
|
|
||||||
'estado' => 'required|in:activo,inactivo,suspendido',
|
|
||||||
'address' => 'nullable|string',
|
|
||||||
'phone' => 'nullable|string|max:20',
|
|
||||||
'email' => 'nullable|email|max:255',
|
|
||||||
'website' => 'nullable|url|max:255',
|
|
||||||
'type' => 'required|in:owner,constructor,subcontractor,consultant,supplier,other',
|
|
||||||
'notes' => 'nullable|string',
|
|
||||||
'logo' => 'nullable|image|max:2048', // 2MB max
|
|
||||||
];
|
|
||||||
|
|
||||||
public function mount()
|
|
||||||
{
|
|
||||||
$this->resetForm();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function resetForm()
|
|
||||||
{
|
|
||||||
$this->name = '';
|
|
||||||
$this->tax_id = '';
|
|
||||||
$this->address = '';
|
|
||||||
$this->phone = '';
|
|
||||||
$this->email = '';
|
|
||||||
$this->website = '';
|
|
||||||
$this->type = 'other';
|
|
||||||
$this->notes = '';
|
|
||||||
$this->apodo = '';
|
|
||||||
$this->estado = 'activo';
|
|
||||||
$this->logo = null;
|
|
||||||
$this->editingCompanyId = null;
|
|
||||||
$this->showCreateForm = false;
|
|
||||||
$this->showEditForm = false;
|
|
||||||
$this->resetErrorBag();
|
|
||||||
$this->resetValidation();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function resetFilters()
|
|
||||||
{
|
|
||||||
$this->search = '';
|
|
||||||
$this->filterType = '';
|
|
||||||
$this->filterEstado = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function toggleCreateForm()
|
|
||||||
{
|
|
||||||
$this->showCreateForm = !$this->showCreateForm;
|
|
||||||
if ($this->showCreateForm) {
|
|
||||||
$this->showEditForm = false;
|
|
||||||
$this->resetForm();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function editCompany(Company $company)
|
|
||||||
{
|
|
||||||
$this->editingCompanyId = $company->id;
|
|
||||||
$this->name = $company->name;
|
|
||||||
$this->tax_id = $company->tax_id;
|
|
||||||
$this->address = $company->address;
|
|
||||||
$this->phone = $company->phone;
|
|
||||||
$this->email = $company->email;
|
|
||||||
$this->website = $company->website;
|
|
||||||
$this->type = $company->type;
|
|
||||||
$this->notes = $company->notes;
|
|
||||||
$this->apodo = $company->apodo;
|
|
||||||
$this->estado = $company->estado;
|
|
||||||
// Note: logo is not populated for security reasons
|
|
||||||
$this->showEditForm = true;
|
|
||||||
$this->showCreateForm = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function updateCompany()
|
|
||||||
{
|
|
||||||
$this->validate();
|
|
||||||
|
|
||||||
$company = Company::findOrFail($this->editingCompanyId);
|
|
||||||
|
|
||||||
$data = [
|
|
||||||
'name' => $this->name,
|
|
||||||
'tax_id' => $this->tax_id,
|
|
||||||
'address' => $this->address,
|
|
||||||
'phone' => $this->phone,
|
|
||||||
'email' => $this->email,
|
|
||||||
'website' => $this->website,
|
|
||||||
'type' => $this->type,
|
|
||||||
'notes' => $this->notes,
|
|
||||||
];
|
|
||||||
|
|
||||||
if ($this->logo) {
|
|
||||||
$logoPath = $this->logo->store('company-logos', 'public');
|
|
||||||
$data['logo_path'] = $logoPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
$company->update($data);
|
|
||||||
|
|
||||||
session()->flash('message', 'Empresa actualizada correctamente.');
|
|
||||||
$this->resetForm();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function createCompany()
|
|
||||||
{
|
|
||||||
$this->validate();
|
|
||||||
|
|
||||||
$data = [
|
|
||||||
'name' => $this->name,
|
|
||||||
'tax_id' => $this->tax_id,
|
|
||||||
'address' => $this->address,
|
|
||||||
'phone' => $this->phone,
|
|
||||||
'email' => $this->email,
|
|
||||||
'website' => $this->website,
|
|
||||||
'type' => $this->type,
|
|
||||||
'notes' => $this->notes,
|
|
||||||
];
|
|
||||||
|
|
||||||
if ($this->logo) {
|
|
||||||
$logoPath = $this->logo->store('company-logos', 'public');
|
|
||||||
$data['logo_path'] = $logoPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
Company::create($data);
|
|
||||||
|
|
||||||
session()->flash('message', 'Empresa creada correctamente.');
|
|
||||||
$this->resetForm();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function deleteCompany(Company $company)
|
|
||||||
{
|
|
||||||
$company->delete(); // Soft delete
|
|
||||||
session()->flash('message', 'Empresa eliminada correctamente.');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getCompaniesProperty()
|
public function getCompaniesProperty()
|
||||||
{
|
{
|
||||||
return Company::when($this->search, function ($query) {
|
return Company::when($this->search, function ($q) {
|
||||||
$query->where('name', 'like', '%' . $this->search . '%')
|
$s = '%' . $this->search . '%';
|
||||||
->orWhere('apodo', 'like', '%' . $this->search . '%')
|
$q->where(fn($q2) => $q2
|
||||||
->orWhere('tax_id', 'like', '%' . $this->search . '%');
|
->where('name', 'like', $s)
|
||||||
})
|
->orWhere('apodo', 'like', $s)
|
||||||
->when($this->filterType, function ($query) {
|
->orWhere('tax_id', 'like', $s));
|
||||||
$query->where('type', $this->filterType);
|
})
|
||||||
})
|
->when($this->filterType, fn($q) => $q->where('type', $this->filterType))
|
||||||
->when($this->filterEstado, function ($query) {
|
->when($this->filterEstado, fn($q) => $q->where('estado', $this->filterEstado))
|
||||||
$query->where('estado', $this->filterEstado);
|
->withCount('projects')
|
||||||
})
|
->orderBy('name')
|
||||||
->withCount('projects') // Eager load project count
|
->get();
|
||||||
->orderBy('name')
|
}
|
||||||
->get();
|
|
||||||
|
public function deleteCompany(Company $company): void
|
||||||
|
{
|
||||||
|
if ($company->logo_path) {
|
||||||
|
Storage::disk('public')->delete($company->logo_path);
|
||||||
|
}
|
||||||
|
$company->delete();
|
||||||
|
$this->dispatch('notify', 'Empresa eliminada.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function exportCsv()
|
public function exportCsv()
|
||||||
{
|
{
|
||||||
$companies = $this->getCompaniesProperty();
|
$companies = $this->getCompaniesProperty();
|
||||||
|
|
||||||
// Create CSV content
|
return response()->streamDownload(function () use ($companies) {
|
||||||
$headers = [
|
|
||||||
"Content-type: text/csv",
|
|
||||||
"Content-Disposition: attachment; filename=empresas.csv",
|
|
||||||
"Pragma: no-cache",
|
|
||||||
"Cache-Control: must-revalidate, post-check=0, pre-check=0",
|
|
||||||
"Expires: 0"
|
|
||||||
];
|
|
||||||
|
|
||||||
$callback = function() use ($companies) {
|
|
||||||
$handle = fopen('php://output', 'w');
|
$handle = fopen('php://output', 'w');
|
||||||
// Add BOM for UTF-8 in Excel
|
fprintf($handle, chr(0xEF) . chr(0xBB) . chr(0xBF));
|
||||||
fprintf($handle, chr(0xEF).chr(0xBB).chr(0xBF));
|
fputcsv($handle, ['Nombre', 'Apodo', 'NIF/Tax ID', 'Tipo', 'Estado', 'Dirección', 'Teléfono', 'Email', 'Website', 'Proyectos', 'Creación']);
|
||||||
|
foreach ($companies as $c) {
|
||||||
// Header row
|
|
||||||
fputcsv($handle, ['Nombre', 'Apodo', 'NIF/Tax ID', 'Tipo', 'Estado', 'Dirección', 'Teléfono', 'Email', 'Website', 'Proyectos Asociados', 'Fecha Creación']);
|
|
||||||
|
|
||||||
foreach ($companies as $company) {
|
|
||||||
fputcsv($handle, [
|
fputcsv($handle, [
|
||||||
$company->name,
|
$c->name, $c->apodo ?? '', $c->tax_id ?? '',
|
||||||
$company->apodo ?? '',
|
$c->type, $c->estado, $c->address ?? '',
|
||||||
$company->tax_id ?? '',
|
$c->phone ?? '', $c->email ?? '', $c->website ?? '',
|
||||||
$company->type,
|
$c->projects_count ?? 0,
|
||||||
$company->estado,
|
$c->created_at?->format('d/m/Y'),
|
||||||
$company->address ?? '',
|
|
||||||
$company->phone ?? '',
|
|
||||||
$company->email ?? '',
|
|
||||||
$company->website ?? '',
|
|
||||||
$company->projects_count ?? 0,
|
|
||||||
$company->created_at ? $company->created_at->format('d/m/Y H:i') : ''
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
fclose($handle);
|
fclose($handle);
|
||||||
};
|
}, 'empresas.csv', ['Content-Type' => 'text/csv; charset=UTF-8']);
|
||||||
|
|
||||||
return response()->stream($callback, 200, $headers);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use Rappasoft\LaravelLivewireTables\DataTableComponent;
|
||||||
|
use Rappasoft\LaravelLivewireTables\Views\Column;
|
||||||
|
use Rappasoft\LaravelLivewireTables\Views\Filters\SelectFilter;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use App\Models\Company;
|
||||||
|
|
||||||
|
class CompanyTable extends DataTableComponent
|
||||||
|
{
|
||||||
|
protected $model = Company::class;
|
||||||
|
|
||||||
|
public function configure(): void
|
||||||
|
{
|
||||||
|
$this->setPrimaryKey('id')
|
||||||
|
->setDefaultSort('name', 'asc')
|
||||||
|
->setSortingPillsEnabled(false)
|
||||||
|
->setAdditionalSelects([
|
||||||
|
'companies.id as id',
|
||||||
|
'companies.apodo as apodo',
|
||||||
|
'companies.tax_id as tax_id',
|
||||||
|
'companies.phone as phone',
|
||||||
|
'companies.email as email',
|
||||||
|
'companies.logo_path as logo_path',
|
||||||
|
'companies.created_at as created_at',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function builder(): Builder
|
||||||
|
{
|
||||||
|
return Company::withCount('projects');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function columns(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Column::make('Empresa', 'name')
|
||||||
|
->sortable()
|
||||||
|
->searchable()
|
||||||
|
->format(function ($value, $row) {
|
||||||
|
$logoHtml = '';
|
||||||
|
if ($row->logo_path && Storage::disk('public')->exists($row->logo_path)) {
|
||||||
|
$url = Storage::disk('public')->url($row->logo_path);
|
||||||
|
$logoHtml = '<img src="'.e($url).'" class="w-9 h-9 rounded object-contain border border-base-300 shrink-0" />';
|
||||||
|
} else {
|
||||||
|
$logoHtml = '<div class="w-9 h-9 rounded bg-base-200 flex items-center justify-center shrink-0 text-gray-400">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 opacity-40" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-2 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/></svg>
|
||||||
|
</div>';
|
||||||
|
}
|
||||||
|
$html = '<div class="flex items-center gap-3">'.$logoHtml.'<div>';
|
||||||
|
$html .= '<p class="font-semibold text-sm leading-tight">'.e($value).'</p>';
|
||||||
|
if ($row->apodo) $html .= '<p class="text-xs text-gray-500">'.e($row->apodo).'</p>';
|
||||||
|
if ($row->tax_id) $html .= '<p class="text-xs text-gray-400">NIF: '.e($row->tax_id).'</p>';
|
||||||
|
$html .= '</div></div>';
|
||||||
|
return $html;
|
||||||
|
})
|
||||||
|
->html(),
|
||||||
|
|
||||||
|
Column::make('Tipo', 'type')
|
||||||
|
->sortable()
|
||||||
|
->format(function ($value) {
|
||||||
|
$map = [
|
||||||
|
'owner' => ['badge-success', 'Promotor'],
|
||||||
|
'constructor' => ['badge-primary', 'Constructor'],
|
||||||
|
'subcontractor' => ['badge-secondary', 'Subcontratista'],
|
||||||
|
'consultant' => ['badge-info', 'Consultor'],
|
||||||
|
'supplier' => ['badge-warning', 'Proveedor'],
|
||||||
|
];
|
||||||
|
[$cls, $label] = $map[$value] ?? ['badge-ghost', 'Otro'];
|
||||||
|
return '<span class="badge badge-sm '.$cls.'">'.$label.'</span>';
|
||||||
|
})
|
||||||
|
->html(),
|
||||||
|
|
||||||
|
Column::make('Contacto', 'phone')
|
||||||
|
->format(function ($value, $row) {
|
||||||
|
$html = '';
|
||||||
|
if ($row->phone) {
|
||||||
|
$html .= '<div class="flex items-center gap-1 text-sm">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5 opacity-50 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"/></svg>
|
||||||
|
'.e($row->phone).'</div>';
|
||||||
|
}
|
||||||
|
if ($row->email) {
|
||||||
|
$html .= '<div class="flex items-center gap-1 text-xs text-gray-500 max-w-[180px] truncate">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5 opacity-50 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>
|
||||||
|
'.e($row->email).'</div>';
|
||||||
|
}
|
||||||
|
return $html ?: '<span class="text-gray-300">—</span>';
|
||||||
|
})
|
||||||
|
->html(),
|
||||||
|
|
||||||
|
Column::make('Estado', 'estado')
|
||||||
|
->sortable()
|
||||||
|
->format(function ($value) {
|
||||||
|
$map = [
|
||||||
|
'activo' => ['badge-success', 'Activo'],
|
||||||
|
'inactivo' => ['badge-ghost', 'Inactivo'],
|
||||||
|
'suspendido' => ['badge-error', 'Suspendido'],
|
||||||
|
];
|
||||||
|
[$cls, $label] = $map[$value ?? 'activo'] ?? ['badge-ghost', ucfirst($value ?? 'activo')];
|
||||||
|
return '<span class="badge badge-sm '.$cls.'">'.$label.'</span>';
|
||||||
|
})
|
||||||
|
->html(),
|
||||||
|
|
||||||
|
Column::make('Proyectos')
|
||||||
|
->label(fn ($row) =>
|
||||||
|
'<span class="badge badge-outline badge-sm">'.(int)($row->projects_count ?? 0).'</span>'
|
||||||
|
)
|
||||||
|
->html(),
|
||||||
|
|
||||||
|
Column::make('Acciones')
|
||||||
|
->label(function ($row) {
|
||||||
|
$ver = route('companies.show', $row->id);
|
||||||
|
$editar = route('companies.edit', $row->id);
|
||||||
|
$name = addslashes($row->name);
|
||||||
|
|
||||||
|
$html = '<div class="flex items-center justify-end gap-1">';
|
||||||
|
$html .= '<a href="'.$ver.'" class="btn btn-xs btn-outline" title="Ver" wire:navigate>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" 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>
|
||||||
|
</a>';
|
||||||
|
$html .= '<a href="'.$editar.'" class="btn btn-xs btn-outline btn-info" title="Editar" wire:navigate>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" 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>
|
||||||
|
</a>';
|
||||||
|
$html .= '<button wire:click="deleteCompany('.$row->id.')"
|
||||||
|
wire:confirm="¿Eliminar \''.$name.'\'? Esta acción no se puede deshacer."
|
||||||
|
class="btn btn-xs btn-outline btn-error" title="Eliminar">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" 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>';
|
||||||
|
$html .= '</div>';
|
||||||
|
return $html;
|
||||||
|
})
|
||||||
|
->html(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function filters(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
SelectFilter::make('Tipo', 'type')
|
||||||
|
->options([
|
||||||
|
'' => 'Tipo: todos',
|
||||||
|
'owner' => 'Promotor',
|
||||||
|
'constructor' => 'Constructor',
|
||||||
|
'subcontractor' => 'Subcontratista',
|
||||||
|
'consultant' => 'Consultor',
|
||||||
|
'supplier' => 'Proveedor',
|
||||||
|
'other' => 'Otro',
|
||||||
|
])
|
||||||
|
->filter(fn (Builder $query, string $value) => $query->where('type', $value)),
|
||||||
|
|
||||||
|
SelectFilter::make('Estado', 'estado')
|
||||||
|
->options([
|
||||||
|
'' => 'Estado: todos',
|
||||||
|
'activo' => 'Activo',
|
||||||
|
'inactivo' => 'Inactivo',
|
||||||
|
'suspendido' => 'Suspendido',
|
||||||
|
])
|
||||||
|
->filter(fn (Builder $query, string $value) => $query->where('estado', $value)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteCompany(int $id): void
|
||||||
|
{
|
||||||
|
$company = Company::findOrFail($id);
|
||||||
|
if ($company->logo_path) {
|
||||||
|
Storage::disk('public')->delete($company->logo_path);
|
||||||
|
}
|
||||||
|
$company->delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use Livewire\Component;
|
||||||
|
use Livewire\Attributes\Layout;
|
||||||
|
use App\Models\Company;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Issue;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
||||||
|
#[Layout('layouts.app')]
|
||||||
|
class CompanyView extends Component
|
||||||
|
{
|
||||||
|
public Company $company;
|
||||||
|
public string $activeTab = 'summary';
|
||||||
|
|
||||||
|
// Projects tab
|
||||||
|
public ?int $addProjectId = null;
|
||||||
|
public string $addProjectRole = '';
|
||||||
|
public $availableProjects;
|
||||||
|
|
||||||
|
// People tab
|
||||||
|
public ?int $assignUserId = null;
|
||||||
|
public $assignableUsers;
|
||||||
|
|
||||||
|
// Notes tab
|
||||||
|
public string $notes = '';
|
||||||
|
public bool $editingNotes = false;
|
||||||
|
|
||||||
|
// Stats (computed once in mount, refreshed on mutations)
|
||||||
|
public int $usersCount = 0;
|
||||||
|
public int $projectsCount = 0;
|
||||||
|
public float $avgProgress = 0.0;
|
||||||
|
public int $openIssues = 0;
|
||||||
|
|
||||||
|
public function mount(Company $company): void
|
||||||
|
{
|
||||||
|
abort_unless(Auth::user()->can('view companies'), 403);
|
||||||
|
|
||||||
|
$this->company = $company->load(['users.roles', 'projects.phases']);
|
||||||
|
$this->notes = $company->notes ?? '';
|
||||||
|
|
||||||
|
$this->loadAvailableProjects();
|
||||||
|
$this->loadAssignableUsers();
|
||||||
|
$this->computeStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private function loadAvailableProjects(): void
|
||||||
|
{
|
||||||
|
$assignedIds = $this->company->projects->pluck('id');
|
||||||
|
$this->availableProjects = Project::whereNotIn('id', $assignedIds)
|
||||||
|
->orderBy('name')->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function loadAssignableUsers(): void
|
||||||
|
{
|
||||||
|
$this->assignableUsers = User::where(function ($q) {
|
||||||
|
$q->where('company_id', '!=', $this->company->id)
|
||||||
|
->orWhereNull('company_id');
|
||||||
|
})->orderBy('name')->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function computeStats(): void
|
||||||
|
{
|
||||||
|
$this->usersCount = $this->company->users->count();
|
||||||
|
$this->projectsCount = $this->company->projects->count();
|
||||||
|
$this->avgProgress = round(
|
||||||
|
$this->company->projects->flatMap(fn($p) => $p->phases)->avg('progress_percent') ?? 0
|
||||||
|
);
|
||||||
|
$userIds = $this->company->users->pluck('id');
|
||||||
|
$this->openIssues = $userIds->isNotEmpty()
|
||||||
|
? Issue::whereIn('reported_by', $userIds)->where('status', 'open')->count()
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tabs ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function setTab(string $tab): void
|
||||||
|
{
|
||||||
|
$this->activeTab = $tab;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Projects ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function assignProject(): void
|
||||||
|
{
|
||||||
|
$this->validate([
|
||||||
|
'addProjectId' => 'required|exists:projects,id',
|
||||||
|
'addProjectRole' => 'required|string|max:150',
|
||||||
|
], [], ['addProjectId' => 'proyecto', 'addProjectRole' => 'rol en proyecto']);
|
||||||
|
|
||||||
|
$this->company->projects()->attach($this->addProjectId, [
|
||||||
|
'role_in_project' => $this->addProjectRole,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->company->load('projects.phases');
|
||||||
|
$this->addProjectId = null;
|
||||||
|
$this->addProjectRole = '';
|
||||||
|
$this->loadAvailableProjects();
|
||||||
|
$this->computeStats();
|
||||||
|
$this->dispatch('notify', 'Proyecto asignado correctamente.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function removeProject(int $projectId): void
|
||||||
|
{
|
||||||
|
$this->company->projects()->detach($projectId);
|
||||||
|
$this->company->load('projects.phases');
|
||||||
|
$this->loadAvailableProjects();
|
||||||
|
$this->computeStats();
|
||||||
|
$this->dispatch('notify', 'Proyecto desasignado.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── People ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function assignUser(): void
|
||||||
|
{
|
||||||
|
$this->validate([
|
||||||
|
'assignUserId' => 'required|exists:users,id',
|
||||||
|
], [], ['assignUserId' => 'usuario']);
|
||||||
|
|
||||||
|
User::find($this->assignUserId)?->update(['company_id' => $this->company->id]);
|
||||||
|
|
||||||
|
$this->company->load('users.roles');
|
||||||
|
$this->assignUserId = null;
|
||||||
|
$this->loadAssignableUsers();
|
||||||
|
$this->computeStats();
|
||||||
|
$this->dispatch('notify', 'Usuario vinculado a la empresa.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function removeUser(int $userId): void
|
||||||
|
{
|
||||||
|
User::find($userId)?->update(['company_id' => null]);
|
||||||
|
$this->company->load('users.roles');
|
||||||
|
$this->loadAssignableUsers();
|
||||||
|
$this->computeStats();
|
||||||
|
$this->dispatch('notify', 'Usuario desvinculado de la empresa.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Notes ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function saveNotes(): void
|
||||||
|
{
|
||||||
|
$this->validate(['notes' => 'nullable|string']);
|
||||||
|
$this->company->update(['notes' => $this->notes ?: null]);
|
||||||
|
$this->editingNotes = false;
|
||||||
|
$this->dispatch('notify', 'Notas guardadas.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
return view('livewire.company-view');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use App\Models\IssueChecklistTemplate;
|
||||||
|
use App\Models\Project;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Livewire\Attributes\Layout;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
#[Layout('layouts.app')]
|
||||||
|
class IssueChecklistManager extends Component
|
||||||
|
{
|
||||||
|
public Project $project;
|
||||||
|
public $templates = [];
|
||||||
|
|
||||||
|
public bool $showForm = false;
|
||||||
|
public $editingId = null;
|
||||||
|
public string $name = '';
|
||||||
|
public array $items = [''];
|
||||||
|
|
||||||
|
public function mount(Project $project)
|
||||||
|
{
|
||||||
|
$this->project = $project;
|
||||||
|
abort_unless($this->canAccessProject() && Auth::user()->can('edit issues'), 403);
|
||||||
|
$this->loadTemplates();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function canAccessProject(): bool
|
||||||
|
{
|
||||||
|
$user = Auth::user();
|
||||||
|
return $user->can('manage all')
|
||||||
|
|| $this->project->users()->where('user_id', $user->id)->exists();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function loadTemplates(): void
|
||||||
|
{
|
||||||
|
$this->templates = IssueChecklistTemplate::where('project_id', $this->project->id)
|
||||||
|
->orderBy('name')->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function newTemplate(): void
|
||||||
|
{
|
||||||
|
$this->reset(['editingId', 'name']);
|
||||||
|
$this->items = [''];
|
||||||
|
$this->resetErrorBag();
|
||||||
|
$this->showForm = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function edit($id): void
|
||||||
|
{
|
||||||
|
$t = IssueChecklistTemplate::where('project_id', $this->project->id)->findOrFail($id);
|
||||||
|
$this->editingId = $t->id;
|
||||||
|
$this->name = $t->name;
|
||||||
|
$this->items = array_values($t->items ?: ['']) ?: [''];
|
||||||
|
$this->resetErrorBag();
|
||||||
|
$this->showForm = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function addItemLine(): void
|
||||||
|
{
|
||||||
|
$this->items[] = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function removeItemLine(int $i): void
|
||||||
|
{
|
||||||
|
unset($this->items[$i]);
|
||||||
|
$this->items = array_values($this->items);
|
||||||
|
if (empty($this->items)) {
|
||||||
|
$this->items = [''];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save(): void
|
||||||
|
{
|
||||||
|
$this->validate([
|
||||||
|
'name' => 'required|string|max:255',
|
||||||
|
'items' => 'required|array',
|
||||||
|
'items.*' => 'nullable|string|max:255',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$items = array_values(array_filter(array_map('trim', $this->items), fn ($v) => $v !== ''));
|
||||||
|
if (empty($items)) {
|
||||||
|
$this->addError('items', 'Añade al menos una tarea.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
IssueChecklistTemplate::updateOrCreate(
|
||||||
|
['id' => $this->editingId, 'project_id' => $this->project->id],
|
||||||
|
['name' => $this->name, 'items' => $items, 'project_id' => $this->project->id],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->showForm = false;
|
||||||
|
$this->loadTemplates();
|
||||||
|
$this->dispatch('notify', 'Plantilla guardada');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete($id): void
|
||||||
|
{
|
||||||
|
IssueChecklistTemplate::where('project_id', $this->project->id)->findOrFail($id)->delete();
|
||||||
|
$this->loadTemplates();
|
||||||
|
$this->dispatch('notify', 'Plantilla eliminada');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
return view('livewire.issues.issue-checklist-manager');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use App\Models\Issue;
|
||||||
|
use App\Models\IssueChecklistTemplate;
|
||||||
|
use App\Models\IssueComment;
|
||||||
|
use App\Models\IssueTask;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Notifications\IssueCommentedNotification;
|
||||||
|
use App\Notifications\IssueStatusChangedNotification;
|
||||||
|
use App\Notifications\IssueTaskAssignedNotification;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use Livewire\Attributes\Layout;
|
||||||
|
use Livewire\Component;
|
||||||
|
use Livewire\WithFileUploads;
|
||||||
|
|
||||||
|
#[Layout('layouts.app')]
|
||||||
|
class IssueDetail extends Component
|
||||||
|
{
|
||||||
|
use WithFileUploads;
|
||||||
|
|
||||||
|
public Project $project;
|
||||||
|
public Issue $issue;
|
||||||
|
|
||||||
|
// New task form
|
||||||
|
public string $newTaskTitle = '';
|
||||||
|
public $newTaskAssignee = '';
|
||||||
|
public $newTaskDue = '';
|
||||||
|
|
||||||
|
// Checklist templates
|
||||||
|
public $checklistTemplates = [];
|
||||||
|
public $applyTemplateId = '';
|
||||||
|
|
||||||
|
// New comment form
|
||||||
|
public string $newComment = '';
|
||||||
|
public $commentPhoto = null; // single optional photo on a comment
|
||||||
|
|
||||||
|
// Issue-level photos
|
||||||
|
public $issuePhotos = []; // multiple
|
||||||
|
|
||||||
|
// Verification / resolution
|
||||||
|
public string $resolutionNotes = '';
|
||||||
|
|
||||||
|
public $projectUsers = [];
|
||||||
|
|
||||||
|
public function mount(Project $project, Issue $issue)
|
||||||
|
{
|
||||||
|
abort_unless($issue->project_id === $project->id, 404);
|
||||||
|
$this->project = $project;
|
||||||
|
$this->issue = $issue;
|
||||||
|
abort_unless($this->canAccessProject() && Auth::user()->can('view issues'), 403);
|
||||||
|
|
||||||
|
$this->resolutionNotes = $issue->resolution_notes ?? '';
|
||||||
|
$this->projectUsers = $project->users()->orderBy('name')->get();
|
||||||
|
$this->checklistTemplates = IssueChecklistTemplate::where('project_id', $project->id)->orderBy('name')->get();
|
||||||
|
$this->refreshIssue();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function canAccessProject(): bool
|
||||||
|
{
|
||||||
|
$user = Auth::user();
|
||||||
|
return $user->can('manage all')
|
||||||
|
|| $this->project->users()->where('user_id', $user->id)->exists();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function canEdit(): bool
|
||||||
|
{
|
||||||
|
return Auth::user()->can('edit issues');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Notify the issue's stakeholders (reporter + assignee), excluding the current actor. */
|
||||||
|
private function notifyStakeholders($notification): void
|
||||||
|
{
|
||||||
|
$this->issue->loadMissing(['reporter', 'assignee']);
|
||||||
|
collect([$this->issue->reporter, $this->issue->assignee])
|
||||||
|
->filter()
|
||||||
|
->unique('id')
|
||||||
|
->reject(fn ($u) => $u->id === Auth::id())
|
||||||
|
->each(fn ($u) => $u->notify($notification));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function refreshIssue(): void
|
||||||
|
{
|
||||||
|
$this->issue->load([
|
||||||
|
'reporter', 'assignee', 'feature',
|
||||||
|
'tasks.assignee', 'tasks.completer',
|
||||||
|
'comments.user', 'comments.media',
|
||||||
|
'media.uploader',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Checklist ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function addTask(): void
|
||||||
|
{
|
||||||
|
abort_unless($this->canEdit(), 403);
|
||||||
|
$this->validate([
|
||||||
|
'newTaskTitle' => 'required|string|max:255',
|
||||||
|
'newTaskAssignee' => 'nullable|exists:users,id',
|
||||||
|
'newTaskDue' => 'nullable|date',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$task = $this->issue->tasks()->create([
|
||||||
|
'title' => $this->newTaskTitle,
|
||||||
|
'assigned_to' => $this->newTaskAssignee ?: null,
|
||||||
|
'due_date' => $this->newTaskDue ?: null,
|
||||||
|
'order' => ((int) $this->issue->tasks()->max('order')) + 1,
|
||||||
|
'uuid' => (string) \Illuminate\Support\Str::uuid(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Notify the assignee (unless they assigned it to themselves).
|
||||||
|
if ($task->assigned_to && $task->assigned_to !== Auth::id()) {
|
||||||
|
$task->loadMissing('issue');
|
||||||
|
$task->assignee?->notify(new IssueTaskAssignedNotification($task));
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->reset(['newTaskTitle', 'newTaskAssignee', 'newTaskDue']);
|
||||||
|
$this->refreshIssue();
|
||||||
|
$this->dispatch('notify', 'Tarea añadida');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function applyTemplate(): void
|
||||||
|
{
|
||||||
|
abort_unless($this->canEdit(), 403);
|
||||||
|
$this->validate(['applyTemplateId' => 'required|exists:issue_checklist_templates,id']);
|
||||||
|
|
||||||
|
$template = IssueChecklistTemplate::where('project_id', $this->project->id)
|
||||||
|
->findOrFail($this->applyTemplateId);
|
||||||
|
|
||||||
|
$order = (int) $this->issue->tasks()->max('order');
|
||||||
|
foreach ($template->items ?: [] as $title) {
|
||||||
|
$this->issue->tasks()->create([
|
||||||
|
'title' => $title,
|
||||||
|
'order' => ++$order,
|
||||||
|
'uuid' => (string) \Illuminate\Support\Str::uuid(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->applyTemplateId = '';
|
||||||
|
$this->refreshIssue();
|
||||||
|
$this->dispatch('notify', 'Plantilla aplicada');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toggleTask($taskId): void
|
||||||
|
{
|
||||||
|
abort_unless($this->canEdit(), 403);
|
||||||
|
$task = $this->issue->tasks()->findOrFail($taskId);
|
||||||
|
$done = ! $task->is_done;
|
||||||
|
$task->update([
|
||||||
|
'is_done' => $done,
|
||||||
|
'done_at' => $done ? now() : null,
|
||||||
|
'done_by' => $done ? Auth::id() : null,
|
||||||
|
]);
|
||||||
|
$this->refreshIssue();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteTask($taskId): void
|
||||||
|
{
|
||||||
|
abort_unless($this->canEdit(), 403);
|
||||||
|
$this->issue->tasks()->findOrFail($taskId)->delete();
|
||||||
|
$this->refreshIssue();
|
||||||
|
$this->dispatch('notify', 'Tarea eliminada');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Comments + photos ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function addComment(): void
|
||||||
|
{
|
||||||
|
// Anyone who can view the issue (and is a member) may comment / report progress.
|
||||||
|
$this->validate([
|
||||||
|
'newComment' => 'nullable|string|max:5000',
|
||||||
|
'commentPhoto' => 'nullable|image|max:20480', // 20 MB
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (trim($this->newComment) === '' && ! $this->commentPhoto) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'newComment' => 'Escribe un comentario o adjunta una foto.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$comment = $this->issue->comments()->create([
|
||||||
|
'user_id' => Auth::id(),
|
||||||
|
'body' => trim($this->newComment) ?: '(foto)',
|
||||||
|
'uuid' => (string) \Illuminate\Support\Str::uuid(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($this->commentPhoto) {
|
||||||
|
abort_unless(Auth::user()->can('upload media'), 403);
|
||||||
|
$this->storeUpload($this->commentPhoto, $comment, 'comment');
|
||||||
|
}
|
||||||
|
|
||||||
|
$comment->setRelation('issue', $this->issue)->setRelation('user', Auth::user());
|
||||||
|
$this->notifyStakeholders(new IssueCommentedNotification($comment));
|
||||||
|
|
||||||
|
$this->reset(['newComment', 'commentPhoto']);
|
||||||
|
$this->refreshIssue();
|
||||||
|
$this->dispatch('notify', 'Comentario añadido');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function uploadIssuePhotos(): void
|
||||||
|
{
|
||||||
|
abort_unless(Auth::user()->can('upload media'), 403);
|
||||||
|
$this->validate(['issuePhotos.*' => 'required|image|max:20480']);
|
||||||
|
|
||||||
|
foreach ($this->issuePhotos as $photo) {
|
||||||
|
$this->storeUpload($photo, $this->issue, 'issue');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->reset('issuePhotos');
|
||||||
|
$this->refreshIssue();
|
||||||
|
$this->dispatch('notify', 'Fotos subidas');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteMedia($mediaId): void
|
||||||
|
{
|
||||||
|
$media = \App\Models\Media::findOrFail($mediaId);
|
||||||
|
$user = Auth::user();
|
||||||
|
abort_unless($user->can('delete media') || $media->uploaded_by === $user->id, 403);
|
||||||
|
$media->delete();
|
||||||
|
$this->refreshIssue();
|
||||||
|
$this->dispatch('notify', 'Foto eliminada');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Store an uploaded file on the public disk and attach it to a parent model. */
|
||||||
|
private function storeUpload($file, $parent, string $entity): void
|
||||||
|
{
|
||||||
|
$mime = $file->getMimeType();
|
||||||
|
$path = $file->store("uploads/issues/{$this->issue->id}/{$entity}", 'public');
|
||||||
|
|
||||||
|
$parent->media()->create([
|
||||||
|
'name' => $file->getClientOriginalName(),
|
||||||
|
'file_path' => $path,
|
||||||
|
'file_type' => $mime,
|
||||||
|
'file_extension' => $file->getClientOriginalExtension(),
|
||||||
|
'file_size' => $file->getSize(),
|
||||||
|
'category' => str_starts_with($mime, 'image/') ? 'image' : 'document',
|
||||||
|
'uploaded_by' => Auth::id(),
|
||||||
|
'uuid' => (string) \Illuminate\Support\Str::uuid(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Status workflow (verification) ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function sendToReview(): void
|
||||||
|
{
|
||||||
|
abort_unless($this->canEdit(), 403);
|
||||||
|
$this->issue->update(['status' => 'in_review']);
|
||||||
|
$this->notifyStakeholders(new IssueStatusChangedNotification($this->issue, 'in_review'));
|
||||||
|
$this->refreshIssue();
|
||||||
|
$this->dispatch('notify', 'Incidencia enviada a revisión');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function verifyResolve(): void
|
||||||
|
{
|
||||||
|
abort_unless($this->canEdit(), 403);
|
||||||
|
$this->issue->update([
|
||||||
|
'status' => 'resolved',
|
||||||
|
'resolved_at' => $this->issue->resolved_at ?? now(),
|
||||||
|
'resolution_notes' => $this->resolutionNotes ?: null,
|
||||||
|
]);
|
||||||
|
$this->notifyStakeholders(new IssueStatusChangedNotification($this->issue, 'resolved'));
|
||||||
|
$this->refreshIssue();
|
||||||
|
$this->dispatch('notify', 'Incidencia validada y resuelta');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function closeIssue(): void
|
||||||
|
{
|
||||||
|
abort_unless($this->canEdit(), 403);
|
||||||
|
$this->issue->update([
|
||||||
|
'status' => 'closed',
|
||||||
|
'resolved_at' => $this->issue->resolved_at ?? now(),
|
||||||
|
]);
|
||||||
|
$this->notifyStakeholders(new IssueStatusChangedNotification($this->issue, 'closed'));
|
||||||
|
$this->refreshIssue();
|
||||||
|
$this->dispatch('notify', 'Incidencia cerrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reopen(): void
|
||||||
|
{
|
||||||
|
abort_unless($this->canEdit(), 403);
|
||||||
|
$this->issue->update(['status' => 'open', 'resolved_at' => null]);
|
||||||
|
$this->notifyStakeholders(new IssueStatusChangedNotification($this->issue, 'open'));
|
||||||
|
$this->refreshIssue();
|
||||||
|
$this->dispatch('notify', 'Incidencia reabierta');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
return view('livewire.issues.issue-detail', [
|
||||||
|
'canEdit' => $this->canEdit(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use App\Models\Issue;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Notifications\IssueAssignedNotification;
|
||||||
|
use App\Notifications\IssueReportedNotification;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Livewire\Attributes\Layout;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
#[Layout('layouts.app')]
|
||||||
|
class IssueForm extends Component
|
||||||
|
{
|
||||||
|
public Project $project;
|
||||||
|
public ?Issue $issue = null; // null = create, set = edit
|
||||||
|
|
||||||
|
public $projectUsers = [];
|
||||||
|
|
||||||
|
// Form fields
|
||||||
|
public $title = '';
|
||||||
|
public $description = '';
|
||||||
|
public $status = 'open';
|
||||||
|
public $priority = 'medium';
|
||||||
|
public $type = 'defect';
|
||||||
|
public $assignedTo = '';
|
||||||
|
public $resolutionNotes = '';
|
||||||
|
|
||||||
|
// Optional context (e.g. when reporting from a map feature)
|
||||||
|
public $featureId = null;
|
||||||
|
public $inspectionId = null;
|
||||||
|
public $featureName = null; // shown when the issue is pre-linked to a map element
|
||||||
|
|
||||||
|
public function mount(Project $project, ?Issue $issue = null)
|
||||||
|
{
|
||||||
|
$this->project = $project;
|
||||||
|
|
||||||
|
if ($issue) {
|
||||||
|
abort_unless($issue->project_id === $project->id, 404);
|
||||||
|
}
|
||||||
|
abort_unless($this->canAccessProject() && Auth::user()->can($issue ? 'edit issues' : 'create issues'), 403);
|
||||||
|
|
||||||
|
$this->projectUsers = $project->users()->orderBy('name')->get();
|
||||||
|
|
||||||
|
if ($issue) {
|
||||||
|
$this->issue = $issue;
|
||||||
|
$this->title = $issue->title;
|
||||||
|
$this->description = $issue->description ?? '';
|
||||||
|
$this->status = $issue->status;
|
||||||
|
$this->priority = $issue->priority;
|
||||||
|
$this->type = $issue->type ?? 'defect';
|
||||||
|
$this->assignedTo = $issue->assigned_to ?? '';
|
||||||
|
$this->resolutionNotes = $issue->resolution_notes ?? '';
|
||||||
|
$this->featureId = $issue->feature_id;
|
||||||
|
$this->inspectionId = $issue->inspection_id;
|
||||||
|
$this->featureName = $issue->feature?->name;
|
||||||
|
} elseif ($featureId = request()->integer('feature')) {
|
||||||
|
// Pre-link to a map element when reporting from the project map.
|
||||||
|
$feature = \App\Models\Feature::with('layer.phase')->find($featureId);
|
||||||
|
if ($feature && $feature->layer?->phase?->project_id === $project->id) {
|
||||||
|
$this->featureId = $feature->id;
|
||||||
|
$this->featureName = $feature->name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function canAccessProject(): bool
|
||||||
|
{
|
||||||
|
$user = Auth::user();
|
||||||
|
return $user->can('manage all')
|
||||||
|
|| $this->project->users()->where('user_id', $user->id)->exists();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'title' => 'required|string|max:255',
|
||||||
|
'description' => 'nullable|string',
|
||||||
|
'status' => 'required|in:' . implode(',', Issue::STATUSES),
|
||||||
|
'priority' => 'required|in:' . implode(',', Issue::PRIORITIES),
|
||||||
|
'type' => 'required|in:' . implode(',', Issue::TYPES),
|
||||||
|
'assignedTo' => 'nullable|exists:users,id',
|
||||||
|
'resolutionNotes' => 'nullable|string',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save()
|
||||||
|
{
|
||||||
|
abort_unless(Auth::user()->can($this->issue ? 'edit issues' : 'create issues'), 403);
|
||||||
|
$this->validate();
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'title' => $this->title,
|
||||||
|
'description' => $this->description,
|
||||||
|
'status' => $this->status,
|
||||||
|
'priority' => $this->priority,
|
||||||
|
'type' => $this->type,
|
||||||
|
'feature_id' => $this->featureId,
|
||||||
|
'inspection_id' => $this->inspectionId,
|
||||||
|
'assigned_to' => $this->assignedTo ?: null,
|
||||||
|
'resolution_notes' => $this->resolutionNotes ?: null,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Keep resolved_at in sync with the status
|
||||||
|
$payload['resolved_at'] = in_array($this->status, ['resolved', 'closed']) ? now() : null;
|
||||||
|
|
||||||
|
if ($this->issue) {
|
||||||
|
$previousAssignee = $this->issue->assigned_to;
|
||||||
|
// Don't overwrite an existing resolved date
|
||||||
|
if ($this->issue->resolved_at && in_array($this->status, ['resolved', 'closed'])) {
|
||||||
|
unset($payload['resolved_at']);
|
||||||
|
}
|
||||||
|
$this->issue->update($payload);
|
||||||
|
$issue = $this->issue;
|
||||||
|
|
||||||
|
// Notify a newly assigned user (when it changed and isn't the current actor).
|
||||||
|
if ($issue->assigned_to && $issue->assigned_to !== $previousAssignee && $issue->assigned_to !== Auth::id()) {
|
||||||
|
$issue->assignee?->notify(new IssueAssignedNotification($issue));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$issue = Issue::create(array_merge($payload, [
|
||||||
|
'project_id' => $this->project->id,
|
||||||
|
'reported_by' => Auth::id(),
|
||||||
|
]));
|
||||||
|
|
||||||
|
$issue->load(['feature', 'assignee']);
|
||||||
|
$creator = $this->project->creator;
|
||||||
|
if ($creator && $creator->id !== Auth::id()) {
|
||||||
|
$creator->notify(new IssueReportedNotification($issue));
|
||||||
|
}
|
||||||
|
if ($issue->assignee && $issue->assignee->id !== Auth::id()) {
|
||||||
|
$issue->assignee->notify(new IssueReportedNotification($issue));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
session()->flash('message', $this->issue ? 'Incidencia actualizada' : 'Incidencia creada');
|
||||||
|
|
||||||
|
return $this->redirectRoute('projects.issues.show', ['project' => $this->project, 'issue' => $issue], navigate: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
return view('livewire.issues.issue-form');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use Livewire\Component;
|
||||||
|
use Livewire\Attributes\Layout;
|
||||||
|
use Livewire\Attributes\On;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\Issue;
|
||||||
|
|
||||||
|
#[Layout('layouts.app')]
|
||||||
|
class IssueManager extends Component
|
||||||
|
{
|
||||||
|
public Project $project;
|
||||||
|
|
||||||
|
public function mount(Project $project)
|
||||||
|
{
|
||||||
|
$this->project = $project;
|
||||||
|
abort_unless($this->canAccessProject() && Auth::user()->can('view issues'), 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The current user must be a project member (or super-admin) to touch issues. */
|
||||||
|
private function canAccessProject(): bool
|
||||||
|
{
|
||||||
|
$user = Auth::user();
|
||||||
|
return $user->can('manage all')
|
||||||
|
|| $this->project->users()->where('user_id', $user->id)->exists();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-render the stats bar after the embedded table changes an issue. */
|
||||||
|
#[On('issuesChanged')]
|
||||||
|
public function refreshStats(): void
|
||||||
|
{
|
||||||
|
// No state to mutate — the listener simply triggers a re-render so the
|
||||||
|
// stat counters recompute from the database in render().
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
$counts = Issue::where('project_id', $this->project->id)
|
||||||
|
->selectRaw('status, count(*) as c')
|
||||||
|
->groupBy('status')
|
||||||
|
->pluck('c', 'status');
|
||||||
|
|
||||||
|
return view('livewire.issues.issue-manager', [
|
||||||
|
'countOpen' => (int) ($counts['open'] ?? 0),
|
||||||
|
'countInReview' => (int) ($counts['in_review'] ?? 0),
|
||||||
|
'countResolved' => (int) ($counts['resolved'] ?? 0),
|
||||||
|
'countClosed' => (int) ($counts['closed'] ?? 0),
|
||||||
|
'countTotal' => (int) $counts->sum(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use App\Models\Issue;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Rappasoft\LaravelLivewireTables\DataTableComponent;
|
||||||
|
use Rappasoft\LaravelLivewireTables\Views\Column;
|
||||||
|
use Rappasoft\LaravelLivewireTables\Views\Filters\SelectFilter;
|
||||||
|
|
||||||
|
class IssueTable extends DataTableComponent
|
||||||
|
{
|
||||||
|
protected $model = Issue::class;
|
||||||
|
|
||||||
|
public int $projectId;
|
||||||
|
|
||||||
|
public function configure(): void
|
||||||
|
{
|
||||||
|
$this->setPrimaryKey('id')
|
||||||
|
->setDefaultSort('created_at', 'desc')
|
||||||
|
->setSortingPillsEnabled(false)
|
||||||
|
->setAdditionalSelects(['issues.id as id', 'issues.created_at as created_at']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function builder(): Builder
|
||||||
|
{
|
||||||
|
// Defence in depth: only members (or super-admin) may list a project's issues.
|
||||||
|
$user = Auth::user();
|
||||||
|
abort_unless(
|
||||||
|
$user->can('view issues') && (
|
||||||
|
$user->can('manage all')
|
||||||
|
|| \App\Models\Project::whereKey($this->projectId)->whereHas('users', fn ($q) => $q->where('user_id', $user->id))->exists()
|
||||||
|
),
|
||||||
|
403
|
||||||
|
);
|
||||||
|
|
||||||
|
return Issue::where('issues.project_id', $this->projectId)
|
||||||
|
->with(['feature', 'reporter', 'assignee'])
|
||||||
|
->withCount([
|
||||||
|
'comments',
|
||||||
|
'media',
|
||||||
|
'tasks',
|
||||||
|
'tasks as tasks_done_count' => fn (Builder $q) => $q->where('is_done', true),
|
||||||
|
'tasks as overdue_tasks_count' => fn (Builder $q) => $q->where('is_done', false)
|
||||||
|
->whereNotNull('due_date')
|
||||||
|
->whereDate('due_date', '<', now()->toDateString()),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function columns(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Column::make('Prioridad', 'priority')
|
||||||
|
->sortable()
|
||||||
|
->format(function ($value, $row) {
|
||||||
|
$label = ['low' => 'Bajo', 'medium' => 'Medio', 'high' => 'Alto', 'critical' => 'Crítico'][$value] ?? ucfirst($value);
|
||||||
|
$textColor = in_array($value, ['critical', 'high']) ? '#fff' : '#1f2937';
|
||||||
|
return '<span class="badge badge-sm font-semibold" style="background-color:'.$row->priority_color.';color:'.$textColor.';border-color:transparent;">'.$label.'</span>';
|
||||||
|
})
|
||||||
|
->html(),
|
||||||
|
|
||||||
|
Column::make('Título', 'title')
|
||||||
|
->sortable()
|
||||||
|
->searchable()
|
||||||
|
->format(function ($value, $row) {
|
||||||
|
$url = route('projects.issues.show', [$this->projectId, $row->id]);
|
||||||
|
$html = '<a href="'.$url.'" wire:navigate class="font-medium text-sm link link-hover">'.e($value).'</a>';
|
||||||
|
if ($row->description) {
|
||||||
|
$html .= '<div class="text-xs text-base-content/50 truncate max-w-xs">'.e(Str::limit($row->description, 60)).'</div>';
|
||||||
|
}
|
||||||
|
$meta = [];
|
||||||
|
if ($row->reporter) $meta[] = 'Reportado por '.e($row->reporter->name);
|
||||||
|
if ($row->comments_count) $meta[] = '💬 '.$row->comments_count;
|
||||||
|
if ($row->media_count) $meta[] = '📷 '.$row->media_count;
|
||||||
|
if ($meta) {
|
||||||
|
$html .= '<div class="text-xs text-base-content/40 mt-0.5">'.implode(' · ', $meta).'</div>';
|
||||||
|
}
|
||||||
|
if ($row->tasks_count) {
|
||||||
|
$pct = (int) round($row->tasks_done_count / $row->tasks_count * 100);
|
||||||
|
$html .= '<div class="flex items-center gap-2 mt-1 max-w-xs">
|
||||||
|
<progress class="progress progress-success w-24 h-1.5" value="'.$pct.'" max="100"></progress>
|
||||||
|
<span class="text-xs text-base-content/50">'.$row->tasks_done_count.'/'.$row->tasks_count.' tareas</span>
|
||||||
|
</div>';
|
||||||
|
}
|
||||||
|
if ($row->overdue_tasks_count) {
|
||||||
|
$html .= '<div class="mt-1"><span class="badge badge-error badge-sm gap-1">⏰ '.$row->overdue_tasks_count.' vencida'.($row->overdue_tasks_count > 1 ? 's' : '').'</span></div>';
|
||||||
|
}
|
||||||
|
return $html;
|
||||||
|
})
|
||||||
|
->html(),
|
||||||
|
|
||||||
|
Column::make('Tipo', 'type')
|
||||||
|
->sortable()
|
||||||
|
->format(fn ($value, $row) =>
|
||||||
|
'<span class="badge badge-sm" style="background-color:'.$row->type_color.';color:#fff;border-color:transparent;">'.e($row->type_label).'</span>')
|
||||||
|
->html(),
|
||||||
|
|
||||||
|
Column::make('Feature')
|
||||||
|
->label(fn ($row) => $row->feature
|
||||||
|
? '<span class="badge badge-outline badge-sm">'.e($row->feature->name).'</span>'
|
||||||
|
: '<span class="text-base-content/30 text-xs">—</span>')
|
||||||
|
->html(),
|
||||||
|
|
||||||
|
Column::make('Estado', 'status')
|
||||||
|
->sortable()
|
||||||
|
->format(function ($value, $row) {
|
||||||
|
$label = ['open' => 'Abierto', 'in_review' => 'En revisión', 'resolved' => 'Resuelto', 'closed' => 'Cerrado'][$value] ?? ucfirst($value);
|
||||||
|
return '<span class="badge badge-sm" style="background-color:'.$row->status_color.';color:#fff;border-color:transparent;">'.$label.'</span>';
|
||||||
|
})
|
||||||
|
->html(),
|
||||||
|
|
||||||
|
Column::make('Asignado a')
|
||||||
|
->label(fn ($row) => $row->assignee
|
||||||
|
? '<span class="text-sm">'.e($row->assignee->name).'</span>'
|
||||||
|
: '<span class="text-base-content/30 text-xs">Sin asignar</span>')
|
||||||
|
->html(),
|
||||||
|
|
||||||
|
Column::make('Fecha', 'created_at')
|
||||||
|
->sortable()
|
||||||
|
->format(function ($value, $row) {
|
||||||
|
$html = $row->created_at->format('d/m/Y');
|
||||||
|
if ($row->resolved_at) {
|
||||||
|
$html .= '<div class="text-success text-xs">Res. '.$row->resolved_at->format('d/m/Y').'</div>';
|
||||||
|
}
|
||||||
|
return $html;
|
||||||
|
})
|
||||||
|
->html(),
|
||||||
|
|
||||||
|
Column::make('Acciones')
|
||||||
|
->label(function ($row) {
|
||||||
|
$user = Auth::user();
|
||||||
|
$detail = route('projects.issues.show', [$this->projectId, $row->id]);
|
||||||
|
$edit = route('projects.issues.edit', [$this->projectId, $row->id]);
|
||||||
|
|
||||||
|
$html = '<div class="flex items-center justify-end gap-1 flex-wrap">';
|
||||||
|
$html .= '<a href="'.$detail.'" wire:navigate class="btn btn-xs btn-ghost" title="Abrir detalle">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
|
||||||
|
</a>';
|
||||||
|
|
||||||
|
if ($user->can('edit issues')) {
|
||||||
|
$html .= '<a href="'.$edit.'" wire:navigate class="btn btn-xs btn-ghost" title="Editar">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" 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>
|
||||||
|
</a>';
|
||||||
|
if (in_array($row->status, ['open', 'in_review'])) {
|
||||||
|
$html .= '<button wire:click="resolve('.$row->id.')" class="btn btn-xs btn-success" title="Marcar como resuelto">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
|
||||||
|
</button>';
|
||||||
|
}
|
||||||
|
if ($row->status !== 'closed') {
|
||||||
|
$html .= '<button wire:click="close('.$row->id.')" class="btn btn-xs btn-neutral" title="Cerrar">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||||
|
</button>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($user->can('delete issues')) {
|
||||||
|
$html .= '<button wire:click="deleteIssue('.$row->id.')" wire:confirm="¿Eliminar esta incidencia? Esta acción no se puede deshacer."
|
||||||
|
class="btn btn-xs btn-error btn-outline" title="Eliminar">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" 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>';
|
||||||
|
}
|
||||||
|
|
||||||
|
$html .= '</div>';
|
||||||
|
return $html;
|
||||||
|
})
|
||||||
|
->html(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function filters(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
SelectFilter::make('Estado', 'status')
|
||||||
|
->options([
|
||||||
|
'' => 'Estado: todos',
|
||||||
|
'open' => 'Abierto',
|
||||||
|
'in_review' => 'En revisión',
|
||||||
|
'resolved' => 'Resuelto',
|
||||||
|
'closed' => 'Cerrado',
|
||||||
|
])
|
||||||
|
->filter(fn (Builder $query, string $value) => $query->where('issues.status', $value)),
|
||||||
|
|
||||||
|
SelectFilter::make('Prioridad', 'priority')
|
||||||
|
->options([
|
||||||
|
'' => 'Prioridad: todas',
|
||||||
|
'critical' => 'Crítica',
|
||||||
|
'high' => 'Alta',
|
||||||
|
'medium' => 'Media',
|
||||||
|
'low' => 'Baja',
|
||||||
|
])
|
||||||
|
->filter(fn (Builder $query, string $value) => $query->where('issues.priority', $value)),
|
||||||
|
|
||||||
|
SelectFilter::make('Tipo', 'type')
|
||||||
|
->options(['' => 'Tipo: todos'] + \App\Models\Issue::typeLabels())
|
||||||
|
->filter(fn (Builder $query, string $value) => $query->where('issues.type', $value)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Row actions ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private function findIssue(int $id): Issue
|
||||||
|
{
|
||||||
|
return Issue::where('project_id', $this->projectId)->findOrFail($id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resolve(int $id): void
|
||||||
|
{
|
||||||
|
abort_unless(Auth::user()->can('edit issues'), 403);
|
||||||
|
$issue = $this->findIssue($id);
|
||||||
|
$issue->update(['status' => 'resolved', 'resolved_at' => $issue->resolved_at ?? now()]);
|
||||||
|
$this->dispatch('issuesChanged');
|
||||||
|
$this->dispatch('notify', 'Incidencia marcada como resuelta');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function close(int $id): void
|
||||||
|
{
|
||||||
|
abort_unless(Auth::user()->can('edit issues'), 403);
|
||||||
|
$issue = $this->findIssue($id);
|
||||||
|
$issue->update(['status' => 'closed', 'resolved_at' => $issue->resolved_at ?? now()]);
|
||||||
|
$this->dispatch('issuesChanged');
|
||||||
|
$this->dispatch('notify', 'Incidencia cerrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteIssue(int $id): void
|
||||||
|
{
|
||||||
|
abort_unless(Auth::user()->can('delete issues'), 403);
|
||||||
|
$this->findIssue($id)->delete();
|
||||||
|
$this->dispatch('issuesChanged');
|
||||||
|
$this->dispatch('notify', 'Incidencia eliminada');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,20 +9,19 @@ use Illuminate\Support\Facades\Session;
|
|||||||
|
|
||||||
class LanguageSwitcher extends Component
|
class LanguageSwitcher extends Component
|
||||||
{
|
{
|
||||||
public $currentLocale;
|
public string $currentLocale;
|
||||||
|
|
||||||
public function mount()
|
public function mount(): void
|
||||||
{
|
{
|
||||||
$this->currentLocale = App::getLocale();
|
$this->currentLocale = App::getLocale();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function switchLanguage($locale)
|
public function switchLanguage(string $locale): void
|
||||||
{
|
{
|
||||||
if (!in_array($locale, ['en', 'es'])) {
|
if (!in_array($locale, ['en', 'es'])) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
App::setLocale($locale);
|
|
||||||
Session::put('locale', $locale);
|
Session::put('locale', $locale);
|
||||||
|
|
||||||
if (Auth::check()) {
|
if (Auth::check()) {
|
||||||
@@ -31,8 +30,10 @@ class LanguageSwitcher extends Component
|
|||||||
$user->save();
|
$user->save();
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->currentLocale = $locale;
|
// Dispatch a browser event — JavaScript reloads the page.
|
||||||
$this->dispatch('localeChanged', $locale);
|
// 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 render()
|
public function render()
|
||||||
|
|||||||
+245
-157
@@ -8,9 +8,11 @@ use Livewire\Attributes\Layout;
|
|||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\Phase;
|
use App\Models\Phase;
|
||||||
use App\Models\Layer;
|
use App\Models\Layer;
|
||||||
use App\Services\SpatialFileConverter;
|
|
||||||
use App\Models\Feature;
|
use App\Models\Feature;
|
||||||
|
use App\Models\InspectionTemplate;
|
||||||
|
use App\Services\SpatialFileConverter;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
#[Layout('layouts.app')]
|
#[Layout('layouts.app')]
|
||||||
@@ -19,97 +21,109 @@ class LayerManager extends Component
|
|||||||
use WithFileUploads;
|
use WithFileUploads;
|
||||||
|
|
||||||
public Project $project;
|
public Project $project;
|
||||||
public Phase $phase;
|
public Phase $phase;
|
||||||
public $layers;
|
public $layers;
|
||||||
public $selectedLayer = null;
|
public $selectedLayer = null;
|
||||||
public $visibleLayers = []; // IDs de capas visibles
|
public $visibleLayers = [];
|
||||||
|
|
||||||
public $uploadFile = null;
|
public $uploadFile = null;
|
||||||
public $layerName = '';
|
public $layerName = '';
|
||||||
public $layerColor = '#3b82f6';
|
public $layerColor = '#3b82f6';
|
||||||
public $manualGeojson = null;
|
|
||||||
public $drawingMode = false;
|
|
||||||
|
|
||||||
protected $rules = [
|
// Batch assign
|
||||||
'uploadFile' => 'required|file|mimes:geojson,kmz,kml,shp,dwg,zip|max:51200',
|
public $templates = [];
|
||||||
'layerName' => 'required|string|max:255',
|
public $batchTemplateId = null;
|
||||||
'layerColor' => 'nullable|string|size:7',
|
public $batchStatus = '';
|
||||||
];
|
|
||||||
|
|
||||||
public function mount(Project $project, Phase $phase)
|
public function mount(Project $project, Phase $phase)
|
||||||
{
|
{
|
||||||
$this->project = $project;
|
$this->project = $project;
|
||||||
$this->phase = $phase;
|
$this->phase = $phase;
|
||||||
$this->loadLayers();
|
|
||||||
if ($this->phase->project_id !== $this->project->id) {
|
if ($this->phase->project_id !== $this->project->id) abort(404);
|
||||||
abort(404);
|
|
||||||
|
$user = Auth::user();
|
||||||
|
if (!$user->can('manage all') && !$project->users()->where('user_id', $user->id)->exists()) {
|
||||||
|
abort(403);
|
||||||
}
|
}
|
||||||
// Por defecto todas visibles
|
|
||||||
|
$this->templates = InspectionTemplate::where('project_id', $this->project->id)->get();
|
||||||
|
$this->loadLayers();
|
||||||
$this->visibleLayers = $this->layers->pluck('id')->toArray();
|
$this->visibleLayers = $this->layers->pluck('id')->toArray();
|
||||||
$this->emitInitialLayersData();
|
$this->emitInitialLayersData();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Data loaders ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
public function loadLayers()
|
public function loadLayers()
|
||||||
{
|
{
|
||||||
$this->layers = Layer::with('features')->where('phase_id', $this->phase->id)->latest()->get();
|
$this->layers = Layer::withCount('features')
|
||||||
$this->visibleLayers = array_intersect($this->visibleLayers, $this->layers->pluck('id')->toArray());
|
->withAvg('features', 'progress')
|
||||||
|
->where('phase_id', $this->phase->id)
|
||||||
|
->latest()
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$this->visibleLayers = array_values(
|
||||||
|
array_intersect($this->visibleLayers, $this->layers->pluck('id')->toArray())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildLayerPayload(Layer $layer): array
|
||||||
|
{
|
||||||
|
$color = $layer->color ?: '#3b82f6';
|
||||||
|
$features = ($layer->relationLoaded('features') ? $layer->features : $layer->features()->get())
|
||||||
|
->map(fn($f) => [
|
||||||
|
'type' => 'Feature',
|
||||||
|
'id' => $f->id,
|
||||||
|
'geometry' => $f->geometry,
|
||||||
|
'properties' => [
|
||||||
|
'name' => $f->name ?? 'Elemento',
|
||||||
|
'progress' => $f->progress,
|
||||||
|
'status' => $f->status ?? 'planned',
|
||||||
|
'responsible' => $f->responsible,
|
||||||
|
'template_id' => $f->template_id,
|
||||||
|
],
|
||||||
|
])->values()->toArray();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $layer->id,
|
||||||
|
'color' => $color,
|
||||||
|
'geojson' => [
|
||||||
|
'type' => 'FeatureCollection',
|
||||||
|
'features' => $features,
|
||||||
|
'style' => ['color' => $color],
|
||||||
|
],
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function emitInitialLayersData()
|
private function emitInitialLayersData()
|
||||||
{
|
{
|
||||||
$layersData = $this->layers->map(function($layer) {
|
$this->layers->loadMissing('features');
|
||||||
// Usar el color guardado en BD o el color del formulario
|
|
||||||
$color = $layer->color ?: ($this->layerColor ?: '#3b82f6');
|
|
||||||
|
|
||||||
// Construir FeatureCollection a partir de los features de esta capa
|
|
||||||
$features = $layer->features->map(function($feature) {
|
|
||||||
return [
|
|
||||||
'type' => 'Feature',
|
|
||||||
'id' => $feature->id,
|
|
||||||
'geometry' => $feature->geometry,
|
|
||||||
'properties' => [
|
|
||||||
'name' => $feature->name,
|
|
||||||
'progress' => $feature->progress,
|
|
||||||
'responsible' => $feature->responsible,
|
|
||||||
'template_id' => $feature->template_id,
|
|
||||||
]
|
|
||||||
];
|
|
||||||
})->values()->toArray();
|
|
||||||
|
|
||||||
$geojson = [
|
|
||||||
'type' => 'FeatureCollection',
|
|
||||||
'features' => $features,
|
|
||||||
'style' => ['color' => $color]
|
|
||||||
];
|
|
||||||
|
|
||||||
return [
|
|
||||||
'id' => $layer->id,
|
|
||||||
'geojson' => $geojson,
|
|
||||||
'color' => $color,
|
|
||||||
];
|
|
||||||
});
|
|
||||||
|
|
||||||
$this->dispatch('initialLayersData', [
|
$this->dispatch('initialLayersData', [
|
||||||
'layers' => $layersData,
|
'layers' => $this->layers->map(fn($l) => $this->buildLayerPayload($l)),
|
||||||
'visibleLayers' => $this->visibleLayers,
|
'visibleLayers' => $this->visibleLayers,
|
||||||
'selectedLayerId' => $this->selectedLayer?->id,
|
'selectedLayerId' => $this->selectedLayer?->id,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Visibility ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
public function toggleLayerVisibility($layerId)
|
public function toggleLayerVisibility($layerId)
|
||||||
{
|
{
|
||||||
if ($this->selectedLayer && $this->selectedLayer->id == $layerId) {
|
if ($this->selectedLayer && $this->selectedLayer->id == $layerId) {
|
||||||
session()->flash('info', 'No puedes ocultar la capa que estás editando.');
|
$this->dispatch('notify', 'No puedes ocultar la capa que estás editando');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (in_array($layerId, $this->visibleLayers)) {
|
if (in_array($layerId, $this->visibleLayers)) {
|
||||||
$this->visibleLayers = array_diff($this->visibleLayers, [$layerId]);
|
$this->visibleLayers = array_values(array_diff($this->visibleLayers, [$layerId]));
|
||||||
} else {
|
} else {
|
||||||
$this->visibleLayers[] = $layerId;
|
$this->visibleLayers[] = $layerId;
|
||||||
}
|
}
|
||||||
$this->dispatch('visibilityChanged', $this->visibleLayers);
|
$this->dispatch('visibilityChanged', $this->visibleLayers);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Select ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
public function selectLayer($layerId)
|
public function selectLayer($layerId)
|
||||||
{
|
{
|
||||||
$this->selectedLayer = Layer::with('features')->find($layerId);
|
$this->selectedLayer = Layer::with('features')->find($layerId);
|
||||||
@@ -120,185 +134,259 @@ class LayerManager extends Component
|
|||||||
$this->dispatch('visibilityChanged', $this->visibleLayers);
|
$this->dispatch('visibilityChanged', $this->visibleLayers);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Construir el GeoJSON desde los features de la capa seleccionada
|
$payload = $this->buildLayerPayload($this->selectedLayer);
|
||||||
$features = $this->selectedLayer->features->map(function($feature) {
|
|
||||||
return [
|
|
||||||
'type' => 'Feature',
|
|
||||||
'id' => $feature->id,
|
|
||||||
'geometry' => $feature->geometry,
|
|
||||||
'properties' => [
|
|
||||||
'name' => $feature->name,
|
|
||||||
'progress' => $feature->progress,
|
|
||||||
'responsible' => $feature->responsible,
|
|
||||||
'template_id' => $feature->template_id,
|
|
||||||
]
|
|
||||||
];
|
|
||||||
})->values()->toArray();
|
|
||||||
|
|
||||||
$color = $this->selectedLayer->color ?: ($this->layerColor ?: '#3b82f6');
|
|
||||||
$geojson = [
|
|
||||||
'type' => 'FeatureCollection',
|
|
||||||
'features' => $features,
|
|
||||||
'style' => ['color' => $color]
|
|
||||||
];
|
|
||||||
|
|
||||||
$this->dispatch('layerSelectedForEdit', [
|
$this->dispatch('layerSelectedForEdit', [
|
||||||
'layerId' => $layerId,
|
'layerId' => $layerId,
|
||||||
'geojson' => $geojson,
|
'geojson' => $payload['geojson'],
|
||||||
'color' => $color,
|
'color' => $payload['color'],
|
||||||
]);
|
]);
|
||||||
session()->flash('info', 'Editando capa: ' . $this->selectedLayer->name);
|
$this->dispatch('notify', 'Editando: ' . $this->selectedLayer->name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Import file ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
public function importFile()
|
public function importFile()
|
||||||
{
|
{
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
if (!$user->can('upload layers') && !$user->hasRole('Admin')) {
|
if (!$user->can('upload layers')) {
|
||||||
session()->flash('error', 'Sin permisos.');
|
$this->dispatch('notify', 'Sin permisos para subir capas');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validar campos obligatorios y tamaño máximo
|
|
||||||
$this->validate([
|
$this->validate([
|
||||||
'uploadFile' => 'required|file|max:51200',
|
'uploadFile' => 'required|file|max:51200',
|
||||||
'layerName' => 'required|string|max:255',
|
'layerName' => 'required|string|max:255',
|
||||||
'layerColor' => 'nullable|string|size:7',
|
'layerColor' => 'nullable|string|size:7',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$extension = strtolower($this->uploadFile->getClientOriginalExtension());
|
$ext = strtolower($this->uploadFile->getClientOriginalExtension());
|
||||||
$mime = $this->uploadFile->getMimeType();
|
$allowed = ['geojson', 'json', 'kmz', 'kml', 'shp', 'dwg', 'zip'];
|
||||||
|
if (!in_array($ext, $allowed)) {
|
||||||
$allowedExtensions = ['geojson', 'kmz', 'kml', 'shp', 'dwg', 'zip'];
|
$this->dispatch('notify', 'Extensión no permitida. Válidas: ' . implode(', ', $allowed));
|
||||||
$allowedMimes = [
|
|
||||||
'application/vnd.google-earth.kml+xml',
|
|
||||||
'application/vnd.google-earth.kmz',
|
|
||||||
'application/zip',
|
|
||||||
'application/x-zip-compressed',
|
|
||||||
'application/x-shapefile',
|
|
||||||
'image/vnd.dwg',
|
|
||||||
'application/acad',
|
|
||||||
'application/geo+json',
|
|
||||||
'text/xml', // ✅ Aceptar KML con text/xml
|
|
||||||
'application/xml', // ✅ Alternativa
|
|
||||||
];
|
|
||||||
|
|
||||||
if (!in_array($extension, $allowedExtensions) && !in_array($mime, $allowedMimes)) {
|
|
||||||
session()->flash('error', 'Tipo de archivo no permitido. Extensiones válidas: ' . implode(', ', $allowedExtensions));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$projectDir = "uploads/projects/{$this->project->id}/layers";
|
|
||||||
$originalPath = $this->uploadFile->store($projectDir, 'public');
|
|
||||||
$geojson = SpatialFileConverter::convertToGeoJson($this->uploadFile);
|
$geojson = SpatialFileConverter::convertToGeoJson($this->uploadFile);
|
||||||
|
|
||||||
if (!$geojson) {
|
if (!$geojson) {
|
||||||
session()->flash('error', 'Conversión fallida. Asegúrate de que el archivo sea válido (KML, GeoJSON, etc.).');
|
$this->dispatch('notify', 'No se pudo convertir el archivo. Comprueba que sea GeoJSON, KML o Shapefile válido.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$layerColor = $this->layerColor ?: '#3b82f6';
|
$layerColor = $this->layerColor ?: '#3b82f6';
|
||||||
$geojson['style'] = ['color' => $layerColor];
|
$layerName = $this->layerName;
|
||||||
|
|
||||||
$layer = Layer::create([
|
try {
|
||||||
'project_id' => $this->project->id,
|
DB::transaction(function () use ($geojson, $layerColor, $layerName, $user) {
|
||||||
'phase_id' => $this->phase->id,
|
$path = $this->uploadFile->store(
|
||||||
'name' => $this->layerName,
|
"uploads/projects/{$this->project->id}/layers", 'public'
|
||||||
'color' => $layerColor,
|
);
|
||||||
'original_file' => $originalPath,
|
|
||||||
'uploaded_by' => $user->id,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Crear features a partir del GeoJSON
|
$layer = Layer::create([
|
||||||
if (isset($geojson['features'])) {
|
'project_id' => $this->project->id,
|
||||||
foreach ($geojson['features'] as $featureData) {
|
'phase_id' => $this->phase->id,
|
||||||
Feature::create([
|
'name' => $layerName,
|
||||||
'layer_id' => $layer->id,
|
'color' => $layerColor,
|
||||||
'name' => $featureData['properties']['name'] ?? null,
|
'original_file' => $path,
|
||||||
'geometry' => $featureData['geometry'],
|
'uploaded_by' => $user->id,
|
||||||
'properties' => $featureData['properties'] ?? [],
|
|
||||||
'template_id' => $featureData['properties']['template_id'] ?? null,
|
|
||||||
'progress' => $featureData['properties']['progress'] ?? 0,
|
|
||||||
'responsible' => $featureData['properties']['responsible'] ?? null,
|
|
||||||
]);
|
]);
|
||||||
}
|
|
||||||
|
$idx = 0;
|
||||||
|
foreach ($geojson['features'] ?? [] as $fd) {
|
||||||
|
$idx++;
|
||||||
|
$name = trim($fd['properties']['name'] ?? '');
|
||||||
|
if ($name === '') $name = $layerName . ' — Elemento ' . $idx;
|
||||||
|
|
||||||
|
Feature::create([
|
||||||
|
'layer_id' => $layer->id,
|
||||||
|
'name' => $name,
|
||||||
|
'geometry' => $fd['geometry'],
|
||||||
|
'properties' => $fd['properties'] ?? [],
|
||||||
|
'template_id' => $fd['properties']['template_id'] ?? null,
|
||||||
|
'progress' => $fd['properties']['progress'] ?? 0,
|
||||||
|
'status' => in_array($fd['properties']['status'] ?? '', Feature::STATUSES)
|
||||||
|
? $fd['properties']['status']
|
||||||
|
: 'planned',
|
||||||
|
'responsible' => $fd['properties']['responsible'] ?? null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->visibleLayers[] = $layer->id;
|
||||||
|
});
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->dispatch('notify', 'Error al importar: ' . $e->getMessage());
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->loadLayers();
|
$this->loadLayers();
|
||||||
$this->visibleLayers[] = $layer->id;
|
|
||||||
$this->reset(['uploadFile', 'layerName']);
|
$this->reset(['uploadFile', 'layerName']);
|
||||||
$this->emitInitialLayersData();
|
$this->emitInitialLayersData();
|
||||||
session()->flash('message', 'Capa importada correctamente.');
|
$this->dispatch('notify', 'Capa importada correctamente');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Create empty layer ────────────────────────────────────────────────────
|
||||||
|
|
||||||
public function createEmptyLayer()
|
public function createEmptyLayer()
|
||||||
{
|
{
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
|
if (!$user->can('upload layers')) {
|
||||||
|
$this->dispatch('notify', 'Sin permisos para crear capas');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$layer = Layer::create([
|
$layer = Layer::create([
|
||||||
'project_id' => $this->project->id,
|
'project_id' => $this->project->id,
|
||||||
'phase_id' => $this->phase->id,
|
'phase_id' => $this->phase->id,
|
||||||
'name' => $this->layerName ?: 'Nueva capa',
|
'name' => $this->layerName ?: 'Nueva capa',
|
||||||
'color' => $this->layerColor ?: '#3b82f6',
|
'color' => $this->layerColor ?: '#3b82f6',
|
||||||
'original_file' => null,
|
'original_file' => null,
|
||||||
'uploaded_by' => $user->id,
|
'uploaded_by' => $user->id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->loadLayers();
|
$this->loadLayers();
|
||||||
$this->visibleLayers[] = $layer->id;
|
$this->visibleLayers[] = $layer->id;
|
||||||
$this->selectLayer($layer->id);
|
$this->selectLayer($layer->id);
|
||||||
$this->emitInitialLayersData();
|
$this->emitInitialLayersData();
|
||||||
session()->flash('message', 'Capa vacía creada. Usa el editor para añadir elementos.');
|
$this->dispatch('notify', 'Capa vacía creada. Dibuja elementos en el mapa.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Save drawn GeoJSON ────────────────────────────────────────────────────
|
||||||
|
|
||||||
public function saveManualGeojson($geojsonString)
|
public function saveManualGeojson($geojsonString)
|
||||||
{
|
{
|
||||||
if (!$this->selectedLayer) {
|
if (!$this->selectedLayer) {
|
||||||
session()->flash('error', 'No hay capa seleccionada.');
|
$this->dispatch('notify', 'No hay capa seleccionada');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$geojson = json_decode($geojsonString, true);
|
$geojson = json_decode($geojsonString, true);
|
||||||
if (json_last_error() !== JSON_ERROR_NONE || !isset($geojson['features'])) {
|
if (json_last_error() !== JSON_ERROR_NONE || !isset($geojson['features'])) {
|
||||||
session()->flash('error', 'GeoJSON inválido.');
|
$this->dispatch('notify', 'GeoJSON inválido');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Eliminar todos los features existentes de esta capa
|
$layerId = $this->selectedLayer->id;
|
||||||
$this->selectedLayer->features()->delete();
|
$layerName = $this->selectedLayer->name;
|
||||||
|
|
||||||
// Crear nuevos features a partir del GeoJSON
|
try {
|
||||||
foreach ($geojson['features'] as $featureData) {
|
DB::transaction(function () use ($geojson, $layerId, $layerName) {
|
||||||
Feature::create([
|
// forceDelete: reemplazamos completamente los elementos de la capa
|
||||||
'layer_id' => $this->selectedLayer->id,
|
Feature::where('layer_id', $layerId)->forceDelete();
|
||||||
'name' => $featureData['properties']['name'] ?? null,
|
|
||||||
'geometry' => $featureData['geometry'],
|
$idx = 0;
|
||||||
'properties' => $featureData['properties'] ?? [],
|
foreach ($geojson['features'] as $fd) {
|
||||||
'template_id' => $featureData['properties']['template_id'] ?? null,
|
$idx++;
|
||||||
'progress' => $featureData['properties']['progress'] ?? 0,
|
$name = trim($fd['properties']['name'] ?? '');
|
||||||
'responsible' => $featureData['properties']['responsible'] ?? null,
|
if ($name === '') $name = $layerName . ' — Elemento ' . $idx;
|
||||||
]);
|
|
||||||
|
Feature::create([
|
||||||
|
'layer_id' => $layerId,
|
||||||
|
'name' => $name,
|
||||||
|
'geometry' => $fd['geometry'],
|
||||||
|
'properties' => $fd['properties'] ?? [],
|
||||||
|
'template_id' => $fd['properties']['template_id'] ?? null,
|
||||||
|
'progress' => $fd['properties']['progress'] ?? 0,
|
||||||
|
'status' => in_array($fd['properties']['status'] ?? '', Feature::STATUSES)
|
||||||
|
? $fd['properties']['status']
|
||||||
|
: 'planned',
|
||||||
|
'responsible' => $fd['properties']['responsible'] ?? null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->dispatch('notify', 'Error al guardar: ' . $e->getMessage());
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->loadLayers();
|
$this->loadLayers();
|
||||||
$this->selectLayer($this->selectedLayer->id);
|
$this->selectLayer($this->selectedLayer->id);
|
||||||
$this->emitInitialLayersData();
|
$this->emitInitialLayersData();
|
||||||
session()->flash('message', 'Capa guardada con ' . count($geojson['features']) . ' elementos.');
|
$this->dispatch('notify', count($geojson['features']) . ' elementos guardados');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Delete layer ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
public function deleteLayer($layerId)
|
public function deleteLayer($layerId)
|
||||||
{
|
{
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
if (!$user->can('delete layers') && !$user->hasRole('Admin')) abort(403);
|
if (!$user->can('delete layers')) abort(403);
|
||||||
$layer = Layer::find($layerId);
|
|
||||||
|
// Verify it belongs to this phase (prevents cross-project deletion)
|
||||||
|
$layer = Layer::where('id', $layerId)->where('phase_id', $this->phase->id)->first();
|
||||||
if (!$layer) return;
|
if (!$layer) return;
|
||||||
|
|
||||||
if ($layer->original_file) Storage::disk('public')->delete($layer->original_file);
|
if ($layer->original_file) Storage::disk('public')->delete($layer->original_file);
|
||||||
$layer->features()->delete(); // opcional, si no usas cascade
|
$layer->features()->delete();
|
||||||
$layer->delete();
|
$layer->delete();
|
||||||
|
|
||||||
$this->loadLayers();
|
$this->loadLayers();
|
||||||
if ($this->selectedLayer && $this->selectedLayer->id == $layerId) {
|
if ($this->selectedLayer && $this->selectedLayer->id == $layerId) {
|
||||||
$this->selectedLayer = null;
|
$this->selectedLayer = null;
|
||||||
|
$this->dispatch('layerSelectedForEdit', null);
|
||||||
}
|
}
|
||||||
$this->emitInitialLayersData();
|
$this->emitInitialLayersData();
|
||||||
session()->flash('message', 'Capa eliminada.');
|
$this->dispatch('notify', 'Capa eliminada');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Export GeoJSON ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function exportLayer($layerId)
|
||||||
|
{
|
||||||
|
$layer = Layer::with('features')
|
||||||
|
->where('id', $layerId)
|
||||||
|
->where('phase_id', $this->phase->id)
|
||||||
|
->first();
|
||||||
|
if (!$layer) return;
|
||||||
|
|
||||||
|
$fc = [
|
||||||
|
'type' => 'FeatureCollection',
|
||||||
|
'name' => $layer->name,
|
||||||
|
'features' => $layer->features->map(fn($f) => [
|
||||||
|
'type' => 'Feature',
|
||||||
|
'geometry' => $f->geometry,
|
||||||
|
'properties' => array_merge($f->properties ?? [], [
|
||||||
|
'name' => $f->name,
|
||||||
|
'progress' => $f->progress,
|
||||||
|
'status' => $f->status,
|
||||||
|
'responsible' => $f->responsible,
|
||||||
|
'template_id' => $f->template_id,
|
||||||
|
]),
|
||||||
|
])->values()->toArray(),
|
||||||
|
];
|
||||||
|
|
||||||
|
$filename = preg_replace('/[^a-z0-9_\-]/i', '_', $layer->name) . '.geojson';
|
||||||
|
|
||||||
|
return response()->streamDownload(function () use ($fc) {
|
||||||
|
echo json_encode($fc, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||||
|
}, $filename, ['Content-Type' => 'application/geo+json']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Batch assign template / status ────────────────────────────────────────
|
||||||
|
|
||||||
|
public function batchAssign($layerId)
|
||||||
|
{
|
||||||
|
$layer = Layer::where('id', $layerId)->where('phase_id', $this->phase->id)->first();
|
||||||
|
if (!$layer) return;
|
||||||
|
|
||||||
|
$data = [];
|
||||||
|
if ($this->batchStatus && in_array($this->batchStatus, Feature::STATUSES)) {
|
||||||
|
$data['status'] = $this->batchStatus;
|
||||||
|
}
|
||||||
|
if ($this->batchTemplateId) {
|
||||||
|
$data['template_id'] = (int) $this->batchTemplateId;
|
||||||
|
}
|
||||||
|
if (empty($data)) {
|
||||||
|
$this->dispatch('notify', 'Selecciona un estado o template para asignar');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$count = $layer->features()->update($data);
|
||||||
|
$this->loadLayers();
|
||||||
|
$this->emitInitialLayersData();
|
||||||
|
$this->dispatch('notify', "$count elemento(s) actualizados");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cancel editing ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
public function cancelEditing()
|
public function cancelEditing()
|
||||||
{
|
{
|
||||||
$this->selectedLayer = null;
|
$this->selectedLayer = null;
|
||||||
|
|||||||
@@ -1,124 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Livewire;
|
|
||||||
|
|
||||||
use Livewire\Component;
|
|
||||||
use Livewire\WithFileUploads;
|
|
||||||
use App\Models\Project;
|
|
||||||
use App\Models\Phase;
|
|
||||||
use App\Models\Layer;
|
|
||||||
use App\Models\Feature;
|
|
||||||
use App\Services\SpatialFileConverter;
|
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
use Illuminate\Support\Facades\Storage;
|
|
||||||
|
|
||||||
class LayerUpload extends Component
|
|
||||||
{
|
|
||||||
use WithFileUploads;
|
|
||||||
|
|
||||||
public $projectId;
|
|
||||||
public $phaseId;
|
|
||||||
public $uploadFile = null;
|
|
||||||
public $layerName = '';
|
|
||||||
public $layerColor = '#3b82f6';
|
|
||||||
|
|
||||||
protected $rules = [
|
|
||||||
'uploadFile' => 'required|file|max:51200',
|
|
||||||
'layerName' => 'required|string|max:255',
|
|
||||||
'layerColor' => 'nullable|string|size:7',
|
|
||||||
];
|
|
||||||
|
|
||||||
public function mount($projectId = null, $phaseId = null)
|
|
||||||
{
|
|
||||||
$this->projectId = $projectId;
|
|
||||||
$this->phaseId = $phaseId;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function upload()
|
|
||||||
{
|
|
||||||
$user = Auth::user();
|
|
||||||
if (!$user->can('upload layers') && !$user->hasRole('Admin')) {
|
|
||||||
session()->flash('error', 'Sin permisos.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->validate();
|
|
||||||
|
|
||||||
if (!$this->projectId || !$this->phaseId) {
|
|
||||||
session()->flash('error', 'Faltan datos del proyecto/fase.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$project = Project::findOrFail($this->projectId);
|
|
||||||
$phase = Phase::findOrFail($this->phaseId);
|
|
||||||
|
|
||||||
$extension = strtolower($this->uploadFile->getClientOriginalExtension());
|
|
||||||
$mime = $this->uploadFile->getMimeType();
|
|
||||||
|
|
||||||
$allowedExtensions = ['geojson', 'kmz', 'kml', 'shp', 'dwg', 'zip'];
|
|
||||||
$allowedMimes = [
|
|
||||||
'application/vnd.google-earth.kml+xml',
|
|
||||||
'application/vnd.google-earth.kmz',
|
|
||||||
'application/zip',
|
|
||||||
'application/x-zip-compressed',
|
|
||||||
'application/x-shapefile',
|
|
||||||
'image/vnd.dwg',
|
|
||||||
'application/acad',
|
|
||||||
'application/geo+json',
|
|
||||||
'text/xml',
|
|
||||||
'application/xml',
|
|
||||||
];
|
|
||||||
|
|
||||||
if (!in_array($extension, $allowedExtensions) && !in_array($mime, $allowedMimes)) {
|
|
||||||
session()->flash('error', 'Tipo de archivo no permitido.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$projectDir = "uploads/projects/{$project->id}/layers";
|
|
||||||
$originalPath = $this->uploadFile->store($projectDir, 'public');
|
|
||||||
$geojson = SpatialFileConverter::convertToGeoJson($this->uploadFile);
|
|
||||||
|
|
||||||
if (!$geojson) {
|
|
||||||
session()->flash('error', 'Conversión fallida.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$layerColor = $this->layerColor ?: '#3b82f6';
|
|
||||||
$geojson['style'] = ['color' => $layerColor];
|
|
||||||
|
|
||||||
$layer = Layer::create([
|
|
||||||
'project_id' => $project->id,
|
|
||||||
'phase_id' => $phase->id,
|
|
||||||
'name' => $this->layerName,
|
|
||||||
'color' => $layerColor,
|
|
||||||
'original_file' => $originalPath,
|
|
||||||
'uploaded_by' => $user->id,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (isset($geojson['features'])) {
|
|
||||||
foreach ($geojson['features'] as $featureData) {
|
|
||||||
Feature::create([
|
|
||||||
'layer_id' => $layer->id,
|
|
||||||
'name' => $featureData['properties']['name'] ?? null,
|
|
||||||
'geometry' => $featureData['geometry'],
|
|
||||||
'properties' => $featureData['properties'] ?? [],
|
|
||||||
'template_id' => $featureData['properties']['template_id'] ?? null,
|
|
||||||
'progress' => $featureData['properties']['progress'] ?? 0,
|
|
||||||
'responsible' => $featureData['properties']['responsible'] ?? null,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->reset(['uploadFile', 'layerName']);
|
|
||||||
session()->flash('message', "Capa '{$layer->name}' importada correctamente con " . count($geojson['features'] ?? []) . ' elementos.');
|
|
||||||
$this->dispatch('layerUploaded', projectId: $project->id);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function render()
|
|
||||||
{
|
|
||||||
$projects = Project::accessibleBy(Auth::user())->get();
|
|
||||||
$phases = $this->projectId ? Phase::where('project_id', $this->projectId)->orderBy('order')->get() : collect();
|
|
||||||
|
|
||||||
return view('livewire.layer-upload', compact('projects', 'phases'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -65,7 +65,7 @@ class MediaManager extends Component
|
|||||||
public function upload()
|
public function upload()
|
||||||
{
|
{
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
if (!$user->can('upload layers') && !$user->hasRole('Admin')) {
|
if (!$user->can('upload layers')) {
|
||||||
session()->flash('error', 'Sin permisos.');
|
session()->flash('error', 'Sin permisos.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -130,7 +130,7 @@ class MediaManager extends Component
|
|||||||
$media = Media::findOrFail($mediaId);
|
$media = Media::findOrFail($mediaId);
|
||||||
|
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
if (!$user->hasRole('Admin') && $media->uploaded_by !== $user->id) {
|
if (!$user->can('delete media') && $media->uploaded_by !== $user->id) {
|
||||||
session()->flash('error', 'No puedes borrar archivos de otro usuario.');
|
session()->flash('error', 'No puedes borrar archivos de otro usuario.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use Livewire\Component;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
||||||
|
class NotificationBell extends Component
|
||||||
|
{
|
||||||
|
public $notifications = [];
|
||||||
|
public $unreadCount = 0;
|
||||||
|
public $showDropdown = false;
|
||||||
|
|
||||||
|
public function mount()
|
||||||
|
{
|
||||||
|
$this->loadNotifications();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function loadNotifications()
|
||||||
|
{
|
||||||
|
$user = Auth::user();
|
||||||
|
$this->notifications = $user->notifications()->latest()->take(10)->get()->toArray();
|
||||||
|
$this->unreadCount = $user->unreadNotifications()->count();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markAsRead($id)
|
||||||
|
{
|
||||||
|
Auth::user()->notifications()->where('id', $id)->update(['read_at' => now()]);
|
||||||
|
$this->loadNotifications();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markAllAsRead()
|
||||||
|
{
|
||||||
|
Auth::user()->unreadNotifications->markAsRead();
|
||||||
|
$this->loadNotifications();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
return view('livewire.notification-bell');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<?php
|
||||||
|
namespace App\Livewire;
|
||||||
|
use Livewire\Component;
|
||||||
|
use Livewire\Attributes\Layout;
|
||||||
|
use App\Models\Project;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
||||||
|
#[Layout('layouts.app')]
|
||||||
|
class PhaseGantt extends Component
|
||||||
|
{
|
||||||
|
public Project $project;
|
||||||
|
public $ganttData = [];
|
||||||
|
|
||||||
|
public function mount(Project $project)
|
||||||
|
{
|
||||||
|
$user = Auth::user();
|
||||||
|
if (!$user->can('manage all') && !$project->users()->where('user_id', $user->id)->exists()) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
$this->project = $project;
|
||||||
|
$this->loadGanttData();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function loadGanttData()
|
||||||
|
{
|
||||||
|
$phases = $this->project->phases()->with(['layers.features'])->orderBy('order')->get();
|
||||||
|
$projectStart = $this->project->start_date ?? now()->startOfMonth();
|
||||||
|
$projectEnd = $this->project->end_date_estimated ?? now()->addMonths(6);
|
||||||
|
|
||||||
|
$this->ganttData = $phases->map(function($phase) use ($projectStart, $projectEnd) {
|
||||||
|
$planned_start = $phase->planned_start ?? $projectStart;
|
||||||
|
$planned_end = $phase->planned_end ?? $projectEnd;
|
||||||
|
$actual_start = $phase->actual_start;
|
||||||
|
$actual_end = $phase->actual_end;
|
||||||
|
|
||||||
|
$totalDays = max(1, $projectStart->diffInDays($projectEnd));
|
||||||
|
|
||||||
|
$pStartOffset = max(0, $projectStart->diffInDays($planned_start));
|
||||||
|
$pDuration = max(1, $planned_start->diffInDays($planned_end));
|
||||||
|
$pStartPct = round(($pStartOffset / $totalDays) * 100, 2);
|
||||||
|
$pWidthPct = round(($pDuration / $totalDays) * 100, 2);
|
||||||
|
|
||||||
|
$aStartPct = null; $aWidthPct = null;
|
||||||
|
if ($actual_start) {
|
||||||
|
$aStart = max(0, $projectStart->diffInDays($actual_start));
|
||||||
|
$aEnd = $actual_end ?? now();
|
||||||
|
$aDuration = max(1, $actual_start->diffInDays($aEnd));
|
||||||
|
$aStartPct = round(($aStart / $totalDays) * 100, 2);
|
||||||
|
$aWidthPct = round(($aDuration / $totalDays) * 100, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
$isDelayed = $phase->planned_end && $phase->planned_end->isPast() && $phase->progress_percent < 100;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $phase->id,
|
||||||
|
'name' => $phase->name,
|
||||||
|
'color' => $phase->color ?? '#3b82f6',
|
||||||
|
'progress' => $phase->progress_percent,
|
||||||
|
'planned_start' => $planned_start->format('d/m/Y'),
|
||||||
|
'planned_end' => $planned_end->format('d/m/Y'),
|
||||||
|
'actual_start' => $actual_start?->format('d/m/Y'),
|
||||||
|
'actual_end' => $actual_end?->format('d/m/Y'),
|
||||||
|
'p_start_pct' => $pStartPct,
|
||||||
|
'p_width_pct' => min($pWidthPct, 100 - $pStartPct),
|
||||||
|
'a_start_pct' => $aStartPct,
|
||||||
|
'a_width_pct' => $aWidthPct ? min($aWidthPct, 100 - $aStartPct) : null,
|
||||||
|
'is_delayed' => $isDelayed,
|
||||||
|
'features_count' => $phase->layers->sum(fn($l) => $l->features->count()),
|
||||||
|
];
|
||||||
|
})->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatePhaseDates($phaseId, $plannedStart, $plannedEnd, $actualStart = null, $actualEnd = null)
|
||||||
|
{
|
||||||
|
$phase = $this->project->phases()->findOrFail($phaseId);
|
||||||
|
$phase->update([
|
||||||
|
'planned_start' => $plannedStart ?: null,
|
||||||
|
'planned_end' => $plannedEnd ?: null,
|
||||||
|
'actual_start' => $actualStart ?: null,
|
||||||
|
'actual_end' => $actualEnd ?: null,
|
||||||
|
]);
|
||||||
|
$this->loadGanttData();
|
||||||
|
$this->dispatch('notify', 'Fechas actualizadas');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
return view('livewire.projects.phase-gantt', [
|
||||||
|
'project' => $this->project,
|
||||||
|
'phases' => $this->project->phases()->orderBy('order')->get(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
+115
-14
@@ -2,36 +2,137 @@
|
|||||||
|
|
||||||
namespace App\Livewire;
|
namespace App\Livewire;
|
||||||
|
|
||||||
use Livewire\Component;
|
|
||||||
use App\Models\Project;
|
|
||||||
use App\Models\Phase;
|
use App\Models\Phase;
|
||||||
|
use App\Models\Project;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Livewire\Attributes\On;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
class PhaseList extends Component
|
class PhaseList extends Component
|
||||||
{
|
{
|
||||||
public Project $project;
|
public Project $project;
|
||||||
public $phases;
|
|
||||||
|
// Modal state
|
||||||
|
public bool $showForm = false;
|
||||||
|
public $editingId = null;
|
||||||
|
|
||||||
|
// Form fields
|
||||||
|
public string $name = '';
|
||||||
|
public string $description = '';
|
||||||
|
public string $color = '#3b82f6';
|
||||||
|
public int $order = 1;
|
||||||
|
public int $progressPercent = 0;
|
||||||
|
public string $plannedStart = '';
|
||||||
|
public string $plannedEnd = '';
|
||||||
|
public string $actualStart = '';
|
||||||
|
public string $actualEnd = '';
|
||||||
|
|
||||||
public function mount(Project $project)
|
public function mount(Project $project)
|
||||||
{
|
{
|
||||||
$this->project = $project;
|
$this->project = $project;
|
||||||
$this->phases = $project->phases;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function addPhase()
|
protected function rules(): array
|
||||||
{
|
{
|
||||||
$this->project->phases()->create([
|
return [
|
||||||
'name' => 'Nueva fase',
|
'name' => 'required|string|max:255',
|
||||||
'order' => $this->phases->count() + 1,
|
'description' => 'nullable|string',
|
||||||
'color' => '#'.substr(md5(rand()), 0, 6)
|
'color' => 'required|string|max:7',
|
||||||
|
'order' => 'required|integer|min:0',
|
||||||
|
'progressPercent' => 'required|integer|min:0|max:100',
|
||||||
|
'plannedStart' => 'nullable|date',
|
||||||
|
'plannedEnd' => 'nullable|date|after_or_equal:plannedStart',
|
||||||
|
'actualStart' => 'nullable|date',
|
||||||
|
'actualEnd' => 'nullable|date|after_or_equal:actualStart',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected $validationAttributes = [
|
||||||
|
'name' => 'nombre',
|
||||||
|
'color' => 'color',
|
||||||
|
'order' => 'orden',
|
||||||
|
'progressPercent' => 'progreso',
|
||||||
|
'plannedStart' => 'inicio previsto',
|
||||||
|
'plannedEnd' => 'fin previsto',
|
||||||
|
'actualStart' => 'inicio real',
|
||||||
|
'actualEnd' => 'fin real',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function openForm($phaseId = null): void
|
||||||
|
{
|
||||||
|
abort_unless(Auth::user()->can('manage phases'), 403);
|
||||||
|
$this->resetForm();
|
||||||
|
|
||||||
|
if ($phaseId) {
|
||||||
|
$phase = $this->project->phases()->findOrFail($phaseId);
|
||||||
|
$this->editingId = $phase->id;
|
||||||
|
$this->name = $phase->name;
|
||||||
|
$this->description = $phase->description ?? '';
|
||||||
|
$this->color = $phase->color ?? '#3b82f6';
|
||||||
|
$this->order = (int) $phase->order;
|
||||||
|
$this->progressPercent = (int) $phase->progress_percent;
|
||||||
|
$this->plannedStart = $phase->planned_start?->format('Y-m-d') ?? '';
|
||||||
|
$this->plannedEnd = $phase->planned_end?->format('Y-m-d') ?? '';
|
||||||
|
$this->actualStart = $phase->actual_start?->format('Y-m-d') ?? '';
|
||||||
|
$this->actualEnd = $phase->actual_end?->format('Y-m-d') ?? '';
|
||||||
|
} else {
|
||||||
|
$this->order = (int) $this->project->phases()->max('order') + 1;
|
||||||
|
$this->color = '#' . substr(md5((string) rand()), 0, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->showForm = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opened from the table's edit button. */
|
||||||
|
#[On('phase-edit')]
|
||||||
|
public function editPhase($id): void
|
||||||
|
{
|
||||||
|
$this->openForm($id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function closeForm(): void
|
||||||
|
{
|
||||||
|
$this->showForm = false;
|
||||||
|
$this->resetForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resetForm(): void
|
||||||
|
{
|
||||||
|
$this->reset([
|
||||||
|
'editingId', 'name', 'description', 'plannedStart', 'plannedEnd', 'actualStart', 'actualEnd',
|
||||||
]);
|
]);
|
||||||
$this->phases = $this->project->phases()->get();
|
$this->color = '#3b82f6';
|
||||||
session()->flash('message', 'Fase agregada');
|
$this->order = 1;
|
||||||
|
$this->progressPercent = 0;
|
||||||
|
$this->resetErrorBag();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function deletePhase($phaseId)
|
public function save(): void
|
||||||
{
|
{
|
||||||
Phase::find($phaseId)->delete();
|
abort_unless(Auth::user()->can('manage phases'), 403);
|
||||||
$this->phases = $this->project->phases()->get();
|
$this->validate();
|
||||||
|
|
||||||
|
$data = [
|
||||||
|
'name' => $this->name,
|
||||||
|
'description' => $this->description ?: null,
|
||||||
|
'color' => $this->color,
|
||||||
|
'order' => $this->order,
|
||||||
|
'progress_percent' => $this->progressPercent,
|
||||||
|
'planned_start' => $this->plannedStart ?: null,
|
||||||
|
'planned_end' => $this->plannedEnd ?: null,
|
||||||
|
'actual_start' => $this->actualStart ?: null,
|
||||||
|
'actual_end' => $this->actualEnd ?: null,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($this->editingId) {
|
||||||
|
$this->project->phases()->findOrFail($this->editingId)->update($data);
|
||||||
|
} else {
|
||||||
|
$this->project->phases()->create($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->closeForm();
|
||||||
|
$this->dispatch('phases-changed');
|
||||||
|
$this->dispatch('notify', 'Fase guardada correctamente');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
|
|||||||
@@ -3,8 +3,10 @@
|
|||||||
namespace App\Livewire;
|
namespace App\Livewire;
|
||||||
|
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
use Livewire\Attributes\Layout;
|
||||||
use App\Models\Phase;
|
use App\Models\Phase;
|
||||||
|
|
||||||
|
#[Layout('layouts.app')]
|
||||||
class PhaseProgress extends Component
|
class PhaseProgress extends Component
|
||||||
{
|
{
|
||||||
public Phase $phase;
|
public Phase $phase;
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use App\Models\Phase;
|
||||||
|
use App\Models\Project;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Livewire\Attributes\On;
|
||||||
|
use Rappasoft\LaravelLivewireTables\DataTableComponent;
|
||||||
|
use Rappasoft\LaravelLivewireTables\Views\Column;
|
||||||
|
|
||||||
|
class PhaseTable extends DataTableComponent
|
||||||
|
{
|
||||||
|
protected $model = Phase::class;
|
||||||
|
|
||||||
|
public int $projectId;
|
||||||
|
|
||||||
|
public function configure(): void
|
||||||
|
{
|
||||||
|
$this->setPrimaryKey('id')
|
||||||
|
->setDefaultSort('order', 'asc')
|
||||||
|
->setSortingPillsEnabled(false)
|
||||||
|
->setAdditionalSelects(['phases.id as id', 'phases.order as order']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-render when the parent (PhaseList) creates/edits a phase. */
|
||||||
|
#[On('phases-changed')]
|
||||||
|
public function refreshRows(): void
|
||||||
|
{
|
||||||
|
// no-op: triggers a re-render so the builder re-runs with fresh data.
|
||||||
|
}
|
||||||
|
|
||||||
|
public function builder(): Builder
|
||||||
|
{
|
||||||
|
$user = Auth::user();
|
||||||
|
abort_unless(
|
||||||
|
$user->can('manage all') || $user->can('edit projects') ||
|
||||||
|
Project::whereKey($this->projectId)->whereHas('users', fn ($q) => $q->where('user_id', $user->id))->exists(),
|
||||||
|
403
|
||||||
|
);
|
||||||
|
|
||||||
|
return Phase::where('phases.project_id', $this->projectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function columns(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Column::make('Orden', 'order')->sortable(),
|
||||||
|
|
||||||
|
Column::make('Nombre', 'name')
|
||||||
|
->sortable()
|
||||||
|
->searchable()
|
||||||
|
->format(function ($value, $row) {
|
||||||
|
$html = '<span class="font-medium">'.e($value).'</span>';
|
||||||
|
if ($row->description) {
|
||||||
|
$html .= '<div class="text-xs text-base-content/50 truncate max-w-xs">'.e(\Illuminate\Support\Str::limit($row->description, 60)).'</div>';
|
||||||
|
}
|
||||||
|
return $html;
|
||||||
|
})
|
||||||
|
->html(),
|
||||||
|
|
||||||
|
Column::make('Progreso', 'progress_percent')
|
||||||
|
->sortable()
|
||||||
|
->format(fn ($value) =>
|
||||||
|
'<div class="flex items-center gap-2 min-w-[110px]">
|
||||||
|
<progress class="progress progress-primary w-24 h-2" value="'.(int) $value.'" max="100"></progress>
|
||||||
|
<span class="text-xs text-base-content/60 w-8 text-right">'.(int) $value.'%</span>
|
||||||
|
</div>')
|
||||||
|
->html(),
|
||||||
|
|
||||||
|
Column::make('Fechas')
|
||||||
|
->label(function ($row) {
|
||||||
|
$ps = $row->planned_start?->format('d/m/Y');
|
||||||
|
$pe = $row->planned_end?->format('d/m/Y');
|
||||||
|
if (! $ps && ! $pe) {
|
||||||
|
return '<span class="text-base-content/30 text-xs">—</span>';
|
||||||
|
}
|
||||||
|
return '<span class="text-xs">'.($ps ?: '?').' → '.($pe ?: '?').'</span>';
|
||||||
|
})
|
||||||
|
->html(),
|
||||||
|
|
||||||
|
Column::make('Color', 'color')
|
||||||
|
->format(fn ($value) =>
|
||||||
|
'<div class="w-6 h-6 rounded border border-base-300" style="background:'.e($value).'" title="'.e($value).'"></div>')
|
||||||
|
->html(),
|
||||||
|
|
||||||
|
Column::make('Acciones')
|
||||||
|
->label(function ($row) {
|
||||||
|
$user = Auth::user();
|
||||||
|
$progress = route('phases.progress', $row->id);
|
||||||
|
|
||||||
|
$html = '<div class="flex items-center justify-end gap-1">';
|
||||||
|
$html .= '<a href="'.$progress.'" class="btn btn-xs btn-outline btn-info" title="Actualizar progreso" wire:navigate>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/></svg>
|
||||||
|
</a>';
|
||||||
|
if ($user->can('manage phases')) {
|
||||||
|
$html .= '<button wire:click="$dispatch(\'phase-edit\', { id: '.$row->id.' })" class="btn btn-xs btn-ghost" title="Editar">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" 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>';
|
||||||
|
$html .= '<button wire:click="deletePhase('.$row->id.')" wire:confirm="¿Eliminar la fase \''.e($row->name).'\'? Esta acción no se puede deshacer."
|
||||||
|
class="btn btn-xs btn-error btn-outline" title="Eliminar">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" 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>';
|
||||||
|
}
|
||||||
|
$html .= '</div>';
|
||||||
|
return $html;
|
||||||
|
})
|
||||||
|
->html(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deletePhase(int $id): void
|
||||||
|
{
|
||||||
|
abort_unless(Auth::user()->can('manage phases'), 403);
|
||||||
|
Phase::where('project_id', $this->projectId)->findOrFail($id)->delete();
|
||||||
|
$this->dispatch('phases-changed');
|
||||||
|
$this->dispatch('notify', 'Fase eliminada');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,15 +2,15 @@
|
|||||||
|
|
||||||
namespace App\Livewire;
|
namespace App\Livewire;
|
||||||
|
|
||||||
use Livewire\Component;
|
|
||||||
use App\Models\Project;
|
|
||||||
use App\Models\Company;
|
use App\Models\Company;
|
||||||
|
use App\Models\Project;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Livewire\Attributes\On;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
class ProjectCompanies extends Component
|
class ProjectCompanies extends Component
|
||||||
{
|
{
|
||||||
public Project $project;
|
public Project $project;
|
||||||
public $assignedCompanies = [];
|
|
||||||
public $allCompanies = [];
|
public $allCompanies = [];
|
||||||
public $selectedCompanyId = '';
|
public $selectedCompanyId = '';
|
||||||
public $selectedRole = 'other';
|
public $selectedRole = 'other';
|
||||||
@@ -18,64 +18,46 @@ class ProjectCompanies extends Component
|
|||||||
public function mount(Project $project)
|
public function mount(Project $project)
|
||||||
{
|
{
|
||||||
$this->project = $project;
|
$this->project = $project;
|
||||||
$this->loadCompanies();
|
$this->loadAvailable();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function loadCompanies()
|
/** Companies not yet assigned to the project (for the dropdown). */
|
||||||
|
public function loadAvailable(): void
|
||||||
{
|
{
|
||||||
$this->assignedCompanies = $this->project->companies()->withPivot('role_in_project')->get();
|
$assignedIds = $this->project->companies()->pluck('companies.id')->toArray();
|
||||||
$assignedIds = $this->assignedCompanies->pluck('id')->toArray();
|
|
||||||
$this->allCompanies = Company::whereNotIn('id', $assignedIds)->orderBy('name')->get();
|
$this->allCompanies = Company::whereNotIn('id', $assignedIds)->orderBy('name')->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Reload the dropdown when the embedded table changes assignments. */
|
||||||
|
#[On('project-companies-changed')]
|
||||||
|
public function onCompaniesChanged(): void
|
||||||
|
{
|
||||||
|
$this->loadAvailable();
|
||||||
|
}
|
||||||
|
|
||||||
public function assignCompany()
|
public function assignCompany()
|
||||||
{
|
{
|
||||||
$user = Auth::user();
|
abort_unless(Auth::user()->can('assign companies'), 403);
|
||||||
if (!$user->can('assign users') && !$user->hasRole('Admin')) {
|
|
||||||
session()->flash('error', 'No tienes permisos para asignar compañías.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->validate([
|
$this->validate([
|
||||||
'selectedCompanyId' => 'required|exists:companies,id',
|
'selectedCompanyId' => 'required|exists:companies,id',
|
||||||
'selectedRole' => 'required|in:owner,constructor,subcontractor,consultant,supplier,other',
|
'selectedRole' => 'required|in:' . implode(',', array_keys(ProjectCompaniesTable::ROLES)),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->project->companies()->attach($this->selectedCompanyId, [
|
$this->project->companies()->attach($this->selectedCompanyId, [
|
||||||
'role_in_project' => $this->selectedRole
|
'role_in_project' => $this->selectedRole,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->reset(['selectedCompanyId', 'selectedRole']);
|
$this->reset(['selectedCompanyId', 'selectedRole']);
|
||||||
$this->loadCompanies();
|
$this->loadAvailable();
|
||||||
$this->dispatch('notify', 'Compañía asignada al proyecto.');
|
$this->dispatch('project-companies-changed');
|
||||||
}
|
$this->dispatch('notify', 'Empresa asignada al proyecto.');
|
||||||
|
|
||||||
public function removeCompany($companyId)
|
|
||||||
{
|
|
||||||
$user = Auth::user();
|
|
||||||
if (!$user->can('assign users') && !$user->hasRole('Admin')) {
|
|
||||||
session()->flash('error', 'Sin permisos.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->project->companies()->detach($companyId);
|
|
||||||
$this->loadCompanies();
|
|
||||||
$this->dispatch('notify', 'Compañía eliminada del proyecto.');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function changeRole($companyId, $role)
|
|
||||||
{
|
|
||||||
if (!in_array($role, ['owner', 'constructor', 'subcontractor', 'consultant', 'supplier', 'other'])) return;
|
|
||||||
|
|
||||||
$this->project->companies()->updateExistingPivot($companyId, [
|
|
||||||
'role_in_project' => $role
|
|
||||||
]);
|
|
||||||
$this->loadCompanies();
|
|
||||||
$this->dispatch('notify', 'Rol actualizado.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
{
|
{
|
||||||
return view('livewire.project-companies');
|
return view('livewire.project-companies', [
|
||||||
|
'roles' => ProjectCompaniesTable::ROLES,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use App\Models\Company;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Livewire\Attributes\On;
|
||||||
|
use Rappasoft\LaravelLivewireTables\DataTableComponent;
|
||||||
|
use Rappasoft\LaravelLivewireTables\Views\Column;
|
||||||
|
use Rappasoft\LaravelLivewireTables\Views\Filters\SelectFilter;
|
||||||
|
|
||||||
|
class ProjectCompaniesTable extends DataTableComponent
|
||||||
|
{
|
||||||
|
protected $model = Company::class;
|
||||||
|
|
||||||
|
public int $projectId;
|
||||||
|
|
||||||
|
/** role_in_project => label */
|
||||||
|
public const ROLES = [
|
||||||
|
'owner' => 'Promotor',
|
||||||
|
'constructor' => 'Constructor',
|
||||||
|
'subcontractor' => 'Subcontratista',
|
||||||
|
'consultant' => 'Consultor',
|
||||||
|
'supplier' => 'Proveedor',
|
||||||
|
'other' => 'Otro',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function configure(): void
|
||||||
|
{
|
||||||
|
$this->setPrimaryKey('id')
|
||||||
|
->setDefaultSort('companies.name', 'asc')
|
||||||
|
->setSortingPillsEnabled(false)
|
||||||
|
->setAdditionalSelects(['companies.id as id', 'company_project.role_in_project as role_in_project']);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[On('project-companies-changed')]
|
||||||
|
public function refreshRows(): void
|
||||||
|
{
|
||||||
|
// no-op: triggers re-render so the builder re-runs.
|
||||||
|
}
|
||||||
|
|
||||||
|
public function builder(): Builder
|
||||||
|
{
|
||||||
|
return Company::query()
|
||||||
|
->join('company_project', 'company_project.company_id', '=', 'companies.id')
|
||||||
|
->where('company_project.project_id', $this->projectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function columns(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Column::make('Empresa', 'name')
|
||||||
|
->sortable()
|
||||||
|
->searchable()
|
||||||
|
->format(function ($value, $row) {
|
||||||
|
$initial = strtoupper(mb_substr($value ?? '?', 0, 1));
|
||||||
|
$html = '<div class="flex items-center gap-2">
|
||||||
|
<span class="w-7 h-7 rounded-full bg-primary text-primary-content flex items-center justify-center text-xs font-bold shrink-0">'.$initial.'</span>
|
||||||
|
<div><span class="font-medium">'.e($value).'</span>';
|
||||||
|
if ($row->tax_id) {
|
||||||
|
$html .= '<div class="text-xs text-base-content/50">'.e($row->tax_id).'</div>';
|
||||||
|
}
|
||||||
|
$html .= '</div></div>';
|
||||||
|
return $html;
|
||||||
|
})
|
||||||
|
->html(),
|
||||||
|
|
||||||
|
Column::make('Rol', 'role_in_project')
|
||||||
|
->label(function ($row) {
|
||||||
|
$current = $row->role_in_project;
|
||||||
|
if (! Auth::user()->can('assign companies')) {
|
||||||
|
return '<span class="badge badge-sm">'.(self::ROLES[$current] ?? ucfirst((string) $current)).'</span>';
|
||||||
|
}
|
||||||
|
$opts = '';
|
||||||
|
foreach (self::ROLES as $val => $label) {
|
||||||
|
$opts .= '<option value="'.$val.'"'.($current === $val ? ' selected' : '').'>'.$label.'</option>';
|
||||||
|
}
|
||||||
|
return '<select wire:change="changeRole('.$row->id.', $event.target.value)" class="select select-bordered select-xs">'.$opts.'</select>';
|
||||||
|
})
|
||||||
|
->html(),
|
||||||
|
|
||||||
|
Column::make('Acciones')
|
||||||
|
->label(function ($row) {
|
||||||
|
if (! Auth::user()->can('assign companies')) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return '<div class="flex justify-end">
|
||||||
|
<button wire:click="removeCompany('.$row->id.')" wire:confirm="¿Quitar a '.e($row->name).' del proyecto?"
|
||||||
|
class="btn btn-xs btn-error btn-outline" title="Quitar del proyecto">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>';
|
||||||
|
})
|
||||||
|
->html(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function filters(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
SelectFilter::make('Rol', 'role')
|
||||||
|
->options(['' => 'Rol: todos'] + self::ROLES)
|
||||||
|
->filter(fn (Builder $query, string $value) => $query->where('company_project.role_in_project', $value)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function changeRole($companyId, $role): void
|
||||||
|
{
|
||||||
|
abort_unless(Auth::user()->can('assign companies'), 403);
|
||||||
|
if (! array_key_exists($role, self::ROLES)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
\App\Models\Project::findOrFail($this->projectId)
|
||||||
|
->companies()->updateExistingPivot($companyId, ['role_in_project' => $role]);
|
||||||
|
$this->dispatch('project-companies-changed');
|
||||||
|
$this->dispatch('notify', 'Rol actualizado.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function removeCompany($companyId): void
|
||||||
|
{
|
||||||
|
abort_unless(Auth::user()->can('assign companies'), 403);
|
||||||
|
\App\Models\Project::findOrFail($this->projectId)->companies()->detach($companyId);
|
||||||
|
$this->dispatch('project-companies-changed');
|
||||||
|
$this->dispatch('notify', 'Empresa eliminada del proyecto.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use Livewire\Component;
|
||||||
|
use Livewire\Attributes\Layout;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\Phase;
|
||||||
|
use App\Models\Feature;
|
||||||
|
use App\Models\Inspection;
|
||||||
|
use App\Models\Issue;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
||||||
|
#[Layout('layouts.app')]
|
||||||
|
class ProjectDashboard extends Component
|
||||||
|
{
|
||||||
|
public Project $project;
|
||||||
|
|
||||||
|
// Computed stats (cached as properties after mount)
|
||||||
|
public array $stats = [];
|
||||||
|
public $phases;
|
||||||
|
public $recentInspections;
|
||||||
|
public $recentIssues;
|
||||||
|
public $teamMembers;
|
||||||
|
public $companies;
|
||||||
|
|
||||||
|
public function mount(Project $project): void
|
||||||
|
{
|
||||||
|
$this->project = $project;
|
||||||
|
$this->checkAccess();
|
||||||
|
$this->loadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function checkAccess(): void
|
||||||
|
{
|
||||||
|
$user = Auth::user();
|
||||||
|
if ($user->can('manage all')) return;
|
||||||
|
if (!$this->project->users()->where('user_id', $user->id)->exists()) abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function loadData(): void
|
||||||
|
{
|
||||||
|
$pid = $this->project->id;
|
||||||
|
|
||||||
|
$this->phases = Phase::where('project_id', $pid)
|
||||||
|
->withCount('layers')
|
||||||
|
->with(['layers' => fn($q) => $q->withCount('features')])
|
||||||
|
->orderBy('order')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$totalFeatures = Feature::whereHas('layer.phase', fn($q) => $q->where('project_id', $pid))->count();
|
||||||
|
$completedFeatures = Feature::whereHas('layer.phase', fn($q) => $q->where('project_id', $pid))
|
||||||
|
->where('status', 'completed')->count();
|
||||||
|
$verifiedFeatures = Feature::whereHas('layer.phase', fn($q) => $q->where('project_id', $pid))
|
||||||
|
->where('status', 'verified')->count();
|
||||||
|
|
||||||
|
$openIssues = Issue::where('project_id', $pid)->where('status', 'open')->count();
|
||||||
|
$closedIssues = Issue::where('project_id', $pid)->where('status', 'closed')->count();
|
||||||
|
$criticalIssues = Issue::where('project_id', $pid)->where('status', 'open')->where('priority', 'critical')->count();
|
||||||
|
|
||||||
|
$totalInspections = Inspection::where('project_id', $pid)->count();
|
||||||
|
$passedInspections = Inspection::where('project_id', $pid)->where('result', 'pass')->count();
|
||||||
|
$failedInspections = Inspection::where('project_id', $pid)->where('result', 'fail')->count();
|
||||||
|
|
||||||
|
$globalProgress = $this->phases->avg('progress_percent') ?? 0;
|
||||||
|
|
||||||
|
$delayedPhases = $this->phases->filter(fn($p) =>
|
||||||
|
$p->planned_end && $p->planned_end < now() && $p->progress_percent < 100
|
||||||
|
)->count();
|
||||||
|
|
||||||
|
$this->stats = [
|
||||||
|
'global_progress' => round($globalProgress),
|
||||||
|
'total_phases' => $this->phases->count(),
|
||||||
|
'delayed_phases' => $delayedPhases,
|
||||||
|
'total_features' => $totalFeatures,
|
||||||
|
'completed_features' => $completedFeatures,
|
||||||
|
'verified_features' => $verifiedFeatures,
|
||||||
|
'open_issues' => $openIssues,
|
||||||
|
'closed_issues' => $closedIssues,
|
||||||
|
'critical_issues' => $criticalIssues,
|
||||||
|
'total_inspections' => $totalInspections,
|
||||||
|
'passed_inspections' => $passedInspections,
|
||||||
|
'failed_inspections' => $failedInspections,
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->recentInspections = Inspection::where('project_id', $pid)
|
||||||
|
->with(['feature', 'template', 'user'])
|
||||||
|
->latest()->take(6)->get();
|
||||||
|
|
||||||
|
$this->recentIssues = Issue::where('project_id', $pid)
|
||||||
|
->with(['feature', 'reporter'])
|
||||||
|
->where('status', '!=', 'closed')
|
||||||
|
->orderByRaw("FIELD(priority,'critical','high','medium','low')")
|
||||||
|
->take(6)->get();
|
||||||
|
|
||||||
|
$this->teamMembers = $this->project->users()->with('roles')->get();
|
||||||
|
|
||||||
|
$this->companies = $this->project->companies()->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
return view('livewire.projects.project-dashboard', [
|
||||||
|
'project' => $this->project,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Livewire;
|
|
||||||
|
|
||||||
use Livewire\Component;
|
|
||||||
use App\Models\Project;
|
|
||||||
|
|
||||||
class ProjectEditTabs extends Component
|
|
||||||
{
|
|
||||||
public Project $project;
|
|
||||||
public string $activeTab = 'project-data';
|
|
||||||
|
|
||||||
public function mount(Project $project)
|
|
||||||
{
|
|
||||||
$this->project = $project;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setActiveTab($tab)
|
|
||||||
{
|
|
||||||
$this->activeTab = $tab;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function tabChanged($tab, $projectId)
|
|
||||||
{
|
|
||||||
if ($projectId == $this->project->id) {
|
|
||||||
$this->activeTab = $tab;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function updateProject()
|
|
||||||
{
|
|
||||||
$this->project->save();
|
|
||||||
|
|
||||||
session()->flash('message', __('Project updated successfully.'));
|
|
||||||
$this->dispatch('project-updated');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function render()
|
|
||||||
{
|
|
||||||
return view('livewire.project-edit-tabs');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+109
-53
@@ -3,83 +3,139 @@
|
|||||||
namespace App\Livewire;
|
namespace App\Livewire;
|
||||||
|
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
use Livewire\Attributes\Layout;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
|
||||||
|
#[Layout('layouts.app')]
|
||||||
class ProjectForm extends Component
|
class ProjectForm extends Component
|
||||||
{
|
{
|
||||||
public $projectId = null;
|
public ?Project $project = null;
|
||||||
public $name = '';
|
|
||||||
public $address = '';
|
|
||||||
public $lat = null;
|
|
||||||
public $lng = null;
|
|
||||||
public $country = '';
|
|
||||||
public $start_date = '';
|
|
||||||
public $end_date_estimated = '';
|
|
||||||
public $status = 'planning';
|
|
||||||
|
|
||||||
protected $rules = [
|
// Identification
|
||||||
'name' => 'required|string|max:255',
|
public string $name = '';
|
||||||
'address' => 'required|string',
|
public string $reference = '';
|
||||||
'lat' => 'nullable|numeric',
|
public string $status = 'planning';
|
||||||
'lng' => 'nullable|numeric',
|
|
||||||
'start_date' => 'required|date',
|
|
||||||
'end_date_estimated' => 'nullable|date',
|
|
||||||
'status' => 'required|in:planning,in_progress,paused,completed',
|
|
||||||
];
|
|
||||||
|
|
||||||
public function mount($projectId = null)
|
// Location
|
||||||
|
public string $address = '';
|
||||||
|
public string $country = '';
|
||||||
|
public string $lat = '';
|
||||||
|
public string $lng = '';
|
||||||
|
|
||||||
|
// Planning
|
||||||
|
public string $startDate = '';
|
||||||
|
public string $endDateEstimated = '';
|
||||||
|
|
||||||
|
public function mount(?Project $project = null): void
|
||||||
{
|
{
|
||||||
if ($projectId) {
|
if ($project && $project->exists) {
|
||||||
$this->projectId = $projectId;
|
Gate::authorize('edit projects', $project);
|
||||||
$project = Project::findOrFail($projectId);
|
$this->project = $project;
|
||||||
$this->name = $project->name;
|
$this->name = $project->name;
|
||||||
$this->address = $project->address;
|
$this->reference = $project->reference ?? '';
|
||||||
$this->lat = $project->lat;
|
$this->status = $project->status;
|
||||||
$this->lng = $project->lng;
|
$this->address = $project->address;
|
||||||
$this->start_date = $project->start_date->format('Y-m-d');
|
$this->country = $project->country ?? '';
|
||||||
$this->end_date_estimated = $project->end_date_estimated?->format('Y-m-d');
|
$this->lat = (string) ($project->lat ?? '');
|
||||||
$this->status = $project->status;
|
$this->lng = (string) ($project->lng ?? '');
|
||||||
// country? we don't have stored, maybe we can leave blank or compute from lat/lng? We'll leave blank for now.
|
$this->startDate = $project->start_date->format('Y-m-d');
|
||||||
|
$this->endDateEstimated = $project->end_date_estimated?->format('Y-m-d') ?? '';
|
||||||
|
} else {
|
||||||
|
Gate::authorize('create projects');
|
||||||
|
$this->startDate = today()->format('Y-m-d');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setCoordinates($lat, $lng)
|
// Called from JS after map click / marker drag + reverse geocode
|
||||||
|
public function setLocation(string $lat, string $lng, string $address = '', string $country = ''): void
|
||||||
{
|
{
|
||||||
$this->lat = $lat;
|
$this->lat = $lat;
|
||||||
$this->lng = $lng;
|
$this->lng = $lng;
|
||||||
// Optionally, we could trigger reverse geocoding here via JS and update address and country.
|
if ($address) $this->address = $address;
|
||||||
// But we'll do that entirely in JavaScript for better UX.
|
if ($country) $this->country = strtolower($country);
|
||||||
// We'll emit an event to JS to fetch address.
|
|
||||||
$this->dispatch('coordinatesUpdated', lat: $lat, lng: $lng);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function save()
|
protected function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => 'required|string|max:255',
|
||||||
|
'reference' => 'nullable|string|max:100',
|
||||||
|
'status' => 'required|in:planning,in_progress,paused,completed',
|
||||||
|
'address' => 'required|string',
|
||||||
|
'country' => 'nullable|string|size:2',
|
||||||
|
'lat' => 'nullable|numeric|between:-90,90',
|
||||||
|
'lng' => 'nullable|numeric|between:-180,180',
|
||||||
|
'startDate' => 'required|date',
|
||||||
|
'endDateEstimated' => 'nullable|date|after_or_equal:startDate',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected $validationAttributes = [
|
||||||
|
'name' => 'nombre',
|
||||||
|
'reference' => 'referencia',
|
||||||
|
'status' => 'estado',
|
||||||
|
'address' => 'dirección',
|
||||||
|
'country' => 'país',
|
||||||
|
'lat' => 'latitud',
|
||||||
|
'lng' => 'longitud',
|
||||||
|
'startDate' => 'fecha de inicio',
|
||||||
|
'endDateEstimated' => 'fecha de fin estimada',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function save(): void
|
||||||
{
|
{
|
||||||
$this->validate();
|
$this->validate();
|
||||||
|
|
||||||
if ($this->projectId) {
|
$data = [
|
||||||
$project = Project::findOrFail($this->projectId);
|
'name' => $this->name,
|
||||||
|
'reference' => $this->reference ?: null,
|
||||||
|
'status' => $this->status,
|
||||||
|
'address' => $this->address,
|
||||||
|
'country' => $this->country ?: null,
|
||||||
|
'lat' => $this->lat ?: null,
|
||||||
|
'lng' => $this->lng ?: null,
|
||||||
|
'start_date' => $this->startDate,
|
||||||
|
'end_date_estimated' => $this->endDateEstimated ?: null,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($this->project && $this->project->exists) {
|
||||||
|
$this->project->update($data);
|
||||||
|
session()->flash('notify', 'Proyecto actualizado correctamente.');
|
||||||
} else {
|
} else {
|
||||||
$project = new Project();
|
$project = Project::create(array_merge($data, ['created_by' => Auth::id()]));
|
||||||
$project->created_by = auth()->id();
|
$project->users()->attach(Auth::id(), ['role_in_project' => 'supervisor']);
|
||||||
|
session()->flash('notify', 'Proyecto creado correctamente.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$project->name = $this->name;
|
$this->redirect(route('projects.index'), navigate: true);
|
||||||
$project->address = $this->address;
|
|
||||||
$project->lat = $this->lat;
|
|
||||||
$project->lng = $this->lng;
|
|
||||||
$project->start_date = $this->start_date;
|
|
||||||
$project->end_date_estimated = $this->end_date_estimated;
|
|
||||||
$project->status = $this->status;
|
|
||||||
$project->save();
|
|
||||||
|
|
||||||
session()->flash('message', 'Project saved successfully.');
|
|
||||||
|
|
||||||
return redirect()->route('projects.index');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
{
|
{
|
||||||
return view('livewire.projects.project-form');
|
return view('livewire.projects.project-form', [
|
||||||
|
'countryList' => $this->countryList(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ISO alpha-2 (lowercase, matches flagcdn) => display name.
|
||||||
|
*/
|
||||||
|
private function countryList(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'es' => 'España', 'pt' => 'Portugal', 'fr' => 'Francia', 'it' => 'Italia',
|
||||||
|
'de' => 'Alemania', 'gb' => 'Reino Unido', 'ie' => 'Irlanda', 'nl' => 'Países Bajos',
|
||||||
|
'be' => 'Bélgica', 'ch' => 'Suiza', 'at' => 'Austria', 'lu' => 'Luxemburgo',
|
||||||
|
'se' => 'Suecia', 'no' => 'Noruega', 'dk' => 'Dinamarca', 'fi' => 'Finlandia',
|
||||||
|
'pl' => 'Polonia', 'cz' => 'Chequia', 'gr' => 'Grecia', 'ro' => 'Rumanía',
|
||||||
|
'us' => 'Estados Unidos', 'ca' => 'Canadá', 'mx' => 'México', 'gt' => 'Guatemala',
|
||||||
|
'cr' => 'Costa Rica', 'pa' => 'Panamá', 'co' => 'Colombia', 've' => 'Venezuela',
|
||||||
|
'ec' => 'Ecuador', 'pe' => 'Perú', 'bo' => 'Bolivia', 'cl' => 'Chile',
|
||||||
|
'ar' => 'Argentina', 'uy' => 'Uruguay', 'py' => 'Paraguay', 'br' => 'Brasil',
|
||||||
|
'do' => 'República Dominicana', 'ma' => 'Marruecos', 'gq' => 'Guinea Ecuatorial',
|
||||||
|
'ao' => 'Angola', 'cv' => 'Cabo Verde', 'us' => 'Estados Unidos',
|
||||||
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ namespace App\Livewire;
|
|||||||
|
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use Livewire\WithPagination;
|
use Livewire\WithPagination;
|
||||||
|
use Livewire\Attributes\Layout;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
||||||
|
#[Layout('layouts.app')]
|
||||||
class ProjectList extends Component
|
class ProjectList extends Component
|
||||||
{
|
{
|
||||||
use WithPagination;
|
use WithPagination;
|
||||||
|
|||||||
+239
-116
@@ -10,16 +10,17 @@ use App\Models\Layer;
|
|||||||
use App\Models\Feature;
|
use App\Models\Feature;
|
||||||
use App\Models\Inspection;
|
use App\Models\Inspection;
|
||||||
use App\Models\InspectionTemplate;
|
use App\Models\InspectionTemplate;
|
||||||
|
use App\Models\Issue;
|
||||||
|
|
||||||
class ProjectMap extends Component
|
class ProjectMap extends Component
|
||||||
{
|
{
|
||||||
public Project $project;
|
public Project $project;
|
||||||
public $phases;
|
public $phases;
|
||||||
public $activeLayers = [];
|
public $activeLayers = []; // Now stores Layer IDs (not Phase IDs)
|
||||||
public $showLayerModal = false;
|
public $showLayerModal = false;
|
||||||
|
|
||||||
// Editor properties
|
// Editor properties
|
||||||
public $selectedFeature = null; // será instancia de Feature
|
public $selectedFeature = null;
|
||||||
public $selectedPhaseId = null;
|
public $selectedPhaseId = null;
|
||||||
public $editProgress = 0;
|
public $editProgress = 0;
|
||||||
public $editComment = '';
|
public $editComment = '';
|
||||||
@@ -27,6 +28,11 @@ class ProjectMap extends Component
|
|||||||
public $editPhotos = [];
|
public $editPhotos = [];
|
||||||
public $formFullscreen = false;
|
public $formFullscreen = false;
|
||||||
|
|
||||||
|
// Tab management
|
||||||
|
public $activeTab = 'edit';
|
||||||
|
public $allFeatures;
|
||||||
|
public $allInspections;
|
||||||
|
|
||||||
// Templates e inspecciones
|
// Templates e inspecciones
|
||||||
public $templates = [];
|
public $templates = [];
|
||||||
public $selectedTemplateId = null;
|
public $selectedTemplateId = null;
|
||||||
@@ -37,16 +43,61 @@ class ProjectMap extends Component
|
|||||||
public $showFeatureImages = false;
|
public $showFeatureImages = false;
|
||||||
public $featureImageMarkers = [];
|
public $featureImageMarkers = [];
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
public $filterStatus = '';
|
||||||
|
public $filterResponsible = '';
|
||||||
|
public $filterProgressMin = 0;
|
||||||
|
public $filterProgressMax = 100;
|
||||||
|
public $showFilters = false;
|
||||||
|
|
||||||
|
// Inspection workflow
|
||||||
|
public $inspectionResult = '';
|
||||||
|
public $inspectionNotes = '';
|
||||||
|
|
||||||
|
// Issues
|
||||||
|
public $openIssuesCount = 0;
|
||||||
|
|
||||||
|
// Inspection viewer
|
||||||
|
public $viewingInspection = null;
|
||||||
|
|
||||||
public function mount(Project $project)
|
public function mount(Project $project)
|
||||||
{
|
{
|
||||||
$this->project = $project;
|
$this->project = $project;
|
||||||
// Cargar fases con sus capas y los features de esas capas (para mostrarlos en el mapa)
|
$this->authorizeProjectAccess();
|
||||||
$this->phases = $project->phases()->with(['layers' => function ($q) {
|
|
||||||
$q->withCount('features');
|
$this->phases = $project->phases()->with([
|
||||||
}, 'layers.features'])->get();
|
'layers' => fn($q) => $q->withCount('features'),
|
||||||
// Por defecto mostrar todas las capas activas (todas las fases que tengan alguna capa con features)
|
'layers.features',
|
||||||
$this->activeLayers = $this->phases->pluck('id')->toArray();
|
'layers.features.images',
|
||||||
|
])->get();
|
||||||
|
|
||||||
|
// Initialize activeLayers with ALL layer IDs (not phase IDs)
|
||||||
|
$this->activeLayers = $this->phases
|
||||||
|
->flatMap(fn($p) => $p->layers->pluck('id'))
|
||||||
|
->map(fn($id) => (int) $id)
|
||||||
|
->toArray();
|
||||||
|
|
||||||
$this->loadTemplates();
|
$this->loadTemplates();
|
||||||
|
|
||||||
|
$this->allFeatures = Feature::whereHas('layer.phase', function($q) use ($project) {
|
||||||
|
$q->where('project_id', $project->id);
|
||||||
|
})->with(['layer.phase', 'template'])->get();
|
||||||
|
|
||||||
|
$this->allInspections = Inspection::where('project_id', $project->id)
|
||||||
|
->with(['feature.layer.phase', 'template', 'user'])
|
||||||
|
->orderBy('created_at', 'desc')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$this->openIssuesCount = Issue::where('project_id', $project->id)
|
||||||
|
->where('status', 'open')
|
||||||
|
->count();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function authorizeProjectAccess(): void
|
||||||
|
{
|
||||||
|
$user = Auth::user();
|
||||||
|
if ($user->can('manage all')) return;
|
||||||
|
if (!$this->project->users()->where('user_id', $user->id)->exists()) abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function loadTemplates()
|
public function loadTemplates()
|
||||||
@@ -54,90 +105,129 @@ class ProjectMap extends Component
|
|||||||
$this->templates = InspectionTemplate::where('project_id', $this->project->id)->get();
|
$this->templates = InspectionTemplate::where('project_id', $this->project->id)->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function toggleLayer($phaseId)
|
// ─── Layer / Phase visibility ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function toggleLayer($layerId)
|
||||||
{
|
{
|
||||||
if (in_array($phaseId, $this->activeLayers)) {
|
$layerId = (int) $layerId;
|
||||||
$this->activeLayers = array_diff($this->activeLayers, [$phaseId]);
|
if (in_array($layerId, $this->activeLayers)) {
|
||||||
|
$this->activeLayers = array_values(array_diff($this->activeLayers, [$layerId]));
|
||||||
} else {
|
} else {
|
||||||
$this->activeLayers[] = $phaseId;
|
$this->activeLayers[] = $layerId;
|
||||||
}
|
}
|
||||||
$this->dispatch('layersUpdated', $this->activeLayers);
|
$this->dispatch('layersUpdated', $this->activeLayers);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function openLayerModal()
|
public function togglePhase($phaseId)
|
||||||
{
|
{
|
||||||
$this->showLayerModal = true;
|
$phase = $this->phases->find($phaseId);
|
||||||
|
if (!$phase) return;
|
||||||
|
$layerIds = $phase->layers->pluck('id')->map(fn($id) => (int) $id)->toArray();
|
||||||
|
$allActive = !empty($layerIds) && collect($layerIds)->every(fn($id) => in_array($id, $this->activeLayers));
|
||||||
|
if ($allActive) {
|
||||||
|
$this->activeLayers = array_values(array_diff($this->activeLayers, $layerIds));
|
||||||
|
} else {
|
||||||
|
$this->activeLayers = array_values(array_unique(array_merge($this->activeLayers, $layerIds)));
|
||||||
|
}
|
||||||
|
$this->dispatch('layersUpdated', $this->activeLayers);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function closeLayerModal()
|
public function openLayerModal() { $this->showLayerModal = true; }
|
||||||
|
public function closeLayerModal() { $this->showLayerModal = false; }
|
||||||
|
|
||||||
|
// ─── Filters ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function updatedFilterStatus() { $this->applyFilters(); }
|
||||||
|
public function updatedFilterResponsible() { $this->applyFilters(); }
|
||||||
|
public function updatedFilterProgressMin() { $this->applyFilters(); }
|
||||||
|
public function updatedFilterProgressMax() { $this->applyFilters(); }
|
||||||
|
|
||||||
|
public function applyFilters()
|
||||||
{
|
{
|
||||||
$this->showLayerModal = false;
|
$filtered = $this->allFeatures->filter(function($f) {
|
||||||
|
if ($this->filterStatus && $f->status !== $this->filterStatus) return false;
|
||||||
|
if ($this->filterResponsible && !str_contains(strtolower($f->responsible ?? ''), strtolower($this->filterResponsible))) return false;
|
||||||
|
if ($f->progress < $this->filterProgressMin || $f->progress > $this->filterProgressMax) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
$this->dispatch('filtersChanged', $filtered->pluck('id')->values()->toArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function clearFilters()
|
||||||
|
{
|
||||||
|
$this->filterStatus = '';
|
||||||
|
$this->filterResponsible = '';
|
||||||
|
$this->filterProgressMin = 0;
|
||||||
|
$this->filterProgressMax = 100;
|
||||||
|
$this->dispatch('filtersChanged', $this->allFeatures->pluck('id')->values()->toArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Feature status ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function editFeatureStatus($status)
|
||||||
|
{
|
||||||
|
if (!$this->selectedFeature) return;
|
||||||
|
$feature = Feature::with('layer.phase')->findOrFail($this->selectedFeature->id);
|
||||||
|
if ($feature->layer->phase->project_id !== $this->project->id) abort(403);
|
||||||
|
$feature->status = $status;
|
||||||
|
if ($status === 'completed') $feature->progress = 100;
|
||||||
|
if ($status === 'planned') $feature->progress = 0;
|
||||||
|
$feature->save();
|
||||||
|
$this->selectedFeature = $feature;
|
||||||
|
$this->editProgress = $feature->progress;
|
||||||
|
$this->allFeatures = $this->allFeatures->map(fn($f) => $f->id === $feature->id ? $feature : $f);
|
||||||
|
$this->dispatch('featureStatusChanged', $feature->id, $feature->status, $feature->status_color);
|
||||||
|
$this->dispatch('notify', 'Estado actualizado');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Actualizar el progreso de un Feature y recalcular el progreso de la fase.
|
|
||||||
*/
|
|
||||||
public function updateProgress($featureId, $newProgress, $comment = null)
|
public function updateProgress($featureId, $newProgress, $comment = null)
|
||||||
{
|
{
|
||||||
$feature = Feature::findOrFail($featureId);
|
$feature = Feature::with('layer.phase')->findOrFail($featureId);
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
|
if (!$user->can('update progress')) {
|
||||||
if (!$user->can('update progress') && !$user->hasRole('Admin')) {
|
|
||||||
$this->dispatch('notify', 'Sin permisos');
|
$this->dispatch('notify', 'Sin permisos');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if ($feature->layer->phase->project_id !== $this->project->id) abort(403);
|
||||||
$oldProgress = $feature->progress;
|
|
||||||
$feature->progress = min(100, max(0, $newProgress));
|
$feature->progress = min(100, max(0, $newProgress));
|
||||||
$feature->save();
|
$feature->save();
|
||||||
|
$phase = $feature->layer->phase;
|
||||||
// Recalcular el progreso de la fase (promedio de todos sus features)
|
|
||||||
$phase = Phase::find($feature->layer->phase_id);
|
|
||||||
$phase->progress_percent = $phase->features()->avg('progress') ?: 0;
|
$phase->progress_percent = $phase->features()->avg('progress') ?: 0;
|
||||||
$phase->save();
|
$phase->save();
|
||||||
|
|
||||||
// Registrar la actualización en progress_updates
|
|
||||||
$phase->progressUpdates()->create([
|
$phase->progressUpdates()->create([
|
||||||
'user_id' => $user->id,
|
'user_id' => $user->id,
|
||||||
'progress_percent' => $phase->progress_percent,
|
'progress_percent' => $phase->progress_percent,
|
||||||
'comment' => $comment,
|
'comment' => $comment,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->dispatch('progressUpdated', $featureId, $feature->progress);
|
$this->dispatch('progressUpdated', $featureId, $feature->progress);
|
||||||
$this->dispatch('notify', 'Progreso actualizado');
|
$this->dispatch('notify', 'Progreso actualizado');
|
||||||
|
|
||||||
// Si el feature seleccionado es el mismo, actualizar la propiedad local
|
|
||||||
if ($this->selectedFeature && $this->selectedFeature->id == $featureId) {
|
if ($this->selectedFeature && $this->selectedFeature->id == $featureId) {
|
||||||
$this->selectedFeature->progress = $feature->progress;
|
$this->selectedFeature->progress = $feature->progress;
|
||||||
$this->editProgress = $feature->progress;
|
$this->editProgress = $feature->progress;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Seleccionar un Feature al hacer clic en el mapa.
|
|
||||||
*/
|
|
||||||
public function selectFeature($featureId)
|
public function selectFeature($featureId)
|
||||||
{
|
{
|
||||||
$this->selectedFeature = null;
|
$this->selectedFeature = null;
|
||||||
$feature = Feature::with('template')->find($featureId);
|
$feature = Feature::with(['template', 'layer.phase'])->find($featureId);
|
||||||
if (!$feature) return;
|
if (!$feature) return;
|
||||||
|
if ($feature->layer->phase->project_id !== $this->project->id) abort(403);
|
||||||
|
|
||||||
$this->selectedFeature = $feature;
|
$this->selectedFeature = $feature;
|
||||||
$this->selectedPhaseId = $feature->layer->phase_id;
|
$this->selectedPhaseId = $feature->layer->phase_id;
|
||||||
$this->editProgress = $feature->progress;
|
$this->editProgress = $feature->progress;
|
||||||
$this->editResponsible = $feature->responsible ?? '';
|
$this->editResponsible = $feature->responsible ?? '';
|
||||||
$this->editPhotos = $feature->properties['photos'] ?? [];
|
$this->editPhotos = $feature->properties['photos'] ?? [];
|
||||||
$this->selectedTemplateId = $feature->template_id;
|
$this->selectedTemplateId = $feature->template_id;
|
||||||
|
$this->activeTab = 'edit';
|
||||||
|
|
||||||
$this->loadInspectionHistory();
|
$this->loadInspectionHistory();
|
||||||
$this->resetInspectionForm();
|
$this->resetInspectionForm();
|
||||||
|
|
||||||
$this->dispatch('featureSelected', $featureId);
|
$this->dispatch('featureSelected', $featureId, $feature->name);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Cargar el historial de inspecciones del feature seleccionado.
|
|
||||||
*/
|
|
||||||
public function loadInspectionHistory()
|
public function loadInspectionHistory()
|
||||||
{
|
{
|
||||||
if (!$this->selectedFeature) {
|
if (!$this->selectedFeature) {
|
||||||
@@ -150,12 +240,11 @@ class ProjectMap extends Component
|
|||||||
->get();
|
->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Reiniciar el formulario de inspección según el template seleccionado.
|
|
||||||
*/
|
|
||||||
public function resetInspectionForm()
|
public function resetInspectionForm()
|
||||||
{
|
{
|
||||||
$this->inspectionFormData = [];
|
$this->inspectionFormData = [];
|
||||||
|
$this->inspectionResult = '';
|
||||||
|
$this->inspectionNotes = '';
|
||||||
if ($this->selectedTemplateId) {
|
if ($this->selectedTemplateId) {
|
||||||
$template = InspectionTemplate::find($this->selectedTemplateId);
|
$template = InspectionTemplate::find($this->selectedTemplateId);
|
||||||
if ($template) {
|
if ($template) {
|
||||||
@@ -166,19 +255,16 @@ class ProjectMap extends Component
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Guardar una nueva inspección.
|
|
||||||
*/
|
|
||||||
public function saveInspection()
|
public function saveInspection()
|
||||||
{
|
{
|
||||||
if (!$this->selectedFeature || !$this->selectedTemplateId) {
|
if (!$this->selectedFeature || !$this->selectedTemplateId) {
|
||||||
$this->dispatch('notify', 'Selecciona un elemento y un template.');
|
$this->dispatch('notify', 'Selecciona un elemento y un template.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
$feature = Feature::with('layer.phase')->find($this->selectedFeature->id);
|
||||||
|
if (!$feature || $feature->layer->phase->project_id !== $this->project->id) abort(403);
|
||||||
|
|
||||||
$this->validate([
|
$this->validate(['selectedTemplateId' => 'required|exists:inspection_templates,id']);
|
||||||
'selectedTemplateId' => 'required|exists:inspection_templates,id',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$template = InspectionTemplate::find($this->selectedTemplateId);
|
$template = InspectionTemplate::find($this->selectedTemplateId);
|
||||||
foreach ($template->fields as $field) {
|
foreach ($template->fields as $field) {
|
||||||
@@ -189,70 +275,117 @@ class ProjectMap extends Component
|
|||||||
}
|
}
|
||||||
|
|
||||||
$inspection = Inspection::create([
|
$inspection = Inspection::create([
|
||||||
'project_id' => $this->project->id,
|
'project_id' => $this->project->id,
|
||||||
'layer_id' => $this->selectedFeature->layer_id,
|
'layer_id' => $this->selectedFeature->layer_id,
|
||||||
'feature_id' => $this->selectedFeature->id,
|
'feature_id' => $this->selectedFeature->id,
|
||||||
'template_id' => $this->selectedTemplateId,
|
'template_id' => $this->selectedTemplateId,
|
||||||
'user_id' => auth()->id(),
|
'user_id' => auth()->id(),
|
||||||
'data' => $this->inspectionFormData,
|
'inspector_user_id' => auth()->id(),
|
||||||
|
'status' => 'completed',
|
||||||
|
'completed_at' => now(),
|
||||||
|
'result' => $this->inspectionResult ?: null,
|
||||||
|
'notes' => $this->inspectionNotes ?: null,
|
||||||
|
'data' => $this->inspectionFormData,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Si el template tiene un campo llamado 'progress', actualizar el progreso del feature
|
if ($this->inspectionResult === 'fail') {
|
||||||
if (isset($this->inspectionFormData['progress'])) {
|
Issue::create([
|
||||||
$this->updateProgress($this->selectedFeature->id, (int)$this->inspectionFormData['progress'], 'Inspección registrada');
|
'project_id' => $this->project->id,
|
||||||
|
'feature_id' => $this->selectedFeature->id,
|
||||||
|
'inspection_id' => $inspection->id,
|
||||||
|
'title' => 'Fallo en inspección: ' . ($template->name ?? 'Sin nombre'),
|
||||||
|
'description' => $this->inspectionNotes,
|
||||||
|
'priority' => 'high',
|
||||||
|
'status' => 'open',
|
||||||
|
'reported_by' => auth()->id(),
|
||||||
|
]);
|
||||||
|
$this->openIssuesCount = Issue::where('project_id', $this->project->id)
|
||||||
|
->where('status', 'open')->count();
|
||||||
|
$this->dispatch('notify', 'Inspección fallida — Issue creado automáticamente');
|
||||||
|
} else {
|
||||||
|
if (isset($this->inspectionFormData['progress'])) {
|
||||||
|
$this->updateProgress($this->selectedFeature->id, (int)$this->inspectionFormData['progress'], 'Inspección registrada');
|
||||||
|
}
|
||||||
|
$this->dispatch('notify', 'Inspección guardada correctamente');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reload global list
|
||||||
|
$this->allInspections = Inspection::where('project_id', $this->project->id)
|
||||||
|
->with(['feature.layer.phase', 'template', 'user'])
|
||||||
|
->orderBy('created_at', 'desc')
|
||||||
|
->get();
|
||||||
|
|
||||||
$this->loadInspectionHistory();
|
$this->loadInspectionHistory();
|
||||||
$this->resetInspectionForm();
|
$this->resetInspectionForm();
|
||||||
$this->dispatch('notify', 'Inspección guardada correctamente');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Asignar un template al feature seleccionado.
|
|
||||||
*/
|
|
||||||
public function assignTemplateToFeature($templateId)
|
public function assignTemplateToFeature($templateId)
|
||||||
{
|
{
|
||||||
if (!$this->selectedFeature) return;
|
if (!$this->selectedFeature) return;
|
||||||
|
$template = InspectionTemplate::where('id', $templateId)
|
||||||
$this->selectedFeature->template_id = $templateId;
|
->where('project_id', $this->project->id)->first();
|
||||||
$this->selectedFeature->save();
|
if (!$template) abort(403);
|
||||||
|
$feature = Feature::findOrFail($this->selectedFeature->id);
|
||||||
|
$feature->template_id = $templateId;
|
||||||
|
$feature->save();
|
||||||
|
$this->selectedFeature = $feature;
|
||||||
$this->selectedTemplateId = $templateId;
|
$this->selectedTemplateId = $templateId;
|
||||||
$this->resetInspectionForm();
|
$this->resetInspectionForm();
|
||||||
$this->dispatch('notify', 'Template asignado al elemento');
|
$this->dispatch('notify', 'Template asignado al elemento');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Guardar progreso y responsable del feature seleccionado.
|
|
||||||
*/
|
|
||||||
public function saveFeatureProgress()
|
public function saveFeatureProgress()
|
||||||
{
|
{
|
||||||
if (!$this->selectedFeature) return;
|
if (!$this->selectedFeature) return;
|
||||||
|
$feature = Feature::with('layer.phase')->findOrFail($this->selectedFeature->id);
|
||||||
$this->selectedFeature->progress = min(100, max(0, (int)$this->editProgress));
|
if ($feature->layer->phase->project_id !== $this->project->id) abort(403);
|
||||||
$this->selectedFeature->responsible = $this->editResponsible;
|
$feature->progress = min(100, max(0, (int)$this->editProgress));
|
||||||
$this->selectedFeature->save();
|
$feature->responsible = $this->editResponsible;
|
||||||
|
$feature->save();
|
||||||
// Recalcular progreso de la fase
|
$this->selectedFeature = $feature;
|
||||||
$phase = Phase::find($this->selectedFeature->layer->phase_id);
|
$phase = $feature->layer->phase;
|
||||||
$phase->progress_percent = $phase->features()->avg('progress') ?: 0;
|
$phase->progress_percent = $phase->features()->avg('progress') ?: 0;
|
||||||
$phase->save();
|
$phase->save();
|
||||||
|
|
||||||
$this->dispatch('progressUpdated', $phase->id, $phase->progress_percent);
|
$this->dispatch('progressUpdated', $phase->id, $phase->progress_percent);
|
||||||
$this->dispatch('notify', 'Progreso guardado');
|
$this->dispatch('notify', 'Progreso guardado');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Cuando cambia el template seleccionado, reiniciar el formulario.
|
|
||||||
*/
|
|
||||||
public function onTemplateChange()
|
public function onTemplateChange()
|
||||||
{
|
{
|
||||||
$this->resetInspectionForm();
|
$this->resetInspectionForm();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ─── Inspection viewer ───────────────────────────────────────────────────────
|
||||||
* Toggle mostrar imágenes en el mapa.
|
|
||||||
*/
|
public function viewInspection($id)
|
||||||
|
{
|
||||||
|
$ins = Inspection::where('project_id', $this->project->id)
|
||||||
|
->with(['feature.layer.phase', 'template', 'user'])
|
||||||
|
->find($id);
|
||||||
|
if (!$ins) return;
|
||||||
|
$this->viewingInspection = [
|
||||||
|
'id' => $ins->id,
|
||||||
|
'feature_name' => $ins->feature?->name ?? '—',
|
||||||
|
'layer_name' => $ins->feature?->layer?->name ?? '—',
|
||||||
|
'phase_name' => $ins->feature?->layer?->phase?->name ?? '—',
|
||||||
|
'template_name' => $ins->template?->name ?? '—',
|
||||||
|
'user_name' => $ins->user?->name ?? '—',
|
||||||
|
'date' => $ins->created_at->format('d/m/Y H:i'),
|
||||||
|
'status' => $ins->status,
|
||||||
|
'result' => $ins->result,
|
||||||
|
'notes' => $ins->notes,
|
||||||
|
'data' => $ins->data ?? [],
|
||||||
|
'fields' => $ins->template?->fields ?? [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function closeViewInspection()
|
||||||
|
{
|
||||||
|
$this->viewingInspection = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Feature images ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
public function toggleFeatureImages()
|
public function toggleFeatureImages()
|
||||||
{
|
{
|
||||||
$this->showFeatureImages = !$this->showFeatureImages;
|
$this->showFeatureImages = !$this->showFeatureImages;
|
||||||
@@ -260,44 +393,31 @@ class ProjectMap extends Component
|
|||||||
$this->dispatch('featureImagesToggled', $this->showFeatureImages, $this->featureImageMarkers);
|
$this->dispatch('featureImagesToggled', $this->showFeatureImages, $this->featureImageMarkers);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Cargar marcadores de imágenes para el mapa.
|
|
||||||
*/
|
|
||||||
public function loadFeatureImageMarkers()
|
public function loadFeatureImageMarkers()
|
||||||
{
|
{
|
||||||
if (!$this->showFeatureImages) {
|
if (!$this->showFeatureImages) { $this->featureImageMarkers = []; return; }
|
||||||
$this->featureImageMarkers = [];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$markers = [];
|
$markers = [];
|
||||||
foreach ($this->phases as $phase) {
|
foreach ($this->phases as $phase) {
|
||||||
foreach ($phase->layers as $layer) {
|
foreach ($phase->layers as $layer) {
|
||||||
foreach ($layer->features as $feature) {
|
foreach ($layer->features as $feature) {
|
||||||
$image = $feature->images()->first();
|
$image = $feature->images->first();
|
||||||
if ($image) {
|
if ($image) {
|
||||||
$geo = $feature->geometry;
|
$geo = $feature->geometry;
|
||||||
$coords = null;
|
$coords = null;
|
||||||
if ($geo && isset($geo['coordinates'])) {
|
if ($geo && isset($geo['coordinates'])) {
|
||||||
if ($geo['type'] === 'Point') {
|
if ($geo['type'] === 'Point') {
|
||||||
$coords = [
|
$coords = ['lat' => $geo['coordinates'][1], 'lng' => $geo['coordinates'][0]];
|
||||||
'lat' => $geo['coordinates'][1],
|
|
||||||
'lng' => $geo['coordinates'][0],
|
|
||||||
];
|
|
||||||
} elseif (in_array($geo['type'], ['Polygon', 'LineString'])) {
|
} elseif (in_array($geo['type'], ['Polygon', 'LineString'])) {
|
||||||
$coords = [
|
$coords = ['lat' => $geo['coordinates'][0][1] ?? null, 'lng' => $geo['coordinates'][0][0] ?? null];
|
||||||
'lat' => $geo['coordinates'][0][1] ?? null,
|
|
||||||
'lng' => $geo['coordinates'][0][0] ?? null,
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ($coords && $coords['lat'] && $coords['lng']) {
|
if ($coords && $coords['lat'] && $coords['lng']) {
|
||||||
$markers[] = [
|
$markers[] = [
|
||||||
'feature_id' => $feature->id,
|
'feature_id' => $feature->id,
|
||||||
'name' => $feature->name,
|
'name' => $feature->name,
|
||||||
'lat' => $coords['lat'],
|
'lat' => $coords['lat'],
|
||||||
'lng' => $coords['lng'],
|
'lng' => $coords['lng'],
|
||||||
'image_url' => $image->url,
|
'image_url' => $image->url,
|
||||||
'image_name' => $image->name,
|
'image_name' => $image->name,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -311,16 +431,19 @@ class ProjectMap extends Component
|
|||||||
public function toggleFullscreen()
|
public function toggleFullscreen()
|
||||||
{
|
{
|
||||||
$this->formFullscreen = !$this->formFullscreen;
|
$this->formFullscreen = !$this->formFullscreen;
|
||||||
if (!$this->formFullscreen) {
|
if (!$this->formFullscreen) $this->dispatch('mapResize');
|
||||||
$this->dispatch('mapResize');
|
}
|
||||||
}
|
|
||||||
|
public function setActiveTab($tab)
|
||||||
|
{
|
||||||
|
$this->activeTab = $tab;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
{
|
{
|
||||||
return view('livewire.projects.project-map', [
|
return view('livewire.projects.project-map', [
|
||||||
'project' => $this->project,
|
'project' => $this->project,
|
||||||
'phases' => $this->phases,
|
'phases' => $this->phases,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,9 +4,8 @@ namespace App\Livewire;
|
|||||||
|
|
||||||
use Rappasoft\LaravelLivewireTables\DataTableComponent;
|
use Rappasoft\LaravelLivewireTables\DataTableComponent;
|
||||||
use Rappasoft\LaravelLivewireTables\Views\Column;
|
use Rappasoft\LaravelLivewireTables\Views\Column;
|
||||||
use Rappasoft\LaravelLivewireTables\Views\Columns\{BooleanColumn, ButtonGroupColumn, LinkColumn, ImageColumn};
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Rappasoft\LaravelLivewireTables\Views\Filters\{DateFilter, MultiSelectFilter, SelectFilter};
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
|
|
||||||
class ProjectTable extends DataTableComponent
|
class ProjectTable extends DataTableComponent
|
||||||
@@ -17,86 +16,102 @@ class ProjectTable extends DataTableComponent
|
|||||||
{
|
{
|
||||||
$this->setPrimaryKey('id')
|
$this->setPrimaryKey('id')
|
||||||
->setDefaultSort('created_at', 'desc')
|
->setDefaultSort('created_at', 'desc')
|
||||||
->setTableAttributes(['class' => 'table-auto w-full']);
|
->setSortingPillsEnabled(false)
|
||||||
|
->setAdditionalSelects(['projects.id as id', 'projects.created_at as created_at']);
|
||||||
|
}
|
||||||
|
|
||||||
$this->setThAttributes(function(Column $column) {
|
public function builder(): Builder
|
||||||
return ['class' => 'px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider'];
|
{
|
||||||
});
|
return Project::accessibleBy(Auth::user())
|
||||||
|
->with('phases');
|
||||||
$this->setTdAttributes(function(Column $column) {
|
|
||||||
return ['class' => 'px-4 py-2 whitespace-nowrap text-sm text-gray-900'];
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function columns(): array
|
public function columns(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Column::make(__('ID'), 'id')
|
Column::make('Referencia', 'reference')
|
||||||
->sortable()
|
->sortable()
|
||||||
->searchable(),
|
->searchable()
|
||||||
|
->format(function ($value, $row) {
|
||||||
|
$url = route('projects.dashboard', $row->id);
|
||||||
|
return $value
|
||||||
|
? '<a href="'.$url.'" class="font-mono text-xs text-primary hover:underline" wire:navigate>'.e($value).'</a>'
|
||||||
|
: '<span class="text-gray-300">—</span>';
|
||||||
|
})
|
||||||
|
->html(),
|
||||||
|
|
||||||
Column::make(__('Project Name'), 'name')
|
Column::make(__('Name'), 'name')
|
||||||
->sortable()
|
->sortable()
|
||||||
->searchable(),
|
->searchable(),
|
||||||
|
|
||||||
Column::make(__('Address'), 'address')
|
Column::make(__('Address'), 'address')
|
||||||
->sortable()
|
->sortable()
|
||||||
->searchable(),
|
->searchable()
|
||||||
|
->format(fn ($value) => $value
|
||||||
|
? '<span class="truncate block max-w-xs" title="'.e($value).'">'.e($value).'</span>'
|
||||||
|
: '<span class="text-gray-400">—</span>')
|
||||||
|
->html(),
|
||||||
|
|
||||||
Column::make(__('Status'), 'status')
|
Column::make(__('Status'), 'status')
|
||||||
->sortable(),
|
->sortable()
|
||||||
|
->format(function ($value) {
|
||||||
|
$map = [
|
||||||
|
'planning' => ['badge-ghost', 'Planificación'],
|
||||||
|
'in_progress' => ['badge-primary', 'En progreso'],
|
||||||
|
'paused' => ['badge-warning', 'Pausado'],
|
||||||
|
'completed' => ['badge-success', 'Completado'],
|
||||||
|
];
|
||||||
|
[$cls, $label] = $map[$value] ?? ['badge-ghost', ucfirst($value)];
|
||||||
|
return '<span class="badge '.$cls.'">'.$label.'</span>';
|
||||||
|
})
|
||||||
|
->html(),
|
||||||
|
|
||||||
|
Column::make(__('Progress'))
|
||||||
|
->label(function ($row) {
|
||||||
|
$avg = $row->phases->avg('progress_percent') ?? 0;
|
||||||
|
$pct = round($avg);
|
||||||
|
return '
|
||||||
|
<div class="flex items-center gap-2 min-w-[100px]">
|
||||||
|
<div class="flex-1 bg-gray-200 rounded-full h-2">
|
||||||
|
<div class="bg-primary h-2 rounded-full" style="width:'.$pct.'%"></div>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-gray-500 w-8 text-right">'.$pct.'%</span>
|
||||||
|
</div>';
|
||||||
|
})
|
||||||
|
->html(),
|
||||||
|
|
||||||
Column::make(__('Start Date'), 'start_date')
|
Column::make(__('Start Date'), 'start_date')
|
||||||
->sortable()
|
->sortable()
|
||||||
->format(fn ($value, $row, $column) => $value ? $value->format('Y-m-d') : ''),
|
->format(fn ($value) => $value ? $value->format('d/m/Y') : '—'),
|
||||||
|
|
||||||
Column::make(__('Estimated End Date'), 'end_date_estimated')
|
Column::make(__('Est. End'), 'end_date_estimated')
|
||||||
->sortable()
|
->sortable()
|
||||||
->format(fn ($value, $row, $column) => $value ? $value->format('Y-m-d') : ''),
|
->format(fn ($value) => $value ? $value->format('d/m/Y') : '—'),
|
||||||
|
|
||||||
Column::make(__('Actions'))
|
Column::make(__('Actions'))
|
||||||
->label(function ($row) {
|
->label(function ($row) {
|
||||||
$confirm = __('Are you sure you want to delete this project?');
|
$dashboard = route('projects.dashboard', $row->id);
|
||||||
|
$map = route('projects.map', $row->id);
|
||||||
|
$edit = route('projects.edit', $row->id);
|
||||||
|
|
||||||
return '
|
$canEdit = Auth::user()->can('edit projects');
|
||||||
<div class="flex space-x-2">
|
|
||||||
<a href="'.route('projects.edit', $row->id).'" class="btn btn-sm">'.__('Edit').'</a>
|
|
||||||
<form action="'.route('projects.destroy', $row->id).'" method="POST" onsubmit="return confirm(\''.$confirm.'\');">
|
|
||||||
'.csrf_field().'
|
|
||||||
<input type="hidden" name="_method" value="DELETE">
|
|
||||||
<button type="submit" class="btn btn-sm">'.__('Delete').'</button>
|
|
||||||
</form>
|
|
||||||
</div>';
|
|
||||||
})
|
|
||||||
->html(),
|
|
||||||
|
|
||||||
ButtonGroupColumn::make(__('Actions'))
|
$html = '<div class="flex items-center gap-1">';
|
||||||
->attributes(function($row) {
|
$html .= '<a href="'.$dashboard.'" class="btn btn-xs btn-outline" title="Dashboard" wire:navigate>
|
||||||
return [
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><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-2V6zm10 0a2 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-2zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/></svg>
|
||||||
'class' => 'space-x-2',
|
</a>';
|
||||||
];
|
$html .= '<a href="'.$map.'" class="btn btn-xs btn-outline" title="Mapa" wire:navigate>
|
||||||
})
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7"/></svg>
|
||||||
->buttons([
|
</a>';
|
||||||
LinkColumn::make('Edit')
|
if ($canEdit) {
|
||||||
->title(fn($row) => __('Edit'))
|
$html .= '<a href="'.$edit.'" class="btn btn-xs btn-warning" title="Editar" wire:navigate>
|
||||||
->location(fn($row) => route('projects.edit', $row->id))
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" 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>
|
||||||
->attributes(function($row) {
|
</a>';
|
||||||
return [
|
}
|
||||||
'target' => '_blank',
|
$html .= '</div>';
|
||||||
'class' => 'text-blue-500 hover:underline',
|
return $html;
|
||||||
];
|
})
|
||||||
}),
|
->html(),
|
||||||
|
|
||||||
LinkColumn::make('View') // make() has no effect in this case but needs to be set anyway
|
|
||||||
->title(fn($row) => __('View'))
|
|
||||||
->location(fn($row) => route('projects.map', $row->id))
|
|
||||||
->attributes(function($row) {
|
|
||||||
return [
|
|
||||||
'class' => 'text-blue-500 hover:underline',
|
|
||||||
];
|
|
||||||
}),
|
|
||||||
|
|
||||||
]),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,15 +2,15 @@
|
|||||||
|
|
||||||
namespace App\Livewire;
|
namespace App\Livewire;
|
||||||
|
|
||||||
use Livewire\Component;
|
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Livewire\Attributes\On;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
class ProjectUsers extends Component
|
class ProjectUsers extends Component
|
||||||
{
|
{
|
||||||
public Project $project;
|
public Project $project;
|
||||||
public $assignedUsers = [];
|
|
||||||
public $allUsers = [];
|
public $allUsers = [];
|
||||||
public $selectedUserId = '';
|
public $selectedUserId = '';
|
||||||
public $selectedRole = 'viewer';
|
public $selectedRole = 'viewer';
|
||||||
@@ -18,64 +18,46 @@ class ProjectUsers extends Component
|
|||||||
public function mount(Project $project)
|
public function mount(Project $project)
|
||||||
{
|
{
|
||||||
$this->project = $project;
|
$this->project = $project;
|
||||||
$this->loadUsers();
|
$this->loadAvailable();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function loadUsers()
|
/** Users not yet assigned to the project (for the dropdown). */
|
||||||
|
public function loadAvailable(): void
|
||||||
{
|
{
|
||||||
$this->assignedUsers = $this->project->users()->withPivot('role_in_project')->get();
|
$assignedIds = $this->project->users()->pluck('users.id')->toArray();
|
||||||
$assignedIds = $this->assignedUsers->pluck('id')->toArray();
|
|
||||||
$this->allUsers = User::whereNotIn('id', $assignedIds)->orderBy('name')->get();
|
$this->allUsers = User::whereNotIn('id', $assignedIds)->orderBy('name')->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Reload the dropdown when the embedded table changes assignments. */
|
||||||
|
#[On('project-users-changed')]
|
||||||
|
public function onUsersChanged(): void
|
||||||
|
{
|
||||||
|
$this->loadAvailable();
|
||||||
|
}
|
||||||
|
|
||||||
public function assignUser()
|
public function assignUser()
|
||||||
{
|
{
|
||||||
$user = Auth::user();
|
abort_unless(Auth::user()->can('assign users'), 403);
|
||||||
if (!$user->can('assign users') && !$user->hasRole('Admin')) {
|
|
||||||
session()->flash('error', 'No tienes permisos para asignar usuarios.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->validate([
|
$this->validate([
|
||||||
'selectedUserId' => 'required|exists:users,id',
|
'selectedUserId' => 'required|exists:users,id',
|
||||||
'selectedRole' => 'required|in:supervisor,consultant,client,viewer',
|
'selectedRole' => 'required|in:' . implode(',', array_keys(ProjectUsersTable::ROLES)),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->project->users()->attach($this->selectedUserId, [
|
$this->project->users()->attach($this->selectedUserId, [
|
||||||
'role_in_project' => $this->selectedRole
|
'role_in_project' => $this->selectedRole,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->reset(['selectedUserId', 'selectedRole']);
|
$this->reset(['selectedUserId', 'selectedRole']);
|
||||||
$this->loadUsers();
|
$this->loadAvailable();
|
||||||
|
$this->dispatch('project-users-changed');
|
||||||
$this->dispatch('notify', 'Usuario asignado al proyecto.');
|
$this->dispatch('notify', 'Usuario asignado al proyecto.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function removeUser($userId)
|
|
||||||
{
|
|
||||||
$user = Auth::user();
|
|
||||||
if (!$user->can('assign users') && !$user->hasRole('Admin')) {
|
|
||||||
session()->flash('error', 'Sin permisos.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->project->users()->detach($userId);
|
|
||||||
$this->loadUsers();
|
|
||||||
$this->dispatch('notify', 'Usuario eliminado del proyecto.');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function changeRole($userId, $role)
|
|
||||||
{
|
|
||||||
if (!in_array($role, ['supervisor', 'consultant', 'client', 'viewer'])) return;
|
|
||||||
|
|
||||||
$this->project->users()->updateExistingPivot($userId, [
|
|
||||||
'role_in_project' => $role
|
|
||||||
]);
|
|
||||||
$this->loadUsers();
|
|
||||||
$this->dispatch('notify', 'Rol actualizado.');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
{
|
{
|
||||||
return view('livewire.project-users');
|
return view('livewire.project-users', [
|
||||||
|
'roles' => ProjectUsersTable::ROLES,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Livewire\Attributes\On;
|
||||||
|
use Rappasoft\LaravelLivewireTables\DataTableComponent;
|
||||||
|
use Rappasoft\LaravelLivewireTables\Views\Column;
|
||||||
|
use Rappasoft\LaravelLivewireTables\Views\Filters\SelectFilter;
|
||||||
|
|
||||||
|
class ProjectUsersTable extends DataTableComponent
|
||||||
|
{
|
||||||
|
protected $model = User::class;
|
||||||
|
|
||||||
|
public int $projectId;
|
||||||
|
|
||||||
|
/** role_in_project => label */
|
||||||
|
public const ROLES = [
|
||||||
|
'supervisor' => 'Supervisor',
|
||||||
|
'consultant' => 'Consultor',
|
||||||
|
'client' => 'Cliente',
|
||||||
|
'viewer' => 'Observador',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function configure(): void
|
||||||
|
{
|
||||||
|
$this->setPrimaryKey('id')
|
||||||
|
->setDefaultSort('users.name', 'asc')
|
||||||
|
->setSortingPillsEnabled(false)
|
||||||
|
->setAdditionalSelects(['users.id as id', 'project_user.role_in_project as role_in_project']);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[On('project-users-changed')]
|
||||||
|
public function refreshRows(): void
|
||||||
|
{
|
||||||
|
// no-op: triggers re-render so the builder re-runs.
|
||||||
|
}
|
||||||
|
|
||||||
|
public function builder(): Builder
|
||||||
|
{
|
||||||
|
return User::query()
|
||||||
|
->join('project_user', 'project_user.user_id', '=', 'users.id')
|
||||||
|
->where('project_user.project_id', $this->projectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function columns(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Column::make('Nombre', 'name')
|
||||||
|
->sortable()
|
||||||
|
->searchable()
|
||||||
|
->format(function ($value, $row) {
|
||||||
|
$initial = strtoupper(mb_substr($value ?? '?', 0, 1));
|
||||||
|
return '<div class="flex items-center gap-2">
|
||||||
|
<span class="w-7 h-7 rounded-full bg-primary text-primary-content flex items-center justify-center text-xs font-bold shrink-0">'.$initial.'</span>
|
||||||
|
<span class="font-medium">'.e($value).'</span>
|
||||||
|
</div>';
|
||||||
|
})
|
||||||
|
->html(),
|
||||||
|
|
||||||
|
Column::make('Email', 'email')
|
||||||
|
->sortable()
|
||||||
|
->searchable(),
|
||||||
|
|
||||||
|
Column::make('Rol', 'role_in_project')
|
||||||
|
->label(function ($row) {
|
||||||
|
$current = $row->role_in_project;
|
||||||
|
if (! Auth::user()->can('assign users')) {
|
||||||
|
return '<span class="badge badge-sm">'.(self::ROLES[$current] ?? ucfirst((string) $current)).'</span>';
|
||||||
|
}
|
||||||
|
$opts = '';
|
||||||
|
foreach (self::ROLES as $val => $label) {
|
||||||
|
$opts .= '<option value="'.$val.'"'.($current === $val ? ' selected' : '').'>'.$label.'</option>';
|
||||||
|
}
|
||||||
|
return '<select wire:change="changeRole('.$row->id.', $event.target.value)" class="select select-bordered select-xs">'.$opts.'</select>';
|
||||||
|
})
|
||||||
|
->html(),
|
||||||
|
|
||||||
|
Column::make('Acciones')
|
||||||
|
->label(function ($row) {
|
||||||
|
if (! Auth::user()->can('assign users')) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return '<div class="flex justify-end">
|
||||||
|
<button wire:click="removeUser('.$row->id.')" wire:confirm="¿Quitar a '.e($row->name).' del proyecto?"
|
||||||
|
class="btn btn-xs btn-error btn-outline" title="Quitar del proyecto">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>';
|
||||||
|
})
|
||||||
|
->html(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function filters(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
SelectFilter::make('Rol', 'role')
|
||||||
|
->options(['' => 'Rol: todos'] + self::ROLES)
|
||||||
|
->filter(fn (Builder $query, string $value) => $query->where('project_user.role_in_project', $value)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function changeRole($userId, $role): void
|
||||||
|
{
|
||||||
|
abort_unless(Auth::user()->can('assign users'), 403);
|
||||||
|
if (! array_key_exists($role, self::ROLES)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
\App\Models\Project::findOrFail($this->projectId)
|
||||||
|
->users()->updateExistingPivot($userId, ['role_in_project' => $role]);
|
||||||
|
$this->dispatch('project-users-changed');
|
||||||
|
$this->dispatch('notify', 'Rol actualizado.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function removeUser($userId): void
|
||||||
|
{
|
||||||
|
abort_unless(Auth::user()->can('assign users'), 403);
|
||||||
|
\App\Models\Project::findOrFail($this->projectId)->users()->detach($userId);
|
||||||
|
$this->dispatch('project-users-changed');
|
||||||
|
$this->dispatch('notify', 'Usuario eliminado del proyecto.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,11 +3,13 @@
|
|||||||
namespace App\Livewire\Reports;
|
namespace App\Livewire\Reports;
|
||||||
|
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
use Livewire\Attributes\Layout;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\Phase;
|
use App\Models\Phase;
|
||||||
use App\Models\Inspection;
|
use App\Models\Inspection;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
|
|
||||||
|
#[Layout('layouts.app')]
|
||||||
class ReportsDashboard extends Component
|
class ReportsDashboard extends Component
|
||||||
{
|
{
|
||||||
public $dateRange = 'month'; // week, month, quarter, year
|
public $dateRange = 'month'; // week, month, quarter, year
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use Livewire\Component;
|
||||||
|
use Livewire\Attributes\Layout;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Spatie\Permission\Models\Role;
|
||||||
|
use Spatie\Permission\PermissionRegistrar;
|
||||||
|
|
||||||
|
#[Layout('layouts.app')]
|
||||||
|
class RoleForm extends Component
|
||||||
|
{
|
||||||
|
public ?Role $role = null;
|
||||||
|
|
||||||
|
public string $name = '';
|
||||||
|
public string $description = '';
|
||||||
|
|
||||||
|
private const PROTECTED_ROLES = ['Admin'];
|
||||||
|
private const CORE_PERMISSION = 'manage all';
|
||||||
|
|
||||||
|
public function mount(?Role $role = null): void
|
||||||
|
{
|
||||||
|
abort_unless(Auth::user()?->can('manage roles'), 403);
|
||||||
|
|
||||||
|
if ($role && $role->exists) {
|
||||||
|
$this->role = $role;
|
||||||
|
$this->name = $role->name;
|
||||||
|
$this->description = $role->description ?? '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save()
|
||||||
|
{
|
||||||
|
$this->validate([
|
||||||
|
'name' => 'required|string|max:50|unique:roles,name' . ($this->role ? ',' . $this->role->id : ''),
|
||||||
|
'description' => 'nullable|string|max:255',
|
||||||
|
], [], ['name' => 'nombre', 'description' => 'descripción']);
|
||||||
|
|
||||||
|
if ($this->role) {
|
||||||
|
// Protected roles can't be renamed
|
||||||
|
if (! in_array($this->role->name, self::PROTECTED_ROLES, true)) {
|
||||||
|
$this->role->name = $this->name;
|
||||||
|
}
|
||||||
|
$this->role->description = $this->description ?: null;
|
||||||
|
$this->role->save();
|
||||||
|
} else {
|
||||||
|
Role::create([
|
||||||
|
'name' => $this->name,
|
||||||
|
'description' => $this->description ?: null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||||
|
session()->flash('message', 'Rol guardado correctamente.');
|
||||||
|
|
||||||
|
return $this->redirect(route('admin.roles'), navigate: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
return view('livewire.roles.role-form', [
|
||||||
|
'isProtected' => $this->role && in_array($this->role->name, self::PROTECTED_ROLES, true),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use Livewire\Component;
|
||||||
|
use Livewire\Attributes\Layout;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Spatie\Permission\Models\Role;
|
||||||
|
use Spatie\Permission\Models\Permission;
|
||||||
|
use Spatie\Permission\PermissionRegistrar;
|
||||||
|
|
||||||
|
#[Layout('layouts.app')]
|
||||||
|
class RolePermissionManager extends Component
|
||||||
|
{
|
||||||
|
public string $newRole = '';
|
||||||
|
public string $newPermission = '';
|
||||||
|
|
||||||
|
/** Roles that must not be deleted or stripped of core powers. */
|
||||||
|
private const PROTECTED_ROLES = ['Admin'];
|
||||||
|
private const CORE_PERMISSION = 'manage all';
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
abort_unless(Auth::user()?->can('manage roles'), 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function flushCache(): void
|
||||||
|
{
|
||||||
|
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function togglePermission(int $roleId, string $permissionName): void
|
||||||
|
{
|
||||||
|
$role = Role::findOrFail($roleId);
|
||||||
|
|
||||||
|
if ($role->hasPermissionTo($permissionName)) {
|
||||||
|
// Admin must always keep the core permission
|
||||||
|
if ($role->name === 'Admin' && $permissionName === self::CORE_PERMISSION) {
|
||||||
|
$this->dispatch('notify', "El rol Admin no puede perder '" . self::CORE_PERMISSION . "'.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$role->revokePermissionTo($permissionName);
|
||||||
|
} else {
|
||||||
|
$role->givePermissionTo($permissionName);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->flushCache();
|
||||||
|
$this->dispatch('notify', 'Permisos actualizados');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function addRole(): void
|
||||||
|
{
|
||||||
|
$this->validate([
|
||||||
|
'newRole' => 'required|string|max:50|unique:roles,name',
|
||||||
|
], [], ['newRole' => 'nombre de rol']);
|
||||||
|
|
||||||
|
Role::create(['name' => trim($this->newRole)]);
|
||||||
|
$this->newRole = '';
|
||||||
|
$this->flushCache();
|
||||||
|
$this->dispatch('notify', 'Rol creado');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteRole(int $roleId): void
|
||||||
|
{
|
||||||
|
$role = Role::findOrFail($roleId);
|
||||||
|
|
||||||
|
if (in_array($role->name, self::PROTECTED_ROLES, true)) {
|
||||||
|
$this->dispatch('notify', "El rol '{$role->name}' está protegido y no se puede borrar.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$role->delete();
|
||||||
|
$this->flushCache();
|
||||||
|
$this->dispatch('notify', 'Rol eliminado');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function addPermission(): void
|
||||||
|
{
|
||||||
|
$this->validate([
|
||||||
|
'newPermission' => 'required|string|max:50|unique:permissions,name',
|
||||||
|
], [], ['newPermission' => 'nombre de permiso']);
|
||||||
|
|
||||||
|
Permission::create(['name' => trim($this->newPermission)]);
|
||||||
|
$this->newPermission = '';
|
||||||
|
$this->flushCache();
|
||||||
|
$this->dispatch('notify', 'Permiso creado');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deletePermission(int $permissionId): void
|
||||||
|
{
|
||||||
|
$permission = Permission::findOrFail($permissionId);
|
||||||
|
|
||||||
|
if ($permission->name === self::CORE_PERMISSION) {
|
||||||
|
$this->dispatch('notify', "El permiso '" . self::CORE_PERMISSION . "' está protegido y no se puede borrar.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$permission->delete();
|
||||||
|
$this->flushCache();
|
||||||
|
$this->dispatch('notify', 'Permiso eliminado');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
return view('livewire.role-permission-manager', [
|
||||||
|
'roles' => Role::with('permissions')->orderBy('name')->get(),
|
||||||
|
'permissions' => Permission::orderBy('name')->get(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use Rappasoft\LaravelLivewireTables\DataTableComponent;
|
||||||
|
use Rappasoft\LaravelLivewireTables\Views\Column;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Spatie\Permission\Models\Role;
|
||||||
|
use Spatie\Permission\PermissionRegistrar;
|
||||||
|
|
||||||
|
class RoleTable extends DataTableComponent
|
||||||
|
{
|
||||||
|
protected $model = Role::class;
|
||||||
|
|
||||||
|
private const PROTECTED_ROLES = ['Admin'];
|
||||||
|
|
||||||
|
public function configure(): void
|
||||||
|
{
|
||||||
|
$this->setPrimaryKey('id')
|
||||||
|
->setDefaultSort('name', 'asc')
|
||||||
|
->setSortingPillsEnabled(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function builder(): Builder
|
||||||
|
{
|
||||||
|
return Role::withCount(['permissions', 'users']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function columns(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Column::make(__('Name'), 'name')
|
||||||
|
->sortable()
|
||||||
|
->searchable()
|
||||||
|
->format(fn ($value, $row) =>
|
||||||
|
'<a href="'.route('admin.roles.show', $row->id).'" class="font-semibold text-primary hover:underline" wire:navigate>'.e($value).'</a>'
|
||||||
|
. (in_array($row->name, self::PROTECTED_ROLES, true) ? ' <span class="badge badge-ghost badge-xs">protegido</span>' : '')
|
||||||
|
)
|
||||||
|
->html(),
|
||||||
|
|
||||||
|
Column::make(__('Description'), 'description')
|
||||||
|
->sortable()
|
||||||
|
->searchable()
|
||||||
|
->format(fn ($value) => $value
|
||||||
|
? '<span class="text-sm text-gray-500">'.e($value).'</span>'
|
||||||
|
: '<span class="text-gray-300">—</span>')
|
||||||
|
->html(),
|
||||||
|
|
||||||
|
Column::make(__('Permissions'))
|
||||||
|
->label(fn ($row) => '<span class="badge badge-outline badge-sm">'.(int) $row->permissions_count.'</span>')
|
||||||
|
->html(),
|
||||||
|
|
||||||
|
Column::make(__('Users'))
|
||||||
|
->label(fn ($row) => '<span class="badge badge-ghost badge-sm">'.(int) $row->users_count.'</span>')
|
||||||
|
->html(),
|
||||||
|
|
||||||
|
Column::make(__('Actions'))
|
||||||
|
->label(function ($row) {
|
||||||
|
$show = route('admin.roles.show', $row->id);
|
||||||
|
$edit = route('admin.roles.edit', $row->id);
|
||||||
|
$eye = '<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>';
|
||||||
|
$pencil = '<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>';
|
||||||
|
$trash = '<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>';
|
||||||
|
|
||||||
|
$html = '<div class="flex items-center gap-1">';
|
||||||
|
$html .= '<a href="'.$show.'" class="btn btn-xs btn-ghost" title="Ver" wire:navigate>'.$eye.'</a>';
|
||||||
|
$html .= '<a href="'.$edit.'" class="btn btn-xs btn-ghost text-info" title="Editar" wire:navigate>'.$pencil.'</a>';
|
||||||
|
if (! in_array($row->name, self::PROTECTED_ROLES, true)) {
|
||||||
|
$html .= '<button wire:click="deleteRole('.$row->id.')" wire:confirm="¿Eliminar el rol \''.e($row->name).'\'?" class="btn btn-xs btn-ghost text-error" title="Eliminar">'.$trash.'</button>';
|
||||||
|
}
|
||||||
|
$html .= '</div>';
|
||||||
|
return $html;
|
||||||
|
})
|
||||||
|
->html(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function bulkActions(): array
|
||||||
|
{
|
||||||
|
return ['bulkDelete' => __('Delete selected')];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function bulkDelete(): void
|
||||||
|
{
|
||||||
|
$roles = Role::whereIn('id', $this->selected)->get();
|
||||||
|
foreach ($roles as $role) {
|
||||||
|
if (in_array($role->name, self::PROTECTED_ROLES, true)) continue;
|
||||||
|
$role->delete();
|
||||||
|
}
|
||||||
|
$this->clearSelected();
|
||||||
|
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||||
|
$this->dispatch('notify', __('Roles deleted'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteRole(int $id): void
|
||||||
|
{
|
||||||
|
$role = Role::findOrFail($id);
|
||||||
|
if (in_array($role->name, self::PROTECTED_ROLES, true)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$role->delete();
|
||||||
|
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||||
|
$this->dispatch('notify', __('Role deleted'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use Livewire\Component;
|
||||||
|
use Livewire\Attributes\Layout;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use App\Models\User;
|
||||||
|
use Spatie\Permission\Models\Role;
|
||||||
|
use Spatie\Permission\Models\Permission;
|
||||||
|
use Spatie\Permission\PermissionRegistrar;
|
||||||
|
|
||||||
|
#[Layout('layouts.app')]
|
||||||
|
class RoleView extends Component
|
||||||
|
{
|
||||||
|
public Role $role;
|
||||||
|
public string $tab = 'ficha'; // ficha | permisos
|
||||||
|
public $newUserId = '';
|
||||||
|
|
||||||
|
private const PROTECTED_ROLES = ['Admin'];
|
||||||
|
private const CORE_PERMISSION = 'manage all';
|
||||||
|
|
||||||
|
public function mount(Role $role): void
|
||||||
|
{
|
||||||
|
abort_unless(Auth::user()?->can('manage roles'), 403);
|
||||||
|
$this->role = $role;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setTab(string $tab): void
|
||||||
|
{
|
||||||
|
$this->tab = in_array($tab, ['ficha', 'permisos'], true) ? $tab : 'ficha';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function togglePermission(string $permissionName): void
|
||||||
|
{
|
||||||
|
// Admin must always keep the core permission
|
||||||
|
if ($this->role->name === 'Admin'
|
||||||
|
&& $permissionName === self::CORE_PERMISSION
|
||||||
|
&& $this->role->hasPermissionTo($permissionName)) {
|
||||||
|
$this->dispatch('notify', "El rol Admin no puede perder '" . self::CORE_PERMISSION . "'.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->role->hasPermissionTo($permissionName)) {
|
||||||
|
$this->role->revokePermissionTo($permissionName);
|
||||||
|
} else {
|
||||||
|
$this->role->givePermissionTo($permissionName);
|
||||||
|
}
|
||||||
|
|
||||||
|
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||||
|
$this->role->load('permissions');
|
||||||
|
$this->dispatch('notify', 'Permisos actualizados');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function addUser(): void
|
||||||
|
{
|
||||||
|
$this->validate(['newUserId' => 'required|exists:users,id'], [], ['newUserId' => 'usuario']);
|
||||||
|
|
||||||
|
User::findOrFail($this->newUserId)->assignRole($this->role->name);
|
||||||
|
$this->newUserId = '';
|
||||||
|
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||||
|
$this->dispatch('notify', 'Usuario añadido al rol');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function removeUser(int $userId): void
|
||||||
|
{
|
||||||
|
User::findOrFail($userId)->removeRole($this->role->name);
|
||||||
|
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||||
|
$this->dispatch('notify', 'Usuario quitado del rol');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setGroup(string $group, bool $enabled): void
|
||||||
|
{
|
||||||
|
$names = Permission::where('group', $group)->pluck('name');
|
||||||
|
|
||||||
|
foreach ($names as $name) {
|
||||||
|
// Admin must always keep the core permission
|
||||||
|
if (! $enabled && $this->role->name === 'Admin' && $name === self::CORE_PERMISSION) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$enabled ? $this->role->givePermissionTo($name) : $this->role->revokePermissionTo($name);
|
||||||
|
}
|
||||||
|
|
||||||
|
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||||
|
$this->role->load('permissions');
|
||||||
|
$this->dispatch('notify', $enabled ? 'Permisos del grupo activados' : 'Permisos del grupo desactivados');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete()
|
||||||
|
{
|
||||||
|
if (in_array($this->role->name, self::PROTECTED_ROLES, true)) {
|
||||||
|
$this->dispatch('notify', "El rol '{$this->role->name}' está protegido y no se puede borrar.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->role->delete();
|
||||||
|
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||||
|
session()->flash('message', 'Rol eliminado.');
|
||||||
|
|
||||||
|
return $this->redirect(route('admin.roles'), navigate: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Section title for a permission name (groups by the resource / last word). */
|
||||||
|
private function sectionFor(string $name): string
|
||||||
|
{
|
||||||
|
if ($name === self::CORE_PERMISSION) {
|
||||||
|
return 'General';
|
||||||
|
}
|
||||||
|
$resource = Str::afterLast($name, ' ');
|
||||||
|
return Str::headline($resource ?: 'General');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
$users = $this->role->users()
|
||||||
|
->orderBy('first_name')
|
||||||
|
->orderBy('name')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$order = [
|
||||||
|
'Proyectos', 'Fases y progreso', 'Capas y elementos', 'Inspecciones',
|
||||||
|
'Incidencias', 'Empresas', 'Usuarios', 'Roles', 'Informes', 'Archivos', 'General',
|
||||||
|
];
|
||||||
|
|
||||||
|
$grouped = Permission::orderBy('name')->get()
|
||||||
|
->groupBy(fn ($perm) => $perm->group ?: $this->sectionFor($perm->name))
|
||||||
|
->sortBy(function ($perms, $section) use ($order) {
|
||||||
|
$i = array_search($section, $order, true);
|
||||||
|
return $i === false ? 999 : $i;
|
||||||
|
});
|
||||||
|
|
||||||
|
$availableUsers = User::whereDoesntHave('roles', fn ($q) => $q->where('roles.id', $this->role->id))
|
||||||
|
->orderBy('first_name')->orderBy('name')->get();
|
||||||
|
|
||||||
|
return view('livewire.roles.role-view', [
|
||||||
|
'users' => $users,
|
||||||
|
'availableUsers' => $availableUsers,
|
||||||
|
'grouped' => $grouped,
|
||||||
|
'rolePerms' => $this->role->permissions->pluck('name')->toArray(),
|
||||||
|
'isProtected' => in_array($this->role->name, self::PROTECTED_ROLES, true),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,35 +3,55 @@
|
|||||||
namespace App\Livewire;
|
namespace App\Livewire;
|
||||||
|
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
use Livewire\WithFileUploads;
|
||||||
use App\Models\InspectionTemplate;
|
use App\Models\InspectionTemplate;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\Phase;
|
use App\Models\Phase;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||||
|
|
||||||
class TemplateManager extends Component
|
class TemplateManager extends Component
|
||||||
{
|
{
|
||||||
|
use WithFileUploads;
|
||||||
|
|
||||||
public $project;
|
public $project;
|
||||||
public $templates;
|
public $templates;
|
||||||
public $phases;
|
public $phases;
|
||||||
|
|
||||||
|
// ── Formulario principal ───────────────────────────────────────────────
|
||||||
public $editingTemplate = null;
|
public $editingTemplate = null;
|
||||||
public $showForm = false; // Controla si mostrar el formulario
|
public $showForm = false;
|
||||||
public $form = [
|
public $form = [
|
||||||
'name' => '',
|
'name' => '',
|
||||||
'description' => '',
|
'description' => '',
|
||||||
'phase_id' => null,
|
'phase_id' => null,
|
||||||
'fields' => [],
|
'fields' => [],
|
||||||
];
|
|
||||||
public $fieldTypes = [
|
|
||||||
'text' => 'Texto corto',
|
|
||||||
'textarea' => 'Texto largo',
|
|
||||||
'integer' => 'Número entero',
|
|
||||||
'decimal' => 'Número decimal',
|
|
||||||
'percentage' => 'Porcentaje (0-100)',
|
|
||||||
'boolean' => 'Sí/No (checkbox)',
|
|
||||||
'date' => 'Fecha',
|
|
||||||
'select' => 'Lista desplegable',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $listeners = ['showTemplateForm' => 'newTemplate'];
|
// ── Importar desde CSV/Excel ───────────────────────────────────────────
|
||||||
|
public $showImportFileModal = false;
|
||||||
|
public $importFile = null;
|
||||||
|
public $importPreviewFields = [];
|
||||||
|
public $importTemplateName = '';
|
||||||
|
public $importError = '';
|
||||||
|
|
||||||
|
// ── Importar desde otro proyecto ──────────────────────────────────────
|
||||||
|
public $showImportProjectModal = false;
|
||||||
|
public $availableProjects = [];
|
||||||
|
public $importProjectId = null;
|
||||||
|
public $importableTemplates = [];
|
||||||
|
public $selectedImportTemplateIds = [];
|
||||||
|
|
||||||
|
public $fieldTypes = [
|
||||||
|
'text' => 'Texto corto',
|
||||||
|
'textarea' => 'Texto largo',
|
||||||
|
'integer' => 'Número entero',
|
||||||
|
'decimal' => 'Número decimal',
|
||||||
|
'percentage' => 'Porcentaje (0-100)',
|
||||||
|
'boolean' => 'Sí/No (checkbox)',
|
||||||
|
'date' => 'Fecha',
|
||||||
|
'select' => 'Lista desplegable',
|
||||||
|
];
|
||||||
|
|
||||||
public function mount(Project $project)
|
public function mount(Project $project)
|
||||||
{
|
{
|
||||||
@@ -47,20 +67,28 @@ class TemplateManager extends Component
|
|||||||
|
|
||||||
public function loadTemplates()
|
public function loadTemplates()
|
||||||
{
|
{
|
||||||
$this->templates = InspectionTemplate::where('project_id', $this->project->id)->get();
|
$this->templates = InspectionTemplate::where('project_id', $this->project->id)
|
||||||
|
->with('phase')
|
||||||
|
->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Formulario manual ─────────────────────────────────────────────────
|
||||||
|
|
||||||
public function newTemplate()
|
public function newTemplate()
|
||||||
{
|
{
|
||||||
$this->resetForm();
|
$this->resetForm();
|
||||||
$this->editingTemplate = null;
|
|
||||||
$this->showForm = true;
|
$this->showForm = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function editTemplate($id)
|
public function editTemplate($id)
|
||||||
{
|
{
|
||||||
$template = InspectionTemplate::find($id);
|
$template = InspectionTemplate::findOrFail($id);
|
||||||
$this->form = $template->only(['name', 'description', 'phase_id', 'fields']);
|
$this->form = [
|
||||||
|
'name' => $template->name,
|
||||||
|
'description' => $template->description ?? '',
|
||||||
|
'phase_id' => $template->phase_id,
|
||||||
|
'fields' => $template->fields ?? [],
|
||||||
|
];
|
||||||
$this->editingTemplate = $id;
|
$this->editingTemplate = $id;
|
||||||
$this->showForm = true;
|
$this->showForm = true;
|
||||||
}
|
}
|
||||||
@@ -74,10 +102,10 @@ class TemplateManager extends Component
|
|||||||
public function resetForm()
|
public function resetForm()
|
||||||
{
|
{
|
||||||
$this->form = [
|
$this->form = [
|
||||||
'name' => '',
|
'name' => '',
|
||||||
'description' => '',
|
'description' => '',
|
||||||
'phase_id' => null,
|
'phase_id' => null,
|
||||||
'fields' => [],
|
'fields' => [],
|
||||||
];
|
];
|
||||||
$this->editingTemplate = null;
|
$this->editingTemplate = null;
|
||||||
}
|
}
|
||||||
@@ -85,14 +113,14 @@ class TemplateManager extends Component
|
|||||||
public function addField()
|
public function addField()
|
||||||
{
|
{
|
||||||
$this->form['fields'][] = [
|
$this->form['fields'][] = [
|
||||||
'name' => '',
|
'name' => '',
|
||||||
'label' => '',
|
'label' => '',
|
||||||
'type' => 'text',
|
'type' => 'text',
|
||||||
'options' => [],
|
'options' => '',
|
||||||
'required' => false,
|
'required' => false,
|
||||||
'min' => null,
|
'min' => null,
|
||||||
'max' => null,
|
'max' => null,
|
||||||
'step' => null,
|
'step' => null,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,24 +133,25 @@ class TemplateManager extends Component
|
|||||||
public function saveTemplate()
|
public function saveTemplate()
|
||||||
{
|
{
|
||||||
$this->validate([
|
$this->validate([
|
||||||
'form.name' => 'required|string|max:255',
|
'form.name' => 'required|string|max:255',
|
||||||
'form.phase_id' => 'nullable|exists:phases,id',
|
'form.phase_id' => 'nullable|exists:phases,id',
|
||||||
'form.fields' => 'array',
|
'form.fields' => 'array',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$data = [
|
||||||
|
'name' => $this->form['name'],
|
||||||
|
'description' => $this->form['description'],
|
||||||
|
'project_id' => $this->project->id,
|
||||||
|
'phase_id' => $this->form['phase_id'] ?: null,
|
||||||
|
'fields' => array_values($this->form['fields']),
|
||||||
|
];
|
||||||
|
|
||||||
if ($this->editingTemplate) {
|
if ($this->editingTemplate) {
|
||||||
$template = InspectionTemplate::find($this->editingTemplate);
|
InspectionTemplate::findOrFail($this->editingTemplate)->update($data);
|
||||||
$template->update($this->form);
|
$this->dispatch('notify', 'Template actualizado correctamente');
|
||||||
session()->flash('message', 'Template actualizado');
|
|
||||||
} else {
|
} else {
|
||||||
InspectionTemplate::create([
|
InspectionTemplate::create($data);
|
||||||
'name' => $this->form['name'],
|
$this->dispatch('notify', 'Template creado correctamente');
|
||||||
'description' => $this->form['description'],
|
|
||||||
'project_id' => $this->project->id,
|
|
||||||
'phase_id' => $this->form['phase_id'],
|
|
||||||
'fields' => $this->form['fields'],
|
|
||||||
]);
|
|
||||||
session()->flash('message', 'Template creado');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->cancelForm();
|
$this->cancelForm();
|
||||||
@@ -131,9 +160,272 @@ class TemplateManager extends Component
|
|||||||
|
|
||||||
public function deleteTemplate($id)
|
public function deleteTemplate($id)
|
||||||
{
|
{
|
||||||
InspectionTemplate::find($id)->delete();
|
InspectionTemplate::findOrFail($id)->delete();
|
||||||
$this->loadTemplates();
|
$this->loadTemplates();
|
||||||
session()->flash('message', 'Template eliminado');
|
$this->dispatch('notify', 'Template eliminado');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Exportar template a CSV ────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function exportTemplate($id)
|
||||||
|
{
|
||||||
|
$template = InspectionTemplate::findOrFail($id);
|
||||||
|
$rows = [];
|
||||||
|
$rows[] = ['name', 'label', 'type', 'required', 'options', 'min', 'max', 'step'];
|
||||||
|
|
||||||
|
foreach ($template->fields as $field) {
|
||||||
|
$rows[] = [
|
||||||
|
$field['name'] ?? '',
|
||||||
|
$field['label'] ?? '',
|
||||||
|
$field['type'] ?? 'text',
|
||||||
|
($field['required'] ?? false) ? '1' : '0',
|
||||||
|
$field['options'] ?? '',
|
||||||
|
$field['min'] ?? '',
|
||||||
|
$field['max'] ?? '',
|
||||||
|
$field['step'] ?? '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$filename = preg_replace('/[^a-z0-9_\-]/i', '_', $template->name) . '.csv';
|
||||||
|
|
||||||
|
return response()->streamDownload(function () use ($rows) {
|
||||||
|
$out = fopen('php://output', 'w');
|
||||||
|
// BOM para Excel con UTF-8
|
||||||
|
fwrite($out, "\xEF\xBB\xBF");
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
fputcsv($out, $row);
|
||||||
|
}
|
||||||
|
fclose($out);
|
||||||
|
}, $filename, ['Content-Type' => 'text/csv; charset=UTF-8']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function downloadExampleCsv()
|
||||||
|
{
|
||||||
|
$rows = [
|
||||||
|
['name', 'label', 'type', 'required', 'options', 'min', 'max', 'step'],
|
||||||
|
['altura_viga', 'Altura de viga (mm)', 'decimal', '1', '', '0', '2000', '1'],
|
||||||
|
['estado_armado', 'Estado del armado', 'select', '1', 'Conforme,No conforme,Obs.', '', '', ''],
|
||||||
|
['fotos_tomadas', '¿Fotos tomadas?', 'boolean', '1', '', '', '', ''],
|
||||||
|
['observaciones', 'Observaciones generales','textarea', '0', '', '', '', ''],
|
||||||
|
['fecha_visita', 'Fecha de visita', 'date', '0', '', '', '', ''],
|
||||||
|
['avance_pct', 'Avance medido (%)', 'percentage', '0', '', '0', '100', '1'],
|
||||||
|
];
|
||||||
|
|
||||||
|
return response()->streamDownload(function () use ($rows) {
|
||||||
|
$out = fopen('php://output', 'w');
|
||||||
|
fwrite($out, "\xEF\xBB\xBF");
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
fputcsv($out, $row);
|
||||||
|
}
|
||||||
|
fclose($out);
|
||||||
|
}, 'ejemplo-template.csv', ['Content-Type' => 'text/csv; charset=UTF-8']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Importar desde CSV / Excel ─────────────────────────────────────────
|
||||||
|
|
||||||
|
public function openImportFileModal()
|
||||||
|
{
|
||||||
|
$this->importFile = null;
|
||||||
|
$this->importPreviewFields = [];
|
||||||
|
$this->importTemplateName = '';
|
||||||
|
$this->importError = '';
|
||||||
|
$this->showImportFileModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function parseImportFile()
|
||||||
|
{
|
||||||
|
$this->importError = '';
|
||||||
|
$this->validate([
|
||||||
|
'importFile' => 'required|file|mimes:csv,txt,xlsx,xls|max:5120',
|
||||||
|
'importTemplateName' => 'required|string|max:255',
|
||||||
|
], [
|
||||||
|
'importFile.required' => 'Selecciona un archivo.',
|
||||||
|
'importFile.mimes' => 'Solo se aceptan archivos CSV o Excel (xlsx/xls).',
|
||||||
|
'importTemplateName.required' => 'Escribe un nombre para el template.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$rows = $this->readFileRows();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->importError = 'No se pudo leer el archivo: ' . $e->getMessage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$fields = $this->parseRows($rows);
|
||||||
|
|
||||||
|
if (empty($fields)) {
|
||||||
|
$this->importError = 'No se encontraron filas de datos válidas. Revisa que el archivo tenga el formato correcto.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->importPreviewFields = $fields;
|
||||||
|
$this->dispatch('notify', count($fields) . ' campos detectados. Revisa la vista previa.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function confirmImportFile()
|
||||||
|
{
|
||||||
|
if (empty($this->importPreviewFields) || empty($this->importTemplateName)) return;
|
||||||
|
|
||||||
|
InspectionTemplate::create([
|
||||||
|
'name' => $this->importTemplateName,
|
||||||
|
'description' => 'Importado desde archivo',
|
||||||
|
'project_id' => $this->project->id,
|
||||||
|
'phase_id' => null,
|
||||||
|
'fields' => array_values($this->importPreviewFields),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->showImportFileModal = false;
|
||||||
|
$this->importPreviewFields = [];
|
||||||
|
$this->importTemplateName = '';
|
||||||
|
$this->importFile = null;
|
||||||
|
$this->loadTemplates();
|
||||||
|
$this->dispatch('notify', 'Template importado correctamente desde archivo');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function readFileRows(): array
|
||||||
|
{
|
||||||
|
$ext = strtolower($this->importFile->getClientOriginalExtension());
|
||||||
|
$path = $this->importFile->getRealPath();
|
||||||
|
|
||||||
|
if ($ext === 'xlsx' || $ext === 'xls') {
|
||||||
|
$spreadsheet = IOFactory::load($path);
|
||||||
|
$sheet = $spreadsheet->getActiveSheet();
|
||||||
|
$rows = $sheet->toArray(null, true, true, false);
|
||||||
|
array_shift($rows); // quitar cabecera
|
||||||
|
return array_filter($rows, fn($r) => !empty($r[0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSV / TXT
|
||||||
|
$rows = [];
|
||||||
|
$handle = fopen($path, 'r');
|
||||||
|
// Detectar y descartar BOM UTF-8
|
||||||
|
$bom = fread($handle, 3);
|
||||||
|
if ($bom !== "\xEF\xBB\xBF") rewind($handle);
|
||||||
|
|
||||||
|
fgetcsv($handle); // cabecera
|
||||||
|
while (($row = fgetcsv($handle)) !== false) {
|
||||||
|
if (!empty($row[0])) $rows[] = $row;
|
||||||
|
}
|
||||||
|
fclose($handle);
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parseRows(array $rows): array
|
||||||
|
{
|
||||||
|
$fields = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$row = array_values((array) $row);
|
||||||
|
$rawName = trim($row[0] ?? '');
|
||||||
|
if ($rawName === '') continue;
|
||||||
|
|
||||||
|
$fields[] = [
|
||||||
|
'name' => $this->slugify($rawName),
|
||||||
|
'label' => trim($row[1] ?? $rawName),
|
||||||
|
'type' => $this->normalizeType($row[2] ?? 'text'),
|
||||||
|
'required' => in_array(strtolower(trim($row[3] ?? '0')), ['1', 'si', 'sí', 'yes', 'true']),
|
||||||
|
'options' => trim($row[4] ?? ''),
|
||||||
|
'min' => $row[5] !== '' && $row[5] !== null ? $row[5] : null,
|
||||||
|
'max' => $row[6] !== '' && $row[6] !== null ? $row[6] : null,
|
||||||
|
'step' => $row[7] !== '' && $row[7] !== null ? $row[7] : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function slugify(string $str): string
|
||||||
|
{
|
||||||
|
$str = mb_strtolower(trim($str));
|
||||||
|
$str = preg_replace('/\s+/', '_', $str);
|
||||||
|
$str = preg_replace('/[^a-z0-9_]/i', '', $str);
|
||||||
|
return trim($str, '_') ?: 'campo';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeType(string $type): string
|
||||||
|
{
|
||||||
|
$map = [
|
||||||
|
'texto' => 'text', 'text' => 'text', 'string' => 'text', 'corto' => 'text',
|
||||||
|
'textarea' => 'textarea', 'largo' => 'textarea', 'parrafo' => 'textarea',
|
||||||
|
'integer' => 'integer', 'entero' => 'integer', 'int' => 'integer', 'numero' => 'integer',
|
||||||
|
'decimal' => 'decimal', 'float' => 'decimal', 'number' => 'decimal', 'numerico' => 'decimal',
|
||||||
|
'percentage' => 'percentage', 'porcentaje' => 'percentage', 'pct' => 'percentage', '%' => 'percentage',
|
||||||
|
'boolean' => 'boolean', 'bool' => 'boolean', 'checkbox' => 'boolean', 'sino' => 'boolean', 'si/no' => 'boolean',
|
||||||
|
'date' => 'date', 'fecha' => 'date',
|
||||||
|
'select' => 'select', 'lista' => 'select', 'dropdown' => 'select', 'opciones' => 'select',
|
||||||
|
];
|
||||||
|
return $map[strtolower(trim($type))] ?? 'text';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Importar desde otro proyecto ──────────────────────────────────────
|
||||||
|
|
||||||
|
public function openImportProjectModal()
|
||||||
|
{
|
||||||
|
$user = Auth::user();
|
||||||
|
$this->availableProjects = Project::accessibleBy($user)
|
||||||
|
->where('id', '!=', $this->project->id)
|
||||||
|
->orderBy('name')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$this->importProjectId = null;
|
||||||
|
$this->importableTemplates = [];
|
||||||
|
$this->selectedImportTemplateIds = [];
|
||||||
|
$this->showImportProjectModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedImportProjectId()
|
||||||
|
{
|
||||||
|
$this->selectedImportTemplateIds = [];
|
||||||
|
if (!$this->importProjectId) {
|
||||||
|
$this->importableTemplates = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Solo mostrar templates de proyectos accesibles
|
||||||
|
$user = Auth::user();
|
||||||
|
$allowed = Project::accessibleBy($user)->pluck('id');
|
||||||
|
if (!$allowed->contains($this->importProjectId)) {
|
||||||
|
$this->importableTemplates = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->importableTemplates = InspectionTemplate::where('project_id', $this->importProjectId)->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function importFromProject()
|
||||||
|
{
|
||||||
|
if (empty($this->selectedImportTemplateIds)) {
|
||||||
|
$this->dispatch('notify', 'Selecciona al menos un template.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar que los templates pertenecen a un proyecto accesible
|
||||||
|
$user = Auth::user();
|
||||||
|
$allowed = Project::accessibleBy($user)->pluck('id');
|
||||||
|
|
||||||
|
$imported = 0;
|
||||||
|
foreach ($this->selectedImportTemplateIds as $templateId) {
|
||||||
|
$source = InspectionTemplate::find($templateId);
|
||||||
|
if (!$source || !$allowed->contains($source->project_id)) continue;
|
||||||
|
|
||||||
|
// Evitar duplicados por nombre
|
||||||
|
$name = $source->name;
|
||||||
|
if (InspectionTemplate::where('project_id', $this->project->id)->where('name', $name)->exists()) {
|
||||||
|
$name .= ' (copia)';
|
||||||
|
}
|
||||||
|
|
||||||
|
InspectionTemplate::create([
|
||||||
|
'name' => $name,
|
||||||
|
'description' => $source->description,
|
||||||
|
'project_id' => $this->project->id,
|
||||||
|
'phase_id' => null,
|
||||||
|
'fields' => $source->fields,
|
||||||
|
]);
|
||||||
|
$imported++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->showImportProjectModal = false;
|
||||||
|
$this->importProjectId = null;
|
||||||
|
$this->importableTemplates = [];
|
||||||
|
$this->selectedImportTemplateIds = [];
|
||||||
|
$this->loadTemplates();
|
||||||
|
$this->dispatch('notify', "$imported template(s) importado(s) desde otro proyecto");
|
||||||
}
|
}
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use Livewire\Component;
|
||||||
|
use Livewire\Attributes\Layout;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Company;
|
||||||
|
use Spatie\Permission\Models\Role;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Validation\Rules\Password;
|
||||||
|
|
||||||
|
#[Layout('layouts.app')]
|
||||||
|
class UserForm extends Component
|
||||||
|
{
|
||||||
|
public ?User $user = null;
|
||||||
|
|
||||||
|
// Información personal
|
||||||
|
public string $title = '';
|
||||||
|
public string $lastName = '';
|
||||||
|
public string $firstName = '';
|
||||||
|
|
||||||
|
// Validación
|
||||||
|
public string $userStatus = 'active';
|
||||||
|
public string $validFrom = '';
|
||||||
|
public string $validUntil = '';
|
||||||
|
public string $formPassword = '';
|
||||||
|
|
||||||
|
// Contacto
|
||||||
|
public ?int $companyId = null;
|
||||||
|
public string $address = '';
|
||||||
|
public string $phone = '';
|
||||||
|
public string $email = '';
|
||||||
|
|
||||||
|
// Permisos
|
||||||
|
public string $formRole = '';
|
||||||
|
|
||||||
|
// Preferencias
|
||||||
|
public string $locale = 'es';
|
||||||
|
|
||||||
|
// Notas
|
||||||
|
public string $notes = '';
|
||||||
|
|
||||||
|
// Catálogos
|
||||||
|
public $roles;
|
||||||
|
public $companies;
|
||||||
|
|
||||||
|
/** Idiomas disponibles (código => nombre + archivo de bandera). */
|
||||||
|
public array $languages = [
|
||||||
|
'es' => ['name' => 'Español', 'flag' => 'es.svg'],
|
||||||
|
'en' => ['name' => 'English', 'flag' => 'gb.svg'],
|
||||||
|
];
|
||||||
|
|
||||||
|
public function mount(?User $user = null): void
|
||||||
|
{
|
||||||
|
abort_unless(Auth::user()->can('create users') || Auth::user()->can('edit users'), 403);
|
||||||
|
|
||||||
|
$this->roles = Role::orderBy('name')->get();
|
||||||
|
$this->companies = Company::where('estado', 'activo')->orderBy('name')->get();
|
||||||
|
$this->formRole = $this->roles->first()?->name ?? '';
|
||||||
|
|
||||||
|
if ($user && $user->exists) {
|
||||||
|
$this->user = $user;
|
||||||
|
$this->title = $user->title ?? '';
|
||||||
|
$this->lastName = $user->last_name ?? '';
|
||||||
|
$this->firstName = $user->first_name ?? '';
|
||||||
|
$this->userStatus = $user->status ?? 'active';
|
||||||
|
$this->validFrom = $user->valid_from?->format('Y-m-d') ?? '';
|
||||||
|
$this->validUntil = $user->valid_until?->format('Y-m-d') ?? '';
|
||||||
|
$this->companyId = $user->company_id;
|
||||||
|
$this->address = $user->address ?? '';
|
||||||
|
$this->phone = $user->phone ?? '';
|
||||||
|
$this->email = $user->email;
|
||||||
|
$this->notes = $user->notes ?? '';
|
||||||
|
$this->formRole = $user->roles->first()?->name ?? $this->formRole;
|
||||||
|
$this->locale = $user->locale ?? $this->locale;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function rules(): array
|
||||||
|
{
|
||||||
|
$id = $this->user?->id ?? 'NULL';
|
||||||
|
$rules = [
|
||||||
|
'lastName' => 'required|string|max:100',
|
||||||
|
'firstName' => 'required|string|max:100',
|
||||||
|
'title' => 'nullable|string|max:20',
|
||||||
|
'userStatus' => 'required|in:active,inactive,suspended',
|
||||||
|
'validFrom' => 'nullable|date',
|
||||||
|
'validUntil' => 'nullable|date|after_or_equal:validFrom',
|
||||||
|
'companyId' => 'required|exists:companies,id',
|
||||||
|
'address' => 'nullable|string',
|
||||||
|
'phone' => 'nullable|string|max:30',
|
||||||
|
'email' => "required|email|max:255|unique:users,email,{$id}",
|
||||||
|
'formRole' => 'required|exists:roles,name',
|
||||||
|
'locale' => 'required|in:' . implode(',', array_keys($this->languages)),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!$this->user) {
|
||||||
|
$rules['formPassword'] = ['required', Password::min(8)->letters()->mixedCase()->numbers()];
|
||||||
|
} elseif ($this->formPassword !== '') {
|
||||||
|
$rules['formPassword'] = [Password::min(8)->letters()->mixedCase()->numbers()];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rules;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected $validationAttributes = [
|
||||||
|
'lastName' => 'apellidos',
|
||||||
|
'firstName' => 'nombre',
|
||||||
|
'userStatus' => 'estado',
|
||||||
|
'validFrom' => 'fecha de inicio',
|
||||||
|
'validUntil' => 'fecha de fin',
|
||||||
|
'companyId' => 'empresa',
|
||||||
|
'formPassword'=> 'contraseña',
|
||||||
|
'formRole' => 'rol',
|
||||||
|
'locale' => 'idioma',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function copyCompanyAddress(): void
|
||||||
|
{
|
||||||
|
if (!$this->companyId) return;
|
||||||
|
$company = Company::find($this->companyId);
|
||||||
|
if ($company?->address) {
|
||||||
|
$this->address = $company->address;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save(): void
|
||||||
|
{
|
||||||
|
$this->validate();
|
||||||
|
|
||||||
|
if ($this->user && $this->user->id === Auth::id()
|
||||||
|
&& $this->user->hasRole('Admin') && $this->formRole !== 'Admin') {
|
||||||
|
$this->addError('formRole', 'No puedes quitarte el rol Admin a ti mismo.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$fullName = trim($this->firstName . ' ' . $this->lastName);
|
||||||
|
|
||||||
|
$data = [
|
||||||
|
'name' => $fullName,
|
||||||
|
'title' => $this->title ?: null,
|
||||||
|
'first_name' => $this->firstName,
|
||||||
|
'last_name' => $this->lastName,
|
||||||
|
'status' => $this->userStatus,
|
||||||
|
'valid_from' => $this->validFrom ?: null,
|
||||||
|
'valid_until'=> $this->validUntil ?: null,
|
||||||
|
'company_id' => $this->companyId,
|
||||||
|
'address' => $this->address ?: null,
|
||||||
|
'phone' => $this->phone ?: null,
|
||||||
|
'email' => $this->email,
|
||||||
|
'notes' => $this->notes ?: null,
|
||||||
|
'locale' => $this->locale,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($this->formPassword !== '') {
|
||||||
|
$data['password'] = Hash::make($this->formPassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->user && $this->user->exists) {
|
||||||
|
$this->user->update($data);
|
||||||
|
$this->user->syncRoles([$this->formRole]);
|
||||||
|
session()->flash('notify', 'Usuario actualizado correctamente.');
|
||||||
|
} else {
|
||||||
|
$user = User::create($data);
|
||||||
|
$user->assignRole($this->formRole);
|
||||||
|
session()->flash('notify', 'Usuario creado correctamente.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->redirect(route('admin.users'), navigate: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
return view('livewire.user-form');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use Rappasoft\LaravelLivewireTables\DataTableComponent;
|
||||||
|
use Rappasoft\LaravelLivewireTables\Views\Column;
|
||||||
|
use Rappasoft\LaravelLivewireTables\Views\Filters\SelectFilter;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Spatie\Permission\Models\Role;
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
|
class UserTable extends DataTableComponent
|
||||||
|
{
|
||||||
|
protected $model = User::class;
|
||||||
|
|
||||||
|
public function configure(): void
|
||||||
|
{
|
||||||
|
$this->setPrimaryKey('id')
|
||||||
|
->setDefaultSort('name', 'asc')
|
||||||
|
->setSortingPillsEnabled(false)
|
||||||
|
->setAdditionalSelects([
|
||||||
|
'users.id as id',
|
||||||
|
'users.email as email',
|
||||||
|
'users.email_verified_at as email_verified_at',
|
||||||
|
'users.status as status',
|
||||||
|
'users.phone as phone',
|
||||||
|
'users.company_id as company_id',
|
||||||
|
'users.created_at as created_at',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function builder(): Builder
|
||||||
|
{
|
||||||
|
return User::with(['roles', 'company']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function columns(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Column::make('Usuario', 'name')
|
||||||
|
->sortable()
|
||||||
|
->searchable()
|
||||||
|
->format(function ($value, $row) {
|
||||||
|
$initial = strtoupper(mb_substr($value, 0, 1));
|
||||||
|
$html = '<div class="flex items-center gap-3">';
|
||||||
|
$html .= '<div class="avatar placeholder shrink-0">
|
||||||
|
<div class="bg-neutral text-neutral-content rounded-full w-8">
|
||||||
|
<span class="text-xs font-semibold">'.$initial.'</span>
|
||||||
|
</div>
|
||||||
|
</div>';
|
||||||
|
$html .= '<div>';
|
||||||
|
$html .= '<p class="font-semibold text-sm leading-tight">'.e($value).'</p>';
|
||||||
|
$html .= '<p class="text-xs text-gray-500">'.e($row->email).'</p>';
|
||||||
|
$html .= '</div></div>';
|
||||||
|
return $html;
|
||||||
|
})
|
||||||
|
->html(),
|
||||||
|
|
||||||
|
Column::make('Empresa')
|
||||||
|
->label(fn ($row) =>
|
||||||
|
$row->company
|
||||||
|
? '<span class="text-sm">'.e($row->company->name).'</span>'
|
||||||
|
: '<span class="text-gray-300 text-sm">—</span>'
|
||||||
|
)
|
||||||
|
->html(),
|
||||||
|
|
||||||
|
Column::make('Rol')
|
||||||
|
->label(function ($row) {
|
||||||
|
if ($row->roles->isEmpty()) {
|
||||||
|
return '<span class="badge badge-sm badge-ghost">Sin rol</span>';
|
||||||
|
}
|
||||||
|
return $row->roles->map(fn ($role) =>
|
||||||
|
'<span class="badge badge-sm '.($role->name === 'Admin' ? 'badge-error' : 'badge-primary').'">'.e($role->name).'</span>'
|
||||||
|
)->implode(' ');
|
||||||
|
})
|
||||||
|
->html(),
|
||||||
|
|
||||||
|
Column::make('Estado', 'status')
|
||||||
|
->sortable()
|
||||||
|
->format(function ($value) {
|
||||||
|
$map = [
|
||||||
|
'active' => ['badge-success', 'Activo'],
|
||||||
|
'inactive' => ['badge-ghost', 'Inactivo'],
|
||||||
|
'suspended' => ['badge-error', 'Suspendido'],
|
||||||
|
];
|
||||||
|
[$cls, $label] = $map[$value ?? 'active'] ?? ['badge-ghost', ucfirst($value ?? '')];
|
||||||
|
return '<span class="badge badge-sm '.$cls.'">'.$label.'</span>';
|
||||||
|
})
|
||||||
|
->html(),
|
||||||
|
|
||||||
|
Column::make('Verificado', 'email_verified_at')
|
||||||
|
->sortable()
|
||||||
|
->format(fn ($value) =>
|
||||||
|
$value
|
||||||
|
? '<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>'
|
||||||
|
: '<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-warning" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>'
|
||||||
|
)
|
||||||
|
->html(),
|
||||||
|
|
||||||
|
Column::make('Acciones')
|
||||||
|
->label(function ($row) {
|
||||||
|
$ver = route('admin.users.show', $row->id);
|
||||||
|
$editar = route('admin.users.edit', $row->id);
|
||||||
|
$name = addslashes($row->name);
|
||||||
|
$isSelf = $row->id === Auth::id();
|
||||||
|
|
||||||
|
$html = '<div class="flex items-center justify-end gap-1">';
|
||||||
|
$html .= '<a href="'.$ver.'" class="btn btn-xs btn-outline" title="Ver" wire:navigate>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" 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>
|
||||||
|
</a>';
|
||||||
|
$html .= '<a href="'.$editar.'" class="btn btn-xs btn-outline btn-info" title="Editar" wire:navigate>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" 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>
|
||||||
|
</a>';
|
||||||
|
if (! $isSelf) {
|
||||||
|
$html .= '<button wire:click="deleteUser('.$row->id.')"
|
||||||
|
wire:confirm="¿Eliminar a \''.$name.'\'? Se perderán todos sus datos."
|
||||||
|
class="btn btn-xs btn-outline btn-error" title="Eliminar">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" 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>';
|
||||||
|
}
|
||||||
|
$html .= '</div>';
|
||||||
|
return $html;
|
||||||
|
})
|
||||||
|
->html(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function filters(): array
|
||||||
|
{
|
||||||
|
$roleOptions = [''] + Role::orderBy('name')->pluck('name', 'name')->prepend('Rol: todos', '')->toArray();
|
||||||
|
|
||||||
|
return [
|
||||||
|
SelectFilter::make('Rol')
|
||||||
|
->options($roleOptions)
|
||||||
|
->filter(fn (Builder $query, string $value) =>
|
||||||
|
$query->whereHas('roles', fn ($q) => $q->where('name', $value))
|
||||||
|
),
|
||||||
|
|
||||||
|
SelectFilter::make('Estado', 'status')
|
||||||
|
->options([
|
||||||
|
'' => 'Estado: todos',
|
||||||
|
'active' => 'Activo',
|
||||||
|
'inactive' => 'Inactivo',
|
||||||
|
'suspended' => 'Suspendido',
|
||||||
|
])
|
||||||
|
->filter(fn (Builder $query, string $value) => $query->where('status', $value)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteUser(int $id): void
|
||||||
|
{
|
||||||
|
if ($id === Auth::id()) return;
|
||||||
|
User::findOrFail($id)->delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use Livewire\Component;
|
||||||
|
use Livewire\Attributes\Layout;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\Inspection;
|
||||||
|
use App\Models\Issue;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Spatie\Permission\Models\Permission;
|
||||||
|
use Spatie\Permission\PermissionRegistrar;
|
||||||
|
|
||||||
|
#[Layout('layouts.app')]
|
||||||
|
class UserView extends Component
|
||||||
|
{
|
||||||
|
public User $user;
|
||||||
|
public string $activeTab = 'ficha';
|
||||||
|
|
||||||
|
// Projects tab
|
||||||
|
public ?int $addProjectId = null;
|
||||||
|
public string $addProjectRole = '';
|
||||||
|
public $availableProjects;
|
||||||
|
|
||||||
|
// Notes tab
|
||||||
|
public string $notes = '';
|
||||||
|
public bool $editingNotes = false;
|
||||||
|
|
||||||
|
// Recent activity (loaded once)
|
||||||
|
public $recentInspections;
|
||||||
|
public $recentIssues;
|
||||||
|
|
||||||
|
public function mount(User $user): void
|
||||||
|
{
|
||||||
|
abort_unless(Auth::user()->can('view users'), 403);
|
||||||
|
|
||||||
|
$this->user = $user->load(['roles', 'company', 'projects.phases']);
|
||||||
|
$this->notes = $user->notes ?? '';
|
||||||
|
|
||||||
|
$this->loadAvailableProjects();
|
||||||
|
$this->loadActivity();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function loadAvailableProjects(): void
|
||||||
|
{
|
||||||
|
$assignedIds = $this->user->projects->pluck('id');
|
||||||
|
$this->availableProjects = Project::whereNotIn('id', $assignedIds)
|
||||||
|
->orderBy('name')->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function loadActivity(): void
|
||||||
|
{
|
||||||
|
$this->recentInspections = Inspection::where('user_id', $this->user->id)
|
||||||
|
->with(['feature.layer.phase.project', 'template'])
|
||||||
|
->latest()->take(8)->get();
|
||||||
|
|
||||||
|
$this->recentIssues = Issue::where('reported_by', $this->user->id)
|
||||||
|
->with(['feature', 'project'])
|
||||||
|
->latest()->take(8)->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tabs ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function setTab(string $tab): void
|
||||||
|
{
|
||||||
|
$this->activeTab = $tab;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Projects ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function assignProject(): void
|
||||||
|
{
|
||||||
|
$this->validate([
|
||||||
|
'addProjectId' => 'required|exists:projects,id',
|
||||||
|
'addProjectRole' => 'nullable|string|max:100',
|
||||||
|
], [], ['addProjectId' => 'proyecto', 'addProjectRole' => 'rol en proyecto']);
|
||||||
|
|
||||||
|
$this->user->projects()->attach($this->addProjectId, [
|
||||||
|
'role_in_project' => $this->addProjectRole ?: null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->user->load('projects.phases');
|
||||||
|
$this->addProjectId = null;
|
||||||
|
$this->addProjectRole = '';
|
||||||
|
$this->loadAvailableProjects();
|
||||||
|
$this->dispatch('notify', 'Proyecto asignado.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function removeProject(int $projectId): void
|
||||||
|
{
|
||||||
|
$this->user->projects()->detach($projectId);
|
||||||
|
$this->user->load('projects.phases');
|
||||||
|
$this->loadAvailableProjects();
|
||||||
|
$this->dispatch('notify', 'Proyecto desasignado.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Permissions (direct, per user) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
public function togglePermission(string $name): void
|
||||||
|
{
|
||||||
|
if ($this->user->hasDirectPermission($name)) {
|
||||||
|
$this->user->revokePermissionTo($name);
|
||||||
|
} else {
|
||||||
|
$this->user->givePermissionTo($name);
|
||||||
|
}
|
||||||
|
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||||
|
$this->user->load('roles', 'permissions');
|
||||||
|
$this->dispatch('notify', 'Permisos del usuario actualizados');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setUserGroup(string $group, bool $enabled): void
|
||||||
|
{
|
||||||
|
foreach (Permission::where('group', $group)->pluck('name') as $name) {
|
||||||
|
if ($enabled) {
|
||||||
|
if (! $this->user->hasPermissionTo($name)) {
|
||||||
|
$this->user->givePermissionTo($name);
|
||||||
|
}
|
||||||
|
} elseif ($this->user->hasDirectPermission($name)) {
|
||||||
|
$this->user->revokePermissionTo($name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||||
|
$this->user->load('roles', 'permissions');
|
||||||
|
$this->dispatch('notify', $enabled ? 'Permisos del grupo concedidos' : 'Permisos directos del grupo quitados');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Notes ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function saveNotes(): void
|
||||||
|
{
|
||||||
|
$this->validate(['notes' => 'nullable|string']);
|
||||||
|
$this->user->update(['notes' => $this->notes ?: null]);
|
||||||
|
$this->editingNotes = false;
|
||||||
|
$this->dispatch('notify', 'Notas guardadas.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
$order = [
|
||||||
|
'Proyectos', 'Fases y progreso', 'Capas y elementos', 'Inspecciones',
|
||||||
|
'Incidencias', 'Empresas', 'Usuarios', 'Roles', 'Informes', 'Archivos', 'General',
|
||||||
|
];
|
||||||
|
|
||||||
|
$grouped = Permission::orderBy('name')->get()
|
||||||
|
->groupBy(fn ($perm) => $perm->group ?: 'General')
|
||||||
|
->sortBy(function ($perms, $section) use ($order) {
|
||||||
|
$i = array_search($section, $order, true);
|
||||||
|
return $i === false ? 999 : $i;
|
||||||
|
});
|
||||||
|
|
||||||
|
return view('livewire.user-view', [
|
||||||
|
'grouped' => $grouped,
|
||||||
|
'directPerms' => $this->user->getDirectPermissions()->pluck('name')->toArray(),
|
||||||
|
'rolePerms' => $this->user->getPermissionsViaRoles()->pluck('name')->toArray(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
||||||
|
class ActivityLog extends Model
|
||||||
|
{
|
||||||
|
public $timestamps = false;
|
||||||
|
|
||||||
|
protected $fillable = ['action', 'model_type', 'model_id', 'user_id', 'changes', 'created_at'];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'changes' => 'array',
|
||||||
|
'created_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
public static function record(string $action, Model $model, array $changes = []): void
|
||||||
|
{
|
||||||
|
static::create([
|
||||||
|
'action' => $action,
|
||||||
|
'model_type' => class_basename($model),
|
||||||
|
'model_id' => $model->getKey(),
|
||||||
|
'user_id' => Auth::id(),
|
||||||
|
'changes' => empty($changes) ? null : $changes,
|
||||||
|
'created_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function user()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
@@ -26,6 +27,11 @@ class Company extends Model
|
|||||||
protected $dates = ['deleted_at'];
|
protected $dates = ['deleted_at'];
|
||||||
|
|
||||||
// Relationships
|
// Relationships
|
||||||
|
public function users()
|
||||||
|
{
|
||||||
|
return $this->hasMany(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function projects()
|
public function projects()
|
||||||
{
|
{
|
||||||
return $this->belongsToMany(Project::class, 'company_project')
|
return $this->belongsToMany(Project::class, 'company_project')
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class Device extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'user_id', 'name', 'token_id', 'app_version', 'last_seen_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'last_seen_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function user()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
+32
-2
@@ -3,15 +3,23 @@
|
|||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
use App\Traits\LogsActivity;
|
||||||
|
|
||||||
class Feature extends Model
|
class Feature extends Model
|
||||||
{
|
{
|
||||||
|
use SoftDeletes, LogsActivity;
|
||||||
|
|
||||||
|
const STATUSES = ['planned', 'started', 'in_progress', 'completed', 'verified'];
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'layer_id', 'name', 'geometry', 'properties', 'template_id', 'progress', 'responsible'
|
'layer_id', 'name', 'geometry', 'properties', 'template_id',
|
||||||
|
'progress', 'status', 'responsible', 'responsible_user_id',
|
||||||
|
'uuid', 'client_updated_at',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'geometry' => 'array',
|
'geometry' => 'array',
|
||||||
'properties' => 'array',
|
'properties' => 'array',
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -30,6 +38,16 @@ class Feature extends Model
|
|||||||
return $this->hasMany(Inspection::class, 'feature_id');
|
return $this->hasMany(Inspection::class, 'feature_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function issues()
|
||||||
|
{
|
||||||
|
return $this->hasMany(Issue::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function responsibleUser()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'responsible_user_id');
|
||||||
|
}
|
||||||
|
|
||||||
public function media()
|
public function media()
|
||||||
{
|
{
|
||||||
return $this->morphMany(Media::class, 'mediable');
|
return $this->morphMany(Media::class, 'mediable');
|
||||||
@@ -39,4 +57,16 @@ class Feature extends Model
|
|||||||
{
|
{
|
||||||
return $this->morphMany(Media::class, 'mediable')->where('category', 'image');
|
return $this->morphMany(Media::class, 'mediable')->where('category', 'image');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getStatusColorAttribute(): string
|
||||||
|
{
|
||||||
|
return match($this->status) {
|
||||||
|
'planned' => '#6b7280',
|
||||||
|
'started' => '#3b82f6',
|
||||||
|
'in_progress' => '#f59e0b',
|
||||||
|
'completed' => '#10b981',
|
||||||
|
'verified' => '#8b5cf6',
|
||||||
|
default => '#6b7280',
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -3,12 +3,26 @@
|
|||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
use App\Traits\LogsActivity;
|
||||||
|
|
||||||
class Inspection extends Model
|
class Inspection extends Model
|
||||||
{
|
{
|
||||||
protected $fillable = ['project_id', 'layer_id', 'feature_id', 'template_id', 'user_id', 'data'];
|
use SoftDeletes, LogsActivity;
|
||||||
|
|
||||||
protected $casts = ['data' => 'array'];
|
const STATUSES = ['pending', 'in_progress', 'completed', 'approved', 'rejected'];
|
||||||
|
const RESULTS = ['pass', 'fail', 'conditional'];
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'project_id', 'layer_id', 'feature_id', 'template_id', 'user_id',
|
||||||
|
'data', 'status', 'inspector_user_id', 'completed_at', 'result', 'notes',
|
||||||
|
'uuid', 'client_updated_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'data' => 'array',
|
||||||
|
'completed_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
public function project()
|
public function project()
|
||||||
{
|
{
|
||||||
@@ -30,8 +44,22 @@ class Inspection extends Model
|
|||||||
return $this->belongsTo(User::class);
|
return $this->belongsTo(User::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function inspector()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'inspector_user_id');
|
||||||
|
}
|
||||||
|
|
||||||
public function feature()
|
public function feature()
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Feature::class, 'feature_id');
|
return $this->belongsTo(Feature::class, 'feature_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function issues()
|
||||||
|
{
|
||||||
|
return $this->hasMany(Issue::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopePending($q) { return $q->where('status', 'pending'); }
|
||||||
|
public function scopeCompleted($q) { return $q->where('status', 'completed'); }
|
||||||
|
public function scopeRejected($q) { return $q->where('status', 'rejected'); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
use App\Traits\LogsActivity;
|
||||||
|
|
||||||
|
class Issue extends Model
|
||||||
|
{
|
||||||
|
use SoftDeletes, LogsActivity;
|
||||||
|
|
||||||
|
const STATUSES = ['open', 'in_review', 'resolved', 'closed'];
|
||||||
|
const PRIORITIES = ['low', 'medium', 'high', 'critical'];
|
||||||
|
const TYPES = ['defect', 'safety', 'quality', 'documentation', 'other'];
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'project_id', 'feature_id', 'inspection_id',
|
||||||
|
'title', 'description', 'status', 'priority', 'type',
|
||||||
|
'reported_by', 'assigned_to', 'resolved_at', 'resolution_notes',
|
||||||
|
'uuid', 'client_updated_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = ['resolved_at' => 'datetime'];
|
||||||
|
|
||||||
|
public function project() { return $this->belongsTo(Project::class); }
|
||||||
|
public function feature() { return $this->belongsTo(Feature::class); }
|
||||||
|
public function inspection() { return $this->belongsTo(Inspection::class); }
|
||||||
|
public function reporter() { return $this->belongsTo(User::class, 'reported_by'); }
|
||||||
|
public function assignee() { return $this->belongsTo(User::class, 'assigned_to'); }
|
||||||
|
public function media() { return $this->morphMany(Media::class, 'mediable'); }
|
||||||
|
public function tasks() { return $this->hasMany(IssueTask::class)->orderBy('order')->orderBy('id'); }
|
||||||
|
public function comments() { return $this->hasMany(IssueComment::class)->orderBy('created_at'); }
|
||||||
|
|
||||||
|
public function scopeOpen($q) { return $q->where('status', 'open'); }
|
||||||
|
public function scopeCritical($q) { return $q->where('priority', 'critical'); }
|
||||||
|
|
||||||
|
/** Resolution progress derived from the checklist: done tasks / total. */
|
||||||
|
public function getProgressAttribute(): int
|
||||||
|
{
|
||||||
|
$total = $this->tasks->count();
|
||||||
|
if ($total === 0) {
|
||||||
|
return in_array($this->status, ['resolved', 'closed'], true) ? 100 : 0;
|
||||||
|
}
|
||||||
|
return (int) round($this->tasks->where('is_done', true)->count() / $total * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when there is at least one task and all of them are done. */
|
||||||
|
public function getTasksCompleteAttribute(): bool
|
||||||
|
{
|
||||||
|
return $this->tasks->count() > 0 && $this->tasks->every(fn ($t) => $t->is_done);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPriorityColorAttribute(): string
|
||||||
|
{
|
||||||
|
return match($this->priority) {
|
||||||
|
'low' => '#6b7280',
|
||||||
|
'medium' => '#f59e0b',
|
||||||
|
'high' => '#ef4444',
|
||||||
|
'critical' => '#7c3aed',
|
||||||
|
default => '#6b7280',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStatusColorAttribute(): string
|
||||||
|
{
|
||||||
|
return match($this->status) {
|
||||||
|
'open' => '#ef4444',
|
||||||
|
'in_review' => '#f59e0b',
|
||||||
|
'resolved' => '#10b981',
|
||||||
|
'closed' => '#6b7280',
|
||||||
|
default => '#6b7280',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human label (Spanish) for each issue type. */
|
||||||
|
public static function typeLabels(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'defect' => 'Defecto',
|
||||||
|
'safety' => 'Seguridad',
|
||||||
|
'quality' => 'Calidad',
|
||||||
|
'documentation' => 'Documentación',
|
||||||
|
'other' => 'Otro',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTypeLabelAttribute(): string
|
||||||
|
{
|
||||||
|
return self::typeLabels()[$this->type] ?? ucfirst((string) $this->type);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTypeColorAttribute(): string
|
||||||
|
{
|
||||||
|
return match($this->type) {
|
||||||
|
'defect' => '#ef4444',
|
||||||
|
'safety' => '#f97316',
|
||||||
|
'quality' => '#0ea5e9',
|
||||||
|
'documentation' => '#8b5cf6',
|
||||||
|
default => '#6b7280',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
|
class IssueChecklistTemplate extends Model
|
||||||
|
{
|
||||||
|
use SoftDeletes;
|
||||||
|
|
||||||
|
protected $fillable = ['project_id', 'name', 'items'];
|
||||||
|
|
||||||
|
protected $casts = ['items' => 'array'];
|
||||||
|
|
||||||
|
public function project()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Project::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
|
class IssueComment extends Model
|
||||||
|
{
|
||||||
|
use SoftDeletes;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'issue_id', 'user_id', 'body',
|
||||||
|
'uuid', 'client_updated_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function issue() { return $this->belongsTo(Issue::class); }
|
||||||
|
public function user() { return $this->belongsTo(User::class); }
|
||||||
|
public function media() { return $this->morphMany(Media::class, 'mediable'); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
|
class IssueTask extends Model
|
||||||
|
{
|
||||||
|
use SoftDeletes;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'issue_id', 'title', 'is_done', 'done_at', 'done_by',
|
||||||
|
'assigned_to', 'due_date', 'order', 'overdue_notified_at',
|
||||||
|
'uuid', 'client_updated_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'is_done' => 'boolean',
|
||||||
|
'done_at' => 'datetime',
|
||||||
|
'due_date' => 'date',
|
||||||
|
'overdue_notified_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Tasks that are past their due date and not yet completed. */
|
||||||
|
public function scopeOverdue($q)
|
||||||
|
{
|
||||||
|
return $q->where('is_done', false)
|
||||||
|
->whereNotNull('due_date')
|
||||||
|
->whereDate('due_date', '<', now()->toDateString());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function issue() { return $this->belongsTo(Issue::class); }
|
||||||
|
public function assignee() { return $this->belongsTo(User::class, 'assigned_to'); }
|
||||||
|
public function completer() { return $this->belongsTo(User::class, 'done_by'); }
|
||||||
|
public function media() { return $this->morphMany(Media::class, 'mediable'); }
|
||||||
|
|
||||||
|
/** Overdue = has a due date in the past and not yet done. */
|
||||||
|
public function getIsOverdueAttribute(): bool
|
||||||
|
{
|
||||||
|
return ! $this->is_done && $this->due_date && $this->due_date->isPast();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,10 +3,13 @@
|
|||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
|
|
||||||
class Layer extends Model
|
class Layer extends Model
|
||||||
{
|
{
|
||||||
|
use SoftDeletes;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'project_id', 'phase_id', 'name', 'color', 'geojson_data', 'original_file', 'uploaded_by'
|
'project_id', 'phase_id', 'name', 'color', 'geojson_data', 'original_file', 'uploaded_by'
|
||||||
];
|
];
|
||||||
@@ -34,6 +37,11 @@ class Layer extends Model
|
|||||||
return $this->hasMany(Feature::class);
|
return $this->hasMany(Feature::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function issues()
|
||||||
|
{
|
||||||
|
return $this->hasMany(Issue::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function media()
|
public function media()
|
||||||
{
|
{
|
||||||
return $this->morphMany(Media::class, 'mediable');
|
return $this->morphMany(Media::class, 'mediable');
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ class Media extends Model
|
|||||||
'mediable_type', 'mediable_id',
|
'mediable_type', 'mediable_id',
|
||||||
'name', 'file_path', 'file_type', 'file_extension', 'file_size',
|
'name', 'file_path', 'file_type', 'file_extension', 'file_size',
|
||||||
'category', 'description', 'metadata', 'uploaded_by',
|
'category', 'description', 'metadata', 'uploaded_by',
|
||||||
|
'uuid', 'client_updated_at',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
|
|||||||
+22
-37
@@ -1,51 +1,36 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
class Phase extends Model
|
class Phase extends Model
|
||||||
{
|
{
|
||||||
|
use SoftDeletes;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'project_id', 'name', 'description', 'order', 'color', 'progress_percent'
|
'project_id', 'name', 'description', 'order', 'color', 'progress_percent',
|
||||||
|
'planned_start', 'planned_end', 'actual_start', 'actual_end'
|
||||||
];
|
];
|
||||||
|
|
||||||
public function project()
|
protected $casts = [
|
||||||
{
|
'planned_start' => 'date',
|
||||||
return $this->belongsTo(Project::class);
|
'planned_end' => 'date',
|
||||||
}
|
'actual_start' => 'date',
|
||||||
|
'actual_end' => 'date',
|
||||||
|
];
|
||||||
|
|
||||||
public function layers()
|
public function project() { return $this->belongsTo(Project::class); }
|
||||||
{
|
public function layers() { return $this->hasMany(Layer::class); }
|
||||||
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 progressUpdates()
|
public function getDeviationDaysAttribute(): ?int
|
||||||
{
|
{
|
||||||
return $this->hasMany(ProgressUpdate::class);
|
if (!$this->planned_end) return null;
|
||||||
}
|
$end = $this->actual_end ?? now();
|
||||||
|
return $this->planned_end->diffInDays($end, false);
|
||||||
// Get latest active layer (most recent upload)
|
|
||||||
public function currentLayer()
|
|
||||||
{
|
|
||||||
return $this->hasOne(Layer::class)->latestOfMany();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all features across all layers of this phase.
|
|
||||||
*/
|
|
||||||
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');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7,11 +7,12 @@ use Illuminate\Database\Eloquent\Model;
|
|||||||
class ProgressUpdate extends Model
|
class ProgressUpdate extends Model
|
||||||
{
|
{
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'phase_id', 'user_id', 'progress_percent', 'comment', 'location'
|
'uuid', 'phase_id', 'user_id', 'progress_percent', 'comment', 'location', 'client_updated_at'
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'location' => 'array', // Store as [lat, lng]
|
'location' => 'array', // Store as [lat, lng]
|
||||||
|
'client_updated_at' => 'datetime',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function phase()
|
public function phase()
|
||||||
|
|||||||
@@ -4,14 +4,15 @@ namespace App\Models;
|
|||||||
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
class Project extends Model
|
class Project extends Model
|
||||||
{
|
{
|
||||||
use HasFactory;
|
use HasFactory, SoftDeletes;
|
||||||
use HasFactory;
|
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'name', 'address', 'lat', 'lng', 'start_date', 'end_date_estimated', 'status', 'created_by'
|
'name', 'reference', 'address', 'country', 'lat', 'lng',
|
||||||
|
'start_date', 'end_date_estimated', 'status', 'created_by',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
@@ -65,7 +66,7 @@ class Project extends Model
|
|||||||
// Scope to filter accessible projects for non-admin users
|
// Scope to filter accessible projects for non-admin users
|
||||||
public function scopeAccessibleBy($query, User $user)
|
public function scopeAccessibleBy($query, User $user)
|
||||||
{
|
{
|
||||||
if ($user->hasRole('Admin')) {
|
if ($user->can('manage all')) {
|
||||||
return $query;
|
return $query;
|
||||||
}
|
}
|
||||||
return $query->whereHas('users', function ($q) use ($user) {
|
return $query->whereHas('users', function ($q) use ($user) {
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class SyncLog extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'user_id', 'op_uuid', 'entity', 'op', 'status', 'server_id', 'error',
|
||||||
|
];
|
||||||
|
}
|
||||||
+15
-5
@@ -7,12 +7,13 @@ use Database\Factories\UserFactory;
|
|||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
|
use Laravel\Sanctum\HasApiTokens;
|
||||||
use Spatie\Permission\Traits\HasRoles;
|
use Spatie\Permission\Traits\HasRoles;
|
||||||
|
|
||||||
class User extends Authenticatable
|
class User extends Authenticatable
|
||||||
{
|
{
|
||||||
/** @use HasFactory<UserFactory> */
|
/** @use HasFactory<UserFactory> */
|
||||||
use HasFactory, Notifiable, HasRoles;
|
use HasFactory, Notifiable, HasRoles, HasApiTokens;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The attributes that are mass assignable.
|
* The attributes that are mass assignable.
|
||||||
@@ -20,9 +21,11 @@ class User extends Authenticatable
|
|||||||
* @var list<string>
|
* @var list<string>
|
||||||
*/
|
*/
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'name',
|
'name', 'title', 'first_name', 'last_name',
|
||||||
'email',
|
'email', 'password',
|
||||||
'password',
|
'status', 'valid_from', 'valid_until',
|
||||||
|
'company_id', 'phone', 'address', 'notes',
|
||||||
|
'locale',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -44,9 +47,16 @@ class User extends Authenticatable
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'email_verified_at' => 'datetime',
|
'email_verified_at' => 'datetime',
|
||||||
'password' => 'hashed',
|
'password' => 'hashed',
|
||||||
|
'valid_from' => 'date',
|
||||||
|
'valid_until' => 'date',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function company()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(\App\Models\Company::class);
|
||||||
|
}
|
||||||
// Many-to-many with projects
|
// Many-to-many with projects
|
||||||
public function projects()
|
public function projects()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Notifications;
|
||||||
|
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Notifications\Notification;
|
||||||
|
use App\Models\Feature;
|
||||||
|
|
||||||
|
class FeatureCompletedNotification extends Notification
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
public function __construct(public Feature $feature) {}
|
||||||
|
|
||||||
|
public function via($notifiable): array
|
||||||
|
{
|
||||||
|
return ['database'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toArray($notifiable): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'type' => 'feature_completed',
|
||||||
|
'feature_id' => $this->feature->id,
|
||||||
|
'project_id' => $this->feature->layer?->phase?->project_id,
|
||||||
|
'feature_name' => $this->feature->name,
|
||||||
|
'progress' => 100,
|
||||||
|
'message' => "Elemento '{$this->feature->name}' marcado como completado",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Notifications;
|
||||||
|
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Notifications\Notification;
|
||||||
|
use Illuminate\Notifications\Messages\MailMessage;
|
||||||
|
use App\Models\Inspection;
|
||||||
|
|
||||||
|
class InspectionCompletedNotification extends Notification
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
public function __construct(public Inspection $inspection) {}
|
||||||
|
|
||||||
|
public function via($notifiable): array
|
||||||
|
{
|
||||||
|
return ['database'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toArray($notifiable): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'type' => 'inspection_completed',
|
||||||
|
'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,
|
||||||
|
'message' => "Inspección completada en '{$this->inspection->feature?->name}': " . ($this->inspection->result ?? 'sin resultado'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Notifications;
|
||||||
|
|
||||||
|
use App\Models\Issue;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Notifications\Notification;
|
||||||
|
|
||||||
|
class IssueAssignedNotification extends Notification
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
public function __construct(public Issue $issue) {}
|
||||||
|
|
||||||
|
public function via($notifiable): array
|
||||||
|
{
|
||||||
|
return ['database'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toArray($notifiable): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'type' => 'issue_assigned',
|
||||||
|
'issue_id' => $this->issue->id,
|
||||||
|
'project_id' => $this->issue->project_id,
|
||||||
|
'priority' => $this->issue->priority,
|
||||||
|
'message' => "Se te ha asignado la incidencia '{$this->issue->title}'",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Notifications;
|
||||||
|
|
||||||
|
use App\Models\IssueComment;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Notifications\Notification;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class IssueCommentedNotification extends Notification
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
public function __construct(public IssueComment $comment) {}
|
||||||
|
|
||||||
|
public function via($notifiable): array
|
||||||
|
{
|
||||||
|
return ['database'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toArray($notifiable): array
|
||||||
|
{
|
||||||
|
$issue = $this->comment->issue;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'type' => 'issue_commented',
|
||||||
|
'issue_id' => $this->comment->issue_id,
|
||||||
|
'project_id' => $issue?->project_id,
|
||||||
|
'author' => $this->comment->user?->name,
|
||||||
|
'message' => "{$this->comment->user?->name} comentó en '{$issue?->title}': " . Str::limit($this->comment->body, 60),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Notifications;
|
||||||
|
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Notifications\Notification;
|
||||||
|
use App\Models\Issue;
|
||||||
|
|
||||||
|
class IssueReportedNotification extends Notification
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
public function __construct(public Issue $issue) {}
|
||||||
|
|
||||||
|
public function via($notifiable): array
|
||||||
|
{
|
||||||
|
return ['database'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toArray($notifiable): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'type' => 'issue_reported',
|
||||||
|
'issue_id' => $this->issue->id,
|
||||||
|
'project_id' => $this->issue->project_id,
|
||||||
|
'feature_name' => $this->issue->feature?->name ?? '—',
|
||||||
|
'priority' => $this->issue->priority,
|
||||||
|
'message' => "Nuevo issue '{$this->issue->title}' (prioridad: {$this->issue->priority})",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Notifications;
|
||||||
|
|
||||||
|
use App\Models\Issue;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Notifications\Notification;
|
||||||
|
|
||||||
|
class IssueStatusChangedNotification extends Notification
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
public function __construct(public Issue $issue, public string $status) {}
|
||||||
|
|
||||||
|
public function via($notifiable): array
|
||||||
|
{
|
||||||
|
return ['database'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toArray($notifiable): array
|
||||||
|
{
|
||||||
|
$label = [
|
||||||
|
'open' => 'reabierta',
|
||||||
|
'in_review' => 'enviada a revisión',
|
||||||
|
'resolved' => 'resuelta',
|
||||||
|
'closed' => 'cerrada',
|
||||||
|
][$this->status] ?? $this->status;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'type' => 'issue_status_changed',
|
||||||
|
'issue_id' => $this->issue->id,
|
||||||
|
'project_id' => $this->issue->project_id,
|
||||||
|
'status' => $this->status,
|
||||||
|
'message' => "La incidencia '{$this->issue->title}' ha sido {$label}",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Notifications;
|
||||||
|
|
||||||
|
use App\Models\IssueTask;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Notifications\Notification;
|
||||||
|
|
||||||
|
class IssueTaskAssignedNotification extends Notification
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
public function __construct(public IssueTask $task) {}
|
||||||
|
|
||||||
|
public function via($notifiable): array
|
||||||
|
{
|
||||||
|
return ['database'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toArray($notifiable): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'type' => 'issue_task_assigned',
|
||||||
|
'issue_id' => $this->task->issue_id,
|
||||||
|
'task_id' => $this->task->id,
|
||||||
|
'project_id' => $this->task->issue?->project_id,
|
||||||
|
'due_date' => $this->task->due_date?->toDateString(),
|
||||||
|
'message' => "Se te ha asignado la tarea '{$this->task->title}'",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Notifications;
|
||||||
|
|
||||||
|
use App\Models\IssueTask;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Notifications\Notification;
|
||||||
|
|
||||||
|
class IssueTaskOverdueNotification extends Notification
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
public function __construct(public IssueTask $task) {}
|
||||||
|
|
||||||
|
public function via($notifiable): array
|
||||||
|
{
|
||||||
|
return ['database'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toArray($notifiable): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'type' => 'issue_task_overdue',
|
||||||
|
'issue_id' => $this->task->issue_id,
|
||||||
|
'task_id' => $this->task->id,
|
||||||
|
'project_id' => $this->task->issue?->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')})",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
|
||||||
class AppServiceProvider extends ServiceProvider
|
class AppServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
@@ -19,6 +20,15 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
*/
|
*/
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
//
|
// Super-admin bypass: anyone with the "manage all" permission
|
||||||
|
// (the Admin role has it) passes every authorization check.
|
||||||
|
// Return true to allow, or null to let normal checks run — never false.
|
||||||
|
Gate::before(function ($user, $ability) {
|
||||||
|
try {
|
||||||
|
return $user->hasPermissionTo('manage all') ? true : null;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Traits;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use App\Models\ActivityLog;
|
||||||
|
|
||||||
|
trait LogsActivity
|
||||||
|
{
|
||||||
|
public static function bootLogsActivity(): void
|
||||||
|
{
|
||||||
|
static::created(function ($model) {
|
||||||
|
ActivityLog::record('created', $model);
|
||||||
|
});
|
||||||
|
|
||||||
|
static::updated(function ($model) {
|
||||||
|
ActivityLog::record('updated', $model, $model->getDirty());
|
||||||
|
});
|
||||||
|
|
||||||
|
static::deleted(function ($model) {
|
||||||
|
ActivityLog::record('deleted', $model);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,11 +7,21 @@ use Illuminate\Foundation\Configuration\Middleware;
|
|||||||
return Application::configure(basePath: dirname(__DIR__))
|
return Application::configure(basePath: dirname(__DIR__))
|
||||||
->withRouting(
|
->withRouting(
|
||||||
web: __DIR__.'/../routes/web.php',
|
web: __DIR__.'/../routes/web.php',
|
||||||
|
api: __DIR__.'/../routes/api.php',
|
||||||
commands: __DIR__.'/../routes/console.php',
|
commands: __DIR__.'/../routes/console.php',
|
||||||
health: '/up',
|
health: '/up',
|
||||||
)
|
)
|
||||||
->withMiddleware(function (Middleware $middleware): void {
|
->withMiddleware(function (Middleware $middleware): void {
|
||||||
$middleware->appendToGroup('web', \App\Http\Middleware\SetLocale::class);
|
$middleware->appendToGroup('web', \App\Http\Middleware\SetLocale::class);
|
||||||
|
|
||||||
|
// Spatie permission + Sanctum ability middleware aliases
|
||||||
|
$middleware->alias([
|
||||||
|
'role' => \Spatie\Permission\Middleware\RoleMiddleware::class,
|
||||||
|
'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class,
|
||||||
|
'role_or_permission' => \Spatie\Permission\Middleware\RoleOrPermissionMiddleware::class,
|
||||||
|
'abilities' => \Laravel\Sanctum\Http\Middleware\CheckAbilities::class,
|
||||||
|
'ability' => \Laravel\Sanctum\Http\Middleware\CheckForAnyAbility::class,
|
||||||
|
]);
|
||||||
})
|
})
|
||||||
->withExceptions(function (Exceptions $exceptions): void {
|
->withExceptions(function (Exceptions $exceptions): void {
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"blade-ui-kit/blade-heroicons": "^2.7",
|
"blade-ui-kit/blade-heroicons": "^2.7",
|
||||||
"gasparesganga/php-shapefile": "^3.4",
|
"gasparesganga/php-shapefile": "^3.4",
|
||||||
"laravel/framework": "^12.0",
|
"laravel/framework": "^12.0",
|
||||||
|
"laravel/sanctum": "^4.3",
|
||||||
"laravel/tinker": "^2.10.1",
|
"laravel/tinker": "^2.10.1",
|
||||||
"league/geotools": "^1.3",
|
"league/geotools": "^1.3",
|
||||||
"livewire/livewire": "^3.6.4",
|
"livewire/livewire": "^3.6.4",
|
||||||
|
|||||||
Generated
+67
-4
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "1b74f08906a514a8a24b6e299587d9f3",
|
"content-hash": "45553317b713050f78b4233c204790f9",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "blade-ui-kit/blade-heroicons",
|
"name": "blade-ui-kit/blade-heroicons",
|
||||||
@@ -1753,6 +1753,69 @@
|
|||||||
},
|
},
|
||||||
"time": "2026-04-20T16:07:33+00:00"
|
"time": "2026-04-20T16:07:33+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "laravel/sanctum",
|
||||||
|
"version": "v4.3.2",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/laravel/sanctum.git",
|
||||||
|
"reference": "2a9bccc18e9907808e0018dd15fa643937886b1e"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/laravel/sanctum/zipball/2a9bccc18e9907808e0018dd15fa643937886b1e",
|
||||||
|
"reference": "2a9bccc18e9907808e0018dd15fa643937886b1e",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"ext-json": "*",
|
||||||
|
"illuminate/console": "^11.0|^12.0|^13.0",
|
||||||
|
"illuminate/contracts": "^11.0|^12.0|^13.0",
|
||||||
|
"illuminate/database": "^11.0|^12.0|^13.0",
|
||||||
|
"illuminate/support": "^11.0|^12.0|^13.0",
|
||||||
|
"php": "^8.2",
|
||||||
|
"symfony/console": "^7.0|^8.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"mockery/mockery": "^1.6",
|
||||||
|
"orchestra/testbench": "^9.15|^10.8|^11.0",
|
||||||
|
"phpstan/phpstan": "^1.10"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"laravel": {
|
||||||
|
"providers": [
|
||||||
|
"Laravel\\Sanctum\\SanctumServiceProvider"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Laravel\\Sanctum\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Taylor Otwell",
|
||||||
|
"email": "taylor@laravel.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Laravel Sanctum provides a featherweight authentication system for SPAs and simple APIs.",
|
||||||
|
"keywords": [
|
||||||
|
"auth",
|
||||||
|
"laravel",
|
||||||
|
"sanctum"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/laravel/sanctum/issues",
|
||||||
|
"source": "https://github.com/laravel/sanctum"
|
||||||
|
},
|
||||||
|
"time": "2026-04-30T11:46:25+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "laravel/serializable-closure",
|
"name": "laravel/serializable-closure",
|
||||||
"version": "v2.0.12",
|
"version": "v2.0.12",
|
||||||
@@ -10429,12 +10492,12 @@
|
|||||||
],
|
],
|
||||||
"aliases": [],
|
"aliases": [],
|
||||||
"minimum-stability": "stable",
|
"minimum-stability": "stable",
|
||||||
"stability-flags": [],
|
"stability-flags": {},
|
||||||
"prefer-stable": true,
|
"prefer-stable": true,
|
||||||
"prefer-lowest": false,
|
"prefer-lowest": false,
|
||||||
"platform": {
|
"platform": {
|
||||||
"php": "^8.2"
|
"php": "^8.2"
|
||||||
},
|
},
|
||||||
"platform-dev": [],
|
"platform-dev": {},
|
||||||
"plugin-api-version": "2.6.0"
|
"plugin-api-version": "2.9.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Cookie\Middleware\EncryptCookies;
|
||||||
|
use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken;
|
||||||
|
use Laravel\Sanctum\Http\Middleware\AuthenticateSession;
|
||||||
|
use Laravel\Sanctum\Sanctum;
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Stateful Domains
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Requests from the following domains / hosts will receive stateful API
|
||||||
|
| authentication cookies. Typically, these should include your local
|
||||||
|
| and production domains which access your API via a frontend SPA.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
|
||||||
|
'%s%s',
|
||||||
|
'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
|
||||||
|
Sanctum::currentApplicationUrlWithPort(),
|
||||||
|
// Sanctum::currentRequestHost(),
|
||||||
|
))),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Sanctum Guards
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This array contains the authentication guards that will be checked when
|
||||||
|
| Sanctum is trying to authenticate a request. If none of these guards
|
||||||
|
| are able to authenticate the request, Sanctum will use the bearer
|
||||||
|
| token that's present on an incoming request for authentication.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'guard' => ['web'],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Expiration Minutes
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This value controls the number of minutes until an issued token will be
|
||||||
|
| considered expired. This will override any values set in the token's
|
||||||
|
| "expires_at" attribute, but first-party sessions are not affected.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'expiration' => null,
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Token Prefix
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Sanctum can prefix new tokens in order to take advantage of numerous
|
||||||
|
| security scanning initiatives maintained by open source platforms
|
||||||
|
| that notify developers if they commit tokens into repositories.
|
||||||
|
|
|
||||||
|
| See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Sanctum Middleware
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| When authenticating your first-party SPA with Sanctum you may need to
|
||||||
|
| customize some of the middleware Sanctum uses while processing the
|
||||||
|
| request. You may change the middleware listed below as required.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'middleware' => [
|
||||||
|
'authenticate_session' => AuthenticateSession::class,
|
||||||
|
'encrypt_cookies' => EncryptCookies::class,
|
||||||
|
'validate_csrf_token' => ValidateCsrfToken::class,
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('features', function (Blueprint $table) {
|
||||||
|
$table->enum('status', ['planned', 'started', 'in_progress', 'completed', 'verified'])
|
||||||
|
->default('planned')
|
||||||
|
->after('progress');
|
||||||
|
|
||||||
|
$table->foreignId('responsible_user_id')
|
||||||
|
->nullable()
|
||||||
|
->constrained('users')
|
||||||
|
->nullOnDelete()
|
||||||
|
->after('responsible');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('features', function (Blueprint $table) {
|
||||||
|
$table->dropForeign(['responsible_user_id']);
|
||||||
|
$table->dropColumn(['status', 'responsible_user_id']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('inspections', function (Blueprint $table) {
|
||||||
|
$table->enum('status', ['pending', 'in_progress', 'completed', 'approved', 'rejected'])
|
||||||
|
->default('pending')
|
||||||
|
->after('data');
|
||||||
|
|
||||||
|
$table->foreignId('inspector_user_id')
|
||||||
|
->nullable()
|
||||||
|
->constrained('users')
|
||||||
|
->nullOnDelete()
|
||||||
|
->after('status');
|
||||||
|
|
||||||
|
$table->timestamp('completed_at')
|
||||||
|
->nullable()
|
||||||
|
->after('inspector_user_id');
|
||||||
|
|
||||||
|
$table->enum('result', ['pass', 'fail', 'conditional'])
|
||||||
|
->nullable()
|
||||||
|
->after('completed_at');
|
||||||
|
|
||||||
|
$table->text('notes')
|
||||||
|
->nullable()
|
||||||
|
->after('result');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('inspections', function (Blueprint $table) {
|
||||||
|
$table->dropForeign(['inspector_user_id']);
|
||||||
|
$table->dropColumn(['status', 'inspector_user_id', 'completed_at', 'result', 'notes']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('phases', function (Blueprint $table) {
|
||||||
|
$table->date('planned_start')->nullable()->after('progress_percent');
|
||||||
|
$table->date('planned_end')->nullable()->after('planned_start');
|
||||||
|
$table->date('actual_start')->nullable()->after('planned_end');
|
||||||
|
$table->date('actual_end')->nullable()->after('actual_start');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('phases', function (Blueprint $table) {
|
||||||
|
$table->dropColumn(['planned_start', 'planned_end', 'actual_start', 'actual_end']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
$tables = ['projects', 'phases', 'layers', 'features', 'inspections'];
|
||||||
|
|
||||||
|
foreach ($tables as $table) {
|
||||||
|
if (!Schema::hasColumn($table, 'deleted_at')) {
|
||||||
|
Schema::table($table, function (Blueprint $t) {
|
||||||
|
$t->softDeletes();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
$tables = ['projects', 'phases', 'layers', 'features', 'inspections'];
|
||||||
|
|
||||||
|
foreach ($tables as $table) {
|
||||||
|
if (Schema::hasColumn($table, 'deleted_at')) {
|
||||||
|
Schema::table($table, function (Blueprint $t) {
|
||||||
|
$t->dropSoftDeletes();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('issues', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
|
||||||
|
$table->foreignId('project_id')
|
||||||
|
->constrained('projects')
|
||||||
|
->cascadeOnDelete();
|
||||||
|
|
||||||
|
$table->foreignId('feature_id')
|
||||||
|
->nullable()
|
||||||
|
->constrained('features')
|
||||||
|
->nullOnDelete();
|
||||||
|
|
||||||
|
$table->foreignId('inspection_id')
|
||||||
|
->nullable()
|
||||||
|
->constrained('inspections')
|
||||||
|
->nullOnDelete();
|
||||||
|
|
||||||
|
$table->string('title');
|
||||||
|
$table->text('description')->nullable();
|
||||||
|
|
||||||
|
$table->enum('status', ['open', 'in_review', 'resolved', 'closed'])
|
||||||
|
->default('open');
|
||||||
|
|
||||||
|
$table->enum('priority', ['low', 'medium', 'high', 'critical'])
|
||||||
|
->default('medium');
|
||||||
|
|
||||||
|
$table->foreignId('reported_by')
|
||||||
|
->constrained('users');
|
||||||
|
|
||||||
|
$table->foreignId('assigned_to')
|
||||||
|
->nullable()
|
||||||
|
->constrained('users')
|
||||||
|
->nullOnDelete();
|
||||||
|
|
||||||
|
$table->timestamp('resolved_at')->nullable();
|
||||||
|
$table->text('resolution_notes')->nullable();
|
||||||
|
|
||||||
|
$table->timestamps();
|
||||||
|
$table->softDeletes();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('issues');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('notifications', function (Blueprint $table) {
|
||||||
|
$table->uuid('id')->primary();
|
||||||
|
$table->string('type');
|
||||||
|
$table->morphs('notifiable');
|
||||||
|
$table->text('data');
|
||||||
|
$table->timestamp('read_at')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('notifications');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('activity_logs', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('action');
|
||||||
|
$table->string('model_type');
|
||||||
|
$table->unsignedBigInteger('model_id');
|
||||||
|
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
|
||||||
|
$table->json('changes')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
$table->index(['model_type', 'model_id']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('activity_logs');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?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::table('users', function (Blueprint $table) {
|
||||||
|
$table->string('title', 20)->nullable()->after('id');
|
||||||
|
$table->string('first_name')->nullable()->after('title');
|
||||||
|
$table->string('last_name')->nullable()->after('first_name');
|
||||||
|
$table->string('status', 20)->default('active')->after('name');
|
||||||
|
$table->date('valid_from')->nullable()->after('status');
|
||||||
|
$table->date('valid_until')->nullable()->after('valid_from');
|
||||||
|
$table->foreignId('company_id')->nullable()->constrained('companies')->nullOnDelete()->after('valid_until');
|
||||||
|
$table->string('phone', 30)->nullable()->after('company_id');
|
||||||
|
$table->text('address')->nullable()->after('phone');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->dropForeign(['company_id']);
|
||||||
|
$table->dropColumn([
|
||||||
|
'title', 'first_name', 'last_name', 'status',
|
||||||
|
'valid_from', 'valid_until', 'company_id', 'phone', 'address',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?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::table('users', function (Blueprint $table) {
|
||||||
|
$table->text('notes')->nullable()->after('address');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('notes');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?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::table('projects', function (Blueprint $table) {
|
||||||
|
$table->char('country', 2)->nullable()->after('address');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('projects', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('country');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->string('locale', 5)->default('es')->change();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset all users still on the old default so they load in Spanish.
|
||||||
|
// Users that explicitly chose 'en' keep their preference.
|
||||||
|
DB::table('users')->where('locale', 'en')->update(['locale' => 'es']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
DB::table('users')->where('locale', 'es')->update(['locale' => 'en']);
|
||||||
|
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->string('locale', 5)->default('en')->change();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
$table = config('permission.table_names.roles', 'roles');
|
||||||
|
|
||||||
|
Schema::table($table, function (Blueprint $table) {
|
||||||
|
if (! Schema::hasColumn($table->getTable(), 'description')) {
|
||||||
|
$table->string('description')->nullable()->after('name');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
$table = config('permission.table_names.roles', 'roles');
|
||||||
|
|
||||||
|
Schema::table($table, function (Blueprint $table) {
|
||||||
|
if (Schema::hasColumn($table->getTable(), 'description')) {
|
||||||
|
$table->dropColumn('description');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
$table = config('permission.table_names.permissions', 'permissions');
|
||||||
|
|
||||||
|
Schema::table($table, function (Blueprint $table) {
|
||||||
|
if (! Schema::hasColumn($table->getTable(), 'group')) {
|
||||||
|
$table->string('group')->nullable()->after('name');
|
||||||
|
}
|
||||||
|
if (! Schema::hasColumn($table->getTable(), 'description')) {
|
||||||
|
$table->string('description')->nullable()->after('group');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
$table = config('permission.table_names.permissions', 'permissions');
|
||||||
|
|
||||||
|
Schema::table($table, function (Blueprint $table) {
|
||||||
|
foreach (['group', 'description'] as $col) {
|
||||||
|
if (Schema::hasColumn($table->getTable(), $col)) {
|
||||||
|
$table->dropColumn($col);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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('personal_access_tokens', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->morphs('tokenable');
|
||||||
|
$table->text('name');
|
||||||
|
$table->string('token', 64)->unique();
|
||||||
|
$table->text('abilities')->nullable();
|
||||||
|
$table->timestamp('last_used_at')->nullable();
|
||||||
|
$table->timestamp('expires_at')->nullable()->index();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('personal_access_tokens');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('devices', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->string('name'); // device_name del login
|
||||||
|
$table->unsignedBigInteger('token_id')->nullable(); // id del personal_access_token actual
|
||||||
|
$table->string('app_version')->nullable();
|
||||||
|
$table->timestamp('last_seen_at')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->unique(['user_id', 'name']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('devices');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('progress_updates', function (Blueprint $table) {
|
||||||
|
if (! Schema::hasColumn('progress_updates', 'uuid')) {
|
||||||
|
$table->uuid('uuid')->nullable()->unique()->after('id');
|
||||||
|
}
|
||||||
|
if (! Schema::hasColumn('progress_updates', 'client_updated_at')) {
|
||||||
|
$table->timestamp('client_updated_at')->nullable()->after('location');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('progress_updates', function (Blueprint $table) {
|
||||||
|
foreach (['uuid', 'client_updated_at'] as $col) {
|
||||||
|
if (Schema::hasColumn('progress_updates', $col)) {
|
||||||
|
$table->dropColumn($col);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* `reference` (and `external_reference_1`) exist in the live DB but were never
|
||||||
|
* created by a migration (the "add_reference_and_country" migration only added
|
||||||
|
* `country`). This guarded migration reconciles the schema: on the live DB the
|
||||||
|
* columns already exist and are skipped; on a fresh install they get created.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('projects', function (Blueprint $table) {
|
||||||
|
if (! Schema::hasColumn('projects', 'reference')) {
|
||||||
|
$table->string('reference')->nullable()->after('id');
|
||||||
|
}
|
||||||
|
if (! Schema::hasColumn('projects', 'external_reference_1')) {
|
||||||
|
$table->string('external_reference_1')->nullable()->after('reference');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('projects', function (Blueprint $table) {
|
||||||
|
foreach (['reference', 'external_reference_1'] as $col) {
|
||||||
|
if (Schema::hasColumn('projects', $col)) {
|
||||||
|
$table->dropColumn($col);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
private array $tables = ['features', 'inspections', 'issues'];
|
||||||
|
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
foreach ($this->tables as $name) {
|
||||||
|
Schema::table($name, function (Blueprint $table) use ($name) {
|
||||||
|
if (! Schema::hasColumn($name, 'uuid')) {
|
||||||
|
$table->uuid('uuid')->nullable()->unique()->after('id');
|
||||||
|
}
|
||||||
|
if (! Schema::hasColumn($name, 'client_updated_at')) {
|
||||||
|
$table->timestamp('client_updated_at')->nullable();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
foreach ($this->tables as $name) {
|
||||||
|
Schema::table($name, function (Blueprint $table) use ($name) {
|
||||||
|
foreach (['uuid', 'client_updated_at'] as $col) {
|
||||||
|
if (Schema::hasColumn($name, $col)) {
|
||||||
|
$table->dropColumn($col);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('media', function (Blueprint $table) {
|
||||||
|
if (! Schema::hasColumn('media', 'uuid')) {
|
||||||
|
$table->uuid('uuid')->nullable()->unique()->after('id');
|
||||||
|
}
|
||||||
|
if (! Schema::hasColumn('media', 'client_updated_at')) {
|
||||||
|
$table->timestamp('client_updated_at')->nullable();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('media', function (Blueprint $table) {
|
||||||
|
foreach (['uuid', 'client_updated_at'] as $col) {
|
||||||
|
if (Schema::hasColumn('media', $col)) {
|
||||||
|
$table->dropColumn($col);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('sync_logs', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
|
||||||
|
$table->uuid('op_uuid')->index(); // idempotency key of the operation
|
||||||
|
$table->string('entity');
|
||||||
|
$table->string('op');
|
||||||
|
$table->string('status'); // applied | duplicate | conflict | error
|
||||||
|
$table->unsignedBigInteger('server_id')->nullable();
|
||||||
|
$table->text('error')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
// One processed result per (entity, op, op_uuid).
|
||||||
|
$table->unique(['entity', 'op', 'op_uuid']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('sync_logs');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('issue_tasks', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('issue_id')->constrained('issues')->cascadeOnDelete();
|
||||||
|
$table->string('title');
|
||||||
|
$table->boolean('is_done')->default(false);
|
||||||
|
$table->timestamp('done_at')->nullable();
|
||||||
|
$table->foreignId('done_by')->nullable()->constrained('users')->nullOnDelete();
|
||||||
|
$table->foreignId('assigned_to')->nullable()->constrained('users')->nullOnDelete();
|
||||||
|
$table->date('due_date')->nullable();
|
||||||
|
$table->integer('order')->default(0);
|
||||||
|
|
||||||
|
// Offline sync
|
||||||
|
$table->uuid('uuid')->nullable()->unique();
|
||||||
|
$table->timestamp('client_updated_at')->nullable();
|
||||||
|
|
||||||
|
$table->timestamps();
|
||||||
|
$table->softDeletes();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('issue_tasks');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('issue_comments', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('issue_id')->constrained('issues')->cascadeOnDelete();
|
||||||
|
$table->foreignId('user_id')->constrained('users');
|
||||||
|
$table->text('body');
|
||||||
|
|
||||||
|
// Offline sync
|
||||||
|
$table->uuid('uuid')->nullable()->unique();
|
||||||
|
$table->timestamp('client_updated_at')->nullable();
|
||||||
|
|
||||||
|
$table->timestamps();
|
||||||
|
$table->softDeletes();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('issue_comments');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('issue_checklist_templates', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('project_id')->constrained('projects')->cascadeOnDelete();
|
||||||
|
$table->string('name');
|
||||||
|
$table->json('items')->nullable(); // array of task titles
|
||||||
|
$table->timestamps();
|
||||||
|
$table->softDeletes();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('issue_checklist_templates');
|
||||||
|
}
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user