Compare commits
57 Commits
dbe43a04f3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| cf3d32a6fa | |||
| 52f586f815 | |||
| 0f1aa2c38e | |||
| 2da0eb817e | |||
| 971420ebaa | |||
| 0f720567c3 | |||
| 0bf2d82ee1 | |||
| 4ab7935c17 | |||
| 07ffce437f | |||
| d4d5097fe2 | |||
| c556a4910b | |||
| fd166edbc6 | |||
| 8ca8dfbccc | |||
| 4f5569a156 |
@@ -22,3 +22,4 @@
|
|||||||
Homestead.json
|
Homestead.json
|
||||||
Homestead.yaml
|
Homestead.yaml
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
.claude/worktrees/
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# ConstruProgress
|
# Avante
|
||||||
|
|
||||||
Sistema de gestión de proyectos de construcción con mapas interactivos, control de progreso, inspecciones y soporte offline.
|
Sistema de gestión de proyectos de construcción con mapas interactivos, control de progreso, inspecciones y soporte offline.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Exports;
|
||||||
|
|
||||||
|
use App\Models\Inspection;
|
||||||
|
use Maatwebsite\Excel\Concerns\FromCollection;
|
||||||
|
use Maatwebsite\Excel\Concerns\WithHeadings;
|
||||||
|
|
||||||
|
class InspectionsExport implements FromCollection, WithHeadings
|
||||||
|
{
|
||||||
|
public function collection()
|
||||||
|
{
|
||||||
|
return Inspection::select([
|
||||||
|
'id',
|
||||||
|
'project_id',
|
||||||
|
'feature_id',
|
||||||
|
'template_id',
|
||||||
|
'status',
|
||||||
|
'notes',
|
||||||
|
'created_at',
|
||||||
|
'updated_at'
|
||||||
|
])->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function headings(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'ID',
|
||||||
|
'ID Proyecto',
|
||||||
|
'ID Característica',
|
||||||
|
'ID Plantilla',
|
||||||
|
'Estado',
|
||||||
|
'Notas',
|
||||||
|
'Creado el',
|
||||||
|
'Actualizado el'
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Exports;
|
||||||
|
|
||||||
|
use App\Models\Phase;
|
||||||
|
use Maatwebsite\Excel\Concerns\FromCollection;
|
||||||
|
use Maatwebsite\Excel\Concerns\WithHeadings;
|
||||||
|
|
||||||
|
class PhasesExport implements FromCollection, WithHeadings
|
||||||
|
{
|
||||||
|
public function collection()
|
||||||
|
{
|
||||||
|
return Phase::select([
|
||||||
|
'id',
|
||||||
|
'project_id',
|
||||||
|
'name',
|
||||||
|
'progress_percent',
|
||||||
|
'start_date',
|
||||||
|
'end_date',
|
||||||
|
'created_at',
|
||||||
|
'updated_at'
|
||||||
|
])->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function headings(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'ID',
|
||||||
|
'ID Proyecto',
|
||||||
|
'Nombre',
|
||||||
|
'Progreso (%)',
|
||||||
|
'Fecha de inicio',
|
||||||
|
'Fecha de fin',
|
||||||
|
'Creado el',
|
||||||
|
'Actualizado el'
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Exports;
|
||||||
|
|
||||||
|
use App\Models\Project;
|
||||||
|
use Maatwebsite\Excel\Concerns\FromCollection;
|
||||||
|
use Maatwebsite\Excel\Concerns\WithHeadings;
|
||||||
|
|
||||||
|
class ProjectsExport implements FromCollection, WithHeadings
|
||||||
|
{
|
||||||
|
public function collection()
|
||||||
|
{
|
||||||
|
return Project::select([
|
||||||
|
'id',
|
||||||
|
'name',
|
||||||
|
'description',
|
||||||
|
'start_date',
|
||||||
|
'end_date',
|
||||||
|
'status',
|
||||||
|
'created_at',
|
||||||
|
'updated_at'
|
||||||
|
])->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function headings(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'ID',
|
||||||
|
'Nombre',
|
||||||
|
'Descripción',
|
||||||
|
'Fecha de inicio',
|
||||||
|
'Fecha de fin',
|
||||||
|
'Estado',
|
||||||
|
'Creado el',
|
||||||
|
'Actualizado el'
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,106 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Feature;
|
||||||
|
use App\Models\Issue;
|
||||||
|
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,
|
||||||
|
'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,
|
||||||
|
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,196 @@
|
|||||||
|
<?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\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();
|
||||||
|
|
||||||
|
$featureIds = Feature::whereIn('layer_id', $allLayerIds)->pluck('id');
|
||||||
|
$issueIds = Issue::where('project_id', $project->id)->pluck('id');
|
||||||
|
$media = $changed(Media::where(function ($q) use ($project, $featureIds, $issueIds) {
|
||||||
|
$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));
|
||||||
|
}))->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(),
|
||||||
|
'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) : (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): 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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
'reported_by' => $i->reported_by, 'assigned_to' => $i->assigned_to,
|
||||||
|
'resolved_at' => $i->resolved_at?->toIso8601String(), 'updated_at' => $i->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',
|
||||||
|
][$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,331 @@
|
|||||||
|
<?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\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),
|
||||||
|
'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)],
|
||||||
|
]);
|
||||||
|
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',
|
||||||
|
'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)],
|
||||||
|
'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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,15 +4,19 @@ namespace App\Http\Controllers;
|
|||||||
|
|
||||||
use App\Models\PendingSync;
|
use App\Models\PendingSync;
|
||||||
use App\Models\Phase;
|
use App\Models\Phase;
|
||||||
|
use App\Models\Inspection;
|
||||||
|
use App\Models\Feature;
|
||||||
|
use App\Models\Media;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
class OfflineSyncController extends Controller
|
class OfflineSyncController extends Controller
|
||||||
{
|
{
|
||||||
public function storePending(Request $request)
|
public function storePending(Request $request)
|
||||||
{
|
{
|
||||||
$payload = $request->validate([
|
$payload = $request->validate([
|
||||||
'action' => 'required|in:progress_update,task_complete',
|
'action' => 'required|in:progress_update,inspection,feature_create,media_upload,task_complete',
|
||||||
'payload' => 'required|array',
|
'payload' => 'required|array',
|
||||||
]);
|
]);
|
||||||
$pending = PendingSync::create([
|
$pending = PendingSync::create([
|
||||||
@@ -27,23 +31,78 @@ class OfflineSyncController extends Controller
|
|||||||
{
|
{
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
$pendings = PendingSync::where('user_id', $user->id)->whereNull('synced_at')->get();
|
$pendings = PendingSync::where('user_id', $user->id)->whereNull('synced_at')->get();
|
||||||
|
$results = [];
|
||||||
foreach ($pendings as $pending) {
|
foreach ($pendings as $pending) {
|
||||||
if ($pending->action === 'progress_update') {
|
$result = ['id' => $pending->id, 'action' => $pending->action, 'success' => false, 'error' => null];
|
||||||
$phase = Phase::find($pending->payload['phase_id']);
|
try {
|
||||||
if ($phase) {
|
if ($pending->action === 'progress_update') {
|
||||||
$phase->progress_percent = $pending->payload['progress'];
|
$phase = Phase::find($pending->payload['phase_id']);
|
||||||
$phase->save();
|
if ($phase) {
|
||||||
$phase->progressUpdates()->create([
|
$phase->progress_percent = $pending->payload['progress'];
|
||||||
'user_id' => $user->id,
|
$phase->save();
|
||||||
'progress_percent' => $pending->payload['progress'],
|
$phase->progressUpdates()->create([
|
||||||
'comment' => $pending->payload['comment'] ?? '',
|
'user_id' => $user->id,
|
||||||
'location' => $pending->payload['location'] ?? null,
|
'progress_percent' => $pending->payload['progress'],
|
||||||
]);
|
'comment' => $pending->payload['comment'] ?? '',
|
||||||
|
'location' => $pending->payload['location'] ?? null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
$result['success'] = true;
|
||||||
|
} elseif ($pending->action === 'inspection') {
|
||||||
|
$inspection = Inspection::create($pending->payload);
|
||||||
|
$result['success'] = true;
|
||||||
|
$result['data'] = ['inspection_id' => $inspection->id];
|
||||||
|
} elseif ($pending->action === 'feature_create') {
|
||||||
|
$feature = Feature::create($pending->payload);
|
||||||
|
$result['success'] = true;
|
||||||
|
$result['data'] = ['feature_id' => $feature->id];
|
||||||
|
} elseif ($pending->action === 'media_upload') {
|
||||||
|
// Assuming payload has: 'file' (base64), 'path', 'model_type', 'model_id'
|
||||||
|
// We'll decode the base64 and store the file
|
||||||
|
if (isset($pending->payload['file'], $pending->payload['path'])) {
|
||||||
|
$decoded = base64_decode($pending->payload['file']);
|
||||||
|
if ($decoded !== false) {
|
||||||
|
$path = Storage::put($pending->payload['path'], $decoded);
|
||||||
|
// Attach to model if model_type and model_id are provided
|
||||||
|
if (isset($pending->payload['model_type'], $pending->payload['model_id'])) {
|
||||||
|
$model = new $pending->payload['model_type'];
|
||||||
|
$model = $model->find($pending->payload['model_id']);
|
||||||
|
if ($model) {
|
||||||
|
$model->media()->create([
|
||||||
|
'name' => $pending->payload['name'] ?? 'unnamed',
|
||||||
|
'path' => $path,
|
||||||
|
'mime_type' => $pending->payload['mime_type'] ?? 'application/octet-stream',
|
||||||
|
'disk' => 'public',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$result['success'] = true;
|
||||||
|
$result['data'] = ['path' => $path];
|
||||||
|
} else {
|
||||||
|
$result['error'] = 'Failed to decode base64 file';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$result['error'] = 'Missing file or path in payload';
|
||||||
|
}
|
||||||
|
} elseif ($pending->action === 'task_complete') {
|
||||||
|
// Example: mark a task as complete (you can adjust as needed)
|
||||||
|
// For now, just log and mark as success
|
||||||
|
\Log::info('Task completed offline', $pending->payload);
|
||||||
|
$result['success'] = true;
|
||||||
|
} else {
|
||||||
|
$result['error'] = 'Unknown action type';
|
||||||
}
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$result['error'] = $e->getMessage();
|
||||||
}
|
}
|
||||||
$pending->synced_at = now();
|
|
||||||
$pending->save();
|
if ($result['success']) {
|
||||||
|
$pending->synced_at = now();
|
||||||
|
$pending->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
$results[] = $result;
|
||||||
}
|
}
|
||||||
return response()->json(['synced' => count($pendings)]);
|
return response()->json(['synced' => $results]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Reports;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\Phase;
|
||||||
|
use App\Models\Inspection;
|
||||||
|
use Maatwebsite\Excel\Facades\Excel;
|
||||||
|
use App\Exports\ProjectsExport;
|
||||||
|
use App\Exports\PhasesExport;
|
||||||
|
use App\Exports\InspectionsExport;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class ExportController extends Controller
|
||||||
|
{
|
||||||
|
public function exportProjects(Request $request)
|
||||||
|
{
|
||||||
|
return Excel::download(new ProjectsExport, 'projects.xlsx');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function exportPhases(Request $request)
|
||||||
|
{
|
||||||
|
return Excel::download(new PhasesExport, 'phases.xlsx');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function exportInspections(Request $request)
|
||||||
|
{
|
||||||
|
return Excel::download(new InspectionsExport, 'inspections.xlsx');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,174 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Client;
|
||||||
|
|
||||||
|
use Livewire\Component;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\Phase;
|
||||||
|
use App\Models\Inspection;
|
||||||
|
use App\Models\Feature;
|
||||||
|
use App\Models\ChangeOrder;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
|
||||||
|
class ClientProjects extends Component
|
||||||
|
{
|
||||||
|
public $projects = [];
|
||||||
|
public $selectedProject = null;
|
||||||
|
public $projectDetails = [];
|
||||||
|
public $galleryImages = [];
|
||||||
|
public $changeOrders = [];
|
||||||
|
|
||||||
|
public function mount()
|
||||||
|
{
|
||||||
|
$this->loadProjects();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function loadProjects()
|
||||||
|
{
|
||||||
|
// Get projects where the user has the 'client' role
|
||||||
|
$user = auth()->user();
|
||||||
|
$this->projects = $user->projects()
|
||||||
|
->wherePivot('role_in_project', 'client')
|
||||||
|
->with(['phases' => function($query) {
|
||||||
|
$query->select('id', 'project_id', 'name', 'progress_percent');
|
||||||
|
}])
|
||||||
|
->get()
|
||||||
|
->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function selectProject($projectId)
|
||||||
|
{
|
||||||
|
$this->selectedProject = $projectId;
|
||||||
|
$this->loadProjectDetails();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function loadProjectDetails()
|
||||||
|
{
|
||||||
|
if (!$this->selectedProject) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$project = Project::with([
|
||||||
|
'phases.features',
|
||||||
|
'inspections.template',
|
||||||
|
'changeOrders' // Load change orders for this project
|
||||||
|
])->find($this->selectedProject);
|
||||||
|
|
||||||
|
if (!$project) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->projectDetails = [
|
||||||
|
'id' => $project->id,
|
||||||
|
'name' => $project->name,
|
||||||
|
'description' => $project->description,
|
||||||
|
'start_date' => $project->start_date,
|
||||||
|
'end_date' => $project->end_date,
|
||||||
|
'status' => $project->status,
|
||||||
|
'progress' => $project->phases->avg('progress_percent') ?? 0,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Get recent images (we can fetch from media table if needed, but for now we'll keep simulated or link to real)
|
||||||
|
// For simplicity, we'll try to get some media images for the project
|
||||||
|
$mediaImages = $project->media()
|
||||||
|
->where('category', 'image')
|
||||||
|
->latest()
|
||||||
|
->take(3)
|
||||||
|
->get()
|
||||||
|
->map(function($media) {
|
||||||
|
return [
|
||||||
|
'url' => $media->url,
|
||||||
|
'title' => $media->name,
|
||||||
|
'date' => $media->created_at->format('d/m/Y')
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
// If we don't have 3 images, we can fallback to placeholders or just use what we have
|
||||||
|
if (count($mediaImages) > 0) {
|
||||||
|
$this->galleryImages = $mediaImages;
|
||||||
|
} else {
|
||||||
|
// Fallback to placeholders
|
||||||
|
$this->galleryImages = [
|
||||||
|
[
|
||||||
|
'url' => 'https://via.placeholder.com/400x300?text=Avance+1',
|
||||||
|
'title' => 'Avance inicial',
|
||||||
|
'date' => now()->subDays(30)->format('d/m/Y')
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'url' => 'https://via.placeholder.com/400x300?text=Avance+2',
|
||||||
|
'title' => 'Estructura levantada',
|
||||||
|
'date' => now()->subDays(15)->format('d/m/Y')
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'url' => 'https://via.placeholder.com/400x300?text=Avance+3',
|
||||||
|
'title' => 'Instalaciones',
|
||||||
|
'date' => now()->subDays(5)->format('d/m/Y')
|
||||||
|
]
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get change orders for this project
|
||||||
|
$this->changeOrders = $project->changeOrders
|
||||||
|
->orderBy('requested_at', 'desc')
|
||||||
|
->get()
|
||||||
|
->map(function($order) {
|
||||||
|
return [
|
||||||
|
'id' => $order->id,
|
||||||
|
'title' => $order->title,
|
||||||
|
'description' => $order->description,
|
||||||
|
'status' => $order->status,
|
||||||
|
'requested_at' => $order->requested_at->format('d/m/Y'),
|
||||||
|
'amount' => $order->amount
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function approveChangeOrder($orderId)
|
||||||
|
{
|
||||||
|
// Update the change order in the database
|
||||||
|
$changeOrder = ChangeOrder::find($orderId);
|
||||||
|
if ($changeOrder) {
|
||||||
|
// Check that the change order belongs to the selected project (security)
|
||||||
|
if ($changeOrder->project_id == $this->selectedProject) {
|
||||||
|
$changeOrder->status = 'approved';
|
||||||
|
$changeOrder->responded_at = now()->toDateString();
|
||||||
|
$changeOrder->responded_by = auth()->id();
|
||||||
|
$changeOrder->save();
|
||||||
|
|
||||||
|
// Refresh the change orders list
|
||||||
|
$this->loadProjectDetails();
|
||||||
|
|
||||||
|
// Notify any listeners (optional)
|
||||||
|
$this->dispatch('changeOrderUpdated', ['id' => $orderId, 'status' => 'approved']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rejectChangeOrder($orderId)
|
||||||
|
{
|
||||||
|
// Update the change order in the database
|
||||||
|
$changeOrder = ChangeOrder::find($orderId);
|
||||||
|
if ($changeOrder) {
|
||||||
|
// Check that the change order belongs to the selected project (security)
|
||||||
|
if ($changeOrder->project_id == $this->selectedProject) {
|
||||||
|
$changeOrder->status = 'rejected';
|
||||||
|
$changeOrder->responded_at = now()->toDateString();
|
||||||
|
$changeOrder->responded_by = auth()->id();
|
||||||
|
$changeOrder->save();
|
||||||
|
|
||||||
|
// Refresh the change orders list
|
||||||
|
$this->loadProjectDetails();
|
||||||
|
|
||||||
|
// Notify any listeners (optional)
|
||||||
|
$this->dispatch('changeOrderUpdated', ['id' => $orderId, 'status' => 'rejected']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
return view('livewire.client.client-projects');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use Livewire\Component;
|
||||||
|
use Livewire\Attributes\Layout;
|
||||||
|
use App\Models\Company;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
#[Layout('layouts.app')]
|
||||||
|
class CompanyManagement extends Component
|
||||||
|
{
|
||||||
|
public string $search = '';
|
||||||
|
public string $filterType = '';
|
||||||
|
public string $filterEstado = '';
|
||||||
|
|
||||||
|
public function getCompaniesProperty()
|
||||||
|
{
|
||||||
|
return Company::when($this->search, function ($q) {
|
||||||
|
$s = '%' . $this->search . '%';
|
||||||
|
$q->where(fn($q2) => $q2
|
||||||
|
->where('name', 'like', $s)
|
||||||
|
->orWhere('apodo', 'like', $s)
|
||||||
|
->orWhere('tax_id', 'like', $s));
|
||||||
|
})
|
||||||
|
->when($this->filterType, fn($q) => $q->where('type', $this->filterType))
|
||||||
|
->when($this->filterEstado, fn($q) => $q->where('estado', $this->filterEstado))
|
||||||
|
->withCount('projects')
|
||||||
|
->orderBy('name')
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteCompany(Company $company): void
|
||||||
|
{
|
||||||
|
if ($company->logo_path) {
|
||||||
|
Storage::disk('public')->delete($company->logo_path);
|
||||||
|
}
|
||||||
|
$company->delete();
|
||||||
|
$this->dispatch('notify', 'Empresa eliminada.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function exportCsv()
|
||||||
|
{
|
||||||
|
$companies = $this->getCompaniesProperty();
|
||||||
|
|
||||||
|
return response()->streamDownload(function () use ($companies) {
|
||||||
|
$handle = fopen('php://output', 'w');
|
||||||
|
fprintf($handle, chr(0xEF) . chr(0xBB) . chr(0xBF));
|
||||||
|
fputcsv($handle, ['Nombre', 'Apodo', 'NIF/Tax ID', 'Tipo', 'Estado', 'Dirección', 'Teléfono', 'Email', 'Website', 'Proyectos', 'Creación']);
|
||||||
|
foreach ($companies as $c) {
|
||||||
|
fputcsv($handle, [
|
||||||
|
$c->name, $c->apodo ?? '', $c->tax_id ?? '',
|
||||||
|
$c->type, $c->estado, $c->address ?? '',
|
||||||
|
$c->phone ?? '', $c->email ?? '', $c->website ?? '',
|
||||||
|
$c->projects_count ?? 0,
|
||||||
|
$c->created_at?->format('d/m/Y'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
fclose($handle);
|
||||||
|
}, 'empresas.csv', ['Content-Type' => 'text/csv; charset=UTF-8']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
return view('livewire.company-management');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,191 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use Livewire\Component;
|
||||||
|
use Livewire\Attributes\Layout;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\Issue;
|
||||||
|
use App\Notifications\IssueReportedNotification;
|
||||||
|
|
||||||
|
#[Layout('layouts.app')]
|
||||||
|
class IssueManager extends Component
|
||||||
|
{
|
||||||
|
public Project $project;
|
||||||
|
|
||||||
|
public $issues = [];
|
||||||
|
public $projectUsers = [];
|
||||||
|
|
||||||
|
// Form / modal state
|
||||||
|
public $showForm = false;
|
||||||
|
public $editingIssue = null; // issue id when editing, null when creating
|
||||||
|
|
||||||
|
// Form fields
|
||||||
|
public $title = '';
|
||||||
|
public $description = '';
|
||||||
|
public $status = 'open';
|
||||||
|
public $priority = 'medium';
|
||||||
|
public $assignedTo = '';
|
||||||
|
public $resolutionNotes = '';
|
||||||
|
|
||||||
|
// Optional context (e.g. when reporting from a map feature)
|
||||||
|
public $featureId = null;
|
||||||
|
public $inspectionId = null;
|
||||||
|
|
||||||
|
public function mount(Project $project)
|
||||||
|
{
|
||||||
|
$this->project = $project;
|
||||||
|
$this->loadProjectUsers();
|
||||||
|
$this->loadIssues();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function loadIssues()
|
||||||
|
{
|
||||||
|
$this->issues = Issue::where('project_id', $this->project->id)
|
||||||
|
->with(['feature', 'reporter', 'assignee'])
|
||||||
|
->orderBy('created_at', 'desc')
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function loadProjectUsers()
|
||||||
|
{
|
||||||
|
$this->projectUsers = $this->project->users()->orderBy('name')->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
'assignedTo' => 'nullable|exists:users,id',
|
||||||
|
'resolutionNotes' => 'nullable|string',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function openForm($issueId = null)
|
||||||
|
{
|
||||||
|
$this->resetForm();
|
||||||
|
|
||||||
|
if ($issueId) {
|
||||||
|
$issue = Issue::where('project_id', $this->project->id)->findOrFail($issueId);
|
||||||
|
$this->editingIssue = $issue->id;
|
||||||
|
$this->title = $issue->title;
|
||||||
|
$this->description = $issue->description ?? '';
|
||||||
|
$this->status = $issue->status;
|
||||||
|
$this->priority = $issue->priority;
|
||||||
|
$this->assignedTo = $issue->assigned_to ?? '';
|
||||||
|
$this->resolutionNotes = $issue->resolution_notes ?? '';
|
||||||
|
$this->featureId = $issue->feature_id;
|
||||||
|
$this->inspectionId = $issue->inspection_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->showForm = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function closeForm()
|
||||||
|
{
|
||||||
|
$this->showForm = false;
|
||||||
|
$this->resetForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resetForm(): void
|
||||||
|
{
|
||||||
|
$this->reset([
|
||||||
|
'title', 'description', 'assignedTo', 'resolutionNotes',
|
||||||
|
'featureId', 'inspectionId', 'editingIssue',
|
||||||
|
]);
|
||||||
|
$this->status = 'open';
|
||||||
|
$this->priority = 'medium';
|
||||||
|
$this->resetErrorBag();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save()
|
||||||
|
{
|
||||||
|
$this->validate();
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'title' => $this->title,
|
||||||
|
'description' => $this->description,
|
||||||
|
'status' => $this->status,
|
||||||
|
'priority' => $this->priority,
|
||||||
|
'feature_id' => $this->featureId,
|
||||||
|
'inspection_id' => $this->inspectionId,
|
||||||
|
'assigned_to' => $this->assignedTo ?: null,
|
||||||
|
'resolution_notes' => $this->resolutionNotes ?: null,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Keep resolved_at in sync with the status
|
||||||
|
if (in_array($this->status, ['resolved', 'closed'])) {
|
||||||
|
$payload['resolved_at'] = now();
|
||||||
|
} else {
|
||||||
|
$payload['resolved_at'] = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->editingIssue) {
|
||||||
|
$issue = Issue::where('project_id', $this->project->id)->findOrFail($this->editingIssue);
|
||||||
|
// Don't overwrite an existing resolved date if it was already resolved
|
||||||
|
if ($issue->resolved_at && in_array($this->status, ['resolved', 'closed'])) {
|
||||||
|
unset($payload['resolved_at']);
|
||||||
|
}
|
||||||
|
$issue->update($payload);
|
||||||
|
} else {
|
||||||
|
$issue = Issue::create(array_merge($payload, [
|
||||||
|
'project_id' => $this->project->id,
|
||||||
|
'reported_by' => Auth::id(),
|
||||||
|
]));
|
||||||
|
|
||||||
|
if ($issue->wasRecentlyCreated) {
|
||||||
|
$issue->load(['feature', 'assignee']);
|
||||||
|
$creator = $this->project->creator;
|
||||||
|
if ($creator && $creator->id !== Auth::id()) {
|
||||||
|
$creator->notify(new IssueReportedNotification($issue));
|
||||||
|
}
|
||||||
|
if ($issue->assignee && $issue->assignee->id !== Auth::id()) {
|
||||||
|
$issue->assignee->notify(new IssueReportedNotification($issue));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->closeForm();
|
||||||
|
$this->loadIssues();
|
||||||
|
$this->dispatch('notify', 'Issue guardado correctamente');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resolve($issueId)
|
||||||
|
{
|
||||||
|
$issue = Issue::where('project_id', $this->project->id)->findOrFail($issueId);
|
||||||
|
$issue->update([
|
||||||
|
'status' => 'resolved',
|
||||||
|
'resolved_at' => $issue->resolved_at ?? now(),
|
||||||
|
]);
|
||||||
|
$this->loadIssues();
|
||||||
|
$this->dispatch('notify', 'Issue marcado como resuelto');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function close($issueId)
|
||||||
|
{
|
||||||
|
$issue = Issue::where('project_id', $this->project->id)->findOrFail($issueId);
|
||||||
|
$issue->update([
|
||||||
|
'status' => 'closed',
|
||||||
|
'resolved_at' => $issue->resolved_at ?? now(),
|
||||||
|
]);
|
||||||
|
$this->loadIssues();
|
||||||
|
$this->dispatch('notify', 'Issue cerrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete($issueId)
|
||||||
|
{
|
||||||
|
$issue = Issue::where('project_id', $this->project->id)->findOrFail($issueId);
|
||||||
|
$issue->delete();
|
||||||
|
$this->loadIssues();
|
||||||
|
$this->dispatch('notify', 'Issue eliminado');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
return view('livewire.issues.issue-manager');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ class ProjectCompanies extends Component
|
|||||||
public function assignCompany()
|
public function assignCompany()
|
||||||
{
|
{
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
if (!$user->can('assign users') && !$user->hasRole('Admin')) {
|
if (!$user->can('assign users')) {
|
||||||
session()->flash('error', 'No tienes permisos para asignar compañías.');
|
session()->flash('error', 'No tienes permisos para asignar compañías.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -53,7 +53,7 @@ class ProjectCompanies extends Component
|
|||||||
public function removeCompany($companyId)
|
public function removeCompany($companyId)
|
||||||
{
|
{
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
if (!$user->can('assign users') && !$user->hasRole('Admin')) {
|
if (!$user->can('assign users')) {
|
||||||
session()->flash('error', 'Sin permisos.');
|
session()->flash('error', 'Sin permisos.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,11 +3,139 @@
|
|||||||
namespace App\Livewire;
|
namespace App\Livewire;
|
||||||
|
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
use Livewire\Attributes\Layout;
|
||||||
|
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 ?Project $project = null;
|
||||||
|
|
||||||
|
// Identification
|
||||||
|
public string $name = '';
|
||||||
|
public string $reference = '';
|
||||||
|
public string $status = 'planning';
|
||||||
|
|
||||||
|
// 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 ($project && $project->exists) {
|
||||||
|
Gate::authorize('edit projects', $project);
|
||||||
|
$this->project = $project;
|
||||||
|
$this->name = $project->name;
|
||||||
|
$this->reference = $project->reference ?? '';
|
||||||
|
$this->status = $project->status;
|
||||||
|
$this->address = $project->address;
|
||||||
|
$this->country = $project->country ?? '';
|
||||||
|
$this->lat = (string) ($project->lat ?? '');
|
||||||
|
$this->lng = (string) ($project->lng ?? '');
|
||||||
|
$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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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->lng = $lng;
|
||||||
|
if ($address) $this->address = $address;
|
||||||
|
if ($country) $this->country = strtolower($country);
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
$data = [
|
||||||
|
'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 {
|
||||||
|
$project = Project::create(array_merge($data, ['created_by' => Auth::id()]));
|
||||||
|
$project->users()->attach(Auth::id(), ['role_in_project' => 'supervisor']);
|
||||||
|
session()->flash('notify', 'Proyecto creado correctamente.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->redirect(route('projects.index'), navigate: true);
|
||||||
|
}
|
||||||
|
|
||||||
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,6 +4,8 @@ namespace App\Livewire;
|
|||||||
|
|
||||||
use Rappasoft\LaravelLivewireTables\DataTableComponent;
|
use Rappasoft\LaravelLivewireTables\DataTableComponent;
|
||||||
use Rappasoft\LaravelLivewireTables\Views\Column;
|
use Rappasoft\LaravelLivewireTables\Views\Column;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
|
|
||||||
class ProjectTable extends DataTableComponent
|
class ProjectTable extends DataTableComponent
|
||||||
@@ -14,53 +16,107 @@ 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)
|
||||||
->setThAttributes(['class' => 'px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider'])
|
->setAdditionalSelects(['projects.id as id', 'projects.created_at as created_at']);
|
||||||
->setTdAttributes(['class' => 'px-4 py-2 whitespace-nowrap text-sm text-gray-900']);
|
}
|
||||||
|
|
||||||
|
public function builder(): Builder
|
||||||
|
{
|
||||||
|
return Project::accessibleBy(Auth::user())
|
||||||
|
->with('phases');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function columns(): array
|
public function columns(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Column::make(__('Project Name'), 'name')
|
Column::make('Referencia', 'reference')
|
||||||
|
->sortable()
|
||||||
|
->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(__('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()
|
||||||
->filterable([
|
->format(function ($value) {
|
||||||
'planning' => __('Planning'),
|
$map = [
|
||||||
'in_progress' => __('In progress'),
|
'planning' => ['badge-ghost', 'Planificación'],
|
||||||
'paused' => __('Paused'),
|
'in_progress' => ['badge-primary', 'En progreso'],
|
||||||
'completed' => __('Completed'),
|
'paused' => ['badge-warning', 'Pausado'],
|
||||||
])
|
'completed' => ['badge-success', 'Completado'],
|
||||||
->label(fn ($value, $row, $column) =>
|
];
|
||||||
match ($value) {
|
[$cls, $label] = $map[$value] ?? ['badge-ghost', ucfirst($value)];
|
||||||
'planning' => '<span class="badge badge-primary">'.__('Planning').'</span>',
|
return '<span class="badge '.$cls.'">'.$label.'</span>';
|
||||||
'in_progress' => '<span class="badge badge-success">'.__('In progress').'</span>',
|
})
|
||||||
'paused' => '<span class="badge badge-warning">'.__('Paused').'</span>',
|
->html(),
|
||||||
'completed' => '<span class="badge badge-secondary">'.__('Completed').'</span>',
|
|
||||||
default => $value
|
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(fn ($row) => '<div class="flex space-x-2">
|
->label(function ($row) {
|
||||||
<a href="'.route('projects.edit', $row->id).'" class="btn btn-sm btn-primary">'.__('Edit').'</a>
|
$dashboard = route('projects.dashboard', $row->id);
|
||||||
<form action="'.route('projects.destroy', $row->id).'" method="POST" onsubmit="return confirm(__('Are you sure you want to delete this project?'));">
|
$map = route('projects.map', $row->id);
|
||||||
'.csrf_field().'
|
$edit = route('projects.edit', $row->id);
|
||||||
<input type="hidden" name="_method" value="DELETE">
|
|
||||||
<button type="submit" class="btn btn-sm btn-error">'.__('Delete').'</button>
|
$canEdit = Auth::user()->can('edit projects');
|
||||||
</form>
|
|
||||||
</div>')
|
$html = '<div class="flex items-center gap-1">';
|
||||||
->htmlAttribute(['class' => 'text-right']),
|
$html .= '<a href="'.$dashboard.'" class="btn btn-xs btn-outline" title="Dashboard" 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="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>
|
||||||
|
</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>
|
||||||
|
</a>';
|
||||||
|
if ($canEdit) {
|
||||||
|
$html .= '<a href="'.$edit.'" class="btn btn-xs btn-warning" 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 .= '</div>';
|
||||||
|
return $html;
|
||||||
|
})
|
||||||
|
->html(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function filters(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Livewire;
|
|
||||||
|
|
||||||
use Rappasoft\LaravelLivewireTables\DataTableComponent;
|
|
||||||
use Rappasoft\LaravelLivewireTables\Views\Column;
|
|
||||||
use App\Models\Project;
|
|
||||||
|
|
||||||
class ProjectTable extends DataTableComponent
|
|
||||||
{
|
|
||||||
protected $model = Project::class;
|
|
||||||
|
|
||||||
public function configure(): void
|
|
||||||
{
|
|
||||||
$this->setPrimaryKey('id')
|
|
||||||
->setDefaultSort('created_at', 'desc')
|
|
||||||
->setTableAttributes(['class' => 'table-auto w-full'])
|
|
||||||
->setThAttributes(['class' => 'px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider'])
|
|
||||||
->setTdAttributes(['class' => 'px-4 py-2 whitespace-nowrap text-sm text-gray-900']);
|
|
||||||
|
|
||||||
$this->addColumn('name', __('Project Name'))
|
|
||||||
->setSortable()
|
|
||||||
->setSearchable();
|
|
||||||
|
|
||||||
$this->addColumn('address', __('Address'))
|
|
||||||
->setSortable()
|
|
||||||
->setSearchable();
|
|
||||||
|
|
||||||
$this->addColumn('status', __('Status'))
|
|
||||||
->setSortable()
|
|
||||||
->setFilterable([
|
|
||||||
'planning' => __('Planning'),
|
|
||||||
'in_progress' => __('In progress'),
|
|
||||||
'paused' => __('Paused'),
|
|
||||||
'completed' => __('Completed'),
|
|
||||||
])
|
|
||||||
->setLabel(fn ($value, $row, $column, $component) =>
|
|
||||||
match ($value) {
|
|
||||||
'planning' => '<span class="badge badge-primary">'.__('Planning').'</span>',
|
|
||||||
'in_progress' => '<span class="badge badge-success">'.__('In progress').'</span>',
|
|
||||||
'paused' => '<span class="badge badge-warning">'.__('Paused').'</span>',
|
|
||||||
'completed' => '<span class="badge badge-secondary">'.__('Completed').'</span>',
|
|
||||||
default => $value
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->addColumn('start_date', __('Start Date'))
|
|
||||||
->setSortable()
|
|
||||||
->setFormat(fn ($value, $row, $column) => $value ? $value->format('Y-m-d') : '');
|
|
||||||
|
|
||||||
$this->addColumn('end_date_estimated', __('Estimated End Date'))
|
|
||||||
->setSortable()
|
|
||||||
->setFormat(fn ($value, $row, $column) => $value ? $value->format('Y-m-d') : '');
|
|
||||||
|
|
||||||
$this->addColumn('actions', __('Actions'))
|
|
||||||
->setLabel(fn ($row) => '<div class="flex space-x-2">
|
|
||||||
<a href="'.route('projects.edit', $row->id).'" class="btn btn-sm btn-primary">'.__('Edit').'</a>
|
|
||||||
<form action="'.route('projects.destroy', $row->id).'" method="POST" onsubmit="return confirm(''.__('Are you sure you want to delete this project?').'');">
|
|
||||||
'.csrf_field().'
|
|
||||||
<input type="hidden" name="_method" value="DELETE">
|
|
||||||
<button type="submit" class="btn btn-sm btn-error">'.__('Delete').'</button>
|
|
||||||
</form>
|
|
||||||
</div>')
|
|
||||||
->setHtmlAttribute(['class' => 'text-right']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function columns(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
Column::make(__('Project Name'), 'name')
|
|
||||||
->sortable()
|
|
||||||
->searchable(),
|
|
||||||
Column::make(__('Address'), 'address')
|
|
||||||
->sortable()
|
|
||||||
->searchable(),
|
|
||||||
Column::make(__('Status'), 'status')
|
|
||||||
->sortable()
|
|
||||||
->filterable([
|
|
||||||
'planning' => __('Planning'),
|
|
||||||
'in_progress' => __('In progress'),
|
|
||||||
'paused' => __('Paused'),
|
|
||||||
'completed' => __('Completed'),
|
|
||||||
])
|
|
||||||
->label(fn ($value, $row, $column) =>
|
|
||||||
match ($value) {
|
|
||||||
'planning' => '<span class="badge badge-primary">'.__('Planning').'</span>',
|
|
||||||
'in_progress' => '<span class="badge badge-success">'.__('In progress').'</span>',
|
|
||||||
'paused' => '<span class="badge badge-warning">'.__('Paused').'</span>',
|
|
||||||
'completed' => '<span class="badge badge-secondary">'.__('Completed').'</span>',
|
|
||||||
default => $value
|
|
||||||
}
|
|
||||||
),
|
|
||||||
Column::make(__('Start Date'), 'start_date')
|
|
||||||
->sortable()
|
|
||||||
->format(fn ($value, $row, $column) => $value ? $value->format('Y-m-d') : ''),
|
|
||||||
Column::make(__('Estimated End Date'), 'end_date_estimated')
|
|
||||||
->sortable()
|
|
||||||
->format(fn ($value, $row, $column) => $value ? $value->format('Y-m-d') : ''),
|
|
||||||
Column::make(__('Actions'))
|
|
||||||
->label(fn ($row) => '<div class="flex space-x-2">
|
|
||||||
<a href="'.route('projects.edit', $row->id).'" class="btn btn-sm btn-primary">'.__('Edit').'</a>
|
|
||||||
<form action="'.route('projects.destroy', $row->id).'" method="POST" onsubmit="return confirm(''.__('Are you sure you want to delete this project?').'');">
|
|
||||||
'.csrf_field().'
|
|
||||||
<input type="hidden" name="_method" value="DELETE">
|
|
||||||
<button type="submit" class="btn btn-sm btn-error">'.__('Delete').'</button>
|
|
||||||
</form>
|
|
||||||
</div>')
|
|
||||||
->htmlAttribute(['class' => 'text-right']),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -31,7 +31,7 @@ class ProjectUsers extends Component
|
|||||||
public function assignUser()
|
public function assignUser()
|
||||||
{
|
{
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
if (!$user->can('assign users') && !$user->hasRole('Admin')) {
|
if (!$user->can('assign users')) {
|
||||||
session()->flash('error', 'No tienes permisos para asignar usuarios.');
|
session()->flash('error', 'No tienes permisos para asignar usuarios.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -53,7 +53,7 @@ class ProjectUsers extends Component
|
|||||||
public function removeUser($userId)
|
public function removeUser($userId)
|
||||||
{
|
{
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
if (!$user->can('assign users') && !$user->hasRole('Admin')) {
|
if (!$user->can('assign users')) {
|
||||||
session()->flash('error', 'Sin permisos.');
|
session()->flash('error', 'Sin permisos.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Reports;
|
||||||
|
|
||||||
|
use Livewire\Component;
|
||||||
|
use Livewire\Attributes\Layout;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\Phase;
|
||||||
|
use App\Models\Inspection;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
|
||||||
|
#[Layout('layouts.app')]
|
||||||
|
class ReportsDashboard extends Component
|
||||||
|
{
|
||||||
|
public $dateRange = 'month'; // week, month, quarter, year
|
||||||
|
public $chartData = [];
|
||||||
|
|
||||||
|
public function mount()
|
||||||
|
{
|
||||||
|
$this->loadChartData();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function loadChartData()
|
||||||
|
{
|
||||||
|
// Project progress over time (last 6 months)
|
||||||
|
$projects = Project::with(['phases' => function($query) {
|
||||||
|
$query->select('project_id', 'progress_percent', 'updated_at');
|
||||||
|
}])->get();
|
||||||
|
|
||||||
|
// Simulate monthly progress data (since we don't have historical stored)
|
||||||
|
// In a real app, we'd have a progress_history table or similar
|
||||||
|
$months = [];
|
||||||
|
$current = Carbon::now();
|
||||||
|
for ($i = 5; $i >= 0; $i--) {
|
||||||
|
$month = $current->copy()->subMonths($i);
|
||||||
|
$months[] = $month->format('M Y');
|
||||||
|
}
|
||||||
|
|
||||||
|
$projectProgress = [];
|
||||||
|
foreach ($projects as $project) {
|
||||||
|
$progressData = [];
|
||||||
|
foreach ($months as $month) {
|
||||||
|
// For demo, we'll use current progress with some variation
|
||||||
|
$avgProgress = $project->phases->avg('progress_percent') ?? 0;
|
||||||
|
// Add some random variation for demo purposes
|
||||||
|
$variation = rand(-10, 10);
|
||||||
|
$progress = max(0, min(100, $avgProgress + $variation));
|
||||||
|
$progressData[] = round($progress);
|
||||||
|
}
|
||||||
|
$projectProgress[] = [
|
||||||
|
'name' => $project->name,
|
||||||
|
'data' => $progressData
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inspections by type (last 6 months)
|
||||||
|
$inspections = Inspection::with(['template', 'feature'])
|
||||||
|
->whereDate('created_at', '>=', Carbon::now()->subMonths(6))
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$inspectionTypes = $inspections->groupBy(function($inspection) {
|
||||||
|
return $inspection->template ? $inspection->template->name : 'Sin plantilla';
|
||||||
|
})->map(function($group) {
|
||||||
|
return $group->count();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Projects by status
|
||||||
|
$projectsByStatus = Project::selectRaw('status, count(*) as count')
|
||||||
|
->groupBy('status')
|
||||||
|
->pluck('count', 'status')
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
// Average phase progress by project
|
||||||
|
$projectPhaseProgress = Project::with(['phases'])
|
||||||
|
->get()
|
||||||
|
->map(function($project) {
|
||||||
|
return [
|
||||||
|
'name' => $project->name,
|
||||||
|
'progress' => $project->phases->avg('progress_percent') ?? 0
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->chartData = [
|
||||||
|
'months' => $months,
|
||||||
|
'projectProgress' => $projectProgress,
|
||||||
|
'inspectionTypes' => [
|
||||||
|
'labels' => $inspectionTypes->keys()->toArray(),
|
||||||
|
'data' => $inspectionTypes->values()->toArray()
|
||||||
|
],
|
||||||
|
'projectsByStatus' => [
|
||||||
|
'labels' => array_map(function($status) {
|
||||||
|
return ucfirst(str_replace('_', ' ', $status));
|
||||||
|
}, array_keys($projectsByStatus)),
|
||||||
|
'data' => array_values($projectsByStatus)
|
||||||
|
],
|
||||||
|
'projectPhaseProgress' => $projectPhaseProgress
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
return view('livewire.reports.reports-dashboard');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,165 @@
|
|||||||
|
<?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 = '';
|
||||||
|
|
||||||
|
// Notas
|
||||||
|
public string $notes = '';
|
||||||
|
|
||||||
|
// Catálogos
|
||||||
|
public $roles;
|
||||||
|
public $companies;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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',
|
||||||
|
];
|
||||||
|
|
||||||
|
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',
|
||||||
|
];
|
||||||
|
|
||||||
|
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,
|
||||||
|
];
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class ChangeOrder extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'project_id',
|
||||||
|
'title',
|
||||||
|
'description',
|
||||||
|
'amount',
|
||||||
|
'status',
|
||||||
|
'requested_at',
|
||||||
|
'responded_at',
|
||||||
|
'responded_by',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'requested_at' => 'date',
|
||||||
|
'responded_at' => 'date',
|
||||||
|
'amount' => 'decimal:2',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function project(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Project::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function responder(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'responded_by');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
@@ -10,6 +11,9 @@ class Company extends Model
|
|||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
|
'apodo',
|
||||||
|
'estado',
|
||||||
|
'logo_path',
|
||||||
'name',
|
'name',
|
||||||
'tax_id',
|
'tax_id',
|
||||||
'address',
|
'address',
|
||||||
@@ -23,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,56 @@
|
|||||||
|
<?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'];
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'project_id', 'feature_id', 'inspection_id',
|
||||||
|
'title', 'description', 'status', 'priority',
|
||||||
|
'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 scopeOpen($q) { return $q->where('status', 'open'); }
|
||||||
|
public function scopeCritical($q) { return $q->where('priority', 'critical'); }
|
||||||
|
|
||||||
|
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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|||||||
+12
-5
@@ -4,20 +4,27 @@ 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;
|
||||||
|
|
||||||
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 = [
|
||||||
'start_date' => 'date',
|
"start_date" => "date",
|
||||||
'end_date_estimated' => 'date',
|
"end_date_estimated" => "date",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
public function changeOrders()
|
||||||
|
{
|
||||||
|
return $this->hasMany(ChangeOrder::class);
|
||||||
|
}
|
||||||
|
|
||||||
// Relationships
|
// Relationships
|
||||||
public function phases()
|
public function phases()
|
||||||
{
|
{
|
||||||
@@ -59,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',
|
||||||
|
];
|
||||||
|
}
|
||||||
+14
-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,10 @@ 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',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -44,9 +46,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,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})",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,10 +10,12 @@
|
|||||||
"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",
|
||||||
"livewire/volt": "^1.7.0",
|
"livewire/volt": "^1.7.0",
|
||||||
|
"maatwebsite/excel": "*",
|
||||||
"mansoor/blade-lets-icons": "^1.0",
|
"mansoor/blade-lets-icons": "^1.0",
|
||||||
"phayes/geophp": "^1.2",
|
"phayes/geophp": "^1.2",
|
||||||
"rappasoft/laravel-livewire-tables": "^3.7",
|
"rappasoft/laravel-livewire-tables": "^3.7",
|
||||||
|
|||||||
Generated
+658
-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": "afa93484318041be0823eb4914a47317",
|
"content-hash": "45553317b713050f78b4233c204790f9",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "blade-ui-kit/blade-heroicons",
|
"name": "blade-ui-kit/blade-heroicons",
|
||||||
@@ -285,6 +285,162 @@
|
|||||||
],
|
],
|
||||||
"time": "2024-02-09T16:56:22+00:00"
|
"time": "2024-02-09T16:56:22+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "composer/pcre",
|
||||||
|
"version": "3.3.2",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/composer/pcre.git",
|
||||||
|
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
|
||||||
|
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": "^7.4 || ^8.0"
|
||||||
|
},
|
||||||
|
"conflict": {
|
||||||
|
"phpstan/phpstan": "<1.11.10"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"phpstan/phpstan": "^1.12 || ^2",
|
||||||
|
"phpstan/phpstan-strict-rules": "^1 || ^2",
|
||||||
|
"phpunit/phpunit": "^8 || ^9"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"phpstan": {
|
||||||
|
"includes": [
|
||||||
|
"extension.neon"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-main": "3.x-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Composer\\Pcre\\": "src"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Jordi Boggiano",
|
||||||
|
"email": "j.boggiano@seld.be",
|
||||||
|
"homepage": "http://seld.be"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "PCRE wrapping library that offers type-safe preg_* replacements.",
|
||||||
|
"keywords": [
|
||||||
|
"PCRE",
|
||||||
|
"preg",
|
||||||
|
"regex",
|
||||||
|
"regular expression"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/composer/pcre/issues",
|
||||||
|
"source": "https://github.com/composer/pcre/tree/3.3.2"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://packagist.com",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/composer",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://tidelift.com/funding/github/packagist/composer/composer",
|
||||||
|
"type": "tidelift"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2024-11-12T16:29:46+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "composer/semver",
|
||||||
|
"version": "3.4.4",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/composer/semver.git",
|
||||||
|
"reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95",
|
||||||
|
"reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": "^5.3.2 || ^7.0 || ^8.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"phpstan/phpstan": "^1.11",
|
||||||
|
"symfony/phpunit-bridge": "^3 || ^7"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-main": "3.x-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Composer\\Semver\\": "src"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Nils Adermann",
|
||||||
|
"email": "naderman@naderman.de",
|
||||||
|
"homepage": "http://www.naderman.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Jordi Boggiano",
|
||||||
|
"email": "j.boggiano@seld.be",
|
||||||
|
"homepage": "http://seld.be"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Rob Bast",
|
||||||
|
"email": "rob.bast@gmail.com",
|
||||||
|
"homepage": "http://robbast.nl"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Semver library that offers utilities, version constraint parsing and validation.",
|
||||||
|
"keywords": [
|
||||||
|
"semantic",
|
||||||
|
"semver",
|
||||||
|
"validation",
|
||||||
|
"versioning"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"irc": "ircs://irc.libera.chat:6697/composer",
|
||||||
|
"issues": "https://github.com/composer/semver/issues",
|
||||||
|
"source": "https://github.com/composer/semver/tree/3.4.4"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://packagist.com",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/composer",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2025-08-20T19:15:30+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "dflydev/dot-access-data",
|
"name": "dflydev/dot-access-data",
|
||||||
"version": "v3.0.3",
|
"version": "v3.0.3",
|
||||||
@@ -658,6 +814,67 @@
|
|||||||
],
|
],
|
||||||
"time": "2025-03-06T22:45:56+00:00"
|
"time": "2025-03-06T22:45:56+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "ezyang/htmlpurifier",
|
||||||
|
"version": "v4.19.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/ezyang/htmlpurifier.git",
|
||||||
|
"reference": "b287d2a16aceffbf6e0295559b39662612b77fcf"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/ezyang/htmlpurifier/zipball/b287d2a16aceffbf6e0295559b39662612b77fcf",
|
||||||
|
"reference": "b287d2a16aceffbf6e0295559b39662612b77fcf",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": "~5.6.0 || ~7.0.0 || ~7.1.0 || ~7.2.0 || ~7.3.0 || ~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"cerdic/css-tidy": "^1.7 || ^2.0",
|
||||||
|
"simpletest/simpletest": "dev-master"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"cerdic/css-tidy": "If you want to use the filter 'Filter.ExtractStyleBlocks'.",
|
||||||
|
"ext-bcmath": "Used for unit conversion and imagecrash protection",
|
||||||
|
"ext-iconv": "Converts text to and from non-UTF-8 encodings",
|
||||||
|
"ext-tidy": "Used for pretty-printing HTML"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"files": [
|
||||||
|
"library/HTMLPurifier.composer.php"
|
||||||
|
],
|
||||||
|
"psr-0": {
|
||||||
|
"HTMLPurifier": "library/"
|
||||||
|
},
|
||||||
|
"exclude-from-classmap": [
|
||||||
|
"/library/HTMLPurifier/Language/"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"LGPL-2.1-or-later"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Edward Z. Yang",
|
||||||
|
"email": "admin@htmlpurifier.org",
|
||||||
|
"homepage": "http://ezyang.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Standards compliant HTML filter written in PHP",
|
||||||
|
"homepage": "http://htmlpurifier.org/",
|
||||||
|
"keywords": [
|
||||||
|
"html"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/ezyang/htmlpurifier/issues",
|
||||||
|
"source": "https://github.com/ezyang/htmlpurifier/tree/v4.19.0"
|
||||||
|
},
|
||||||
|
"time": "2025-10-17T16:34:55+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "fruitcake/php-cors",
|
"name": "fruitcake/php-cors",
|
||||||
"version": "v1.4.0",
|
"version": "v1.4.0",
|
||||||
@@ -1536,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",
|
||||||
@@ -2447,6 +2727,165 @@
|
|||||||
},
|
},
|
||||||
"time": "2026-03-18T14:16:30+00:00"
|
"time": "2026-03-18T14:16:30+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "maatwebsite/excel",
|
||||||
|
"version": "3.1.69",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/SpartnerNL/Laravel-Excel.git",
|
||||||
|
"reference": "ae5d65b7c9a2fac43bff4d44f796ac95d7a8e760"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/SpartnerNL/Laravel-Excel/zipball/ae5d65b7c9a2fac43bff4d44f796ac95d7a8e760",
|
||||||
|
"reference": "ae5d65b7c9a2fac43bff4d44f796ac95d7a8e760",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"composer/semver": "^3.3",
|
||||||
|
"ext-json": "*",
|
||||||
|
"illuminate/support": "5.8.*||^6.0||^7.0||^8.0||^9.0||^10.0||^11.0||^12.0||^13.0",
|
||||||
|
"php": "^7.0||^8.0",
|
||||||
|
"phpoffice/phpspreadsheet": "^1.30.4",
|
||||||
|
"psr/simple-cache": "^1.0||^2.0||^3.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"laravel/scout": "^7.0||^8.0||^9.0||^10.0||^11.0",
|
||||||
|
"orchestra/testbench": "^6.0||^7.0||^8.0||^9.0||^10.0||^11.0",
|
||||||
|
"predis/predis": "^1.1"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"laravel": {
|
||||||
|
"aliases": {
|
||||||
|
"Excel": "Maatwebsite\\Excel\\Facades\\Excel"
|
||||||
|
},
|
||||||
|
"providers": [
|
||||||
|
"Maatwebsite\\Excel\\ExcelServiceProvider"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Maatwebsite\\Excel\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Patrick Brouwers",
|
||||||
|
"email": "patrick@spartner.nl"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Supercharged Excel exports and imports in Laravel",
|
||||||
|
"keywords": [
|
||||||
|
"PHPExcel",
|
||||||
|
"batch",
|
||||||
|
"csv",
|
||||||
|
"excel",
|
||||||
|
"export",
|
||||||
|
"import",
|
||||||
|
"laravel",
|
||||||
|
"php",
|
||||||
|
"phpspreadsheet"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/SpartnerNL/Laravel-Excel/issues",
|
||||||
|
"source": "https://github.com/SpartnerNL/Laravel-Excel/tree/3.1.69"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://laravel-excel.com/commercial-support",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/patrickbrouwers",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2026-04-30T20:03:58+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "maennchen/zipstream-php",
|
||||||
|
"version": "3.2.2",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/maennchen/ZipStream-PHP.git",
|
||||||
|
"reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e",
|
||||||
|
"reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"ext-mbstring": "*",
|
||||||
|
"ext-zlib": "*",
|
||||||
|
"php-64bit": "^8.3"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"brianium/paratest": "^7.7",
|
||||||
|
"ext-zip": "*",
|
||||||
|
"friendsofphp/php-cs-fixer": "^3.86",
|
||||||
|
"guzzlehttp/guzzle": "^7.5",
|
||||||
|
"mikey179/vfsstream": "^1.6",
|
||||||
|
"php-coveralls/php-coveralls": "^2.5",
|
||||||
|
"phpunit/phpunit": "^12.0",
|
||||||
|
"vimeo/psalm": "^6.0"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"guzzlehttp/psr7": "^2.4",
|
||||||
|
"psr/http-message": "^2.0"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"ZipStream\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Paul Duncan",
|
||||||
|
"email": "pabs@pablotron.org"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Jonatan Männchen",
|
||||||
|
"email": "jonatan@maennchen.ch"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Jesse Donat",
|
||||||
|
"email": "donatj@gmail.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "András Kolesár",
|
||||||
|
"email": "kolesar@kolesar.hu"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.",
|
||||||
|
"keywords": [
|
||||||
|
"stream",
|
||||||
|
"zip"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/maennchen/ZipStream-PHP/issues",
|
||||||
|
"source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.2"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://github.com/maennchen",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2026-04-11T18:38:28+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "mansoor/blade-lets-icons",
|
"name": "mansoor/blade-lets-icons",
|
||||||
"version": "v1.0.2",
|
"version": "v1.0.2",
|
||||||
@@ -2511,6 +2950,113 @@
|
|||||||
],
|
],
|
||||||
"time": "2025-05-22T05:35:38+00:00"
|
"time": "2025-05-22T05:35:38+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "markbaker/complex",
|
||||||
|
"version": "3.0.2",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/MarkBaker/PHPComplex.git",
|
||||||
|
"reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/MarkBaker/PHPComplex/zipball/95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
|
||||||
|
"reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": "^7.2 || ^8.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"dealerdirect/phpcodesniffer-composer-installer": "dev-master",
|
||||||
|
"phpcompatibility/php-compatibility": "^9.3",
|
||||||
|
"phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
|
||||||
|
"squizlabs/php_codesniffer": "^3.7"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Complex\\": "classes/src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Mark Baker",
|
||||||
|
"email": "mark@lange.demon.co.uk"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "PHP Class for working with complex numbers",
|
||||||
|
"homepage": "https://github.com/MarkBaker/PHPComplex",
|
||||||
|
"keywords": [
|
||||||
|
"complex",
|
||||||
|
"mathematics"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/MarkBaker/PHPComplex/issues",
|
||||||
|
"source": "https://github.com/MarkBaker/PHPComplex/tree/3.0.2"
|
||||||
|
},
|
||||||
|
"time": "2022-12-06T16:21:08+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "markbaker/matrix",
|
||||||
|
"version": "3.0.1",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/MarkBaker/PHPMatrix.git",
|
||||||
|
"reference": "728434227fe21be27ff6d86621a1b13107a2562c"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/MarkBaker/PHPMatrix/zipball/728434227fe21be27ff6d86621a1b13107a2562c",
|
||||||
|
"reference": "728434227fe21be27ff6d86621a1b13107a2562c",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": "^7.1 || ^8.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"dealerdirect/phpcodesniffer-composer-installer": "dev-master",
|
||||||
|
"phpcompatibility/php-compatibility": "^9.3",
|
||||||
|
"phpdocumentor/phpdocumentor": "2.*",
|
||||||
|
"phploc/phploc": "^4.0",
|
||||||
|
"phpmd/phpmd": "2.*",
|
||||||
|
"phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
|
||||||
|
"sebastian/phpcpd": "^4.0",
|
||||||
|
"squizlabs/php_codesniffer": "^3.7"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Matrix\\": "classes/src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Mark Baker",
|
||||||
|
"email": "mark@demon-angel.eu"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "PHP Class for working with matrices",
|
||||||
|
"homepage": "https://github.com/MarkBaker/PHPMatrix",
|
||||||
|
"keywords": [
|
||||||
|
"mathematics",
|
||||||
|
"matrix",
|
||||||
|
"vector"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/MarkBaker/PHPMatrix/issues",
|
||||||
|
"source": "https://github.com/MarkBaker/PHPMatrix/tree/3.0.1"
|
||||||
|
},
|
||||||
|
"time": "2022-12-02T22:17:43+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "monolog/monolog",
|
"name": "monolog/monolog",
|
||||||
"version": "3.10.0",
|
"version": "3.10.0",
|
||||||
@@ -3141,6 +3687,114 @@
|
|||||||
},
|
},
|
||||||
"time": "2024-10-02T11:20:13+00:00"
|
"time": "2024-10-02T11:20:13+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "phpoffice/phpspreadsheet",
|
||||||
|
"version": "1.30.4",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/PHPOffice/PhpSpreadsheet.git",
|
||||||
|
"reference": "02970383cc12e7bf0bc0707ea6e2e8ed23a7aec9"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/02970383cc12e7bf0bc0707ea6e2e8ed23a7aec9",
|
||||||
|
"reference": "02970383cc12e7bf0bc0707ea6e2e8ed23a7aec9",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"composer/pcre": "^1||^2||^3",
|
||||||
|
"ext-ctype": "*",
|
||||||
|
"ext-dom": "*",
|
||||||
|
"ext-fileinfo": "*",
|
||||||
|
"ext-gd": "*",
|
||||||
|
"ext-iconv": "*",
|
||||||
|
"ext-libxml": "*",
|
||||||
|
"ext-mbstring": "*",
|
||||||
|
"ext-simplexml": "*",
|
||||||
|
"ext-xml": "*",
|
||||||
|
"ext-xmlreader": "*",
|
||||||
|
"ext-xmlwriter": "*",
|
||||||
|
"ext-zip": "*",
|
||||||
|
"ext-zlib": "*",
|
||||||
|
"ezyang/htmlpurifier": "^4.15",
|
||||||
|
"maennchen/zipstream-php": "^2.1 || ^3.0",
|
||||||
|
"markbaker/complex": "^3.0",
|
||||||
|
"markbaker/matrix": "^3.0",
|
||||||
|
"php": ">=7.4.0 <8.5.0",
|
||||||
|
"psr/simple-cache": "^1.0 || ^2.0 || ^3.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"dealerdirect/phpcodesniffer-composer-installer": "dev-main",
|
||||||
|
"doctrine/instantiator": "^1.5",
|
||||||
|
"dompdf/dompdf": "^1.0 || ^2.0 || ^3.0",
|
||||||
|
"friendsofphp/php-cs-fixer": "^3.2",
|
||||||
|
"mitoteam/jpgraph": "^10.3",
|
||||||
|
"mpdf/mpdf": "^8.1.1",
|
||||||
|
"phpcompatibility/php-compatibility": "^9.3",
|
||||||
|
"phpstan/phpstan": "^1.1",
|
||||||
|
"phpstan/phpstan-phpunit": "^1.0",
|
||||||
|
"phpunit/phpunit": "^8.5 || ^9.0",
|
||||||
|
"squizlabs/php_codesniffer": "^3.7",
|
||||||
|
"tecnickcom/tcpdf": "^6.5"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"dompdf/dompdf": "Option for rendering PDF with PDF Writer",
|
||||||
|
"ext-intl": "PHP Internationalization Functions",
|
||||||
|
"mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers",
|
||||||
|
"mpdf/mpdf": "Option for rendering PDF with PDF Writer",
|
||||||
|
"tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"PhpOffice\\PhpSpreadsheet\\": "src/PhpSpreadsheet"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Maarten Balliauw",
|
||||||
|
"homepage": "https://blog.maartenballiauw.be"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Mark Baker",
|
||||||
|
"homepage": "https://markbakeruk.net"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Franck Lefevre",
|
||||||
|
"homepage": "https://rootslabs.net"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Erik Tilt"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Adrien Crivelli"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Owen Leibman"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine",
|
||||||
|
"homepage": "https://github.com/PHPOffice/PhpSpreadsheet",
|
||||||
|
"keywords": [
|
||||||
|
"OpenXML",
|
||||||
|
"excel",
|
||||||
|
"gnumeric",
|
||||||
|
"ods",
|
||||||
|
"php",
|
||||||
|
"spreadsheet",
|
||||||
|
"xls",
|
||||||
|
"xlsx"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues",
|
||||||
|
"source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/1.30.4"
|
||||||
|
},
|
||||||
|
"time": "2026-04-19T06:00:39+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "phpoption/phpoption",
|
"name": "phpoption/phpoption",
|
||||||
"version": "1.9.5",
|
"version": "1.9.5",
|
||||||
@@ -9838,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,35 @@
|
|||||||
|
<?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('change_orders', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('project_id')->constrained()->onDelete('cascade');
|
||||||
|
$table->string('title');
|
||||||
|
$table->text('description');
|
||||||
|
$table->decimal('amount', 10, 2)->default(0.00);
|
||||||
|
$table->enum('status', ['pending', 'approved', 'rejected'])->default('pending');
|
||||||
|
$table->date('requested_at');
|
||||||
|
$table->date('responded_at')->nullable();
|
||||||
|
$table->foreignId('responded_by')->nullable()->constrained('users')->onDelete('set null');
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('change_orders');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('companies', function (Blueprint $table) {
|
||||||
|
$table->string('logo_path')->nullable()->after('notes');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('companies', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('logo_path');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?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('companies', function (Blueprint $table) {
|
||||||
|
$table->string('apodo')->nullable()->after('name');
|
||||||
|
$table->enum('estado', ['activo', 'inactivo', 'suspendido'])->default('activo')->after('apodo');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('companies', function (Blueprint $table) {
|
||||||
|
$table->dropColumn(['apodo', 'estado']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -25,6 +25,7 @@ class DatabaseSeeder extends Seeder
|
|||||||
|
|
||||||
$this->call([
|
$this->call([
|
||||||
RolesAndPermissionsSeeder::class,
|
RolesAndPermissionsSeeder::class,
|
||||||
|
PermissionCatalogSeeder::class,
|
||||||
ProjectExampleSeeder::class,
|
ProjectExampleSeeder::class,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
use Spatie\Permission\Models\Permission;
|
||||||
|
use Spatie\Permission\PermissionRegistrar;
|
||||||
|
|
||||||
|
class PermissionCatalogSeeder extends Seeder
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Full permission catalogue, grouped by section.
|
||||||
|
* Idempotent: updates group/description on existing permissions and
|
||||||
|
* creates the missing ones. Does NOT change role assignments.
|
||||||
|
*/
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$guard = config('auth.defaults.guard', 'web');
|
||||||
|
|
||||||
|
$catalog = [
|
||||||
|
'Proyectos' => [
|
||||||
|
'view projects' => 'Ver listado y fichas de proyectos',
|
||||||
|
'create projects' => 'Crear proyectos',
|
||||||
|
'edit projects' => 'Editar datos del proyecto',
|
||||||
|
'delete projects' => 'Eliminar proyectos',
|
||||||
|
'export projects' => 'Exportar proyectos (Excel/PDF)',
|
||||||
|
],
|
||||||
|
'Fases y progreso' => [
|
||||||
|
'view phases' => 'Ver fases del proyecto',
|
||||||
|
'manage phases' => 'Crear, editar, ordenar y eliminar fases',
|
||||||
|
'update progress' => 'Actualizar el porcentaje de progreso',
|
||||||
|
],
|
||||||
|
'Capas y elementos' => [
|
||||||
|
'view layers' => 'Ver capas y elementos en el mapa',
|
||||||
|
'upload layers' => 'Subir/importar capas',
|
||||||
|
'edit layers' => 'Editar capas y elementos',
|
||||||
|
'delete layers' => 'Eliminar capas/elementos',
|
||||||
|
],
|
||||||
|
'Inspecciones' => [
|
||||||
|
'view inspections' => 'Ver inspecciones e historial',
|
||||||
|
'create inspections' => 'Registrar inspecciones',
|
||||||
|
'delete inspections' => 'Eliminar inspecciones',
|
||||||
|
'manage templates' => 'Gestionar plantillas de inspección',
|
||||||
|
],
|
||||||
|
'Incidencias' => [
|
||||||
|
'view issues' => 'Ver incidencias',
|
||||||
|
'create issues' => 'Crear incidencias',
|
||||||
|
'edit issues' => 'Editar, resolver y cerrar incidencias',
|
||||||
|
'delete issues' => 'Eliminar incidencias',
|
||||||
|
],
|
||||||
|
'Empresas' => [
|
||||||
|
'view companies' => 'Ver empresas',
|
||||||
|
'create companies' => 'Crear empresas',
|
||||||
|
'edit companies' => 'Editar empresas',
|
||||||
|
'delete companies' => 'Eliminar empresas',
|
||||||
|
],
|
||||||
|
'Usuarios' => [
|
||||||
|
'view users' => 'Ver usuarios',
|
||||||
|
'create users' => 'Crear usuarios',
|
||||||
|
'edit users' => 'Editar usuarios',
|
||||||
|
'delete users' => 'Eliminar usuarios',
|
||||||
|
'assign users' => 'Asignar usuarios/roles a proyectos',
|
||||||
|
],
|
||||||
|
'Roles' => [
|
||||||
|
'manage roles' => 'Crear/editar/borrar roles y asignar permisos',
|
||||||
|
],
|
||||||
|
'Informes' => [
|
||||||
|
'view reports' => 'Ver panel de informes',
|
||||||
|
'export reports' => 'Exportar informes',
|
||||||
|
],
|
||||||
|
'Archivos' => [
|
||||||
|
'view media' => 'Ver archivos/galería',
|
||||||
|
'upload media' => 'Subir archivos',
|
||||||
|
'delete media' => 'Eliminar archivos',
|
||||||
|
],
|
||||||
|
'General' => [
|
||||||
|
'manage all' => 'Súper-admin: acceso total al sistema',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($catalog as $group => $permissions) {
|
||||||
|
foreach ($permissions as $name => $description) {
|
||||||
|
Permission::updateOrCreate(
|
||||||
|
['name' => $name, 'guard_name' => $guard],
|
||||||
|
['group' => $group, 'description' => $description]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
# Protocolo de sincronización móvil offline-first
|
||||||
|
|
||||||
|
> Estado: **plan aprobado** (2026-06-17). Auth decidida: **Laravel Sanctum (API tokens)**.
|
||||||
|
> Alcance de este documento: lo necesario **en la webapp** para que una app móvil
|
||||||
|
> descargue plantillas/datos, trabaje sin conexión y sincronice al recuperar red.
|
||||||
|
> No cubre la implementación de la app móvil (la consume este contrato).
|
||||||
|
|
||||||
|
## 1. Modelo general
|
||||||
|
|
||||||
|
Offline-first con **cola en el dispositivo (outbox)** + sync bidireccional:
|
||||||
|
|
||||||
|
- **PULL (descarga):** la app baja un "paquete" del proyecto (estructura + plantillas + registros) para trabajar sin red.
|
||||||
|
- **Trabajo offline:** cada cambio se guarda local con un **UUID generado en el móvil** y se encola.
|
||||||
|
- **PUSH (subida):** al volver la conexión, la app envía la cola; el servidor hace *upsert idempotente* por UUID y responde resultado por ítem.
|
||||||
|
- Sincronización **delta** por `updated_at` (solo lo cambiado desde el último sync).
|
||||||
|
|
||||||
|
## 2. Autenticación — Laravel Sanctum (decidido)
|
||||||
|
|
||||||
|
- Instalar `laravel/sanctum`. Tokens personales por dispositivo (no SPA-cookie; modo **API token**).
|
||||||
|
- Endpoints:
|
||||||
|
- `POST /api/v1/login` — `{ email, password, device_name }` → `{ token, user }`.
|
||||||
|
- `POST /api/v1/logout` — revoca el token actual.
|
||||||
|
- `GET /api/v1/me` — usuario + permisos efectivos.
|
||||||
|
- El móvil envía `Authorization: Bearer <token>`.
|
||||||
|
- Token con **abilities** (p. ej. `mobile-sync`) y **registro de dispositivo** (tabla `devices`) para revocar/caducar.
|
||||||
|
- Caducidad de token configurable + endpoint de refresco o re-login.
|
||||||
|
|
||||||
|
## 3. Cambios de esquema
|
||||||
|
|
||||||
|
Añadir a las tablas sincronizables (`features`, `inspections`, `issues`, `progress_updates`, `media`):
|
||||||
|
|
||||||
|
- `uuid` CHAR(36) único — **lo genera el móvil**; permite crear offline y *upsert* idempotente.
|
||||||
|
- `updated_at` (ya existe) — delta + last-write-wins.
|
||||||
|
- `client_updated_at` TIMESTAMP nullable — marca de tiempo del dispositivo (resolución de conflictos).
|
||||||
|
- Soft-deletes (ya existen) — se exponen como **tombstones** (ids/uuids borrados) en el PULL.
|
||||||
|
|
||||||
|
Tablas nuevas:
|
||||||
|
- `devices` (id, user_id, name, token_id, last_seen_at, …).
|
||||||
|
- `sync_logs` (auditoría: device, operación, entidad, uuid, resultado, timestamp).
|
||||||
|
|
||||||
|
## 4. API (`routes/api.php`, prefijo `/api/v1`, stateless + Sanctum)
|
||||||
|
|
||||||
|
### Descarga / PULL
|
||||||
|
- `GET /api/v1/projects` → proyectos accesibles (reusa `Project::accessibleBy`).
|
||||||
|
- `GET /api/v1/projects/{id}/bundle?since=<ISO8601>` → **paquete offline** (delta si viene `since`).
|
||||||
|
- `GET /api/v1/templates?since=<ISO8601>` → plantillas de inspección con `version`/`hash` (descarga incremental).
|
||||||
|
- `GET /api/v1/media/{id}` o URLs firmadas dentro del bundle → adjuntos existentes.
|
||||||
|
|
||||||
|
Ejemplo de respuesta `bundle`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"server_time": "2026-06-17T20:00:00Z",
|
||||||
|
"project": { "id": 1, "uuid": "…", "name": "…", "updated_at": "…" },
|
||||||
|
"phases": [ { "id": 4, "name": "…", "updated_at": "…" } ],
|
||||||
|
"layers": [ { "id": 4, "phase_id": 4, "name": "…", "updated_at": "…" } ],
|
||||||
|
"features": [ { "id": 5, "uuid": "…", "layer_id": 4, "geometry": {…}, "status": "in_progress", "progress": 40, "updated_at": "…" } ],
|
||||||
|
"templates":[ { "id": 1, "version": 3, "fields": [ … ] } ],
|
||||||
|
"inspections": [ … ],
|
||||||
|
"issues": [ … ],
|
||||||
|
"deleted": { "features": ["uuid…"], "inspections": ["uuid…"] }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Subida / PUSH
|
||||||
|
- `POST /api/v1/sync` — lote de operaciones (idempotente por `uuid`):
|
||||||
|
```json
|
||||||
|
{ "operations": [
|
||||||
|
{ "entity": "progress_update", "op": "create", "uuid": "…", "client_updated_at": "…", "data": { "phase_id": 4, "progress": 60, "comment": "…", "location": {…} } },
|
||||||
|
{ "entity": "inspection", "op": "create", "uuid": "…", "client_updated_at": "…", "data": { "feature_id": 5, "template_id": 1, "data": {…}, "result": "pass" } },
|
||||||
|
{ "entity": "feature", "op": "update", "uuid": "…", "client_updated_at": "…", "data": { "status": "completed", "progress": 100 } },
|
||||||
|
{ "entity": "issue", "op": "create", "uuid": "…", "client_updated_at": "…", "data": { "feature_id": 5, "title": "…", "priority": "high" } }
|
||||||
|
] }
|
||||||
|
```
|
||||||
|
Respuesta por operación:
|
||||||
|
```json
|
||||||
|
{ "results": [
|
||||||
|
{ "uuid": "…", "status": "applied", "server_id": 123 },
|
||||||
|
{ "uuid": "…", "status": "duplicate", "server_id": 124 },
|
||||||
|
{ "uuid": "…", "status": "conflict", "server": { "status": "verified", "updated_at": "…" } },
|
||||||
|
{ "uuid": "…", "status": "error", "error": "validation: …" }
|
||||||
|
] }
|
||||||
|
```
|
||||||
|
- `POST /api/v1/media` — **subida de fotos por multipart** (no base64), referenciando al padre por `uuid` (`parent_entity`, `parent_uuid`, `file`). Soporta reintento; troceado si el archivo es grande.
|
||||||
|
|
||||||
|
## 5. Idempotencia y conflictos
|
||||||
|
|
||||||
|
- **Idempotencia:** el `uuid` evita duplicados si se reenvía la cola (re-sync seguro).
|
||||||
|
- **Append-only (sin conflicto):** `progress_updates`, `inspections` → siempre insertan.
|
||||||
|
- **Editables (con política):** `feature.status/progress`, `issue` → **last-write-wins** comparando `client_updated_at` vs `updated_at` del servidor. Si el servidor es más nuevo → `conflict` y se devuelve el valor del servidor para que el móvil decida/avise.
|
||||||
|
|
||||||
|
## 6. Seguridad
|
||||||
|
|
||||||
|
- **Nunca** `Model::create($payloadCliente)` crudo. Usar FormRequests/DTO; fijar `project_id`/`user_id` **en el servidor** desde el contexto autorizado; validar que `feature/phase` pertenece a un proyecto del usuario (anti-IDOR).
|
||||||
|
- Autorizar cada operación con permisos Spatie (`update progress`, `create inspections`, …) + pertenencia al proyecto (`accessibleBy`).
|
||||||
|
- Rate limiting, caducidad de token, `sync_logs` para auditoría.
|
||||||
|
|
||||||
|
## 7. Versionado
|
||||||
|
|
||||||
|
- Prefijo `/api/v1`; cabecera `X-App-Version`; el servidor responde versión mínima soportada (forzar update del móvil).
|
||||||
|
- Versión/hash por plantilla (descarga incremental).
|
||||||
|
|
||||||
|
## 8. Qué reutilizar / retirar
|
||||||
|
|
||||||
|
- `OfflineSyncController` + `PendingSync`: el **vocabulario de acciones** (progress_update, inspection, feature_create, media_upload, task_complete) es buena base para las operaciones de `/sync`. Pero hay que: pasar a API+token, añadir uuid/validación/autorización, y **mover la cola al dispositivo** (la `PendingSync` del servidor deja de ser necesaria para el móvil; se puede retirar o reaprovechar como `sync_logs`).
|
||||||
|
|
||||||
|
## 9. Entregables en la webapp (por fases)
|
||||||
|
|
||||||
|
- **Fase A — Auth & esqueleto API:** Sanctum, `routes/api.php`, `login`/`logout`/`me`, tabla `devices`, abilities.
|
||||||
|
- **Fase B — PULL:** `projects`, `bundle` + delta, `templates` versionadas, tombstones.
|
||||||
|
- **Fase C — PUSH:** `/sync` idempotente con validación/autorización/conflictos (recoge y endurece la lógica actual).
|
||||||
|
- **Fase D — Media:** subida multipart + descarga.
|
||||||
|
- **Fase E — Endurecimiento + Docs:** rate-limit, `sync_logs`, OpenAPI/Swagger como contrato para el equipo móvil.
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
openapi: 3.0.3
|
||||||
|
info:
|
||||||
|
title: ConstruProgress Mobile API
|
||||||
|
version: "1.0.0"
|
||||||
|
description: >
|
||||||
|
Offline-first sync API for the mobile app. Auth via Laravel Sanctum bearer
|
||||||
|
tokens (ability `mobile-sync`). All protected endpoints require
|
||||||
|
`Authorization: Bearer <token>`. See docs/MOBILE_SYNC_PROTOCOL.md.
|
||||||
|
servers:
|
||||||
|
- url: /api/v1
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
paths:
|
||||||
|
/login:
|
||||||
|
post:
|
||||||
|
summary: Issue a device token
|
||||||
|
security: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [email, password, device_name]
|
||||||
|
properties:
|
||||||
|
email: { type: string, format: email }
|
||||||
|
password: { type: string }
|
||||||
|
device_name: { type: string }
|
||||||
|
app_version: { type: string, nullable: true }
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Token issued
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
token: { type: string }
|
||||||
|
user: { $ref: '#/components/schemas/User' }
|
||||||
|
"422": { description: Invalid credentials }
|
||||||
|
/me:
|
||||||
|
get:
|
||||||
|
summary: Current user + effective permissions
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
user: { $ref: '#/components/schemas/User' }
|
||||||
|
"401": { description: Unauthenticated }
|
||||||
|
/logout:
|
||||||
|
post:
|
||||||
|
summary: Revoke the current device token
|
||||||
|
responses:
|
||||||
|
"200": { description: Logged out }
|
||||||
|
/projects:
|
||||||
|
get:
|
||||||
|
summary: Projects the user can access
|
||||||
|
responses:
|
||||||
|
"200": { description: OK }
|
||||||
|
/projects/{project}/bundle:
|
||||||
|
get:
|
||||||
|
summary: Offline bundle (full, or delta when `since` is given)
|
||||||
|
parameters:
|
||||||
|
- name: project
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema: { type: integer }
|
||||||
|
- name: since
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
description: >
|
||||||
|
ISO8601 timestamp. Returns only records changed after it, plus
|
||||||
|
`deleted` tombstones. MUST be URL-encoded (the `+` offset).
|
||||||
|
schema: { type: string, format: date-time }
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Bundle
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/Bundle' }
|
||||||
|
"403": { description: Not a member of the project }
|
||||||
|
/templates:
|
||||||
|
get:
|
||||||
|
summary: Inspection templates for accessible projects (with version/hash)
|
||||||
|
parameters:
|
||||||
|
- name: since
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema: { type: string, format: date-time }
|
||||||
|
responses:
|
||||||
|
"200": { description: OK }
|
||||||
|
/sync:
|
||||||
|
post:
|
||||||
|
summary: Push a batch of offline mutations (idempotent by uuid)
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [operations]
|
||||||
|
properties:
|
||||||
|
operations:
|
||||||
|
type: array
|
||||||
|
items: { $ref: '#/components/schemas/Operation' }
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Per-operation results
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
results:
|
||||||
|
type: array
|
||||||
|
items: { $ref: '#/components/schemas/OperationResult' }
|
||||||
|
/media:
|
||||||
|
post:
|
||||||
|
summary: Upload a file (multipart) and attach it to a parent record
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
multipart/form-data:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [uuid, parent_entity, parent_id, file]
|
||||||
|
properties:
|
||||||
|
uuid: { type: string, format: uuid }
|
||||||
|
parent_entity: { type: string, enum: [feature, issue, project, phase, layer] }
|
||||||
|
parent_id: { type: integer }
|
||||||
|
file: { type: string, format: binary }
|
||||||
|
category: { type: string, enum: [image, document, other] }
|
||||||
|
description: { type: string }
|
||||||
|
responses:
|
||||||
|
"200": { description: applied | duplicate }
|
||||||
|
"403": { description: Forbidden }
|
||||||
|
components:
|
||||||
|
securitySchemes:
|
||||||
|
bearerAuth:
|
||||||
|
type: http
|
||||||
|
scheme: bearer
|
||||||
|
schemas:
|
||||||
|
User:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id: { type: integer }
|
||||||
|
name: { type: string }
|
||||||
|
email: { type: string }
|
||||||
|
roles: { type: array, items: { type: string } }
|
||||||
|
permissions: { type: array, items: { type: string } }
|
||||||
|
Operation:
|
||||||
|
type: object
|
||||||
|
required: [entity, op, uuid, data]
|
||||||
|
properties:
|
||||||
|
entity: { type: string, enum: [progress_update, inspection, issue, feature] }
|
||||||
|
op: { type: string, enum: [create, update] }
|
||||||
|
uuid: { type: string, format: uuid, description: client-generated idempotency key }
|
||||||
|
client_updated_at: { type: string, format: date-time }
|
||||||
|
data: { type: object }
|
||||||
|
example:
|
||||||
|
entity: feature
|
||||||
|
op: update
|
||||||
|
uuid: 0f8e...-uuid
|
||||||
|
client_updated_at: "2026-06-18T12:00:00+00:00"
|
||||||
|
data: { id: 5, status: completed, progress: 100 }
|
||||||
|
OperationResult:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
uuid: { type: string, format: uuid }
|
||||||
|
status: { type: string, enum: [applied, duplicate, conflict, error] }
|
||||||
|
server_id: { type: integer, nullable: true }
|
||||||
|
error: { type: string, nullable: true }
|
||||||
|
server: { type: object, nullable: true, description: current server value on conflict }
|
||||||
|
Bundle:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
server_time: { type: string, format: date-time }
|
||||||
|
project: { type: object }
|
||||||
|
phases: { type: array, items: { type: object } }
|
||||||
|
layers: { type: array, items: { type: object } }
|
||||||
|
features: { type: array, items: { type: object } }
|
||||||
|
inspections: { type: array, items: { type: object } }
|
||||||
|
issues: { type: array, items: { type: object } }
|
||||||
|
templates: { type: array, items: { type: object } }
|
||||||
|
media: { type: array, items: { type: object } }
|
||||||
|
deleted:
|
||||||
|
type: object
|
||||||
|
description: tombstones (ids of soft-deleted records) when `since` is given
|
||||||
|
properties:
|
||||||
|
phases: { type: array, items: { type: integer } }
|
||||||
|
layers: { type: array, items: { type: integer } }
|
||||||
|
features: { type: array, items: { type: integer } }
|
||||||
|
inspections: { type: array, items: { type: integer } }
|
||||||
|
issues: { type: array, items: { type: integer } }
|
||||||
+252
-2
@@ -128,7 +128,7 @@
|
|||||||
"Longitude": "Longitude",
|
"Longitude": "Longitude",
|
||||||
"Register inspection": "Register inspection",
|
"Register inspection": "Register inspection",
|
||||||
"Files of element": "Files of element",
|
"Files of element": "Files of element",
|
||||||
"Fases and layers": "Phases and layers",
|
"Phases and layers": "Phases and layers",
|
||||||
"Elements": "Elements",
|
"Elements": "Elements",
|
||||||
"optional": "optional",
|
"optional": "optional",
|
||||||
"each": "each",
|
"each": "each",
|
||||||
@@ -145,5 +145,255 @@
|
|||||||
"Viewer": "Viewer",
|
"Viewer": "Viewer",
|
||||||
"Remove": "Remove",
|
"Remove": "Remove",
|
||||||
"No users assigned yet": "No users assigned yet",
|
"No users assigned yet": "No users assigned yet",
|
||||||
"Select": "Select"
|
"Select": "Select",
|
||||||
|
"Log Out": "Log Out",
|
||||||
|
"Company": "Company",
|
||||||
|
"Companies": "Companies",
|
||||||
|
"Company Management": "Company Management",
|
||||||
|
"New Company": "New Company",
|
||||||
|
"Edit Company": "Edit Company",
|
||||||
|
"Delete Company": "Delete Company",
|
||||||
|
"User Management": "User Management",
|
||||||
|
"New User": "New User",
|
||||||
|
"Edit User": "Edit User",
|
||||||
|
"Delete User": "Delete User",
|
||||||
|
"Reference": "Reference",
|
||||||
|
"Contact": "Contact",
|
||||||
|
"Verified": "Verified",
|
||||||
|
"Type": "Type",
|
||||||
|
"Owner": "Owner",
|
||||||
|
"Constructor": "Constructor",
|
||||||
|
"Subcontractor": "Subcontractor",
|
||||||
|
"Supplier": "Supplier",
|
||||||
|
"No role": "No role",
|
||||||
|
"Active": "Active",
|
||||||
|
"Inactive": "Inactive",
|
||||||
|
"Suspended": "Suspended",
|
||||||
|
"Start Date": "Start Date",
|
||||||
|
"Est. End": "Est. End",
|
||||||
|
"Issue": "Issue",
|
||||||
|
"Issues": "Issues",
|
||||||
|
"New Issue": "New Issue",
|
||||||
|
"Open": "Open",
|
||||||
|
"Resolved": "Resolved",
|
||||||
|
"Closed": "Closed",
|
||||||
|
"Priority": "Priority",
|
||||||
|
"High": "High",
|
||||||
|
"Medium": "Medium",
|
||||||
|
"Low": "Low",
|
||||||
|
"Gantt": "Gantt",
|
||||||
|
"Report": "Report",
|
||||||
|
"Reports": "Reports",
|
||||||
|
"Created at": "Created at",
|
||||||
|
"Updated at": "Updated at",
|
||||||
|
"Confirm delete": "Confirm delete",
|
||||||
|
"This action cannot be undone": "This action cannot be undone",
|
||||||
|
"No data": "No data",
|
||||||
|
"Export CSV": "Export CSV",
|
||||||
|
"Export PDF": "Export PDF",
|
||||||
|
"Planned": "Planned",
|
||||||
|
"Started": "Started",
|
||||||
|
"Map filters": "Map filters",
|
||||||
|
"Progress: :min% – :max%": "Progress: :min% – :max%",
|
||||||
|
"Clear": "Clear",
|
||||||
|
"Hide panel": "Hide panel",
|
||||||
|
"Show phases and layers": "Show phases and layers",
|
||||||
|
"Show images": "Show images",
|
||||||
|
"Schedule": "Schedule",
|
||||||
|
"Center map": "Center map",
|
||||||
|
"Select element": "Select element",
|
||||||
|
"Search by name, phase or layer...": "Search by name, phase or layer...",
|
||||||
|
"Element status": "Element status",
|
||||||
|
"Notes": "Notes",
|
||||||
|
"Result": "Result",
|
||||||
|
"No result": "No result",
|
||||||
|
"Approved": "Approved",
|
||||||
|
"Conditional": "Conditional",
|
||||||
|
"Failed": "Failed",
|
||||||
|
"Registered data": "Registered data",
|
||||||
|
"Inspection #:id": "Inspection #:id",
|
||||||
|
"Layer / Phase": "Layer / Phase",
|
||||||
|
"No templates (info)": "No templates.",
|
||||||
|
"Create one": "Create one",
|
||||||
|
"Click on a map element or search above to edit it": "Click on a map element or search above to edit it",
|
||||||
|
"Date": "Date",
|
||||||
|
"Inspector": "Inspector",
|
||||||
|
"View detail": "View detail",
|
||||||
|
"No inspections registered": "No inspections registered",
|
||||||
|
"No elements in this project": "No elements in this project",
|
||||||
|
"Inspections": "Inspections",
|
||||||
|
"Project data": "Project data",
|
||||||
|
"Team": "Team",
|
||||||
|
"Save changes": "Save changes",
|
||||||
|
"Create project": "Create project",
|
||||||
|
"Identification": "Identification",
|
||||||
|
"Location": "Location",
|
||||||
|
"Click on the map or drag the marker to update the location": "Click on the map or drag the marker to update the location",
|
||||||
|
"Coordinates": "Coordinates",
|
||||||
|
"Auto when clicking the map": "Auto when clicking the map",
|
||||||
|
"No country": "No country",
|
||||||
|
"Search country...": "Search country...",
|
||||||
|
"Inspection templates": "Inspection templates",
|
||||||
|
"Import CSV/Excel": "Import CSV/Excel",
|
||||||
|
"Copy from project": "Copy from project",
|
||||||
|
"New template": "New template",
|
||||||
|
"Edit template": "Edit template",
|
||||||
|
"Template name": "Template name",
|
||||||
|
"Associated phase (optional)": "Associated phase (optional)",
|
||||||
|
"Global project": "Global project",
|
||||||
|
"Form fields": "Form fields",
|
||||||
|
"field(s)": "field(s)",
|
||||||
|
"Internal name": "Internal name",
|
||||||
|
"Visible label": "Visible label",
|
||||||
|
"Remove field": "Remove field",
|
||||||
|
"Min": "Min",
|
||||||
|
"Max": "Max",
|
||||||
|
"Step": "Step",
|
||||||
|
"Options (comma separated)": "Options (comma separated)",
|
||||||
|
"Add field": "Add field",
|
||||||
|
"Save template": "Save template",
|
||||||
|
"No templates yet (table)": "No templates. Use the buttons above to create or import.",
|
||||||
|
"Delete template confirmation": "Delete this template? This action cannot be undone.",
|
||||||
|
"Import template from CSV / Excel": "Import template from CSV / Excel",
|
||||||
|
"File format (one row = one field):": "File format (one row = one field):",
|
||||||
|
"Download example": "Download example",
|
||||||
|
"CSV or Excel file": "CSV or Excel file",
|
||||||
|
"Loading file...": "Loading file...",
|
||||||
|
"Preview": "Preview",
|
||||||
|
"Change file": "Change file",
|
||||||
|
"Create template (action)": "Create template",
|
||||||
|
"field(s) detected": "field(s) detected",
|
||||||
|
"Copy template from another project": "Copy template from another project",
|
||||||
|
"Source project": "Source project",
|
||||||
|
"Select project...": "Select project...",
|
||||||
|
"This project has no templates.": "This project has no templates.",
|
||||||
|
"Select the templates to copy": "Select the templates to copy",
|
||||||
|
"selected": "selected",
|
||||||
|
"Select a project to see its templates.": "Select a project to see its templates.",
|
||||||
|
"Copy": "Copy",
|
||||||
|
"Back to map": "Back to map",
|
||||||
|
"Import": "Import",
|
||||||
|
"or": "or",
|
||||||
|
"Layers (:count)": "Layers (:count)",
|
||||||
|
"No layers. Create or import one.": "No layers. Create or import one.",
|
||||||
|
"elem.": "elem.",
|
||||||
|
"Export": "Export",
|
||||||
|
"Bulk assignment": "Bulk assignment",
|
||||||
|
"Apply template or status to all elements of :layer": "Apply template or status to all elements of :layer",
|
||||||
|
"No change": "No change",
|
||||||
|
"Apply to all": "Apply to all",
|
||||||
|
"Apply changes to all elements of this layer?": "Apply changes to all elements of this layer?",
|
||||||
|
"Element editor": "Element editor",
|
||||||
|
"Select a layer to edit": "Select a layer to edit",
|
||||||
|
"Delayed phases": "Delayed phases",
|
||||||
|
"Needs attention": "Needs attention",
|
||||||
|
"No delays": "No delays",
|
||||||
|
"phases": "phases",
|
||||||
|
"Open issues": "Open issues",
|
||||||
|
"critical": "critical",
|
||||||
|
"Pending inspections": "Pending inspections",
|
||||||
|
"To do": "To do",
|
||||||
|
"Completed inspections": "Completed inspections",
|
||||||
|
"Rejected inspections": "Rejected inspections",
|
||||||
|
"Need review": "Need review",
|
||||||
|
"View all": "View all",
|
||||||
|
"No projects available": "No projects available",
|
||||||
|
"phase": "phase",
|
||||||
|
"Recent issues": "Recent issues",
|
||||||
|
"No open issues": "No open issues",
|
||||||
|
"No recent inspections": "No recent inspections",
|
||||||
|
"User": "User",
|
||||||
|
"No users found": "No users found",
|
||||||
|
"No companies assigned yet": "No companies assigned yet",
|
||||||
|
"Select template...": "Select template...",
|
||||||
|
"Observations...": "Observations...",
|
||||||
|
"by": "by",
|
||||||
|
"ago": "ago",
|
||||||
|
"No inspections yet for this element": "No inspections yet for this element",
|
||||||
|
"Inspection History": "Inspection History",
|
||||||
|
"View": "View",
|
||||||
|
"Media for this element": "Media for this element",
|
||||||
|
"No media for this element yet": "No media for this element yet",
|
||||||
|
"Project Media": "Project Media",
|
||||||
|
"No project media yet": "No project media yet",
|
||||||
|
"Feature:": "Element:",
|
||||||
|
"Inspection:": "Inspection:",
|
||||||
|
"Project Data": "Project Data",
|
||||||
|
"Name of responsible": "Name of responsible",
|
||||||
|
"Reports and Analytics": "Reports and Analytics",
|
||||||
|
"Time range:": "Time range:",
|
||||||
|
"This week": "This week",
|
||||||
|
"This month": "This month",
|
||||||
|
"This quarter": "This quarter",
|
||||||
|
"This year": "This year",
|
||||||
|
"Project Progress (last 6 months)": "Project Progress (last 6 months)",
|
||||||
|
"Inspections by Type": "Inspections by Type",
|
||||||
|
"Projects by Status": "Projects by Status",
|
||||||
|
"Average Progress by Project": "Average Progress by Project",
|
||||||
|
"Total Active Projects": "Total Active Projects",
|
||||||
|
"Inspections This Month": "Inspections This Month",
|
||||||
|
"Average Progress": "Average Progress",
|
||||||
|
"Completed Projects": "Completed Projects",
|
||||||
|
"Loading data...": "Loading data...",
|
||||||
|
"Optional": "Optional",
|
||||||
|
"Expand layers": "Expand layers",
|
||||||
|
"New user": "New user",
|
||||||
|
"Search by name or email...": "Search by name or email...",
|
||||||
|
"No users found (table)": "No users found",
|
||||||
|
"Select element (label)": "Select element",
|
||||||
|
"Search by name, layer or phase...": "Search by name, layer or phase...",
|
||||||
|
"No elements found": "No elements found",
|
||||||
|
"No media yet": "No media yet",
|
||||||
|
"Manage the companies that participate in projects": "Manage the companies that participate in projects",
|
||||||
|
"Search companies by name or tax ID...": "Search companies by name or tax ID...",
|
||||||
|
"Complete the company information. Fields marked with * are required.": "Complete the company information. Fields marked with * are required.",
|
||||||
|
"Validation errors": "Validation errors",
|
||||||
|
"Tax ID": "Tax ID",
|
||||||
|
"E.g.: B12345678": "E.g.: B12345678",
|
||||||
|
"Nickname": "Nickname",
|
||||||
|
"E.g.: Acme Construct": "E.g.: Acme Construct",
|
||||||
|
"Select a status": "Select a status",
|
||||||
|
"Company Type": "Company Type",
|
||||||
|
"Select a type": "Select a type",
|
||||||
|
"Phone": "Phone",
|
||||||
|
"Website": "Website",
|
||||||
|
"Company Logo": "Company Logo",
|
||||||
|
"Select file...": "Select file...",
|
||||||
|
"Logo preview": "Logo preview",
|
||||||
|
"Additional notes": "Additional notes",
|
||||||
|
"No companies registered. Create your first company using the button above.": "No companies registered. Create your first company using the button above.",
|
||||||
|
"Logo of": "Logo of",
|
||||||
|
"No tax ID": "No tax ID",
|
||||||
|
"Delete company confirmation": "Delete this company? This action cannot be undone.",
|
||||||
|
"Company list": "Company list",
|
||||||
|
"Add Phase": "Add Phase",
|
||||||
|
"Update": "Update",
|
||||||
|
"Delete file confirmation": "Delete this file? This action cannot be undone.",
|
||||||
|
"Back to map": "Back to map",
|
||||||
|
"Create generic templates that can be used in any phase of the project": "Create generic templates that can be used in any phase of the project",
|
||||||
|
"In Progress": "In Progress",
|
||||||
|
"Select a project to see its templates.": "Select a project to see its templates.",
|
||||||
|
"Select a project to view details": "Select a project to view details",
|
||||||
|
"No description available": "No description available",
|
||||||
|
"completed": "completed",
|
||||||
|
"Back to projects": "Back to projects",
|
||||||
|
"Not defined": "Not defined",
|
||||||
|
"Progress overview": "Progress overview",
|
||||||
|
"General progress": "General progress",
|
||||||
|
"Progress by phase": "Progress by phase",
|
||||||
|
"No phases defined for this project": "No phases defined for this project",
|
||||||
|
"Progress gallery": "Progress gallery",
|
||||||
|
"Change orders": "Change orders",
|
||||||
|
"Requested": "Requested",
|
||||||
|
"Amount": "Amount",
|
||||||
|
"Approve": "Approve",
|
||||||
|
"Reject": "Reject",
|
||||||
|
"No pending change orders": "No pending change orders",
|
||||||
|
"Pending": "Pending",
|
||||||
|
"Total": "Total",
|
||||||
|
"Inspections": "Inspections",
|
||||||
|
"My Projects": "My Projects",
|
||||||
|
"Editable": "Editable",
|
||||||
|
"Name of responsible": "Name of responsible",
|
||||||
|
"Select template...": "Select template..."
|
||||||
}
|
}
|
||||||
|
|||||||
+252
-3
@@ -128,9 +128,8 @@
|
|||||||
"Longitude": "Longitud",
|
"Longitude": "Longitud",
|
||||||
"Register inspection": "Registrar inspección",
|
"Register inspection": "Registrar inspección",
|
||||||
"Files of element": "Archivos del elemento",
|
"Files of element": "Archivos del elemento",
|
||||||
"Fases and layers": "Fases y capas",
|
"Phases and layers": "Fases y capas",
|
||||||
"Elements": "Elementos",
|
"Elements": "Elementos",
|
||||||
"Log Out": "Cerrar sesión",
|
|
||||||
"optional": "opcional",
|
"optional": "opcional",
|
||||||
"each": "cada",
|
"each": "cada",
|
||||||
"Image": "Imagen",
|
"Image": "Imagen",
|
||||||
@@ -146,5 +145,255 @@
|
|||||||
"Viewer": "Espectador",
|
"Viewer": "Espectador",
|
||||||
"Remove": "Eliminar",
|
"Remove": "Eliminar",
|
||||||
"No users assigned yet": "Sin usuarios asignados",
|
"No users assigned yet": "Sin usuarios asignados",
|
||||||
"Select": "Seleccionar"
|
"Select": "Seleccionar",
|
||||||
|
"Log Out": "Cerrar sesión",
|
||||||
|
"Company": "Empresa",
|
||||||
|
"Companies": "Empresas",
|
||||||
|
"Company Management": "Gestión de empresas",
|
||||||
|
"New Company": "Nueva empresa",
|
||||||
|
"Edit Company": "Editar empresa",
|
||||||
|
"Delete Company": "Eliminar empresa",
|
||||||
|
"User Management": "Gestión de usuarios",
|
||||||
|
"New User": "Nuevo usuario",
|
||||||
|
"Edit User": "Editar usuario",
|
||||||
|
"Delete User": "Eliminar usuario",
|
||||||
|
"Reference": "Referencia",
|
||||||
|
"Contact": "Contacto",
|
||||||
|
"Verified": "Verificado",
|
||||||
|
"Type": "Tipo",
|
||||||
|
"Owner": "Promotor",
|
||||||
|
"Constructor": "Constructora",
|
||||||
|
"Subcontractor": "Subcontratista",
|
||||||
|
"Supplier": "Proveedor",
|
||||||
|
"No role": "Sin rol",
|
||||||
|
"Active": "Activo",
|
||||||
|
"Inactive": "Inactivo",
|
||||||
|
"Suspended": "Suspendido",
|
||||||
|
"Start Date": "Fecha inicio",
|
||||||
|
"Est. End": "Fin estimado",
|
||||||
|
"Issue": "Incidencia",
|
||||||
|
"Issues": "Incidencias",
|
||||||
|
"New Issue": "Nueva incidencia",
|
||||||
|
"Open": "Abierta",
|
||||||
|
"Resolved": "Resuelta",
|
||||||
|
"Closed": "Cerrada",
|
||||||
|
"Priority": "Prioridad",
|
||||||
|
"High": "Alta",
|
||||||
|
"Medium": "Media",
|
||||||
|
"Low": "Baja",
|
||||||
|
"Gantt": "Gantt",
|
||||||
|
"Report": "Informe",
|
||||||
|
"Reports": "Informes",
|
||||||
|
"Created at": "Creado el",
|
||||||
|
"Updated at": "Actualizado el",
|
||||||
|
"Confirm delete": "Confirmar eliminación",
|
||||||
|
"This action cannot be undone": "Esta acción no se puede deshacer",
|
||||||
|
"No data": "Sin datos",
|
||||||
|
"Export CSV": "Exportar CSV",
|
||||||
|
"Export PDF": "Exportar PDF",
|
||||||
|
"Planned": "Planificado",
|
||||||
|
"Started": "Iniciado",
|
||||||
|
"Map filters": "Filtros del mapa",
|
||||||
|
"Progress: :min% – :max%": "Progreso: :min% – :max%",
|
||||||
|
"Clear": "Limpiar",
|
||||||
|
"Hide panel": "Ocultar panel",
|
||||||
|
"Show phases and layers": "Mostrar fases y capas",
|
||||||
|
"Show images": "Mostrar imágenes",
|
||||||
|
"Schedule": "Cronograma",
|
||||||
|
"Center map": "Centrar mapa",
|
||||||
|
"Select element": "Seleccionar elemento",
|
||||||
|
"Search by name, phase or layer...": "Buscar por nombre, fase o capa...",
|
||||||
|
"Element status": "Estado del elemento",
|
||||||
|
"Notes": "Notas",
|
||||||
|
"Result": "Resultado",
|
||||||
|
"No result": "Sin resultado",
|
||||||
|
"Approved": "Aprobada",
|
||||||
|
"Conditional": "Condicional",
|
||||||
|
"Failed": "Fallida",
|
||||||
|
"Registered data": "Datos registrados",
|
||||||
|
"Inspection #:id": "Inspección #:id",
|
||||||
|
"Layer / Phase": "Capa / Fase",
|
||||||
|
"No templates (info)": "No hay templates.",
|
||||||
|
"Create one": "Crear uno",
|
||||||
|
"Click on a map element or search above to edit it": "Haz clic en un elemento del mapa o búscalo arriba para editarlo",
|
||||||
|
"Date": "Fecha",
|
||||||
|
"Inspector": "Inspector",
|
||||||
|
"View detail": "Ver detalle",
|
||||||
|
"No inspections registered": "No hay inspecciones registradas",
|
||||||
|
"No elements in this project": "No hay elementos en este proyecto",
|
||||||
|
"Inspections": "Inspecciones",
|
||||||
|
"Project data": "Datos del proyecto",
|
||||||
|
"Team": "Equipo",
|
||||||
|
"Save changes": "Guardar cambios",
|
||||||
|
"Create project": "Crear proyecto",
|
||||||
|
"Identification": "Identificación",
|
||||||
|
"Location": "Ubicación",
|
||||||
|
"Click on the map or drag the marker to update the location": "Pulsa en el mapa o arrastra el marcador para actualizar la ubicación.",
|
||||||
|
"Coordinates": "Coordenadas",
|
||||||
|
"Auto when clicking the map": "Auto al pulsar el mapa",
|
||||||
|
"No country": "— Sin especificar —",
|
||||||
|
"Search country...": "Buscar país…",
|
||||||
|
"Inspection templates": "Templates de inspección",
|
||||||
|
"Import CSV/Excel": "Importar CSV/Excel",
|
||||||
|
"Copy from project": "Copiar de proyecto",
|
||||||
|
"New template": "Nuevo template",
|
||||||
|
"Edit template": "Editar template",
|
||||||
|
"Template name": "Nombre del template",
|
||||||
|
"Associated phase (optional)": "Fase asociada (opcional)",
|
||||||
|
"Global project": "Global del proyecto",
|
||||||
|
"Form fields": "Campos del formulario",
|
||||||
|
"field(s)": "campo(s)",
|
||||||
|
"Internal name": "Nombre interno",
|
||||||
|
"Visible label": "Etiqueta visible",
|
||||||
|
"Remove field": "Quitar",
|
||||||
|
"Min": "Mín",
|
||||||
|
"Max": "Máx",
|
||||||
|
"Step": "Paso",
|
||||||
|
"Options (comma separated)": "Opciones (separadas por coma)",
|
||||||
|
"Add field": "Agregar campo",
|
||||||
|
"Save template": "Guardar template",
|
||||||
|
"No templates yet (table)": "No hay templates. Usa los botones de arriba para crear o importar.",
|
||||||
|
"Delete template confirmation": "¿Eliminar este template? Esta acción no se puede deshacer.",
|
||||||
|
"Import template from CSV / Excel": "Importar template desde CSV / Excel",
|
||||||
|
"File format (one row = one field):": "Formato del archivo (una fila = un campo):",
|
||||||
|
"Download example": "Descargar ejemplo",
|
||||||
|
"CSV or Excel file": "Archivo CSV o Excel",
|
||||||
|
"Loading file...": "Cargando archivo...",
|
||||||
|
"Preview": "Previsualizar",
|
||||||
|
"Change file": "Cambiar archivo",
|
||||||
|
"Create template (action)": "Crear template",
|
||||||
|
"field(s) detected": "campo(s) detectados",
|
||||||
|
"Copy template from another project": "Copiar template de otro proyecto",
|
||||||
|
"Source project": "Proyecto origen",
|
||||||
|
"Select project...": "Seleccionar proyecto...",
|
||||||
|
"This project has no templates.": "Este proyecto no tiene templates.",
|
||||||
|
"Select the templates to copy": "Selecciona los templates a copiar",
|
||||||
|
"selected": "seleccionados",
|
||||||
|
"Select a project to see its templates.": "Selecciona un proyecto para ver sus templates.",
|
||||||
|
"Copy": "Copiar",
|
||||||
|
"Back to map": "Volver al mapa",
|
||||||
|
"Import": "Importar",
|
||||||
|
"or": "o",
|
||||||
|
"Layers (:count)": "Capas (:count)",
|
||||||
|
"No layers. Create or import one.": "Sin capas. Crea o importa una.",
|
||||||
|
"elem.": "elem.",
|
||||||
|
"Export": "Exportar",
|
||||||
|
"Bulk assignment": "Asignación masiva",
|
||||||
|
"Apply template or status to all elements of :layer": "Aplica template o estado a todos los elementos de :layer",
|
||||||
|
"No change": "Sin cambio",
|
||||||
|
"Apply to all": "Aplicar a todos",
|
||||||
|
"Apply changes to all elements of this layer?": "¿Aplicar cambios a todos los elementos de esta capa?",
|
||||||
|
"Element editor": "Editor de elementos",
|
||||||
|
"Select a layer to edit": "Selecciona una capa para editar",
|
||||||
|
"Delayed phases": "Fases con retraso",
|
||||||
|
"Needs attention": "Requiere atención",
|
||||||
|
"No delays": "Sin retrasos",
|
||||||
|
"phases": "fases",
|
||||||
|
"Open issues": "Issues abiertos",
|
||||||
|
"critical": "críticos",
|
||||||
|
"Pending inspections": "Insp. pendientes",
|
||||||
|
"To do": "Por realizar",
|
||||||
|
"Completed inspections": "Insp. completadas",
|
||||||
|
"Rejected inspections": "Insp. rechazadas",
|
||||||
|
"Need review": "Requieren revisión",
|
||||||
|
"View all": "Ver todos",
|
||||||
|
"No projects available": "No hay proyectos disponibles",
|
||||||
|
"phase": "fase",
|
||||||
|
"Recent issues": "Issues recientes",
|
||||||
|
"No open issues": "Sin issues abiertos",
|
||||||
|
"No recent inspections": "Sin inspecciones recientes",
|
||||||
|
"User": "Usuario",
|
||||||
|
"No users found": "No se encontraron usuarios",
|
||||||
|
"No companies assigned yet": "Sin empresas asignadas",
|
||||||
|
"Select template...": "Seleccionar plantilla...",
|
||||||
|
"Observations...": "Observaciones...",
|
||||||
|
"by": "por",
|
||||||
|
"ago": "hace",
|
||||||
|
"No inspections yet for this element": "Sin inspecciones para este elemento",
|
||||||
|
"Inspection History": "Historial de inspecciones",
|
||||||
|
"View": "Ver",
|
||||||
|
"Media for this element": "Archivos de este elemento",
|
||||||
|
"No media for this element yet": "Sin archivos para este elemento",
|
||||||
|
"Project Media": "Archivos del proyecto",
|
||||||
|
"No project media yet": "Sin archivos del proyecto",
|
||||||
|
"Feature:": "Elemento:",
|
||||||
|
"Inspection:": "Inspección:",
|
||||||
|
"Project Data": "Datos del proyecto",
|
||||||
|
"Name of responsible": "Nombre del responsable",
|
||||||
|
"Reports and Analytics": "Reportes y Analítica",
|
||||||
|
"Time range:": "Rango de tiempo:",
|
||||||
|
"This week": "Esta semana",
|
||||||
|
"This month": "Este mes",
|
||||||
|
"This quarter": "Este trimestre",
|
||||||
|
"This year": "Este año",
|
||||||
|
"Project Progress (last 6 months)": "Progreso de Proyectos (últimos 6 meses)",
|
||||||
|
"Inspections by Type": "Inspecciones por Tipo",
|
||||||
|
"Projects by Status": "Distribución de Proyectos por Estado",
|
||||||
|
"Average Progress by Project": "Progreso Promedio por Proyecto",
|
||||||
|
"Total Active Projects": "Total Proyectos Activos",
|
||||||
|
"Inspections This Month": "Inspecciones Este Mes",
|
||||||
|
"Average Progress": "Promedio de Progreso",
|
||||||
|
"Completed Projects": "Proyectos Completados",
|
||||||
|
"Loading data...": "Cargando datos...",
|
||||||
|
"Optional": "Opcional",
|
||||||
|
"Expand layers": "Expandir capas",
|
||||||
|
"New user": "Nuevo usuario",
|
||||||
|
"Search by name or email...": "Buscar por nombre o email…",
|
||||||
|
"No users found (table)": "No se encontraron usuarios",
|
||||||
|
"Select element (label)": "Seleccionar elemento",
|
||||||
|
"Search by name, layer or phase...": "Buscar por nombre, capa o fase...",
|
||||||
|
"No elements found": "No se encontraron elementos",
|
||||||
|
"No media yet": "Sin archivos aún",
|
||||||
|
"Manage the companies that participate in projects": "Gestione las empresas que participan en los proyectos",
|
||||||
|
"Search companies by name or tax ID...": "Buscar empresas por nombre o NIF...",
|
||||||
|
"Complete the company information. Fields marked with * are required.": "Complete la información de la empresa. Los campos marcados con * son obligatorios.",
|
||||||
|
"Validation errors": "Errores de validación",
|
||||||
|
"Tax ID": "NIF/NIE/CIF",
|
||||||
|
"E.g.: B12345678": "Ej: B12345678",
|
||||||
|
"Nickname": "Apodo",
|
||||||
|
"E.g.: Acme Construct": "Ej: Acme Construct",
|
||||||
|
"Select a status": "Seleccione un estado",
|
||||||
|
"Company Type": "Tipo de Empresa",
|
||||||
|
"Select a type": "Seleccione un tipo",
|
||||||
|
"Phone": "Teléfono",
|
||||||
|
"Website": "Sitio Web",
|
||||||
|
"Company Logo": "Logo de la Empresa",
|
||||||
|
"Select file...": "Seleccionar archivo...",
|
||||||
|
"Logo preview": "Vista previa del logo",
|
||||||
|
"Additional notes": "Notas Adicionales",
|
||||||
|
"No companies registered. Create your first company using the button above.": "No hay empresas registradas. Cree su primera empresa usando el botón de arriba.",
|
||||||
|
"Logo of": "Logo de",
|
||||||
|
"No tax ID": "Sin NIF/CIF",
|
||||||
|
"Delete company confirmation": "¿Eliminar esta empresa? Esta acción no se puede deshacer.",
|
||||||
|
"Company list": "Lista de Empresas",
|
||||||
|
"Add Phase": "Agregar Fase",
|
||||||
|
"Update": "Actualizar",
|
||||||
|
"Delete file confirmation": "¿Eliminar este archivo? Esta acción no se puede deshacer.",
|
||||||
|
"Back to map": "Volver al mapa",
|
||||||
|
"Create generic templates that can be used in any phase of the project": "Crea templates genéricos que puedan usarse en cualquier fase del proyecto",
|
||||||
|
"In Progress": "En obra",
|
||||||
|
"Select a project to see its templates.": "Selecciona un proyecto para ver sus templates.",
|
||||||
|
"Select a project to view details": "Seleccione un proyecto para ver detalles",
|
||||||
|
"No description available": "Sin descripción disponible",
|
||||||
|
"completed": "completado",
|
||||||
|
"Back to projects": "Volver a proyectos",
|
||||||
|
"Not defined": "No definida",
|
||||||
|
"Progress overview": "Resumen de Progreso",
|
||||||
|
"General progress": "Progreso General",
|
||||||
|
"Progress by phase": "Progreso por Fase",
|
||||||
|
"No phases defined for this project": "No hay fases definidas para este proyecto",
|
||||||
|
"Progress gallery": "Galería de Progreso",
|
||||||
|
"Change orders": "Órdenes de Cambio",
|
||||||
|
"Requested": "Solicitado",
|
||||||
|
"Amount": "Monto",
|
||||||
|
"Approve": "Aprobar",
|
||||||
|
"Reject": "Rechazar",
|
||||||
|
"No pending change orders": "No hay órdenes de cambio pendientes",
|
||||||
|
"Pending": "Pendiente",
|
||||||
|
"Total": "Total",
|
||||||
|
"Inspections": "Inspecciones",
|
||||||
|
"My Projects": "Mis proyectos",
|
||||||
|
"Editable": "Editable",
|
||||||
|
"Name of responsible": "Nombre del responsable",
|
||||||
|
"Select template...": "Seleccionar plantilla..."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
'failed' => 'Las credenciales introducidas no son válidas.',
|
||||||
|
'password' => 'La contraseña indicada es incorrecta.',
|
||||||
|
'throttle' => 'Demasiados intentos de acceso. Por favor, inténtalo de nuevo en :seconds segundos.',
|
||||||
|
|
||||||
|
];
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
'previous' => '« Anterior',
|
||||||
|
'next' => 'Siguiente »',
|
||||||
|
|
||||||
|
];
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user