Compare commits

...

2 Commits

7 changed files with 195 additions and 326 deletions
+71 -34
View File
@@ -7,6 +7,7 @@ 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
@@ -49,7 +50,8 @@ class ClientProjects extends Component
$project = Project::with([
'phases.features',
'inspections.template'
'inspections.template',
'changeOrders' // Load change orders for this project
])->find($this->selectedProject);
if (!$project) {
@@ -66,7 +68,27 @@ class ClientProjects extends Component
'progress' => $project->phases->avg('progress_percent') ?? 0,
];
// Get recent images (simulated for now)
// 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',
@@ -84,51 +106,66 @@ class ClientProjects extends Component
'date' => now()->subDays(5)->format('d/m/Y')
]
];
}
// Get change orders (simulated for now)
$this->changeOrders = [
[
'id' => 124,
'title' => 'Ampliación de zona de almacenamiento',
'description' => 'Solicitud de ampliación de zona de almacenamiento debido a cambios logísticos.',
'status' => 'pending',
'requested_at' => now()->subDays(10)->format('d/m/Y'),
'amount' => 1500.00
],
[
'id' => 125,
'title' => 'Cambio de material en acabados interiores',
'description' => 'Cambio de cerámica estándar a porcelanato en baños y cocinas.',
'status' => 'pending',
'requested_at' => now()->subDays(5)->format('d/m/Y'),
'amount' => 3200.00
]
// 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)
{
// In a real app, this would update the database
foreach ($this->changeOrders as &$order) {
if ($order['id'] == $orderId) {
$order['status'] = 'approved';
break;
}
}
// 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)
{
// In a real app, this would update the database
foreach ($this->changeOrders as &$order) {
if ($order['id'] == $orderId) {
$order['status'] = 'rejected';
break;
}
}
// 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()
{
-111
View File
@@ -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']),
];
}
}
+36
View File
@@ -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');
}
}
+8 -2
View File
@@ -8,16 +8,22 @@ use Illuminate\Database\Eloquent\Model;
class Project extends Model
{
use HasFactory;
use HasFactory;
protected $fillable = [
'name', 'address', 'lat', 'lng', 'start_date', 'end_date_estimated', 'status', 'created_by'
];
protected $casts = [
'start_date' => 'date',
'end_date_estimated' => 'date',
"start_date" => "date",
"end_date_estimated" => "date",
];
public function changeOrders()
{
return $this->hasMany(ChangeOrder::class);
}
// Relationships
public function phases()
{
@@ -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');
}
};
+29 -3
View File
@@ -42,6 +42,35 @@ window.offlineProgressUpdate = function(phaseId, progress, comment, location) {
window.queueOfflineAction('progress_update', payload);
};
// Function to capture and store image for offline upload
window.captureAndStoreImage = async (file, phaseId, description = '') => {
try {
// Convert file to base64
const base64 = await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result.split(',')[1]); // Get just the base64 part
reader.onerror = reject;
reader.readAsDataURL(file);
});
const payload = {
file: base64,
path: `media/${new Date().getTime()}_${file.name}`, // Simple path, could be improved
model_type: 'App\\Models\\Phase',
model_id: phaseId,
name: file.name,
description: description,
mime_type: file.type
};
window.queueOfflineAction('media_upload', payload);
return true;
} catch (error) {
console.error('Error capturing image:', error);
return false;
}
};
// Sync pending actions when online
window.addEventListener('online', () => {
// Trigger a sync attempt
@@ -49,9 +78,6 @@ window.addEventListener('online', () => {
.then(res => res.json())
.then(data => {
console.log('Synced:', data);
// Optionally, clear the local pending list if the sync was successful
// But note: the backend will mark them as synced, so we rely on the service worker to clean up?
// We'll leave it to the service worker to handle the state.
})
.catch(err => {
console.error('Error triggering sync:', err);
@@ -1,160 +0,0 @@
<div>
<div class="bg-base-100 p-4 rounded shadow">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold">📋 Templates de inspección</h2>
<div>
<button wire:click="newTemplate" class="btn btn-primary btn-sm">
Nuevo template
</button>
</div>
</div>
@if(session()->has('message'))
<div class="alert alert-success mb-4">{{ session('message') }}</div>
@endif
{{-- Formulario de creación/edición con diseño de dos columnas --}}
@if($showForm)
<form wire:submit.prevent="saveTemplate" class="border p-4 rounded mb-6 bg-base-200">
<table class="w-full mb-8">
<tbody>
{{-- Nombre del template --}}
<tr>
<td class="w-1/4 py-3 pr-4 align-top">
{{__('Nombre del template')}}
</td>
<td class="py-3">
<input type="text" wire:model="form.name"
class="input w-full"
required>
</td>
</tr>
{{-- Descripción --}}
<tr>
<td class="w-1/4 py-3 pr-4 align-top">
{{__('Descripción')}}
</td>
<td class="py-3">
<textarea wire:model="form.description" class="textarea textarea-bordered w-full" rows="2"></textarea>
</td>
</tr>
{{-- Fase asociada (opcional) --}}
<tr>
<td class="w-1/4 py-3 pr-4 align-top">
{{__('Fase asociada (opcional)')}}
</td>
<td class="py-3">
<select wire:model="form.phase_id" class="select select-bordered w-full">
<option value="">Ninguna (global para el proyecto)</option>
@foreach($phases as $phase)
<option value="{{ $phase->id }}" {{ old('form.phase_id') == $phase->id ? 'selected' : '' }}>
{{ $phase->name }}
</option>
@endforeach
</select>
</td>
</tr>
</tbody>
</table>
{{-- Campos dinámicos --}}
<div class="border-t pt-4 mt-2">
<h3 class="font-bold mb-3">Campos del formulario</h3>
@foreach($form['fields'] as $index => $field)
<div class="border p-3 rounded mb-3 bg-base-100">
{{-- Fila: nombre interno --}}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-2">
<div class="font-medium">Nombre interno</div>
<div><input type="text" wire:model="form.fields.{{ $index }}.name" placeholder="ej: altura_medida" class="input input-sm w-full"></div>
</div>
{{-- Fila: etiqueta --}}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-2">
<div class="font-medium">Etiqueta visible</div>
<div><input type="text" wire:model="form.fields.{{ $index }}.label" placeholder="ej: Altura medida (m)" class="input input-sm w-full"></div>
</div>
{{-- Fila: tipo --}}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-2">
<div class="font-medium">Tipo de campo</div>
<div>
<select wire:model="form.fields.{{ $index }}.type" class="select select-sm w-full">
@foreach($fieldTypes as $typeValue => $typeLabel)
<option value="{{ $typeValue }}">{{ $typeLabel }}</option>
@endforeach
</select>
</div>
</div>
{{-- Fila: requerido y botón eliminar --}}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-2">
<div class="font-medium">Requerido</div>
<div class="flex justify-between items-center">
<input type="checkbox" wire:model="form.fields.{{ $index }}.required" class="checkbox checkbox-sm">
<button type="button" wire:click="removeField({{ $index }})" class="btn btn-xs btn-error">Eliminar campo</button>
</div>
</div>
{{-- Campos adicionales según tipo --}}
@if(in_array($field['type'], ['integer', 'decimal', 'percentage']))
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-2">
<div class="font-medium">Mínimo / Máximo / Paso</div>
<div class="flex gap-2">
<input type="number" wire:model="form.fields.{{ $index }}.min" placeholder="Mín" class="input input-xs w-20">
<input type="number" wire:model="form.fields.{{ $index }}.max" placeholder="Máx" class="input input-xs w-20">
<input type="number" step="any" wire:model="form.fields.{{ $index }}.step" placeholder="Paso" class="input input-xs w-20">
</div>
</div>
@elseif($field['type'] === 'select')
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-2">
<div class="font-medium">Opciones (separadas por coma)</div>
<div><input type="text" wire:model="form.fields.{{ $index }}.options" placeholder="ej: Bueno,Regular,Malo" class="input input-sm w-full"></div>
</div>
@endif
</div>
@endforeach
<button type="button" wire:click="addField" class="btn btn-sm btn-secondary mt-2">+ Agregar campo</button>
</div>
<div class="flex gap-2 mt-4">
<button type="submit" class="btn btn-primary">Guardar template</button>
<button type="button" wire:click="cancelForm" class="btn">Cancelar</button>
</div>
</form>
@endif
{{-- Tabla de templates existentes --}}
<div class="overflow-x-auto">
<table class="table table-zebra">
<thead>
<tr>
<th>Nombre</th>
<th>Descripción</th>
<th>Fase</th>
<th>Campos</th>
<th>Acciones</th>
</tr>
</thead>
<tbody>
@forelse($templates as $template)
<tr>
<td>{{ $template->name }}</td>
<td>{{ $template->description ?? '-' }}</td>
<td>{{ $template->phase ? $template->phase->name : 'Global' }}</td>
<td>{{ count($template->fields) }}</td>
<td>
<button wire:click="editTemplate({{ $template->id }})" class="btn btn-xs btn-warning">
Editar
</button>
<button wire:click="deleteTemplate({{ $template->id }})" class="btn btn-xs btn-error" onclick="return confirm('¿Eliminar template?')">Eliminar</button>
</td>
</tr>
@empty
<tr>
<td colspan="5" class="text-center">No hay templates creados. Presiona "Nuevo template" para comenzar.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
</div>