diff --git a/app/Http/Controllers/ContractCommentController.php b/app/Http/Controllers/ContractCommentController.php new file mode 100644 index 0000000..4441554 --- /dev/null +++ b/app/Http/Controllers/ContractCommentController.php @@ -0,0 +1,132 @@ +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'); + } +} diff --git a/app/Http/Controllers/ContractController.php b/app/Http/Controllers/ContractController.php index 7890149..d65fc26 100755 --- a/app/Http/Controllers/ContractController.php +++ b/app/Http/Controllers/ContractController.php @@ -165,6 +165,7 @@ class ContractController extends Controller $rules = [ 'postulation_id' => 'required|numeric', 'supplier_id' => 'required|numeric', + 'selected_date' => 'required|in:date_1,date_2', 'payment_intent_id' => $paymentBypass ? 'nullable|string' : 'required|string', 'coupon' => 'nullable|string', ]; @@ -192,6 +193,14 @@ class ContractController extends Controller $ichambafee = iChambaParameter::where('parameter', 'ichamba_fee')->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 $contract = new CurrentContracts(); @@ -206,16 +215,17 @@ class ContractController extends Controller $contract->int_number = 0; } $contract->references = $postulation->references; - $contract->appointment = $postulation->appointment; - $contract->amount = ($supplier->minimun_fee < 150 ? 150 : $supplier->minimun_fee); + $selectedDate = $request->selected_date; + $contract->appointment = Carbon::parse($pivotSupplier->pivot->$selectedDate); + $contract->amount = $finalAmount; if (isset($IVA->num_value) && isset($IVA->num_value)) { $contract->IVA = $IVA->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 { $contract->IVA = 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->details = $postulation->details; @@ -229,40 +239,18 @@ class ContractController extends Controller if ($coupon->limit > 0) { 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)); $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 { - $fee = ($supplier->minimun_fee < 150 ? 150 : $supplier->minimun_fee); + $fee = $finalAmount; $discount = 0; $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 - ); - } } @@ -344,8 +332,9 @@ class ContractController extends Controller $rules = [ 'postulation_id' => 'required|numeric', - 'supplier_id' => 'required|numeric', - 'coupon' => 'required|string|regex:/(^[A-Za-z0-9 ]+$)+/', + 'supplier_id' => 'required|numeric', + 'selected_date' => 'required|in:date_1,date_2', + 'coupon' => 'required|string|regex:/(^[A-Za-z0-9 ]+$)+/', ]; $validator = Validator::make($request->all(), $rules); @@ -387,16 +376,23 @@ class ContractController extends Controller $contract->int_number = 0; } $contract->references = $postulation->references; - $contract->appointment = $postulation->appointment; - $contract->amount = ($supplier->minimun_fee < 150 ? 150 : $supplier->minimun_fee); + $pivotForCoupon = $postulation->suppliers()->where('suppliers.id', $request->supplier_id)->first(); + $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)) { $contract->IVA = $IVA->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 { $contract->IVA = 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->details = $postulation->details; diff --git a/app/Http/Controllers/PaymentController.php b/app/Http/Controllers/PaymentController.php index 9754f13..5672bba 100755 --- a/app/Http/Controllers/PaymentController.php +++ b/app/Http/Controllers/PaymentController.php @@ -9,6 +9,8 @@ use Carbon\Carbon; use App\Models\User; use App\Models\Suppliers; use App\Models\Payments; +use App\Models\Postulations; +use App\Models\Coupon; use App\Models\FinishedContracts; use App\Models\PaymentBatch; use Stripe\Stripe; @@ -94,19 +96,43 @@ class PaymentController extends Controller public function intent(Request $request) { $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()) { return response()->json($validator->errors(), 422); } - $user = Auth::user(); - $supplier = Suppliers::find($request->supplier_id); + $user = Auth::user(); + $postulation = Postulations::find($request->postulation_id); - if (!$supplier) { - return response()->json(['message' => 'Proveedor no encontrado'], 404); + if ($postulation->user_id !== $user->id) { + 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')); if (!$user->stripe_customer_id) { @@ -118,8 +144,6 @@ class PaymentController extends Controller $user->save(); } - $amount = max((int) $supplier->minimun_fee, 150); - try { $ephemeralKey = \Stripe\EphemeralKey::create( ['customer' => $user->stripe_customer_id], @@ -127,7 +151,7 @@ class PaymentController extends Controller ); $intent = \Stripe\PaymentIntent::create([ - 'amount' => $amount * 100, + 'amount' => (int) ($chargeAmount * 100), 'currency' => env('STRIPE_CURRENCY', 'mxn'), 'customer' => $user->stripe_customer_id, 'automatic_payment_methods' => ['enabled' => true], @@ -141,6 +165,7 @@ class PaymentController extends Controller 'client_secret' => $intent->client_secret, 'customer' => $user->stripe_customer_id, 'ephemeral_key' => $ephemeralKey->secret, + 'amount' => $chargeAmount, ]); } diff --git a/app/Http/Controllers/PostulationController.php b/app/Http/Controllers/PostulationController.php index d1842a6..d3e543e 100755 --- a/app/Http/Controllers/PostulationController.php +++ b/app/Http/Controllers/PostulationController.php @@ -9,10 +9,13 @@ use OneSignal; use App\Models\Suppliers; use App\Models\Categories; use App\Models\Postulations; +use App\Models\Property; +use App\Models\iChambaParameter; use MissaelAnda\Whatsapp; use MissaelAnda\Whatsapp\Messages; use App\Models\FinishedContracts; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Validator; use TarfinLabs\LaravelSpatial\Types\Point; @@ -89,15 +92,13 @@ class PostulationController extends Controller public function create(Request $request) { $rules = [ - 'category' => 'required|string', - 'address' => 'required|string', - 'int_number' => 'numeric|nullable', - 'references' => 'string|nullable', - 'setdate' => 'required|string', - 'sethour' => 'required|string', - 'details' => 'string|nullable', - 'lat' => 'required|numeric', - 'lng' => 'required|numeric', + 'category' => 'required|string', + 'property_id' => 'required|numeric', + 'references' => 'string|nullable', + 'details' => 'string|nullable', + 'photos' => 'nullable|array', + 'photos.*' => 'nullable|image|max:4096', + 'related_postulation_id' => 'nullable|numeric|exists:postulations,id', ]; $validator = Validator::make($request->all(), $rules); @@ -105,8 +106,14 @@ class PostulationController extends Controller return response()->json($validator->messages()); } else { - $user = Auth::user(); - $geometry = new Point($request->lat, $request->lng); + $user = Auth::user(); + $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(); $distance = 5000; // metros (5 km) @@ -115,20 +122,32 @@ class PostulationController extends Controller if ($suppliers != '[]') { $postulation = new Postulations(); - $postulation->user_id = $user->id; + $postulation->user_id = $user->id; $postulation->category_id = $category->id; - $postulation->address = strip_tags($request->address); - $postulation->location = $geometry; - $postulation->int_number = $request->int_number; - $postulation->references = preg_replace('/\d+/', '', strip_tags($request->references)); - // Parsear fecha y hora del formato ISO que envía el frontend - $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->property_id = $property->id; + $postulation->address = $property->address; + $postulation->location = $geometry; + $postulation->int_number = $property->int_number; + $postulation->references = preg_replace('/\d+/', '', strip_tags($request->references)); + $postulation->amount = null; + $postulation->details = preg_replace('/\d+/', '', strip_tags($request->details)); + $postulation->status = 'active'; + $postulation->related_postulation_id = $request->related_postulation_id; $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 { OneSignal::sendNotificationToExternalUser( "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(); try { @@ -200,81 +206,90 @@ class PostulationController extends Controller $rules = [ 'postulation_id' => 'required|numeric', + 'date_1' => 'required|date', + 'date_2' => 'required|date', + 'amount' => 'required|numeric|min:0', ]; $validator = Validator::make($request->all(), $rules); if ($validator->fails()) { - return redirect()->back()->withInput($request->all())->withErrors($validator); - } else { + return response()->json($validator->errors(), 422); + } - $user = Auth::user(); - $postulation = Postulations::where('id', $request->postulation_id)->first(); - $time_created = Carbon::parse($postulation->created_at); - $time_limit = (9900 - Carbon::now()->diffInMinutes($time_created)); - $supplier = Suppliers::where('user_id', $user->id)->first(); + $user = Auth::user(); + $postulation = Postulations::where('id', $request->postulation_id)->first(); + $time_created = Carbon::parse($postulation->created_at); + $time_limit = (1000 - Carbon::now()->diffInMinutes($time_created)); + $supplier = Suppliers::where('user_id', $user->id)->first(); - if (!$supplier) { - return response()->json([ - 'success' => false, - 'message' => 'No tienes un perfil de proveedor registrado' - ], 400); - } + if (!$supplier) { + return response()->json([ + 'success' => false, + 'message' => 'No tienes un perfil de proveedor registrado' + ], 400); + } - if ($time_limit > 0) { - if (in_array($postulation->category_id, $supplier->categories->pluck('id')->toArray())) { - if($supplier->membership == 1) { - try { - OneSignal::sendNotificationToExternalUser( - "Dirígete a la sección de contratos en la app para ver más detalles", - (string) $postulation->user_id, - null, null, null, null, - "Un proveedor certificado se ha postulado" - ); - } 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'), - // ]))); + // 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); + } - $supplier->postulations()->attach($request->postulation_id); - $supplier->save(); + if ($time_limit > 0) { + if (in_array($postulation->category_id, $supplier->categories->pluck('id')->toArray())) { + if ($supplier->membership == 1) { + try { + OneSignal::sendNotificationToExternalUser( + "Dirígete a la sección de contratos en la app para ver más detalles", + (string) $postulation->user_id, + null, null, null, null, + "Un proveedor certificado se ha postulado" + ); + } catch (\Exception $e) {} + } + + $supplier->postulations()->attach($request->postulation_id, [ + 'date_1' => Carbon::parse($request->date_1), + 'date_2' => Carbon::parse($request->date_2), + 'amount' => $request->amount, + ]); return response()->json([ 'message' => 'Se ha postulado al servicio exitosamente' ]); } - } else { - return response()->json([ - 'message' => 'La postulación ha caducado' - ]); - } + return response()->json([ + 'message' => 'La postulación ha caducado' + ]); } } public function getpendingcontracts(Request $request) { $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(); 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(); $pendingcontractinfo = array( - 'id' => $postulation->id, - 'category' => $category->name, + 'id' => $postulation->id, + 'category' => $category->name, 'en_category' => $category->en_name, - 'address' => $postulation->address, - 'date' => $postulation->appointment, - 'amount' => $postulation->amount + 'address' => $postulation->address, + 'date' => $postulation->appointment, + 'amount' => $postulation->amount, + 'property_id' => $postulation->property_id, + 'references' => $postulation->references, + 'details' => $postulation->details, + 'time_created' => $postulation->created_at, ); $pendingcontracts[] = $pendingcontractinfo; - } } return response()->json($pendingcontracts); @@ -323,66 +338,74 @@ class PostulationController extends Controller $validator = Validator::make($request->all(), $rules); if ($validator->fails()) { - return redirect()->back()->withInput($request->all())->withErrors($validator); - } else { + return response()->json($validator->errors(), 422); + } - $user = Auth::user(); - $postulation = Postulations::where('id', $request->postulation_id)->first(); + $user = Auth::user(); + $postulation = Postulations::with('suppliers')->where('id', $request->postulation_id)->first(); - if ($postulation->user_id == $user->id) { - $category = Categories::where('id', $postulation->category_id)->first(); - $suppliers = Suppliers::whereHas('postulations', function($q) use ($request) { - $q->where('postulations_id', $request->postulation_id); - })->get(); + if ($postulation->user_id == $user->id) { + $category = Categories::where('id', $postulation->category_id)->first(); + $suppliers = $postulation->suppliers; - $pcontractsuppliers = array(); - - if ($suppliers != '[]') { - foreach($suppliers as $supplier) { - $pcontractsupplier = array( - 'id' => $postulation->id, - 'category' => $category->name, - 'en_category' => $category->en_name, - 'address' => $postulation->address, - 'date' => $postulation->appointment, - 'amount' => $postulation->amount, - 'supplier_id' => $supplier->id, - 'supplier' => $supplier->company_name, - 'tags' => $supplier->tags, - 'cover_photo' => $supplier->cover_photo, - 'membership' => $supplier->membership, - 'fee' => $supplier->minimun_fee, - 'score' => round($supplier->total_score/$supplier->finished_jobs, 1), - ); - $pcontractsuppliers[] = $pcontractsupplier; - } - - $pcontractsuppliercollection = collect($pcontractsuppliers)->sortByDesc('membership')->sortByDesc('score'); - $pcontractsupplier = $pcontractsuppliercollection->values()->all(); - - } else { - $pcontractsupplier = array( - 'id' => $postulation->id, - 'category' => $category->name, - 'en_category' => $category->en_name, - 'address' => $postulation->address, - 'date' => $postulation->appointment, - 'amount' => $postulation->amount, - 'supplier_id' => null, - 'supplier' => null, - 'tags' => null, - 'cover_photo' => null, - 'membership' => null, - 'fee' => null, - 'score' => null, - ); + $pcontractsuppliers = array(); + if ($suppliers->isNotEmpty()) { + foreach ($suppliers as $supplier) { + $pivot = $supplier->pivot; + $pcontractsupplier = array( + 'id' => $postulation->id, + 'category' => $category->name, + 'en_category' => $category->en_name, + 'address' => $postulation->address, + 'supplier_id' => $supplier->id, + 'supplier' => $supplier->company_name, + 'tags' => $supplier->tags, + 'cover_photo' => $supplier->cover_photo, + 'membership' => $supplier->membership, + 'fee' => $pivot->amount, + '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; - $pcontractsupplier = $pcontractsuppliers; + } + + $collection = collect($pcontractsuppliers); + $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 { + $pcontractsupplier = array( + 'id' => $postulation->id, + 'category' => $category->name, + 'en_category' => $category->en_name, + 'address' => $postulation->address, + 'supplier_id' => null, + 'supplier' => null, + 'tags' => null, + 'cover_photo' => null, + 'membership' => null, + 'fee' => null, + 'date_1' => null, + 'date_2' => null, + 'score' => null, + ); + + $pcontractsuppliers[] = $pcontractsupplier; + $pcontractsupplier = $pcontractsuppliers; } return response()->json($pcontractsupplier); } - } } public function deleteexpired() @@ -390,6 +413,63 @@ class PostulationController extends Controller $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) { Postulations::destroy($id); diff --git a/app/Http/Controllers/PropertyController.php b/app/Http/Controllers/PropertyController.php new file mode 100644 index 0000000..a1f5b0f --- /dev/null +++ b/app/Http/Controllers/PropertyController.php @@ -0,0 +1,57 @@ +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']); + } +} diff --git a/app/Models/ContractComment.php b/app/Models/ContractComment.php new file mode 100644 index 0000000..cfad312 --- /dev/null +++ b/app/Models/ContractComment.php @@ -0,0 +1,22 @@ +belongsTo(CurrentContracts::class, 'contract_id'); + } + + public function user() + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/CurrentContracts.php b/app/Models/CurrentContracts.php index 49a8533..117960a 100755 --- a/app/Models/CurrentContracts.php +++ b/app/Models/CurrentContracts.php @@ -8,6 +8,7 @@ use App\Models\Suppliers; use App\Models\Coupon; use App\Models\Status; use App\Models\Technician; +use App\Models\ContractComment; use Illuminate\Database\Eloquent\Model; use TarfinLabs\LaravelSpatial\Casts\LocationCast; use TarfinLabs\LaravelSpatial\Traits\HasSpatial; @@ -68,4 +69,8 @@ class CurrentContracts extends Model return $this->belongsTo(Technician::class, 'technical_id'); } + public function contractComments() + { + return $this->hasMany(ContractComment::class, 'contract_id'); + } } diff --git a/app/Models/Postulations.php b/app/Models/Postulations.php index b182870..884c363 100755 --- a/app/Models/Postulations.php +++ b/app/Models/Postulations.php @@ -6,6 +6,7 @@ use App\Models\User; use App\Models\Categories; use App\Models\Suppliers; use App\Models\Status; +use App\Models\Property; use Illuminate\Database\Eloquent\Model; use TarfinLabs\LaravelSpatial\Casts\LocationCast; use TarfinLabs\LaravelSpatial\Traits\HasSpatial; @@ -18,17 +19,22 @@ class Postulations extends Model protected $fillable = [ 'user_id', 'category_id', + 'property_id', 'address', 'int_number', 'references', 'appointment', 'amount', 'details', + 'photos', + 'status', + 'related_postulation_id', 'status_id', ]; protected $casts = [ - 'location' => LocationCast::class + 'location' => LocationCast::class, + 'photos' => 'array', ]; public function user() @@ -44,7 +50,19 @@ class Postulations extends Model 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() diff --git a/app/Models/Property.php b/app/Models/Property.php new file mode 100644 index 0000000..8caa51d --- /dev/null +++ b/app/Models/Property.php @@ -0,0 +1,17 @@ +belongsTo(User::class); + } +} diff --git a/app/Models/Suppliers.php b/app/Models/Suppliers.php index a986133..5e9197f 100755 --- a/app/Models/Suppliers.php +++ b/app/Models/Suppliers.php @@ -56,7 +56,9 @@ class Suppliers extends Model 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() diff --git a/app/Models/User.php b/app/Models/User.php index 66f426c..0988777 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -8,6 +8,8 @@ use App\Models\Suppliers; use App\Models\Postulations; use App\Models\Report; use App\Models\ReportComment; +use App\Models\Property; +use App\Models\ContractComment; use App\Models\CurrentContracts; use App\Models\FinishedContracts; use Laravel\Passport\HasApiTokens; @@ -82,6 +84,16 @@ class User extends Authenticatable return $this->hasMany(ReportComment::class); } + public function properties() + { + return $this->hasMany(Property::class); + } + + public function contractcomments() + { + return $this->hasMany(ContractComment::class); + } + public function roles() { return $this->belongsTo(Role::class, 'role_id'); diff --git a/betos_branch.md b/betos_branch.md new file mode 100644 index 0000000..61705d9 --- /dev/null +++ b/betos_branch.md @@ -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. diff --git a/database/migrations/2026_06_23_000001_create_properties_table.php b/database/migrations/2026_06_23_000001_create_properties_table.php new file mode 100644 index 0000000..179b2d4 --- /dev/null +++ b/database/migrations/2026_06_23_000001_create_properties_table.php @@ -0,0 +1,30 @@ +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'); + } +} diff --git a/database/migrations/2026_06_23_000002_alter_postulations_add_property_nullable_amount.php b/database/migrations/2026_06_23_000002_alter_postulations_add_property_nullable_amount.php new file mode 100644 index 0000000..bdf2e63 --- /dev/null +++ b/database/migrations/2026_06_23_000002_alter_postulations_add_property_nullable_amount.php @@ -0,0 +1,28 @@ +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(); + }); + } +} diff --git a/database/migrations/2026_06_23_000003_alter_postulations_suppliers_add_offer_fields.php b/database/migrations/2026_06_23_000003_alter_postulations_suppliers_add_offer_fields.php new file mode 100644 index 0000000..ef44694 --- /dev/null +++ b/database/migrations/2026_06_23_000003_alter_postulations_suppliers_add_offer_fields.php @@ -0,0 +1,24 @@ +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']); + }); + } +} diff --git a/database/migrations/2026_06_23_000004_create_contract_comments_table.php b/database/migrations/2026_06_23_000004_create_contract_comments_table.php new file mode 100644 index 0000000..d2a6169 --- /dev/null +++ b/database/migrations/2026_06_23_000004_create_contract_comments_table.php @@ -0,0 +1,27 @@ +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'); + } +} diff --git a/database/migrations/2026_06_23_000005_alter_postulations_add_status_related.php b/database/migrations/2026_06_23_000005_alter_postulations_add_status_related.php new file mode 100644 index 0000000..0209b7f --- /dev/null +++ b/database/migrations/2026_06_23_000005_alter_postulations_add_status_related.php @@ -0,0 +1,27 @@ +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']); + }); + } +}; diff --git a/resources/views/currentcontracts/comments.blade.php b/resources/views/currentcontracts/comments.blade.php new file mode 100644 index 0000000..0a45b2e --- /dev/null +++ b/resources/views/currentcontracts/comments.blade.php @@ -0,0 +1,230 @@ +@extends('layouts.app') + +@push('styles') + +@endpush + +@section('content') +@php + $clientId = $contract->user_id; + $supplierId = $contract->suppliers->user_id ?? null; +@endphp + +
+ + {{-- Info del contrato --}} +
+ Contrato Activo #{{ $contract->id }} + Cliente: {{ $contract->user->name ?? '—' }} + Proveedor: {{ $contract->suppliers->company_name ?? '—' }} + Categoría: {{ $contract->categories->name ?? '—' }} + Monto: ${{ $contract->amount }} + Cita: {{ $contract->appointment }} + Status: {{ $contract->status->name ?? $contract->status_id }} +
+ +
+
+ + {{-- Leyenda --}} +
+ Cliente + Proveedor +
+ + {{-- Mensajes --}} +
+ @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 + +
+
+
{{ $label }} — {{ $comment->user->name ?? '—' }}
+
+ {{ $comment->comment }} +
{{ $comment->created_at->format('d/m/Y H:i') }}
+
+
+
+ @empty +

Sin mensajes aún.

+ @endforelse +
+ + {{-- Paginación --}} + @if($comments->hasPages()) +
{{ $comments->links() }}
+ @endif + + {{-- Input del moderador --}} +
+
+ @csrf + @error('comment') +
{{ $message }}
+ @enderror + + +
+
+ +
+ +{{-- Modal fuera del chat-wrapper --}} + +@endsection + +@section('js') + +@endsection diff --git a/resources/views/currentcontracts/index.blade.php b/resources/views/currentcontracts/index.blade.php index b10acce..a38d4eb 100755 --- a/resources/views/currentcontracts/index.blade.php +++ b/resources/views/currentcontracts/index.blade.php @@ -101,6 +101,10 @@ {{ $currentcontract->created_at }} {{ $currentcontract->updated_at }} + + + diff --git a/routes/api.php b/routes/api.php index cfee4d1..56b990e 100755 --- a/routes/api.php +++ b/routes/api.php @@ -78,6 +78,8 @@ Route::group([ Route::get('postulations/reports', 'ReportController@getsupplierreports'); Route::get('get-postulants', 'PostulationController@getpostulants'); Route::post('postulate', 'PostulationController@postulate'); + Route::delete('postulations/{id}', 'PostulationController@cancelPostulation'); + Route::put('postulations/{id}', 'PostulationController@updatePostulation'); // Técnicos Route::get('technicians', 'TechnicianController@index'); @@ -85,6 +87,15 @@ Route::group([ Route::post('technicians/add', 'TechnicianController@store'); Route::delete('technicians/{id}', 'TechnicianController@destroy'); 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'); diff --git a/routes/web.php b/routes/web.php index d2bf4e3..620fa9d 100755 --- a/routes/web.php +++ b/routes/web.php @@ -111,6 +111,9 @@ Route::group([ Route::get('/delete-missed', 'ContractController@deletemissed')->middleware('appenginecron'); Route::get('/map', 'ContractController@mapcurrentcontracts')->middleware('rolecheck:6'); 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([