Compare commits

1 Commits
main ... betos

Author SHA1 Message Date
700ddafba4 feat: branch betos — propiedades, oferta de proveedores, chat en contratos y flujo de postulacion renovado
- Sistema de propiedades: tabla, modelo, CRUD API para que el cliente guarde direcciones nombradas
- Postulacion renovada: cliente elige propiedad sin precio ni fecha, sube fotos a GCS/S3
- Proveedores ofertan date_1, date_2 y amount al postularse via pivot; sort por fecha/monto/membresia
- Contratacion: selected_date recibe date_1 o date_2, amount desde pivot (depreca minimun_fee)
- Cancelacion/repostulacion: archive con status=perdido, related_postulation_id para vincular
- Chat en contratos activos: tabla contract_comments, controller, rutas web+API, vista panel, notificaciones bilingues
- PaymentIntent calcula amount desde pivot en vez de recibirlo del front
- getpendingcontracts: sin filtro de tiempo, filtra por status=active, agrega time_created
- Expiracion de postulaciones reducida a 1000 min

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 11:26:10 -06:00
21 changed files with 944 additions and 180 deletions

View File

@@ -0,0 +1,132 @@
<?php
namespace App\Http\Controllers;
use OneSignal;
use App\Models\Suppliers;
use App\Models\ContractComment;
use App\Models\CurrentContracts;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Validator;
class ContractCommentController extends Controller
{
public function index(Request $request, $id)
{
$contract = CurrentContracts::with(['user', 'suppliers.user', 'categories', 'status'])->find($id);
if (!$contract) {
abort(404);
}
$comments = ContractComment::with('user')
->where('contract_id', $id)
->orderBy('created_at', 'asc')
->paginate(50);
return view('currentcontracts.comments', compact('comments', 'contract'));
}
public function store(Request $request, $id)
{
$validator = Validator::make($request->all(), [
'comment' => 'required|string',
]);
if ($validator->fails()) {
if ($request->expectsJson()) {
return response()->json($validator->errors(), 422);
}
return redirect()->back()->withErrors($validator);
}
$contract = CurrentContracts::find($id);
if (!$contract) {
if ($request->expectsJson()) {
return response()->json(['message' => 'Contrato no encontrado'], 404);
}
abort(404);
}
$comment = new ContractComment();
$comment->contract_id = $id;
$comment->user_id = Auth::id();
$comment->comment = strip_tags($request->comment);
$comment->save();
$supplier = Suppliers::find($contract->supplier_id);
$isClient = Auth::id() === $contract->user_id;
$recipientId = $isClient ? ($supplier->user_id ?? null) : $contract->user_id;
if ($recipientId) {
try {
OneSignal::sendNotificationCustom([
'include_external_user_ids' => [(string) $recipientId],
'contents' => [
'es' => Auth::user()->name . ': ' . $comment->comment,
'en' => Auth::user()->name . ': ' . $comment->comment,
],
'headings' => [
'es' => 'Nuevo mensaje en tu contrato',
'en' => 'New message on your contract',
],
]);
} catch (\Exception $e) {}
}
if ($request->expectsJson()) {
return response()->json([
'id' => $comment->id,
'sender_id' => Auth::id(),
'sender_name'=> Auth::user()->name,
'comment' => $comment->comment,
'created_at' => $comment->created_at,
], 201);
}
return redirect()->back();
}
public function apiIndex(Request $request, $id)
{
$user = Auth::user();
$contract = CurrentContracts::with('suppliers')->find($id);
if (!$contract) {
return response()->json(['message' => 'Contrato no encontrado'], 404);
}
$supplier = Suppliers::find($contract->supplier_id);
$supplierUserId = $supplier->user_id ?? null;
if ($user->id !== $contract->user_id && $user->id !== $supplierUserId) {
return response()->json(['message' => 'No autorizado'], 403);
}
$comments = ContractComment::with('user')
->where('contract_id', $id)
->orderBy('created_at', 'asc')
->get()
->map(function ($c) use ($contract, $supplierUserId) {
$isSupplier = $c->user_id === $supplierUserId;
return [
'id' => $c->id,
'sender_id' => $c->user_id,
'sender_name' => $c->user->name ?? null,
'role_id' => $c->user->role_id ?? null,
'comment' => $c->comment,
'created_at' => $c->created_at,
];
});
return response()->json($comments);
}
public function destroy($id, $comment_id)
{
ContractComment::destroy($comment_id);
return redirect('currentcontracts/' . $id . '/comments');
}
}

View File

@@ -165,6 +165,7 @@ class ContractController extends Controller
$rules = [ $rules = [
'postulation_id' => 'required|numeric', 'postulation_id' => 'required|numeric',
'supplier_id' => 'required|numeric', 'supplier_id' => 'required|numeric',
'selected_date' => 'required|in:date_1,date_2',
'payment_intent_id' => $paymentBypass ? 'nullable|string' : 'required|string', 'payment_intent_id' => $paymentBypass ? 'nullable|string' : 'required|string',
'coupon' => 'nullable|string', 'coupon' => 'nullable|string',
]; ];
@@ -192,6 +193,14 @@ class ContractController extends Controller
$ichambafee = iChambaParameter::where('parameter', 'ichamba_fee')->first(); $ichambafee = iChambaParameter::where('parameter', 'ichamba_fee')->first();
$category = Categories::where('id', $postulation->category_id)->first(); $category = Categories::where('id', $postulation->category_id)->first();
// Tomar amount del pivot (oferta del proveedor elegido por el cliente)
$pivotSupplier = $postulation->suppliers()
->where('suppliers.id', $request->supplier_id)
->first();
$minFee = 150;
$pivotAmount = $pivotSupplier ? ($pivotSupplier->pivot->amount ?? $minFee) : $minFee;
$finalAmount = max($pivotAmount, $minFee);
if (true) { // autorización verificada arriba con user_id == postulation->user_id if (true) { // autorización verificada arriba con user_id == postulation->user_id
$contract = new CurrentContracts(); $contract = new CurrentContracts();
@@ -206,16 +215,17 @@ class ContractController extends Controller
$contract->int_number = 0; $contract->int_number = 0;
} }
$contract->references = $postulation->references; $contract->references = $postulation->references;
$contract->appointment = $postulation->appointment; $selectedDate = $request->selected_date;
$contract->amount = ($supplier->minimun_fee < 150 ? 150 : $supplier->minimun_fee); $contract->appointment = Carbon::parse($pivotSupplier->pivot->$selectedDate);
$contract->amount = $finalAmount;
if (isset($IVA->num_value) && isset($IVA->num_value)) { if (isset($IVA->num_value) && isset($IVA->num_value)) {
$contract->IVA = $IVA->num_value; $contract->IVA = $IVA->num_value;
$contract->ISR = $ISR->num_value; $contract->ISR = $ISR->num_value;
$contract->revenue = ((($supplier->minimun_fee < 150 ? 150 : $supplier->minimun_fee) * ((100 - $ichambafee->num_value) / 100)) * ((100 - $IVA->num_value - $ISR->num_value) / 100)); $contract->revenue = (($finalAmount * ((100 - $ichambafee->num_value) / 100)) * ((100 - $IVA->num_value - $ISR->num_value) / 100));
} else { } else {
$contract->IVA = 0; $contract->IVA = 0;
$contract->ISR = 0; $contract->ISR = 0;
$contract->revenue = (($supplier->minimun_fee < 150 ? 150 : $supplier->minimun_fee) * ((100 - $ichambafee->num_value) / 100)); $contract->revenue = ($finalAmount * ((100 - $ichambafee->num_value) / 100));
} }
$contract->ichamba_fee = $ichambafee->num_value; $contract->ichamba_fee = $ichambafee->num_value;
$contract->details = $postulation->details; $contract->details = $postulation->details;
@@ -229,40 +239,18 @@ class ContractController extends Controller
if ($coupon->limit > 0) { if ($coupon->limit > 0) {
if(!isset($checkccontracts) && !isset($checkccontracts)) { if(!isset($checkccontracts) && !isset($checkccontracts)) {
$fee = ($supplier->minimun_fee < 150 ? 150 : $supplier->minimun_fee); $fee = $finalAmount;
$discount = (($fee*(($coupon->percentage = null ? 0 : $coupon->percentage)/100))+($coupon->amount = null ? 0 : $coupon->amount)); $discount = (($fee*(($coupon->percentage = null ? 0 : $coupon->percentage)/100))+($coupon->amount = null ? 0 : $coupon->amount));
$contract->coupon_id = $coupon->id; $contract->coupon_id = $coupon->id;
// Solo crear chargeData si no estamos en bypass mode
if (!$paymentBypass && $card) {
$chargeData = array(
'source_id' => $card->token,
'method' => 'card',
'amount' => ((($supplier->minimun_fee < 150 ? 150 : $supplier->minimun_fee)*(1 - (($coupon->percentage = null ? 0 : $coupon->percentage)/100)))-($coupon->amount = null ? 0 : $coupon->amount)),
'description' => ('Contrato del usuario: ' . $user->name . ' del servicio ' . $category->name . ' realizado por el proveedor: ' . $supplier->company_name),
'device_session_id' => $request->device_id,
'cvv2' => $request->code
);
}
} }
} }
} else { } else {
$fee = ($supplier->minimun_fee < 150 ? 150 : $supplier->minimun_fee); $fee = $finalAmount;
$discount = 0; $discount = 0;
$contract->coupon_id = null; $contract->coupon_id = null;
// Solo crear chargeData si no estamos en bypass mode
if (!$paymentBypass && $card) {
$chargeData = array(
'source_id' => $card->token,
'method' => 'card',
'amount' => ($supplier->minimun_fee < 150 ? 150 : $supplier->minimun_fee),
'description' => ('Contrato del usuario: ' . $user->name . ' del servicio ' . $category->name . ' realizado por el proveedor: ' . $supplier->company_name),
'device_session_id' => $request->device_id,
'cvv2' => $request->code
);
}
} }
@@ -345,6 +333,7 @@ class ContractController extends Controller
$rules = [ $rules = [
'postulation_id' => 'required|numeric', 'postulation_id' => 'required|numeric',
'supplier_id' => 'required|numeric', 'supplier_id' => 'required|numeric',
'selected_date' => 'required|in:date_1,date_2',
'coupon' => 'required|string|regex:/(^[A-Za-z0-9 ]+$)+/', 'coupon' => 'required|string|regex:/(^[A-Za-z0-9 ]+$)+/',
]; ];
@@ -387,16 +376,23 @@ class ContractController extends Controller
$contract->int_number = 0; $contract->int_number = 0;
} }
$contract->references = $postulation->references; $contract->references = $postulation->references;
$contract->appointment = $postulation->appointment; $pivotForCoupon = $postulation->suppliers()->where('suppliers.id', $request->supplier_id)->first();
$contract->amount = ($supplier->minimun_fee < 150 ? 150 : $supplier->minimun_fee); $couponDate = $request->selected_date;
$contract->appointment = $pivotForCoupon
? Carbon::parse($pivotForCoupon->pivot->$couponDate)
: Carbon::now();
$minFeeC = 150;
$pivotAmountC = $pivotForCoupon ? ($pivotForCoupon->pivot->amount ?? $minFeeC) : $minFeeC;
$finalAmountC = max($pivotAmountC, $minFeeC);
$contract->amount = $finalAmountC;
if (isset($IVA->num_value) && isset($IVA->num_value)) { if (isset($IVA->num_value) && isset($IVA->num_value)) {
$contract->IVA = $IVA->num_value; $contract->IVA = $IVA->num_value;
$contract->ISR = $ISR->num_value; $contract->ISR = $ISR->num_value;
$contract->revenue = ((($supplier->minimun_fee < 150 ? 150 : $supplier->minimun_fee) * ((100 - $ichambafee->num_value) / 100)) * ((100 - $IVA->num_value - $ISR->num_value) / 100)); $contract->revenue = (($finalAmountC * ((100 - $ichambafee->num_value) / 100)) * ((100 - $IVA->num_value - $ISR->num_value) / 100));
} else { } else {
$contract->IVA = 0; $contract->IVA = 0;
$contract->ISR = 0; $contract->ISR = 0;
$contract->revenue = (($supplier->minimun_fee < 150 ? 150 : $supplier->minimun_fee) * ((100 - $ichambafee->num_value) / 100)); $contract->revenue = ($finalAmountC * ((100 - $ichambafee->num_value) / 100));
} }
$contract->ichamba_fee = $ichambafee->num_value; $contract->ichamba_fee = $ichambafee->num_value;
$contract->details = $postulation->details; $contract->details = $postulation->details;

View File

@@ -9,6 +9,8 @@ use Carbon\Carbon;
use App\Models\User; use App\Models\User;
use App\Models\Suppliers; use App\Models\Suppliers;
use App\Models\Payments; use App\Models\Payments;
use App\Models\Postulations;
use App\Models\Coupon;
use App\Models\FinishedContracts; use App\Models\FinishedContracts;
use App\Models\PaymentBatch; use App\Models\PaymentBatch;
use Stripe\Stripe; use Stripe\Stripe;
@@ -94,19 +96,43 @@ class PaymentController extends Controller
public function intent(Request $request) public function intent(Request $request)
{ {
$validator = Validator::make($request->all(), [ $validator = Validator::make($request->all(), [
'supplier_id' => 'required|numeric', 'postulation_id' => 'required|numeric|exists:postulations,id',
'supplier_id' => 'required|numeric|exists:suppliers,id',
'coupon' => 'nullable|string',
]); ]);
if ($validator->fails()) { if ($validator->fails()) {
return response()->json($validator->errors(), 422); return response()->json($validator->errors(), 422);
} }
$user = Auth::user(); $user = Auth::user();
$supplier = Suppliers::find($request->supplier_id); $postulation = Postulations::find($request->postulation_id);
if (!$supplier) { if ($postulation->user_id !== $user->id) {
return response()->json(['message' => 'Proveedor no encontrado'], 404); return response()->json(['message' => 'No autorizado'], 403);
} }
$pivotSupplier = $postulation->suppliers()
->where('suppliers.id', $request->supplier_id)
->first();
if (!$pivotSupplier) {
return response()->json(['message' => 'El proveedor no se ha postulado a este servicio'], 404);
}
$minFee = 150;
$pivotAmount = $pivotSupplier->pivot->amount ?? $minFee;
$finalAmount = max($pivotAmount, $minFee);
$discount = 0;
if ($request->coupon) {
$coupon = Coupon::where('name', $request->coupon)->first();
if ($coupon && $coupon->limit > 0) {
$discount = ($finalAmount * (($coupon->percentage ?? 0) / 100)) + ($coupon->amount ?? 0);
}
}
$chargeAmount = max($finalAmount - $discount, 0);
Stripe::setApiKey(env('STRIPE_SECRET')); Stripe::setApiKey(env('STRIPE_SECRET'));
if (!$user->stripe_customer_id) { if (!$user->stripe_customer_id) {
@@ -118,8 +144,6 @@ class PaymentController extends Controller
$user->save(); $user->save();
} }
$amount = max((int) $supplier->minimun_fee, 150);
try { try {
$ephemeralKey = \Stripe\EphemeralKey::create( $ephemeralKey = \Stripe\EphemeralKey::create(
['customer' => $user->stripe_customer_id], ['customer' => $user->stripe_customer_id],
@@ -127,7 +151,7 @@ class PaymentController extends Controller
); );
$intent = \Stripe\PaymentIntent::create([ $intent = \Stripe\PaymentIntent::create([
'amount' => $amount * 100, 'amount' => (int) ($chargeAmount * 100),
'currency' => env('STRIPE_CURRENCY', 'mxn'), 'currency' => env('STRIPE_CURRENCY', 'mxn'),
'customer' => $user->stripe_customer_id, 'customer' => $user->stripe_customer_id,
'automatic_payment_methods' => ['enabled' => true], 'automatic_payment_methods' => ['enabled' => true],
@@ -141,6 +165,7 @@ class PaymentController extends Controller
'client_secret' => $intent->client_secret, 'client_secret' => $intent->client_secret,
'customer' => $user->stripe_customer_id, 'customer' => $user->stripe_customer_id,
'ephemeral_key' => $ephemeralKey->secret, 'ephemeral_key' => $ephemeralKey->secret,
'amount' => $chargeAmount,
]); ]);
} }

View File

@@ -9,10 +9,13 @@ use OneSignal;
use App\Models\Suppliers; use App\Models\Suppliers;
use App\Models\Categories; use App\Models\Categories;
use App\Models\Postulations; use App\Models\Postulations;
use App\Models\Property;
use App\Models\iChambaParameter;
use MissaelAnda\Whatsapp; use MissaelAnda\Whatsapp;
use MissaelAnda\Whatsapp\Messages; use MissaelAnda\Whatsapp\Messages;
use App\Models\FinishedContracts; use App\Models\FinishedContracts;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
use TarfinLabs\LaravelSpatial\Types\Point; use TarfinLabs\LaravelSpatial\Types\Point;
@@ -90,14 +93,12 @@ class PostulationController extends Controller
$rules = [ $rules = [
'category' => 'required|string', 'category' => 'required|string',
'address' => 'required|string', 'property_id' => 'required|numeric',
'int_number' => 'numeric|nullable',
'references' => 'string|nullable', 'references' => 'string|nullable',
'setdate' => 'required|string',
'sethour' => 'required|string',
'details' => 'string|nullable', 'details' => 'string|nullable',
'lat' => 'required|numeric', 'photos' => 'nullable|array',
'lng' => 'required|numeric', 'photos.*' => 'nullable|image|max:4096',
'related_postulation_id' => 'nullable|numeric|exists:postulations,id',
]; ];
$validator = Validator::make($request->all(), $rules); $validator = Validator::make($request->all(), $rules);
@@ -106,7 +107,13 @@ class PostulationController extends Controller
} else { } else {
$user = Auth::user(); $user = Auth::user();
$geometry = new Point($request->lat, $request->lng); $property = Property::find($request->property_id);
if (!$property || $property->user_id !== $user->id) {
return response()->json(['message' => 'Propiedad no válida'], 403);
}
$geometry = new Point($property->lat, $property->lng);
$category = Categories::where('name', strip_tags($request->category))->orwhere('en_name', strip_tags($request->category))->first(); $category = Categories::where('name', strip_tags($request->category))->orwhere('en_name', strip_tags($request->category))->first();
$distance = 5000; // metros (5 km) $distance = 5000; // metros (5 km)
@@ -117,18 +124,30 @@ class PostulationController extends Controller
$postulation = new Postulations(); $postulation = new Postulations();
$postulation->user_id = $user->id; $postulation->user_id = $user->id;
$postulation->category_id = $category->id; $postulation->category_id = $category->id;
$postulation->address = strip_tags($request->address); $postulation->property_id = $property->id;
$postulation->address = $property->address;
$postulation->location = $geometry; $postulation->location = $geometry;
$postulation->int_number = $request->int_number; $postulation->int_number = $property->int_number;
$postulation->references = preg_replace('/\d+/', '', strip_tags($request->references)); $postulation->references = preg_replace('/\d+/', '', strip_tags($request->references));
// Parsear fecha y hora del formato ISO que envía el frontend $postulation->amount = null;
$dateStr = substr(strip_tags($request->setdate), 0, 10); // "2026-01-28"
$timeStr = substr(strip_tags($request->sethour), 11, 8); // "19:47:00"
$postulation->appointment = Carbon::createFromFormat('Y-m-d H:i:s', $dateStr . ' ' . $timeStr, 'America/Mexico_City')->tz('UTC');
$postulation->amount = 5000;
$postulation->details = preg_replace('/\d+/', '', strip_tags($request->details)); $postulation->details = preg_replace('/\d+/', '', strip_tags($request->details));
$postulation->status = 'active';
$postulation->related_postulation_id = $request->related_postulation_id;
$postulation->save(); $postulation->save();
// Subir fotos a storage
if ($request->hasFile('photos')) {
$disk = Storage::disk(env('STORAGE_DISK', 'gcs'));
$photoUrls = [];
foreach ($request->file('photos') as $i => $photo) {
$filename = time() . '_' . $i . '.' . $photo->getClientOriginalExtension();
$disk->putFileAs('img/postulations/' . $postulation->id . '/', $photo, $filename, 'public');
$photoUrls[] = $disk->url('img/postulations/' . $postulation->id . '/' . $filename);
}
$postulation->photos = $photoUrls;
$postulation->save();
}
try { try {
OneSignal::sendNotificationToExternalUser( OneSignal::sendNotificationToExternalUser(
"Coméntele al Ing. que hay una postulación", "Coméntele al Ing. que hay una postulación",
@@ -160,19 +179,6 @@ class PostulationController extends Controller
} }
} }
$minutes = intval(substr(substr($request->sethour, 14), 0, 2) + 15);
$hours = intval(substr(substr($request->sethour, 11), 0, 2) + 1);
if ($minutes > 59) {
if ($hours > 23){
$delay_msg = Carbon::now()->addDays(1)->toDateString() . ' ' . ($hours - 24) . ':' . ($minutes - 60) . substr(substr($request->sethour, 16), 0, 3);
} else {
$delay_msg = substr($request->sethour, 0, 10) . ' ' . $hours . ':' . ($minutes - 60) . substr(substr($request->sethour, 16), 0, 3);
}
} else {
$delay_msg = substr($request->sethour, 0, 10) . ' ' . $hours . ':' . $minutes . substr(substr($request->sethour, 16), 0, 3);
}
$delay_UTC = Carbon::now()->addMinutes(15)->toString(); $delay_UTC = Carbon::now()->addMinutes(15)->toString();
try { try {
@@ -200,17 +206,20 @@ class PostulationController extends Controller
$rules = [ $rules = [
'postulation_id' => 'required|numeric', 'postulation_id' => 'required|numeric',
'date_1' => 'required|date',
'date_2' => 'required|date',
'amount' => 'required|numeric|min:0',
]; ];
$validator = Validator::make($request->all(), $rules); $validator = Validator::make($request->all(), $rules);
if ($validator->fails()) { if ($validator->fails()) {
return redirect()->back()->withInput($request->all())->withErrors($validator); return response()->json($validator->errors(), 422);
} else { }
$user = Auth::user(); $user = Auth::user();
$postulation = Postulations::where('id', $request->postulation_id)->first(); $postulation = Postulations::where('id', $request->postulation_id)->first();
$time_created = Carbon::parse($postulation->created_at); $time_created = Carbon::parse($postulation->created_at);
$time_limit = (9900 - Carbon::now()->diffInMinutes($time_created)); $time_limit = (1000 - Carbon::now()->diffInMinutes($time_created));
$supplier = Suppliers::where('user_id', $user->id)->first(); $supplier = Suppliers::where('user_id', $user->id)->first();
if (!$supplier) { if (!$supplier) {
@@ -220,6 +229,14 @@ class PostulationController extends Controller
], 400); ], 400);
} }
// Validar cuota mínima del sistema
$minFeeParam = iChambaParameter::where('parameter', 'ichamba_fee')->first();
if ($minFeeParam && $request->amount < $minFeeParam->num_value) {
return response()->json([
'message' => 'El monto es menor a la cuota mínima del sistema (' . $minFeeParam->num_value . ')'
], 422);
}
if ($time_limit > 0) { if ($time_limit > 0) {
if (in_array($postulation->category_id, $supplier->categories->pluck('id')->toArray())) { if (in_array($postulation->category_id, $supplier->categories->pluck('id')->toArray())) {
if ($supplier->membership == 1) { if ($supplier->membership == 1) {
@@ -232,38 +249,33 @@ class PostulationController extends Controller
); );
} catch (\Exception $e) {} } catch (\Exception $e) {}
} }
// TODO: Configurar WhatsApp cuando esté disponible
// Whatsapp::send($postulation->user->phone, Messages\TemplateMessage::create()
// ->name('suppplier_postulated')
// ->language('es_US')
// ->body(Messages\Components\Body::create([
// Messages\Components\Parameters\Text::create('Un proveedor certificado se ha postulado: dirígete a la sección de contratos en JobHero para ver más detalles'),
// ])));
$supplier->postulations()->attach($request->postulation_id); $supplier->postulations()->attach($request->postulation_id, [
$supplier->save(); 'date_1' => Carbon::parse($request->date_1),
'date_2' => Carbon::parse($request->date_2),
'amount' => $request->amount,
]);
return response()->json([ return response()->json([
'message' => 'Se ha postulado al servicio exitosamente' 'message' => 'Se ha postulado al servicio exitosamente'
]); ]);
} }
} else { } else {
return response()->json([ return response()->json([
'message' => 'La postulación ha caducado' 'message' => 'La postulación ha caducado'
]); ]);
} }
} }
}
public function getpendingcontracts(Request $request) { public function getpendingcontracts(Request $request) {
$user = Auth::user(); $user = Auth::user();
$postulations = Postulations::where('user_id', $user->id)->orderBy('created_at', 'DESC')->get(); $postulations = Postulations::where('user_id', $user->id)
->where('status', 'active')
->orderBy('created_at', 'DESC')
->get();
$pendingcontracts = array(); $pendingcontracts = array();
foreach($postulations as $postulation) { foreach($postulations as $postulation) {
$time_limit = Carbon::parse($postulation->appointment);
if ($time_limit->diffInMinutes(Carbon::now(), false) <= -60) {
$category = Categories::where('id', $postulation->category_id)->first(); $category = Categories::where('id', $postulation->category_id)->first();
$pendingcontractinfo = array( $pendingcontractinfo = array(
'id' => $postulation->id, 'id' => $postulation->id,
@@ -271,11 +283,14 @@ class PostulationController extends Controller
'en_category' => $category->en_name, 'en_category' => $category->en_name,
'address' => $postulation->address, 'address' => $postulation->address,
'date' => $postulation->appointment, 'date' => $postulation->appointment,
'amount' => $postulation->amount 'amount' => $postulation->amount,
'property_id' => $postulation->property_id,
'references' => $postulation->references,
'details' => $postulation->details,
'time_created' => $postulation->created_at,
); );
$pendingcontracts[] = $pendingcontractinfo; $pendingcontracts[] = $pendingcontractinfo;
} }
}
return response()->json($pendingcontracts); return response()->json($pendingcontracts);
} }
@@ -323,42 +338,51 @@ class PostulationController extends Controller
$validator = Validator::make($request->all(), $rules); $validator = Validator::make($request->all(), $rules);
if ($validator->fails()) { if ($validator->fails()) {
return redirect()->back()->withInput($request->all())->withErrors($validator); return response()->json($validator->errors(), 422);
} else { }
$user = Auth::user(); $user = Auth::user();
$postulation = Postulations::where('id', $request->postulation_id)->first(); $postulation = Postulations::with('suppliers')->where('id', $request->postulation_id)->first();
if ($postulation->user_id == $user->id) { if ($postulation->user_id == $user->id) {
$category = Categories::where('id', $postulation->category_id)->first(); $category = Categories::where('id', $postulation->category_id)->first();
$suppliers = Suppliers::whereHas('postulations', function($q) use ($request) { $suppliers = $postulation->suppliers;
$q->where('postulations_id', $request->postulation_id);
})->get();
$pcontractsuppliers = array(); $pcontractsuppliers = array();
if ($suppliers != '[]') { if ($suppliers->isNotEmpty()) {
foreach ($suppliers as $supplier) { foreach ($suppliers as $supplier) {
$pivot = $supplier->pivot;
$pcontractsupplier = array( $pcontractsupplier = array(
'id' => $postulation->id, 'id' => $postulation->id,
'category' => $category->name, 'category' => $category->name,
'en_category' => $category->en_name, 'en_category' => $category->en_name,
'address' => $postulation->address, 'address' => $postulation->address,
'date' => $postulation->appointment,
'amount' => $postulation->amount,
'supplier_id' => $supplier->id, 'supplier_id' => $supplier->id,
'supplier' => $supplier->company_name, 'supplier' => $supplier->company_name,
'tags' => $supplier->tags, 'tags' => $supplier->tags,
'cover_photo' => $supplier->cover_photo, 'cover_photo' => $supplier->cover_photo,
'membership' => $supplier->membership, 'membership' => $supplier->membership,
'fee' => $supplier->minimun_fee, 'fee' => $pivot->amount,
'score' => round($supplier->total_score/$supplier->finished_jobs, 1), 'date_1' => $pivot->date_1,
'date_2' => $pivot->date_2,
'score' => $supplier->finished_jobs > 0
? round($supplier->total_score / $supplier->finished_jobs, 1)
: null,
); );
$pcontractsuppliers[] = $pcontractsupplier; $pcontractsuppliers[] = $pcontractsupplier;
} }
$pcontractsuppliercollection = collect($pcontractsuppliers)->sortByDesc('membership')->sortByDesc('score'); $collection = collect($pcontractsuppliers);
$pcontractsupplier = $pcontractsuppliercollection->values()->all(); $sort = $request->get('sort', 'membership');
if ($sort === 'date') {
$pcontractsupplier = $collection->sortBy('date_1')->values()->all();
} elseif ($sort === 'amount') {
$pcontractsupplier = $collection->sortBy('fee')->values()->all();
} else {
$pcontractsupplier = $collection->sortByDesc('membership')->sortByDesc('score')->values()->all();
}
} else { } else {
$pcontractsupplier = array( $pcontractsupplier = array(
@@ -366,14 +390,14 @@ class PostulationController extends Controller
'category' => $category->name, 'category' => $category->name,
'en_category' => $category->en_name, 'en_category' => $category->en_name,
'address' => $postulation->address, 'address' => $postulation->address,
'date' => $postulation->appointment,
'amount' => $postulation->amount,
'supplier_id' => null, 'supplier_id' => null,
'supplier' => null, 'supplier' => null,
'tags' => null, 'tags' => null,
'cover_photo' => null, 'cover_photo' => null,
'membership' => null, 'membership' => null,
'fee' => null, 'fee' => null,
'date_1' => null,
'date_2' => null,
'score' => null, 'score' => null,
); );
@@ -383,13 +407,69 @@ class PostulationController extends Controller
return response()->json($pcontractsupplier); return response()->json($pcontractsupplier);
} }
} }
}
public function deleteexpired() public function deleteexpired()
{ {
$postulations = Postulations::whereDate('appointment', '<', Carbon::now())->delete(); $postulations = Postulations::whereDate('appointment', '<', Carbon::now())->delete();
} }
public function cancelPostulation($id)
{
$user = Auth::user();
$postulation = Postulations::find($id);
if (!$postulation || $postulation->user_id !== $user->id) {
return response()->json(['message' => 'No autorizado'], 403);
}
$postulation->status = 'perdido';
$postulation->save();
return response()->json(['message' => 'Postulación archivada']);
}
public function updatePostulation(Request $request, $id)
{
$user = Auth::user();
$postulation = Postulations::find($id);
if (!$postulation || $postulation->user_id !== $user->id) {
return response()->json(['message' => 'No autorizado'], 403);
}
$rules = [
'references' => 'nullable|string',
'details' => 'nullable|string',
'photos' => 'nullable|array',
'photos.*' => 'nullable|image|max:4096',
];
$validator = Validator::make($request->all(), $rules);
if ($validator->fails()) {
return response()->json($validator->errors(), 422);
}
if ($request->has('references')) {
$postulation->references = preg_replace('/\d+/', '', strip_tags($request->references));
}
if ($request->has('details')) {
$postulation->details = preg_replace('/\d+/', '', strip_tags($request->details));
}
if ($request->hasFile('photos')) {
$disk = Storage::disk(env('STORAGE_DISK', 'gcs'));
$photoUrls = [];
foreach ($request->file('photos') as $i => $photo) {
$filename = time() . '_' . $i . '.' . $photo->getClientOriginalExtension();
$disk->putFileAs('img/postulations/' . $postulation->id . '/', $photo, $filename, 'public');
$photoUrls[] = $disk->url('img/postulations/' . $postulation->id . '/' . $filename);
}
$postulation->photos = $photoUrls;
}
$postulation->save();
return response()->json(['message' => 'Postulación actualizada']);
}
public function destroy($id) public function destroy($id)
{ {
Postulations::destroy($id); Postulations::destroy($id);

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Http\Controllers;
use App\Models\Property;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Validator;
class PropertyController extends Controller
{
public function index()
{
$properties = Auth::user()->properties()->orderBy('name')->get();
return response()->json($properties);
}
public function store(Request $request)
{
$validator = Validator::make($request->all(), [
'name' => 'required|string',
'address' => 'required|string',
'lat' => 'required|numeric',
'lng' => 'required|numeric',
'int_number' => 'nullable|string',
'icon' => 'nullable|in:casa,oficina',
]);
if ($validator->fails()) {
return response()->json($validator->errors(), 422);
}
$property = new Property();
$property->user_id = Auth::id();
$property->name = strip_tags($request->name);
$property->address = strip_tags($request->address);
$property->lat = $request->lat;
$property->lng = $request->lng;
$property->int_number = $request->int_number ? strip_tags($request->int_number) : null;
$property->icon = $request->icon ?? 'casa';
$property->save();
return response()->json($property, 201);
}
public function destroy($id)
{
$property = Property::find($id);
if (!$property || $property->user_id !== Auth::id()) {
return response()->json(['message' => 'No autorizado'], 403);
}
$property->delete();
return response()->json(['message' => 'Propiedad eliminada']);
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class ContractComment extends Model
{
protected $fillable = [
'contract_id', 'user_id', 'comment',
];
public function contract()
{
return $this->belongsTo(CurrentContracts::class, 'contract_id');
}
public function user()
{
return $this->belongsTo(User::class);
}
}

View File

@@ -8,6 +8,7 @@ use App\Models\Suppliers;
use App\Models\Coupon; use App\Models\Coupon;
use App\Models\Status; use App\Models\Status;
use App\Models\Technician; use App\Models\Technician;
use App\Models\ContractComment;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use TarfinLabs\LaravelSpatial\Casts\LocationCast; use TarfinLabs\LaravelSpatial\Casts\LocationCast;
use TarfinLabs\LaravelSpatial\Traits\HasSpatial; use TarfinLabs\LaravelSpatial\Traits\HasSpatial;
@@ -68,4 +69,8 @@ class CurrentContracts extends Model
return $this->belongsTo(Technician::class, 'technical_id'); return $this->belongsTo(Technician::class, 'technical_id');
} }
public function contractComments()
{
return $this->hasMany(ContractComment::class, 'contract_id');
}
} }

View File

@@ -6,6 +6,7 @@ use App\Models\User;
use App\Models\Categories; use App\Models\Categories;
use App\Models\Suppliers; use App\Models\Suppliers;
use App\Models\Status; use App\Models\Status;
use App\Models\Property;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use TarfinLabs\LaravelSpatial\Casts\LocationCast; use TarfinLabs\LaravelSpatial\Casts\LocationCast;
use TarfinLabs\LaravelSpatial\Traits\HasSpatial; use TarfinLabs\LaravelSpatial\Traits\HasSpatial;
@@ -18,17 +19,22 @@ class Postulations extends Model
protected $fillable = [ protected $fillable = [
'user_id', 'user_id',
'category_id', 'category_id',
'property_id',
'address', 'address',
'int_number', 'int_number',
'references', 'references',
'appointment', 'appointment',
'amount', 'amount',
'details', 'details',
'photos',
'status',
'related_postulation_id',
'status_id', 'status_id',
]; ];
protected $casts = [ protected $casts = [
'location' => LocationCast::class 'location' => LocationCast::class,
'photos' => 'array',
]; ];
public function user() public function user()
@@ -44,7 +50,19 @@ class Postulations extends Model
public function suppliers() public function suppliers()
{ {
return $this->belongsToMany(Suppliers::class, 'postulations_suppliers', 'postulations_id', 'suppliers_id')->withTimestamps(); return $this->belongsToMany(Suppliers::class, 'postulations_suppliers', 'postulations_id', 'suppliers_id')
->withPivot('date_1', 'date_2', 'amount')
->withTimestamps();
}
public function property()
{
return $this->belongsTo(Property::class);
}
public function relatedPostulation()
{
return $this->belongsTo(Postulations::class, 'related_postulation_id');
} }
public function status() public function status()

17
app/Models/Property.php Normal file
View File

@@ -0,0 +1,17 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Property extends Model
{
protected $fillable = [
'user_id', 'name', 'address', 'int_number', 'lat', 'lng', 'icon',
];
public function user()
{
return $this->belongsTo(User::class);
}
}

View File

@@ -56,7 +56,9 @@ class Suppliers extends Model
public function postulations() public function postulations()
{ {
return $this->belongsToMany(Postulations::class, 'postulations_suppliers', 'suppliers_id', 'postulations_id')->withTimestamps(); return $this->belongsToMany(Postulations::class, 'postulations_suppliers', 'suppliers_id', 'postulations_id')
->withPivot('date_1', 'date_2', 'amount')
->withTimestamps();
} }
public function payments() public function payments()

View File

@@ -8,6 +8,8 @@ use App\Models\Suppliers;
use App\Models\Postulations; use App\Models\Postulations;
use App\Models\Report; use App\Models\Report;
use App\Models\ReportComment; use App\Models\ReportComment;
use App\Models\Property;
use App\Models\ContractComment;
use App\Models\CurrentContracts; use App\Models\CurrentContracts;
use App\Models\FinishedContracts; use App\Models\FinishedContracts;
use Laravel\Passport\HasApiTokens; use Laravel\Passport\HasApiTokens;
@@ -82,6 +84,16 @@ class User extends Authenticatable
return $this->hasMany(ReportComment::class); return $this->hasMany(ReportComment::class);
} }
public function properties()
{
return $this->hasMany(Property::class);
}
public function contractcomments()
{
return $this->hasMany(ContractComment::class);
}
public function roles() public function roles()
{ {
return $this->belongsTo(Role::class, 'role_id'); return $this->belongsTo(Role::class, 'role_id');

14
betos_branch.md Normal file
View File

@@ -0,0 +1,14 @@
Este documento tiene notas tanto para back como para front. El backend está hecho en Laravel y el front en Ionic, adecua las notas a tu área correspondiente. Los cambios a continuación son bastante radicales, asi que sugiero crear un branch que se llame “betos” para trabajar esto.
Los clientes deben de tener una tabla de propiedades (properties), que guarda las direcciones de sus propiedades (con coordenadas y numero exterior). Esto va a ser un cambio en category.ts, porque ahora donde dice Dirección va a ser Propiedad, con un listbox que se alimente de las propiedades ligadas al usuario de la siguente forma “Nombre de la propiedad: Dirección”, si no hay ninguna o el servicio no es para ninguna de estas, debe de abrirse el modal “Agregar Propiedad”, que pida la dirección (con autocomplete), guarde las coordenadas y el numero exterior (inputs hidden igual que en category.ts), y la guarde con un nombre personalizado asignado por el usuario (E incluso le puede poner un icono para designar si es casa u oficina).
Para las postulaciones vamos a tener cambios radicales; ahora el cliente solo pondrá la categoría, la propiedad y comentarios cuando quiera postular un servicio. Adiós a definir un precio y fecha. También es importante que el cliente pueda adjuntar fotos de lo que necesita que se repare o se instale.
Cuando el proveedor se postula a una postulación, debe definir una fecha en la que puede realizar el servicio y a que costo (ojo, la cuota mínima a nivel sistema se mantiene). Nota para back: eso también quiere decir que ahora ya no debe de haber un mínimum fee en la tabla de suppliers, y que la tabla pivote postulations_suppliers debe de guardar al menos dos fechas tentativas en las que puede dar el servicio y el monto que desea cobrar por el servicio.
En viewsuppliers el cliente debe de poder filtrar a los postulantes por fecha más pronta en la que se puede realizar el servicio, monto más barato y si el proveedor está certificado. Definir si es más fácil hacer estos sorts en front o en back
Una vez que la postulación se vuelva un CurrentContract, debemos de implementar la posibilidad de que el cliente y el proveedor se comuniquen dentro de la app de la misma manera que lo hacen en ReportDiscussion. Nota para back: Hay que agregar un botón en los Contratos Actuales que te permita ver la discusión de cada contrato y los detalles del contrato igual que lo hacemos ya en los Reportes.
Por último, con estos nuevos cambios se vuelve complicado determinar como manejaremos el NoHome, si tienes alguna idea eres libre de comentar.
Dime si estoy omitiendo algo que sea importante o nos pueda dar problemas.

View File

@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreatePropertiesTable extends Migration
{
public function up()
{
Schema::create('properties', function (Blueprint $table) {
$table->bigIncrements('id');
$table->unsignedBigInteger('user_id');
$table->string('name');
$table->string('address');
$table->string('int_number')->nullable();
$table->decimal('lat', 10, 7);
$table->decimal('lng', 10, 7);
$table->enum('icon', ['casa', 'oficina'])->default('casa');
$table->timestamps();
$table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
});
}
public function down()
{
Schema::dropIfExists('properties');
}
}

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AlterPostulationsAddPropertyNullableAmount extends Migration
{
public function up()
{
Schema::table('postulations', function (Blueprint $table) {
$table->unsignedBigInteger('property_id')->nullable()->after('category_id');
$table->json('photos')->nullable()->after('details');
$table->float('amount')->nullable()->change();
$table->foreign('property_id')->references('id')->on('properties')->onDelete('set null');
});
}
public function down()
{
Schema::table('postulations', function (Blueprint $table) {
$table->dropForeign(['property_id']);
$table->dropColumn(['property_id', 'photos']);
$table->float('amount')->nullable(false)->change();
});
}
}

View File

@@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AlterPostulationsSuppliersAddOfferFields extends Migration
{
public function up()
{
Schema::table('postulations_suppliers', function (Blueprint $table) {
$table->timestamp('date_1')->nullable()->after('suppliers_id');
$table->timestamp('date_2')->nullable()->after('date_1');
$table->float('amount')->nullable()->after('date_2');
});
}
public function down()
{
Schema::table('postulations_suppliers', function (Blueprint $table) {
$table->dropColumn(['date_1', 'date_2', 'amount']);
});
}
}

View File

@@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateContractCommentsTable extends Migration
{
public function up()
{
Schema::create('contract_comments', function (Blueprint $table) {
$table->bigIncrements('id');
$table->unsignedBigInteger('contract_id');
$table->unsignedBigInteger('user_id')->nullable();
$table->text('comment')->nullable();
$table->timestamps();
$table->foreign('contract_id')->references('id')->on('current_contracts')->onDelete('cascade');
$table->foreign('user_id')->references('id')->on('users')->onDelete('set null');
});
}
public function down()
{
Schema::dropIfExists('contract_comments');
}
}

View File

@@ -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::table('postulations', function (Blueprint $table) {
$table->string('status')->default('active')->after('photos');
$table->unsignedBigInteger('related_postulation_id')->nullable()->after('status');
$table->foreign('related_postulation_id')
->references('id')->on('postulations')
->onDelete('set null');
});
}
public function down(): void
{
Schema::table('postulations', function (Blueprint $table) {
$table->dropForeign(['related_postulation_id']);
$table->dropColumn(['status', 'related_postulation_id']);
});
}
};

View File

@@ -0,0 +1,230 @@
@extends('layouts.app')
@push('styles')
<style>
.chat-wrapper {
display: flex;
flex-direction: column;
height: 100%;
padding: 1rem;
}
.chat-info {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 0.75rem 3.5rem 0.75rem 1rem;
margin-bottom: 1rem;
font-size: 0.85rem;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 1rem;
position: relative;
}
.chat-info .btn-actions {
position: absolute;
right: 1rem;
top: 50%;
transform: translateY(-50%);
display: flex;
gap: 0.4rem;
}
.chat-info span { color: #495057; }
.chat-info strong { color: #212529; }
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 1rem;
}
.bubble-row {
display: flex;
align-items: flex-end;
gap: 0.5rem;
}
.bubble-row.right { justify-content: flex-end; }
.bubble-row.left { justify-content: flex-start; }
.bubble-row.center { justify-content: center; }
.bubble {
max-width: 60%;
padding: 0.6rem 0.9rem;
border-radius: 16px;
font-size: 0.875rem;
line-height: 1.4;
position: relative;
}
.bubble-row.right .bubble {
background: #0d6efd;
color: #fff;
border-bottom-right-radius: 4px;
}
.bubble-row.left .bubble {
background: #198754;
color: #fff;
border-bottom-left-radius: 4px;
}
.bubble-row.center .bubble {
background: #e9ecef;
color: #495057;
border-radius: 16px;
font-style: italic;
text-align: center;
max-width: 70%;
}
.bubble-meta {
font-size: 0.7rem;
margin-top: 0.25rem;
opacity: 0.75;
}
.bubble-row.right .bubble-meta { text-align: right; color: rgba(255,255,255,0.85); }
.bubble-row.left .bubble-meta { text-align: left; color: rgba(255,255,255,0.85); }
.bubble-row.center .bubble-meta { text-align: center; color: #6c757d; }
.bubble-label {
font-size: 0.7rem;
font-weight: 600;
margin-bottom: 0.2rem;
opacity: 0.85;
}
.chat-input {
border-top: 1px solid #dee2e6;
padding-top: 0.75rem;
}
.chat-input form {
display: flex;
gap: 0.5rem;
align-items: flex-start;
}
.chat-input textarea {
flex: 1;
resize: none;
padding: 0.5rem 1rem;
font-size: 0.875rem;
}
.chat-input button {
border-radius: 20px;
padding: 0.5rem 1.25rem;
}
</style>
@endpush
@section('content')
@php
$clientId = $contract->user_id;
$supplierId = $contract->suppliers->user_id ?? null;
@endphp
<div class="chat-wrapper">
{{-- Info del contrato --}}
<div class="chat-info">
<span><strong>Contrato Activo #{{ $contract->id }}</strong></span>
<span>Cliente: <strong>{{ $contract->user->name ?? '—' }}</strong></span>
<span>Proveedor: <strong>{{ $contract->suppliers->company_name ?? '—' }}</strong></span>
<span>Categoría: <strong>{{ $contract->categories->name ?? '—' }}</strong></span>
<span>Monto: <strong>${{ $contract->amount }}</strong></span>
<span>Cita: <strong>{{ $contract->appointment }}</strong></span>
<span>Status: <strong>{{ $contract->status->name ?? $contract->status_id }}</strong></span>
<div class="btn-actions">
<button type="button" class="btn btn-info btn-xs" title="Ver detalles"
data-toggle="modal" data-target="#modalDetalles">
<i class="fa fa-info-circle"></i>
</button>
</div>
</div>
{{-- Leyenda --}}
<div class="d-flex gap-3 mb-2" style="font-size:0.78rem; gap:1rem;">
<span><span style="display:inline-block;width:12px;height:12px;background:#0d6efd;border-radius:3px;"></span> Cliente</span>
<span><span style="display:inline-block;width:12px;height:12px;background:#198754;border-radius:3px;"></span> Proveedor</span>
</div>
{{-- Mensajes --}}
<div class="chat-messages">
@forelse($comments as $comment)
@php
if ($comment->user_id == $clientId) {
$side = 'right';
$label = 'Cliente';
} elseif ($comment->user_id == $supplierId) {
$side = 'left';
$label = 'Proveedor';
} else {
$side = 'center';
$label = 'Moderador';
}
@endphp
<div class="bubble-row {{ $side }}">
<div>
<div class="bubble-label text-muted">{{ $label }} {{ $comment->user->name ?? '—' }}</div>
<div class="bubble">
{{ $comment->comment }}
<div class="bubble-meta">{{ $comment->created_at->format('d/m/Y H:i') }}</div>
</div>
</div>
</div>
@empty
<p class="text-center text-muted mt-4">Sin mensajes aún.</p>
@endforelse
</div>
{{-- Paginación --}}
@if($comments->hasPages())
<div class="mb-2">{{ $comments->links() }}</div>
@endif
{{-- Input del moderador --}}
<div class="chat-input">
<form method="POST" action="{{ url('currentcontracts/' . $contract->id . '/comments') }}">
@csrf
@error('comment')
<div class="text-danger mb-1" style="font-size:0.8rem;">{{ $message }}</div>
@enderror
<textarea name="comment" rows="2" class="form-control" placeholder="Escribir mensaje como moderador..."></textarea>
<button type="submit" class="btn btn-primary mt-2">Enviar</button>
</form>
</div>
</div>
{{-- Modal fuera del chat-wrapper --}}
<div class="modal fade" id="modalDetalles" tabindex="-1" role="dialog">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Detalles del Contrato Activo #{{ $contract->id }}</h5>
<button type="button" class="close" data-dismiss="modal">
<span>&times;</span>
</button>
</div>
<div class="modal-body">
<table class="table table-sm table-borderless mb-4">
<tr><th style="width:35%">Cliente</th><td>{{ $contract->user->name ?? '—' }}</td></tr>
<tr><th>Proveedor</th><td>{{ $contract->suppliers->company_name ?? '—' }}</td></tr>
<tr><th>Categoría</th><td>{{ $contract->categories->name ?? '—' }}</td></tr>
<tr><th>Dirección</th><td>{{ $contract->address }}</td></tr>
<tr><th>Cita</th><td>{{ $contract->appointment }}</td></tr>
<tr><th>Monto</th><td>${{ $contract->amount }}</td></tr>
<tr><th>PIN</th><td><code>{{ $contract->code }}</code></td></tr>
<tr><th>Status</th><td>{{ $contract->status->name ?? $contract->status_id }}</td></tr>
<tr><th>Transaction ID</th><td><code>{{ $contract->transaction_id ?? '—' }}</code></td></tr>
</table>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Cerrar</button>
</div>
</div>
</div>
</div>
@endsection
@section('js')
<script>
$(document).ready(function () {
$('#modalDetalles').appendTo('body');
});
</script>
@endsection

View File

@@ -101,6 +101,10 @@
<td>{{ $currentcontract->created_at }}</td> <td>{{ $currentcontract->created_at }}</td>
<td>{{ $currentcontract->updated_at }}</td> <td>{{ $currentcontract->updated_at }}</td>
<td> <td>
<a href="{{ url('currentcontracts/' . $currentcontract->id . '/comments') }}"
class="btn btn-info btn-xs" title="Chat">
<i class="fa fa-comments"></i>
</a>
<input type="hidden" name="_method" value="delete"/> <input type="hidden" name="_method" value="delete"/>
<a class="btn btn-danger btn-xs" title="Delete" <a class="btn btn-danger btn-xs" title="Delete"
href="javascript:if(confirm('¿Estás seguro de que quieres eliminar esta contrato?')) javascript:if(confirm('Usualmente no se deben eliminar contratos, ¿Estás seguro?')) ajaxDelete('{{url('currentcontracts/delete/'.$currentcontract->id)}}','{{csrf_token()}}')"> href="javascript:if(confirm('¿Estás seguro de que quieres eliminar esta contrato?')) javascript:if(confirm('Usualmente no se deben eliminar contratos, ¿Estás seguro?')) ajaxDelete('{{url('currentcontracts/delete/'.$currentcontract->id)}}','{{csrf_token()}}')">

View File

@@ -78,6 +78,8 @@ Route::group([
Route::get('postulations/reports', 'ReportController@getsupplierreports'); Route::get('postulations/reports', 'ReportController@getsupplierreports');
Route::get('get-postulants', 'PostulationController@getpostulants'); Route::get('get-postulants', 'PostulationController@getpostulants');
Route::post('postulate', 'PostulationController@postulate'); Route::post('postulate', 'PostulationController@postulate');
Route::delete('postulations/{id}', 'PostulationController@cancelPostulation');
Route::put('postulations/{id}', 'PostulationController@updatePostulation');
// Técnicos // Técnicos
Route::get('technicians', 'TechnicianController@index'); Route::get('technicians', 'TechnicianController@index');
@@ -85,6 +87,15 @@ Route::group([
Route::post('technicians/add', 'TechnicianController@store'); Route::post('technicians/add', 'TechnicianController@store');
Route::delete('technicians/{id}', 'TechnicianController@destroy'); Route::delete('technicians/{id}', 'TechnicianController@destroy');
Route::post('contracts/assign-technician', 'TechnicianController@assign'); Route::post('contracts/assign-technician', 'TechnicianController@assign');
// Propiedades
Route::get('properties', 'PropertyController@index');
Route::post('properties', 'PropertyController@store');
Route::delete('properties/{id}', 'PropertyController@destroy');
// Chat de contratos activos
Route::get('contracts/{id}/comments', 'ContractCommentController@apiIndex');
Route::post('contracts/{id}/comments', 'ContractCommentController@store');
}); });
Route::get('/parameters', 'IChambaParameterController@parameters'); Route::get('/parameters', 'IChambaParameterController@parameters');

View File

@@ -111,6 +111,9 @@ Route::group([
Route::get('/delete-missed', 'ContractController@deletemissed')->middleware('appenginecron'); Route::get('/delete-missed', 'ContractController@deletemissed')->middleware('appenginecron');
Route::get('/map', 'ContractController@mapcurrentcontracts')->middleware('rolecheck:6'); Route::get('/map', 'ContractController@mapcurrentcontracts')->middleware('rolecheck:6');
Route::delete('delete/{id}', 'ContractController@currentdestroy')->middleware('superadmin'); Route::delete('delete/{id}', 'ContractController@currentdestroy')->middleware('superadmin');
Route::get('{id}/comments', 'ContractCommentController@index')->middleware('rolecheck:6');
Route::post('{id}/comments', 'ContractCommentController@store')->middleware('rolecheck:6');
Route::delete('{id}/comments/{comment_id}', 'ContractCommentController@destroy')->middleware('superadmin');
}); });
Route::group([ Route::group([