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>
This commit is contained in:
132
app/Http/Controllers/ContractCommentController.php
Normal file
132
app/Http/Controllers/ContractCommentController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
57
app/Http/Controllers/PropertyController.php
Normal file
57
app/Http/Controllers/PropertyController.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
22
app/Models/ContractComment.php
Normal file
22
app/Models/ContractComment.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
17
app/Models/Property.php
Normal file
17
app/Models/Property.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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');
|
||||
|
||||
14
betos_branch.md
Normal file
14
betos_branch.md
Normal 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.
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
230
resources/views/currentcontracts/comments.blade.php
Normal file
230
resources/views/currentcontracts/comments.blade.php
Normal 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>×</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
|
||||
@@ -101,6 +101,10 @@
|
||||
<td>{{ $currentcontract->created_at }}</td>
|
||||
<td>{{ $currentcontract->updated_at }}</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"/>
|
||||
<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()}}')">
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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([
|
||||
|
||||
Reference in New Issue
Block a user