Quellcode durchsuchen

refactor: fluxo de consultas e exames de agendamentos

Gustavo Zanatta vor 3 Wochen
Ursprung
Commit
a7c933a9c4
49 geänderte Dateien mit 1167 neuen und 149 gelöschten Zeilen
  1. 1 0
      app/Enums/AppointmentStatusEnum.php
  2. 13 0
      app/Enums/AppointmentTypeEnum.php
  3. 13 0
      app/Enums/PartnerAgreementServiceTypeEnum.php
  4. 162 21
      app/Http/Controllers/AppointmentController.php
  5. 14 1
      app/Http/Controllers/PartnerAgreementController.php
  6. 7 2
      app/Http/Controllers/PartnerAgreementServiceController.php
  7. 49 0
      app/Http/Requests/AppointmentExamRequest.php
  8. 15 0
      app/Http/Requests/AppointmentRefusalRequest.php
  9. 8 0
      app/Http/Requests/AppointmentRequest.php
  10. 2 0
      app/Http/Requests/PartnerAgreementServiceRequest.php
  11. 11 0
      app/Http/Resources/AppointmentResource.php
  12. 1 0
      app/Http/Resources/PartnerAgreementListResource.php
  13. 1 0
      app/Http/Resources/PartnerAgreementResource.php
  14. 1 0
      app/Http/Resources/PartnerAgreementServiceResource.php
  15. 1 0
      app/Http/Resources/UserResource.php
  16. 20 0
      app/Models/Appointment.php
  17. 28 0
      app/Models/AppointmentExam.php
  18. 2 0
      app/Models/PartnerAgreementService.php
  19. 13 0
      app/Models/User.php
  20. 35 0
      app/Rules/ExactAdvanceDays.php
  21. 57 10
      app/Services/AppointmentGuideService.php
  22. 216 17
      app/Services/AppointmentService.php
  23. 11 2
      app/Services/AuthService.php
  24. 8 0
      app/Services/ConveniosMedicosImportService.php
  25. 27 1
      app/Services/PartnerAgreementService.php
  26. 3 1
      app/Services/PartnerAgreementServiceService.php
  27. 18 0
      app/Services/UserDependentService.php
  28. 22 2
      app/Services/UserService.php
  29. 30 0
      database/migrations/2026_08_14_000001_add_type_to_partner_agreement_services_table.php
  30. 38 0
      database/migrations/2026_08_14_000002_add_type_and_acceptance_to_appointments_table.php
  31. 31 0
      database/migrations/2026_08_14_000003_create_appointment_exams_table.php
  32. 6 0
      database/seeders/PermissionSeeder.php
  33. 1 0
      database/seeders/UserTypePermissionSeeder.php
  34. 5 1
      lang/en/messages.php
  35. 2 0
      lang/en/validation.php
  36. 5 1
      lang/es/messages.php
  37. 2 0
      lang/es/validation.php
  38. 5 1
      lang/pt/messages.php
  39. 2 0
      lang/pt/validation.php
  40. 127 0
      resources/views/pdf/appointment_exam_guide.blade.php
  41. 28 88
      resources/views/pdf/appointment_guide.blade.php
  42. 31 0
      resources/views/pdf/partials/guide_beneficiary.blade.php
  43. 15 0
      resources/views/pdf/partials/guide_header.blade.php
  44. 59 0
      resources/views/pdf/partials/guide_styles.blade.php
  45. 1 0
      routes/authRoutes/appointment.php
  46. 5 0
      routes/authRoutes/associado_appointment.php
  47. 2 0
      routes/authRoutes/associado_partner_agreement.php
  48. 12 1
      routes/authRoutes/parceiro_appointment.php
  49. 1 0
      routes/authRoutes/partner_agreement.php

+ 1 - 0
app/Enums/AppointmentStatusEnum.php

@@ -9,6 +9,7 @@ enum AppointmentStatusEnum: string
     use EnumHelper;
 
     case PENDENTE = 'pendente';
+    case AGUARDANDO_ACEITE = 'aguardando_aceite';
     case CONFIRMADO = 'confirmado';
     case RECUSADO = 'recusado';
     case CANCELADO = 'cancelado';

+ 13 - 0
app/Enums/AppointmentTypeEnum.php

@@ -0,0 +1,13 @@
+<?php
+
+namespace App\Enums;
+
+use App\Traits\EnumHelper;
+
+enum AppointmentTypeEnum: string
+{
+    use EnumHelper;
+
+    case CONSULTA = 'consulta';
+    case EXAME    = 'exame';
+}

+ 13 - 0
app/Enums/PartnerAgreementServiceTypeEnum.php

@@ -0,0 +1,13 @@
+<?php
+
+namespace App\Enums;
+
+use App\Traits\EnumHelper;
+
+enum PartnerAgreementServiceTypeEnum: string
+{
+    use EnumHelper;
+
+    case CONSULTA = 'consulta';
+    case EXAME    = 'exame';
+}

+ 162 - 21
app/Http/Controllers/AppointmentController.php

@@ -3,13 +3,15 @@
 namespace App\Http\Controllers;
 
 use App\Http\Requests\AppointmentApproveRequest;
+use App\Http\Requests\AppointmentExamRequest;
+use App\Http\Requests\AppointmentRefusalRequest;
 use App\Http\Requests\AppointmentRequest;
 use App\Http\Resources\AppointmentResource;
 use App\Models\Appointment;
 use App\Services\AppointmentGuideService;
 use App\Services\AppointmentService;
-use App\Enums\AppointmentStatusEnum;
-use App\Enums\UserStatusEnum;
+use App\Services\UserDependentService;
+use App\Services\UserService;
 use Illuminate\Http\JsonResponse;
 use Illuminate\Http\Request;
 use Illuminate\Http\Response;
@@ -20,6 +22,8 @@ class AppointmentController extends Controller
     public function __construct(
         protected AppointmentService $service,
         protected AppointmentGuideService $guideService,
+        protected UserService $userService,
+        protected UserDependentService $dependentService,
     ) {}
 
     public function index(): JsonResponse
@@ -48,23 +52,7 @@ class AppointmentController extends Controller
 
     public function store(AppointmentRequest $request): JsonResponse
     {
-        $data = $request->validated();
-        $authUser = Auth::user();
-        $creatingForOther = isset($data['user_id']) && (int) $data['user_id'] !== $authUser->id;
-
-        if ($creatingForOther) {
-            $data['status'] = AppointmentStatusEnum::CONFIRMADO;
-            $data['auto_approved'] = true;
-        } elseif ($authUser->status === UserStatusEnum::ACTIVE) {
-            $data['status'] = AppointmentStatusEnum::CONFIRMADO;
-            $data['auto_approved'] = true;
-        }
-
-        $item = $this->service->create($data);
-
-        if ($creatingForOther) {
-            $this->service->notifyCreation($item);
-        }
+        $item = $this->service->createConsulta($request->validated(), Auth::user());
 
         return $this->successResponse(
             payload: new AppointmentResource($item),
@@ -81,6 +69,10 @@ class AppointmentController extends Controller
 
     public function update(AppointmentRequest $request, int $id): JsonResponse
     {
+        if ($blocked = $this->guardFrozen($id)) {
+            return $blocked;
+        }
+
         $item = $this->service->update($id, $request->validated(), Auth::id());
         return $this->successResponse(
             payload: new AppointmentResource($item),
@@ -90,10 +82,25 @@ class AppointmentController extends Controller
 
     public function destroy(int $id): JsonResponse
     {
+        if ($blocked = $this->guardFrozen($id)) {
+            return $blocked;
+        }
+
         $this->service->delete($id);
         return $this->successResponse(message: __('messages.deleted'), code: 204);
     }
 
+    private function guardFrozen(int $id): ?JsonResponse
+    {
+        $appointment = $this->service->findById($id);
+
+        if ($appointment && $this->service->isFrozen($appointment)) {
+            return $this->errorResponse(message: __('messages.appointment_frozen'), code: 422);
+        }
+
+        return null;
+    }
+
     public function getAdminCounters(): JsonResponse
     {
         return $this->successResponse(payload: $this->service->getAdminCounters());
@@ -101,21 +108,26 @@ class AppointmentController extends Controller
 
     public function getAdminAppointmentsPaginated(Request $request): JsonResponse
     {
-        $filters = $request->only(['status', 'search']);
+        $filters = $request->only(['status', 'search', 'type']);
         $perPage = (int) $request->input('per_page', 10);
         $paginator = $this->service->getAllPaginated($filters, $perPage);
 
         $items = collect($paginator->items())->map(fn($a) => [
             'id'                      => $a->id,
             'order_number'            => $a->order_number,
+            'type'                    => $a->type?->value,
             'registration'            => $a->user?->registration,
             'user_name'               => $a->user?->name,
             'dependent_name'          => $a->userDependent?->name,
             'is_for_dependent'        => $a->user_dependent_id !== null,
             'partner_name'            => $a->partnerAgreement?->company_name,
-            'service_name'            => $a->partnerAgreementService?->name,
+            'service_name'            => $a->isExame()
+                                            ? $a->exams->map(fn($e) => $e->partnerAgreementService?->name)->filter()->implode(', ')
+                                            : $a->partnerAgreementService?->name,
+            'exams_count'             => $a->exams->count(),
             'requested_at'            => $a->requested_at?->format('d/m/Y'),
             'status'                  => $a->status?->value,
+            'refusal_reason'          => $a->refusal_reason,
             'can_issue_guide'         => $this->guideService->canIssue($a),
         ]);
 
@@ -155,6 +167,135 @@ class AppointmentController extends Controller
         return $this->successResponse(payload: new AppointmentResource($item), message: __('messages.updated'));
     }
 
+    // ------------------------------------------------------------------
+    // Exames
+    // ------------------------------------------------------------------
+
+    public function partnerExams(): JsonResponse
+    {
+        if ($blocked = $this->guardConvenioMedico()) {
+            return $blocked;
+        }
+
+        $items = $this->service->getExamsByPartnerUser(Auth::id());
+        return $this->successResponse(payload: AppointmentResource::collection($items));
+    }
+
+    public function showPartnerExam(int $id): JsonResponse
+    {
+        if ($blocked = $this->guardConvenioMedico()) {
+            return $blocked;
+        }
+
+        $item = $this->service->findExamForPartnerUser($id, Auth::id());
+
+        if (!$item) {
+            return $this->errorResponse(message: __('messages.not_found'), code: 404);
+        }
+
+        return $this->successResponse(payload: new AppointmentResource($item));
+    }
+
+    public function partnerExamAssociados(Request $request): JsonResponse
+    {
+        if ($blocked = $this->guardConvenioMedico()) {
+            return $blocked;
+        }
+
+        $perPage   = min((int) $request->get('per_page', 20), 100);
+        $paginator = $this->userService->getAssociadosForSelect($request->get('search'), $perPage);
+
+        return $this->successResponse(payload: [
+            'data'  => collect($paginator->items())->map(fn($user) => [
+                'id'           => $user->id,
+                'name'         => $user->name,
+                'registration' => $user->registration,
+            ]),
+            'total' => $paginator->total(),
+            'from'  => $paginator->firstItem() ?? 0,
+            'to'    => $paginator->lastItem() ?? 0,
+        ]);
+    }
+
+    public function partnerExamDependentes(int $userId): JsonResponse
+    {
+        if ($blocked = $this->guardConvenioMedico()) {
+            return $blocked;
+        }
+
+        return $this->successResponse(
+            payload: $this->dependentService->getApprovedByUserForSelect($userId),
+        );
+    }
+
+    public function storePartnerExam(AppointmentExamRequest $request): JsonResponse
+    {
+        if ($blocked = $this->guardConvenioMedico()) {
+            return $blocked;
+        }
+
+        $data = $request->validated();
+
+        if ((int) $data['partner_agreement_id'] !== (int) Auth::user()->partnerAgreement?->id) {
+            return $this->errorResponse(message: __('messages.unauthorized'), code: 403);
+        }
+
+        return $this->createExamResponse($data);
+    }
+
+    public function storeExam(AppointmentExamRequest $request): JsonResponse
+    {
+        return $this->createExamResponse($request->validated());
+    }
+
+    private function createExamResponse(array $data): JsonResponse
+    {
+        $item = $this->service->createExam($data);
+
+        return $this->successResponse(
+            payload: new AppointmentResource($item),
+            message: __('messages.created'),
+            code: 201,
+        );
+    }
+
+    public function acceptExam(int $id): JsonResponse
+    {
+        $item = $this->service->acceptExam($id, Auth::id());
+
+        if (!$item) {
+            return $this->errorResponse(message: __('messages.exam_decision_unavailable'), code: 422);
+        }
+
+        return $this->successResponse(
+            payload: new AppointmentResource($item),
+            message: __('messages.exam_accepted'),
+        );
+    }
+
+    public function refuseExam(AppointmentRefusalRequest $request, int $id): JsonResponse
+    {
+        $item = $this->service->refuseExam($id, Auth::id(), $request->input('refusal_reason'));
+
+        if (!$item) {
+            return $this->errorResponse(message: __('messages.exam_decision_unavailable'), code: 422);
+        }
+
+        return $this->successResponse(
+            payload: new AppointmentResource($item),
+            message: __('messages.exam_refused'),
+        );
+    }
+
+    private function guardConvenioMedico(): ?JsonResponse
+    {
+        if (!Auth::user()->isConvenioMedico()) {
+            return $this->errorResponse(message: __('messages.unauthorized'), code: 403);
+        }
+
+        return null;
+    }
+
     public function guide(int $id): Response|JsonResponse
     {
         return $this->buildGuide($this->service->findById($id));

+ 14 - 1
app/Http/Controllers/PartnerAgreementController.php

@@ -31,6 +31,19 @@ class PartnerAgreementController extends Controller
         return $this->successResponse(payload: PartnerAgreementListResource::collection($items));
     }
 
+    public function indexPaginatedForSelect(Request $request): JsonResponse
+    {
+        $perPage   = min((int) $request->get('per_page', 20), 100);
+        $paginator = $this->service->getForSelectPaginated($request->only(['type', 'search']), $perPage);
+
+        return $this->successResponse(payload: [
+            'data'  => PartnerAgreementListResource::collection($paginator->items()),
+            'total' => $paginator->total(),
+            'from'  => $paginator->firstItem() ?? 0,
+            'to'    => $paginator->lastItem() ?? 0,
+        ]);
+    }
+
     public function indexTrashed(Request $request): JsonResponse
     {
         $items = $this->service->getTrashed($request->only(['type']));
@@ -58,7 +71,7 @@ class PartnerAgreementController extends Controller
 
     public function indexPaginated(Request $request): JsonResponse
     {
-        $filters = $request->only(['search', 'status', 'expires_in_days', 'created_month', 'type']);
+        $filters = $request->only(['search', 'status', 'expires_in_days', 'created_month', 'type', 'category_id']);
         $perPage = min((int) $request->get('per_page', 10), 100);
         $paginator = $this->service->getAllPaginated($filters, $perPage);
 

+ 7 - 2
app/Http/Controllers/PartnerAgreementServiceController.php

@@ -2,6 +2,7 @@
 
 namespace App\Http\Controllers;
 
+use App\Enums\PartnerAgreementServiceStatusEnum;
 use App\Http\Requests\PartnerAgreementServiceRequest;
 use App\Http\Requests\UploadMediaRequest;
 use App\Http\Resources\MediaResource;
@@ -9,6 +10,7 @@ use App\Http\Resources\PartnerAgreementServiceResource;
 use App\Services\MediaService;
 use App\Services\PartnerAgreementServiceService;
 use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Request;
 
 class PartnerAgreementServiceController extends Controller
 {
@@ -17,9 +19,12 @@ class PartnerAgreementServiceController extends Controller
         protected MediaService $mediaService,
     ) {}
 
-    public function indexByPartner(int $partnerAgreementId): JsonResponse
+    public function indexByPartner(Request $request, int $partnerAgreementId): JsonResponse
     {
-        $items = $this->service->getAllByPartner($partnerAgreementId);
+        $status = $request->query('status');
+        $status = is_string($status) && PartnerAgreementServiceStatusEnum::isValid($status) ? $status : null;
+
+        $items = $this->service->getAllByPartner($partnerAgreementId, $request->query('type'), $status);
         return $this->successResponse(payload: PartnerAgreementServiceResource::collection($items));
     }
 

+ 49 - 0
app/Http/Requests/AppointmentExamRequest.php

@@ -0,0 +1,49 @@
+<?php
+
+namespace App\Http\Requests;
+
+use App\Enums\PartnerAgreementServiceStatusEnum;
+use App\Enums\PartnerAgreementServiceTypeEnum;
+use App\Enums\PartnerAgreementTypeEnum;
+use App\Enums\UserDependentStatusEnum;
+use App\Enums\UserTypeEnum;
+use Illuminate\Foundation\Http\FormRequest;
+use Illuminate\Validation\Rule;
+
+class AppointmentExamRequest extends FormRequest
+{
+    public function rules(): array
+    {
+        $associadoExists = Rule::exists('users', 'id')
+            ->where('type', UserTypeEnum::ASSOCIADO->value);
+
+        $partnerExists = Rule::exists('partner_agreements', 'id')
+            ->where('type', PartnerAgreementTypeEnum::AGREEMENT->value)
+            ->whereNull('deleted_at');
+
+        $examExists = Rule::exists('partner_agreement_services', 'id')
+            ->where('type', PartnerAgreementServiceTypeEnum::EXAME->value)
+            ->where('status', PartnerAgreementServiceStatusEnum::ACTIVE->value)
+            ->whereNull('deleted_at')
+            ->when(
+                $this->filled('partner_agreement_id'),
+                fn ($rule) => $rule->where('partner_agreement_id', $this->input('partner_agreement_id')),
+            );
+
+        $dependentExists = Rule::exists('user_dependents', 'id')
+            ->where('responsible_user_id', $this->input('user_id'))
+            ->where('status', UserDependentStatusEnum::APPROVED->value)
+            ->whereNull('deleted_at');
+
+        return [
+            'user_id'              => ['required', 'integer', $associadoExists],
+            'user_dependent_id'    => ['sometimes', 'nullable', 'integer', $dependentExists],
+            'partner_agreement_id' => ['required', 'integer', $partnerExists],
+            'service_ids'          => 'required|array|min:1',
+            'service_ids.*'        => ['integer', $examExists],
+            'date'                 => 'sometimes|nullable|date',
+            'time'                 => 'sometimes|nullable|date_format:H:i',
+            'observations'         => 'sometimes|nullable|string',
+        ];
+    }
+}

+ 15 - 0
app/Http/Requests/AppointmentRefusalRequest.php

@@ -0,0 +1,15 @@
+<?php
+
+namespace App\Http\Requests;
+
+use Illuminate\Foundation\Http\FormRequest;
+
+class AppointmentRefusalRequest extends FormRequest
+{
+    public function rules(): array
+    {
+        return [
+            'refusal_reason' => 'sometimes|nullable|string|max:500',
+        ];
+    }
+}

+ 8 - 0
app/Http/Requests/AppointmentRequest.php

@@ -3,15 +3,19 @@
 namespace App\Http\Requests;
 
 use App\Enums\AppointmentStatusEnum;
+use App\Enums\PartnerAgreementServiceTypeEnum;
 use App\Enums\PartnerAgreementTypeEnum;
 use App\Enums\UserDependentStatusEnum;
 use App\Models\Appointment;
+use App\Rules\ExactAdvanceDays;
 use Illuminate\Foundation\Http\FormRequest;
 use Illuminate\Support\Facades\Auth;
 use Illuminate\Validation\Rule;
 
 class AppointmentRequest extends FormRequest
 {
+    public const CONSULTA_ADVANCE_DAYS = 2;
+
     public function rules(): array
     {
         $partnerExists = Rule::exists('partner_agreements', 'id')
@@ -19,6 +23,7 @@ class AppointmentRequest extends FormRequest
             ->whereNull('deleted_at');
 
         $serviceExists = Rule::exists('partner_agreement_services', 'id')
+            ->where('type', PartnerAgreementServiceTypeEnum::CONSULTA->value)
             ->whereNull('deleted_at')
             ->when(
                 $this->filled('partner_agreement_id'),
@@ -45,6 +50,9 @@ class AppointmentRequest extends FormRequest
             $rules['user_id']                      = 'required|integer|exists:users,id';
             $rules['partner_agreement_id']         = ['required', 'integer', $partnerExists];
             $rules['partner_agreement_service_id'] = ['required', 'integer', $serviceExists];
+
+            $rules['date'] = ['required', 'date', new ExactAdvanceDays(self::CONSULTA_ADVANCE_DAYS)];
+            $rules['time'] = 'required|date_format:H:i';
         }
 
         return $rules;

+ 2 - 0
app/Http/Requests/PartnerAgreementServiceRequest.php

@@ -3,6 +3,7 @@
 namespace App\Http\Requests;
 
 use App\Enums\PartnerAgreementServiceStatusEnum;
+use App\Enums\PartnerAgreementServiceTypeEnum;
 use Illuminate\Foundation\Http\FormRequest;
 use Illuminate\Validation\Rule;
 
@@ -15,6 +16,7 @@ class PartnerAgreementServiceRequest extends FormRequest
             'service_number'       => 'sometimes|nullable|string|max:50',
             'name'                 => 'sometimes|string|max:255',
             'description'          => 'sometimes|nullable|string',
+            'type'                 => ['sometimes', 'nullable', Rule::enum(PartnerAgreementServiceTypeEnum::class)],
             'category_id'          => 'sometimes|nullable|integer|exists:categories,id',
             'price'                => 'sometimes|nullable|numeric|min:0',
             'associate_price'      => 'sometimes|nullable|numeric|min:0',

+ 11 - 0
app/Http/Resources/AppointmentResource.php

@@ -13,6 +13,7 @@ class AppointmentResource extends JsonResource
         return [
             'id'                           => $this->id,
             'order_number'                 => $this->order_number,
+            'type'                         => $this->type,
             'user_id'                      => $this->user_id,
             'user'                         => $this->whenLoaded('user', fn() => new UserResource($this->user)),
             'user_dependent_id'            => $this->user_dependent_id,
@@ -26,6 +27,12 @@ class AppointmentResource extends JsonResource
             'partner_agreement'            => $this->whenLoaded('partnerAgreement', fn() => new PartnerAgreementResource($this->partnerAgreement)),
             'partner_agreement_service_id' => $this->partner_agreement_service_id,
             'partner_agreement_service'    => $this->whenLoaded('partnerAgreementService', fn() => new PartnerAgreementServiceResource($this->partnerAgreementService)),
+            'exams'                        => $this->whenLoaded('exams', fn() => $this->exams->map(fn($exam) => [
+                'id'                           => $exam->id,
+                'partner_agreement_service_id' => $exam->partner_agreement_service_id,
+                'name'                         => $exam->partnerAgreementService?->name,
+                'service_price'                => $exam->service_price,
+            ])),
             'date'                         => $this->date?->format('Y-m-d'),
             'time'                         => $this->time ? Carbon::parse($this->time)->format('H:i') : null,
             'observations'                 => $this->observations,
@@ -40,6 +47,10 @@ class AppointmentResource extends JsonResource
             'auto_approved'                => $this->auto_approved,
             'approved_by_user_id'          => $this->approved_by_user_id,
             'approved_by_user'             => $this->whenLoaded('approvedByUser', fn() => new UserResource($this->approvedByUser)),
+            'accepted_at'                  => $this->accepted_at?->format('Y-m-d H:i:s'),
+            'refused_at'                   => $this->refused_at?->format('Y-m-d H:i:s'),
+            'refused_by_user_id'           => $this->refused_by_user_id,
+            'refusal_reason'               => $this->refusal_reason,
             'created_at'                   => Carbon::parse($this->created_at)->format('Y-m-d H:i:s'),
             'updated_at'                   => Carbon::parse($this->updated_at)->format('Y-m-d H:i:s'),
         ];

+ 1 - 0
app/Http/Resources/PartnerAgreementListResource.php

@@ -18,6 +18,7 @@ class PartnerAgreementListResource extends JsonResource
             'responsible'         => $this->responsible,
             'email'               => $this->email,
             'phone'               => $this->phone,
+            'whatsapp'            => $this->whatsapp,
             'category_id'         => $this->category_id,
             'category'            => $this->whenLoaded('category', fn() => new CategoryResource($this->category)),
             'address'             => $this->address,

+ 1 - 0
app/Http/Resources/PartnerAgreementResource.php

@@ -15,6 +15,7 @@ class PartnerAgreementResource extends JsonResource
             'id'                  => $this->id,
             'user_id'             => $this->user_id,
             'user'                => $this->whenLoaded('user', fn() => $this->user ? ['id' => $this->user->id, 'name' => $this->user->name] : null),
+            'type'                => $this->type,
             'company_name'        => $this->company_name,
             'cnpj'                => $this->cnpj,
             'category_id'         => $this->category_id,

+ 1 - 0
app/Http/Resources/PartnerAgreementServiceResource.php

@@ -18,6 +18,7 @@ class PartnerAgreementServiceResource extends JsonResource
             'service_number'       => $this->service_number,
             'name'                 => $this->name,
             'description'          => $this->description,
+            'type'                 => $this->type,
             'category_id'          => $this->category_id,
             'category'             => $this->whenLoaded('category', fn() => new CategoryResource($this->category)),
             'price'                => $this->price,

+ 1 - 0
app/Http/Resources/UserResource.php

@@ -26,6 +26,7 @@ class UserResource extends JsonResource
             'registration'   => $this->registration,
             'language'       => $this->language,
             'type'           => $this->type,
+            'partner_type'   => $this->whenLoaded('partnerAgreement', fn() => $this->partnerAgreement?->type),
             'status'         => $this->status,
             'admission_date' => $this->admission_date?->format('d/m/Y'),
             'expiry_date'    => $this->expiry_date?->format('d/m/Y'),

+ 20 - 0
app/Models/Appointment.php

@@ -3,8 +3,10 @@
 namespace App\Models;
 
 use App\Enums\AppointmentStatusEnum;
+use App\Enums\AppointmentTypeEnum;
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use Illuminate\Database\Eloquent\Relations\HasMany;
 use Illuminate\Database\Eloquent\SoftDeletes;
 
 class Appointment extends Model
@@ -18,7 +20,10 @@ class Appointment extends Model
         return [
             'date'              => 'date',
             'requested_at'      => 'datetime',
+            'accepted_at'       => 'datetime',
+            'refused_at'        => 'datetime',
             'status'            => AppointmentStatusEnum::class,
+            'type'              => AppointmentTypeEnum::class,
             'auto_approved'     => 'boolean',
             'service_price'     => 'decimal:2',
             'guide_issued_at'   => 'datetime',
@@ -31,6 +36,11 @@ class Appointment extends Model
         return $this->user_dependent_id !== null;
     }
 
+    public function isExame(): bool
+    {
+        return $this->type === AppointmentTypeEnum::EXAME;
+    }
+
     public function user(): BelongsTo
     {
         return $this->belongsTo(User::class);
@@ -51,6 +61,16 @@ class Appointment extends Model
         return $this->belongsTo(User::class, 'approved_by_user_id');
     }
 
+    public function refusedByUser(): BelongsTo
+    {
+        return $this->belongsTo(User::class, 'refused_by_user_id');
+    }
+
+    public function exams(): HasMany
+    {
+        return $this->hasMany(AppointmentExam::class);
+    }
+
     public function partnerAgreement(): BelongsTo
     {
         return $this->belongsTo(PartnerAgreement::class)->withTrashed();

+ 28 - 0
app/Models/AppointmentExam.php

@@ -0,0 +1,28 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+
+class AppointmentExam extends Model
+{
+    protected $guarded = ['id'];
+
+    protected function casts(): array
+    {
+        return [
+            'service_price' => 'decimal:2',
+        ];
+    }
+
+    public function appointment(): BelongsTo
+    {
+        return $this->belongsTo(Appointment::class);
+    }
+
+    public function partnerAgreementService(): BelongsTo
+    {
+        return $this->belongsTo(PartnerAgreementService::class)->withTrashed();
+    }
+}

+ 2 - 0
app/Models/PartnerAgreementService.php

@@ -3,6 +3,7 @@
 namespace App\Models;
 
 use App\Enums\PartnerAgreementServiceStatusEnum;
+use App\Enums\PartnerAgreementServiceTypeEnum;
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
 use Illuminate\Database\Eloquent\Relations\HasMany;
@@ -22,6 +23,7 @@ class PartnerAgreementService extends Model
             'associate_price'      => 'float',
             'supplier_price'       => 'float',
             'requires_scheduling'  => 'boolean',
+            'type'                 => PartnerAgreementServiceTypeEnum::class,
             'status'               => PartnerAgreementServiceStatusEnum::class,
         ];
     }

+ 13 - 0
app/Models/User.php

@@ -3,6 +3,7 @@
 namespace App\Models;
 
 use App\Enums\LanguageEnum;
+use App\Enums\PartnerAgreementTypeEnum;
 use App\Enums\UserStatusEnum;
 use App\Enums\UserTypeEnum;
 use App\Support\Cpf;
@@ -12,6 +13,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
 use Illuminate\Database\Eloquent\Relations\BelongsToMany;
 use Illuminate\Database\Eloquent\Relations\HasMany;
+use Illuminate\Database\Eloquent\Relations\HasOne;
 use Illuminate\Foundation\Auth\User as Authenticatable;
 use Illuminate\Notifications\Notifiable;
 use Laravel\Sanctum\HasApiTokens;
@@ -110,6 +112,12 @@ class User extends Authenticatable
         return $this->type === UserTypeEnum::PARCEIRO;
     }
 
+    public function isConvenioMedico(): bool
+    {
+        return $this->isParceiro()
+            && $this->partnerAgreement?->type === PartnerAgreementTypeEnum::AGREEMENT;
+    }
+
     public function loginBlockReason(): ?string
     {
         if ($this->isAssociado()) {
@@ -190,6 +198,11 @@ class User extends Authenticatable
         return $this->hasMany(Appointment::class);
     }
 
+    public function partnerAgreement(): HasOne
+    {
+        return $this->hasOne(PartnerAgreement::class);
+    }
+
     public function notificationSends(): HasMany
     {
         return $this->hasMany(NotificationSend::class);

+ 35 - 0
app/Rules/ExactAdvanceDays.php

@@ -0,0 +1,35 @@
+<?php
+
+namespace App\Rules;
+
+use Carbon\Carbon;
+use Closure;
+use Illuminate\Contracts\Validation\ValidationRule;
+
+/**
+ * Regra oficial da consulta: a data precisa estar exatamente N dias à frente de hoje.
+ * A comparação ignora horas — só a data conta.
+ */
+class ExactAdvanceDays implements ValidationRule
+{
+    public function __construct(private int $days) {}
+
+    public function validate(string $attribute, mixed $value, Closure $fail): void
+    {
+        try {
+            $date = Carbon::parse($value)->startOfDay();
+        } catch (\Throwable) {
+            $fail(__('validation.date', ['attribute' => $attribute]));
+            return;
+        }
+
+        $expected = Carbon::today()->addDays($this->days);
+
+        if (!$date->equalTo($expected)) {
+            $fail(__('validation.exact_advance_days', [
+                'days' => $this->days,
+                'date' => $expected->format('d/m/Y'),
+            ]));
+        }
+    }
+}

+ 57 - 10
app/Services/AppointmentGuideService.php

@@ -15,12 +15,20 @@ class AppointmentGuideService
 
     public function canIssue(Appointment $appointment): bool
     {
-        return in_array($appointment->status, [
-                AppointmentStatusEnum::CONFIRMADO,
-                AppointmentStatusEnum::CONCLUIDO,
-            ], true)
-            && $appointment->date !== null
-            && $appointment->time !== null;
+        $released = in_array($appointment->status, [
+            AppointmentStatusEnum::CONFIRMADO,
+            AppointmentStatusEnum::CONCLUIDO,
+        ], true);
+
+        if (!$released) {
+            return false;
+        }
+
+        if ($appointment->isExame()) {
+            return $appointment->exams->isNotEmpty();
+        }
+
+        return $appointment->date !== null && $appointment->time !== null;
     }
 
     public function issue(Appointment $appointment, ?int $issuedByUserId = null): Appointment
@@ -36,14 +44,24 @@ class AppointmentGuideService
             'service_price'           => $appointment->service_price ?? $this->servicePrice($appointment),
         ]);
 
-        return $appointment->fresh(['user', 'userDependent', 'partnerAgreement', 'partnerAgreementService']);
+        return $appointment->fresh([
+            'user',
+            'userDependent',
+            'partnerAgreement',
+            'partnerAgreementService',
+            'exams.partnerAgreementService',
+        ]);
     }
 
     public function pdf(Appointment $appointment, ?int $issuedByUserId = null): PdfWrapper
     {
         $appointment = $this->issue($appointment, $issuedByUserId);
 
-        return Pdf::loadView('pdf.appointment_guide', ['guide' => $this->data($appointment)])
+        $view = $appointment->isExame()
+            ? 'pdf.appointment_exam_guide'
+            : 'pdf.appointment_guide';
+
+        return Pdf::loadView($view, ['guide' => $this->data($appointment)])
             ->setPaper('a4');
     }
 
@@ -53,7 +71,7 @@ class AppointmentGuideService
     }
 
     /**
-     * @return array<string, string|null>
+     * @return array<string, mixed>
      */
     public function data(Appointment $appointment): array
     {
@@ -61,11 +79,14 @@ class AppointmentGuideService
 
         return [
             'order_number'   => $appointment->order_number,
+            'type'           => $appointment->type?->value,
             'holder_name'    => $appointment->user?->name,
             'holder_badge'   => $appointment->user?->registration,
+            'holder_cpf'     => $appointment->user?->cpf,
             'dependent_name' => $appointment->userDependent?->name,
             // O nome do médico está embutido no nome do serviço cadastrado no convênio.
             'doctor_name'    => $appointment->partnerAgreementService?->name,
+            'exams'          => $this->exams($appointment),
             'clinic_name'    => $appointment->partnerAgreement?->company_name,
             'clinic_address' => $this->clinicAddress($appointment),
             'clinic_phone'   => $appointment->partnerAgreement?->phone,
@@ -73,13 +94,39 @@ class AppointmentGuideService
             'time'           => $appointment->time ? Carbon::parse($appointment->time)->format('H:i') : null,
             'issued_at'      => $appointment->guide_issued_at?->format('d/m/Y'),
             'valid_until'    => $appointment->guide_valid_until?->format('d/m/Y'),
-            'price'          => $price !== null ? 'R$ ' . number_format((float) $price, 2, ',', '.') : null,
+            'price'          => $this->money($price),
             'observations'   => $appointment->observations,
         ];
     }
 
+    /**
+     * @return array<int, array<string, string|null>>
+     */
+    private function exams(Appointment $appointment): array
+    {
+        if (!$appointment->isExame()) {
+            return [];
+        }
+
+        return $appointment->exams
+            ->map(fn($exam) => [
+                'name'  => $exam->partnerAgreementService?->name,
+                'price' => $this->money($exam->service_price),
+            ])
+            ->all();
+    }
+
+    private function money(string|float|null $value): ?string
+    {
+        return $value !== null ? 'R$ ' . number_format((float) $value, 2, ',', '.') : null;
+    }
+
     private function servicePrice(Appointment $appointment): ?string
     {
+        if ($appointment->isExame()) {
+            return (string) $appointment->exams->sum(fn($exam) => (float) $exam->service_price);
+        }
+
         $service = $appointment->partnerAgreementService;
 
         return $service?->associate_price ?? $service?->price;

+ 216 - 17
app/Services/AppointmentService.php

@@ -3,34 +3,49 @@
 namespace App\Services;
 
 use App\Enums\AppointmentStatusEnum;
+use App\Enums\AppointmentTypeEnum;
 use App\Enums\NotificationRecipientEnum;
+use App\Enums\PartnerAgreementTypeEnum;
+use App\Enums\UserStatusEnum;
 use App\Models\Appointment;
+use App\Models\PartnerAgreementService;
+use App\Models\User;
 use Carbon\Carbon;
 use Illuminate\Database\Eloquent\Collection;
 use Illuminate\Pagination\LengthAwarePaginator;
+use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Str;
 
 class AppointmentService
 {
+    private const RELATIONS = [
+        'user',
+        'userDependent',
+        'partnerAgreement',
+        'partnerAgreementService',
+        'exams.partnerAgreementService',
+    ];
+
     public function __construct(protected NotificationService $notificationService) {}
     public function getAll(): Collection
     {
-        return Appointment::with(['user', 'userDependent', 'partnerAgreement', 'partnerAgreementService'])
+        return Appointment::with(self::RELATIONS)
             ->orderBy('date', 'desc')
             ->get();
     }
 
     public function getAllByUser(int $userId): Collection
     {
-        return Appointment::with(['userDependent', 'partnerAgreement', 'partnerAgreementService'])
+        return Appointment::with(self::RELATIONS)
             ->where('user_id', $userId)
-            ->orderBy('date', 'desc')
+            ->orderByRaw('COALESCE(appointments.requested_at, appointments.created_at) DESC')
+            ->orderBy('id', 'desc')
             ->get();
     }
 
     public function getAllByPartnerUser(int $userId): Collection
     {
-        return Appointment::with(['user', 'userDependent', 'partnerAgreement', 'partnerAgreementService'])
+        return Appointment::with(self::RELATIONS)
             ->whereHas('partnerAgreement', fn($q) => $q->where('user_id', $userId))
             ->orderBy('date', 'desc')
             ->get();
@@ -38,12 +53,12 @@ class AppointmentService
 
     public function findById(int $id): ?Appointment
     {
-        return Appointment::with(['user', 'userDependent', 'partnerAgreement', 'partnerAgreementService'])->find($id);
+        return Appointment::with(self::RELATIONS)->find($id);
     }
 
     public function findByIdForPartnerUser(int $id, int $userId): ?Appointment
     {
-        return Appointment::with(['user', 'userDependent', 'partnerAgreement', 'partnerAgreementService'])
+        return Appointment::with(self::RELATIONS)
             ->whereHas('partnerAgreement', fn($q) => $q->where('user_id', $userId))
             ->find($id);
     }
@@ -53,8 +68,30 @@ class AppointmentService
         $data['order_number'] = $this->generateOrderNumber();
         $data['requested_at'] = now();
 
-        return Appointment::create($data)
-            ->load(['user', 'userDependent', 'partnerAgreement', 'partnerAgreementService']);
+        return Appointment::create($data)->load(self::RELATIONS);
+    }
+
+    public function createConsulta(array $data, User $authUser): Appointment
+    {
+        $creatingForOther = isset($data['user_id']) && (int) $data['user_id'] !== $authUser->id;
+
+        $data['type'] = AppointmentTypeEnum::CONSULTA;
+
+        if ($creatingForOther || $authUser->status === UserStatusEnum::ACTIVE) {
+            $data['status']        = AppointmentStatusEnum::CONFIRMADO;
+            $data['auto_approved'] = true;
+        } else {
+            $data['status']        = AppointmentStatusEnum::PENDENTE;
+            $data['auto_approved'] = false;
+        }
+
+        $appointment = $this->create($data);
+
+        if ($creatingForOther) {
+            $this->notifyCreation($appointment);
+        }
+
+        return $appointment;
     }
 
     public function notifyCreation(Appointment $model): void
@@ -94,7 +131,7 @@ class AppointmentService
         }
 
         $model->update($data);
-        return $model->fresh(['user', 'userDependent', 'partnerAgreement', 'partnerAgreementService']);
+        return $model->fresh(self::RELATIONS);
     }
 
     public function delete(int $id): bool
@@ -108,24 +145,38 @@ class AppointmentService
         return $model->delete();
     }
 
+    public function isFrozen(Appointment $appointment): bool
+    {
+        if ($appointment->status === AppointmentStatusEnum::RECUSADO) {
+            return true;
+        }
+
+        return $appointment->isExame() && $appointment->accepted_at !== null;
+    }
+
     public function getAdminCounters(): array
     {
         return [
-            'pendentes'  => Appointment::where('status', AppointmentStatusEnum::PENDENTE)->count(),
-            'aprovados'  => Appointment::where('status', AppointmentStatusEnum::CONFIRMADO)->count(),
-            'recusados'  => Appointment::where('status', AppointmentStatusEnum::RECUSADO)->count(),
+            'pendentes'           => Appointment::where('status', AppointmentStatusEnum::PENDENTE)->count(),
+            'aguardando_aceite'   => Appointment::where('status', AppointmentStatusEnum::AGUARDANDO_ACEITE)->count(),
+            'aprovados'           => Appointment::where('status', AppointmentStatusEnum::CONFIRMADO)->count(),
+            'recusados'           => Appointment::where('status', AppointmentStatusEnum::RECUSADO)->count(),
         ];
     }
 
     public function getAllPaginated(array $filters = [], int $perPage = 10): LengthAwarePaginator
     {
-        $query = Appointment::with(['user', 'userDependent', 'partnerAgreement', 'partnerAgreementService'])
+        $query = Appointment::with(self::RELATIONS)
             ->orderBy('requested_at', 'desc');
 
         if (!empty($filters['status'])) {
             $query->where('status', $filters['status']);
         }
 
+        if (!empty($filters['type'])) {
+            $query->where('type', $filters['type']);
+        }
+
         if (!empty($filters['search'])) {
             $term = '%' . mb_strtolower($filters['search']) . '%';
             $query->where(function ($q) use ($term) {
@@ -137,6 +188,8 @@ class AppointmentService
                     $pq->whereRaw('UNACCENT(LOWER(company_name)) LIKE UNACCENT(?)', [$term]);
                 })->orWhereHas('partnerAgreementService', function ($sq) use ($term) {
                     $sq->whereRaw('UNACCENT(LOWER(name)) LIKE UNACCENT(?)', [$term]);
+                })->orWhereHas('exams.partnerAgreementService', function ($eq) use ($term) {
+                    $eq->whereRaw('UNACCENT(LOWER(name)) LIKE UNACCENT(?)', [$term]);
                 })->orWhereRaw('UNACCENT(LOWER(order_number)) LIKE UNACCENT(?)', [$term]);
             });
         }
@@ -162,7 +215,7 @@ class AppointmentService
             'source'    => 'appointment',
             'source_id' => $model->id,
         ], $model->user_id);
-        return $model->fresh(['user', 'userDependent', 'partnerAgreement', 'partnerAgreementService']);
+        return $model->fresh(self::RELATIONS);
     }
 
     public function reject(int $id): ?Appointment
@@ -177,7 +230,7 @@ class AppointmentService
             'source'    => 'appointment',
             'source_id' => $model->id,
         ], $model->user_id);
-        return $model->fresh(['user', 'userDependent', 'partnerAgreement', 'partnerAgreementService']);
+        return $model->fresh(self::RELATIONS);
     }
 
     public function approveByPartner(int $id, int $userId, string $date, string $time): ?Appointment
@@ -198,7 +251,7 @@ class AppointmentService
             'source'    => 'appointment',
             'source_id' => $model->id,
         ], $model->user_id);
-        return $model->fresh(['user', 'userDependent', 'partnerAgreement', 'partnerAgreementService']);
+        return $model->fresh(self::RELATIONS);
     }
 
     public function rejectByPartner(int $id, int $userId): ?Appointment
@@ -213,7 +266,153 @@ class AppointmentService
             'source'    => 'appointment',
             'source_id' => $model->id,
         ], $model->user_id);
-        return $model->fresh(['user', 'userDependent', 'partnerAgreement', 'partnerAgreementService']);
+        return $model->fresh(self::RELATIONS);
+    }
+
+    // ------------------------------------------------------------------
+    // Exames
+    // ------------------------------------------------------------------
+
+    public function getExamsByPartnerUser(int $userId): Collection
+    {
+        return $this->examsForPartnerQuery($userId)
+            ->orderBy('requested_at', 'desc')
+            ->get();
+    }
+
+    public function findExamForPartnerUser(int $id, int $userId): ?Appointment
+    {
+        return $this->examsForPartnerQuery($userId)->find($id);
+    }
+
+    private function examsForPartnerQuery(int $userId)
+    {
+        return Appointment::with(self::RELATIONS)
+            ->where('type', AppointmentTypeEnum::EXAME)
+            ->whereHas('partnerAgreement', fn($q) => $q
+                ->where('user_id', $userId)
+                ->where('type', PartnerAgreementTypeEnum::AGREEMENT));
+    }
+
+    public function createExam(array $data): Appointment
+    {
+        $serviceIds = $data['service_ids'];
+        unset($data['service_ids']);
+
+        return DB::transaction(function () use ($data, $serviceIds) {
+            $services = PartnerAgreementService::whereIn('id', $serviceIds)->get();
+
+            $data['type']                         = AppointmentTypeEnum::EXAME;
+            $data['status']                       = AppointmentStatusEnum::AGUARDANDO_ACEITE;
+            $data['auto_approved']                = false;
+            $data['partner_agreement_service_id'] = null;
+            $data['service_price']                = $services->sum(fn($s) => (float) $this->examPrice($s));
+
+            $appointment = $this->create($data);
+
+            foreach ($services as $service) {
+                $appointment->exams()->create([
+                    'partner_agreement_service_id' => $service->id,
+                    'service_price'                => $this->examPrice($service),
+                ]);
+            }
+
+            $this->notifyExamIssued($appointment, $services->count());
+
+            return $appointment->fresh(self::RELATIONS);
+        });
+    }
+
+    public function acceptExam(int $id, int $userId): ?Appointment
+    {
+        $model = $this->pendingExamForUser($id, $userId);
+
+        if (!$model) {
+            return null;
+        }
+
+        $model->update([
+            'status'      => AppointmentStatusEnum::CONFIRMADO,
+            'accepted_at' => now(),
+        ]);
+
+        $this->notifyPartnerExamDecision($model, accepted: true);
+
+        return $model->fresh(self::RELATIONS);
+    }
+
+    public function refuseExam(int $id, int $userId, ?string $reason = null): ?Appointment
+    {
+        $model = $this->pendingExamForUser($id, $userId);
+
+        if (!$model) {
+            return null;
+        }
+
+        $model->update([
+            'status'             => AppointmentStatusEnum::RECUSADO,
+            'refused_at'         => now(),
+            'refused_by_user_id' => $userId,
+            'refusal_reason'     => $reason,
+        ]);
+
+        $this->notifyPartnerExamDecision($model, accepted: false);
+
+        return $model->fresh(self::RELATIONS);
+    }
+
+    private function pendingExamForUser(int $id, int $userId): ?Appointment
+    {
+        return Appointment::with(self::RELATIONS)
+            ->where('user_id', $userId)
+            ->where('type', AppointmentTypeEnum::EXAME)
+            ->where('status', AppointmentStatusEnum::AGUARDANDO_ACEITE)
+            ->find($id);
+    }
+
+    private function examPrice(PartnerAgreementService $service): ?string
+    {
+        return $service->associate_price ?? $service->price;
+    }
+
+    private function notifyExamIssued(Appointment $model, int $examCount): void
+    {
+        $clinic    = $model->partnerAgreement?->company_name;
+        $dependent = $model->userDependent?->name;
+        $target    = $dependent ? "para o dependente {$dependent}" : 'para você';
+        $plural    = $examCount === 1 ? 'exame' : 'exames';
+
+        $this->notificationService->createAutoForUser([
+            'title'     => 'Guia de exames para aprovação',
+            'message'   => "{$clinic} gerou uma guia com {$examCount} {$plural} {$target}. Acesse seus agendamentos para aceitar ou recusar a guia #{$model->order_number}.",
+            'recipient' => NotificationRecipientEnum::ASSOCIADO,
+            'source'    => 'appointment',
+            'source_id' => $model->id,
+        ], $model->user_id);
+    }
+
+    private function notifyPartnerExamDecision(Appointment $model, bool $accepted): void
+    {
+        $partnerUserId = $model->partnerAgreement?->user_id;
+
+        if (!$partnerUserId) {
+            return;
+        }
+
+        $associate = $model->user?->name;
+
+        $message = $accepted
+            ? "{$associate} aceitou a guia de exames #{$model->order_number}."
+            : "{$associate} recusou a guia de exames #{$model->order_number}. Gere uma nova guia com os ajustes necessários."
+                . ($model->refusal_reason ? " Motivo: {$model->refusal_reason}" : '');
+
+        $this->notificationService->createAutoForUser([
+            'title'     => $accepted ? 'Guia de exames aceita' : 'Guia de exames recusada',
+            'message'   => $message,
+            'recipient' => NotificationRecipientEnum::PARCEIRO,
+            'source'    => 'appointment',
+            'source_id' => $model->id,
+        ], $partnerUserId);
     }
 
     private function generateOrderNumber(): string

+ 11 - 2
app/Services/AuthService.php

@@ -50,12 +50,21 @@ class AuthService
         return [
             "payload" => [
                 "access_token" => $accessToken,
-                "user" => $user,
+                "user" => $this->withPartnerAgreement($user),
             ],
             "refreshToken" => $refreshToken,
         ];
     }
 
+    private function withPartnerAgreement(User $user): User
+    {
+        if ($user->isParceiro()) {
+            $user->loadMissing('partnerAgreement');
+        }
+
+        return $user;
+    }
+
     public function refresh(string $refreshToken): ?array
     {
         if (!$refreshToken) {
@@ -84,7 +93,7 @@ class AuthService
         return [
             "payload" => [
                 "access_token" => $tokens["access_token"],
-                "user" => $user,
+                "user" => $this->withPartnerAgreement($user),
             ],
             "refreshToken" => $tokens["refresh_token"],
         ];

+ 8 - 0
app/Services/ConveniosMedicosImportService.php

@@ -3,6 +3,7 @@
 namespace App\Services;
 
 use App\Enums\PartnerAgreementServiceStatusEnum;
+use App\Enums\PartnerAgreementServiceTypeEnum;
 use App\Enums\PartnerAgreementStatusEnum;
 use App\Imports\ParceirosImport;
 use App\Models\Category;
@@ -90,6 +91,7 @@ class ConveniosMedicosImportService
                 $clinicGroups[$clinicName]['services'][] = [
                     'name'  => $serviceName,
                     'price' => $this->parsePrice($priceRaw),
+                    'type'  => PartnerAgreementServiceTypeEnum::CONSULTA,
                 ];
             } elseif ($mode === self::MODE_LABORATORIO) {
                 $rest = array_slice($filled, 1);
@@ -165,6 +167,11 @@ class ConveniosMedicosImportService
                         $changed = true;
                     }
 
+                    if ($service->type !== $svcData['type']) {
+                        $service->type = $svcData['type'];
+                        $changed = true;
+                    }
+
                     if ($changed) {
                         $service->save();
                         $stats['services_updated']++;
@@ -176,6 +183,7 @@ class ConveniosMedicosImportService
                         'partner_agreement_id' => $partner->id,
                         'name'                 => $svcData['name'],
                         'associate_price'      => $svcData['price'],
+                        'type'                 => $svcData['type'],
                         'status'               => PartnerAgreementServiceStatusEnum::ACTIVE,
                     ]);
 

+ 27 - 1
app/Services/PartnerAgreementService.php

@@ -27,6 +27,24 @@ class PartnerAgreementService
         return $query->get();
     }
 
+    public function getForSelectPaginated(array $filters, int $perPage): LengthAwarePaginator
+    {
+        $query = PartnerAgreement::with(['category', 'city', 'logo'])
+            ->where('status', PartnerAgreementStatusEnum::ACTIVE)
+            ->orderBy('company_name');
+
+        if (!empty($filters['type'])) {
+            $query->where('type', $filters['type']);
+        }
+
+        if (!empty($filters['search'])) {
+            $term = '%' . mb_strtolower($filters['search']) . '%';
+            $query->whereRaw('UNACCENT(LOWER(company_name)) LIKE UNACCENT(?)', [$term]);
+        }
+
+        return $query->paginate($perPage);
+    }
+
     public function getAllPaginated(array $filters = [], int $perPage = 10): LengthAwarePaginator
     {
         $query = $this->baseQuery($filters)
@@ -69,12 +87,20 @@ class PartnerAgreementService
             $query->where('type', $filters['type']);
         }
 
+        if (!empty($filters['category_id'])) {
+            $query->where('category_id', (int) $filters['category_id']);
+        }
+
         if (!empty($filters['search'])) {
             $term = '%' . mb_strtolower($filters['search']) . '%';
             $query->where(function ($q) use ($term) {
                 $q->whereRaw('UNACCENT(LOWER(company_name)) LIKE UNACCENT(?)', [$term])
                   ->orWhereRaw('UNACCENT(LOWER(COALESCE(responsible, \'\'))) LIKE UNACCENT(?)', [$term])
-                  ->orWhereRaw('UNACCENT(LOWER(COALESCE(cnpj, \'\'))) LIKE UNACCENT(?)', [$term]);
+                  ->orWhereRaw('UNACCENT(LOWER(COALESCE(cnpj, \'\'))) LIKE UNACCENT(?)', [$term])
+                  ->orWhereRaw('UNACCENT(LOWER(COALESCE(email, \'\'))) LIKE UNACCENT(?)', [$term])
+                  ->orWhereRaw('UNACCENT(LOWER(COALESCE(address, \'\'))) LIKE UNACCENT(?)', [$term])
+                  ->orWhereHas('category', fn($cq) => $cq->whereRaw('UNACCENT(LOWER(name)) LIKE UNACCENT(?)', [$term]))
+                  ->orWhereHas('city', fn($cq) => $cq->whereRaw('UNACCENT(LOWER(name)) LIKE UNACCENT(?)', [$term]));
             });
         }
 

+ 3 - 1
app/Services/PartnerAgreementServiceService.php

@@ -9,10 +9,12 @@ use Illuminate\Database\Eloquent\Collection;
 
 class PartnerAgreementServiceService
 {
-    public function getAllByPartner(int $partnerAgreementId): Collection
+    public function getAllByPartner(int $partnerAgreementId, ?string $type = null, ?string $status = null): Collection
     {
         return PartnerAgreementService::with(['category', 'media'])
             ->where('partner_agreement_id', $partnerAgreementId)
+            ->when($type, fn($query) => $query->where('type', $type))
+            ->when($status, fn($query) => $query->where('status', $status))
             ->orderBy('name')
             ->get();
     }

+ 18 - 0
app/Services/UserDependentService.php

@@ -20,6 +20,24 @@ class UserDependentService
             ->get();
     }
 
+    /**
+     * Dependentes aprovados para preencher select, com os campos mínimos.
+     *
+     * @return \Illuminate\Support\Collection<int, array<string, mixed>>
+     */
+    public function getApprovedByUserForSelect(int $userId): \Illuminate\Support\Collection
+    {
+        return UserDependent::where('responsible_user_id', $userId)
+            ->where('status', UserDependentStatusEnum::APPROVED)
+            ->orderBy('name')
+            ->get(['id', 'name', 'status'])
+            ->map(fn(UserDependent $dependent) => [
+                'id'     => $dependent->id,
+                'name'   => $dependent->name,
+                'status' => $dependent->status,
+            ]);
+    }
+
     public function getAllPaginated(array $filters = [], int $perPage = 10): LengthAwarePaginator
     {
         $query = $this->baseQuery($filters)

+ 22 - 2
app/Services/UserService.php

@@ -15,8 +15,17 @@ class UserService
     public function authUser(): ?User
     {
         $user = Auth::user();
-        return $user?->load(['position', 'sector'])
-                     ->loadCount(['notificationSends as unread_notifications_count' => fn($q) => $q->where('read', false)]);
+
+        if (!$user) {
+            return null;
+        }
+
+        if ($user->isParceiro()) {
+            $user->loadMissing('partnerAgreement');
+        }
+
+        return $user->load(['position', 'sector'])
+                    ->loadCount(['notificationSends as unread_notifications_count' => fn($q) => $q->where('read', false)]);
     }
 
     public function getAll(): Collection
@@ -24,6 +33,17 @@ class UserService
         return User::with(['position', 'sector'])->orderBy("created_at", "desc")->get();
     }
 
+    public function getAssociadosForSelect(?string $search, int $perPage): \Illuminate\Pagination\LengthAwarePaginator
+    {
+        return $this->baseQuery([
+                'type'   => UserTypeEnum::ASSOCIADO->value,
+                'search' => $search,
+            ])
+            ->where('status', UserStatusEnum::ACTIVE)
+            ->orderBy('name')
+            ->paginate($perPage, ['id', 'name', 'registration']);
+    }
+
     public function getAllPaginated(array $filters = [], int $perPage = 10): \Illuminate\Pagination\LengthAwarePaginator
     {
         $query = $this->baseQuery($filters)

+ 30 - 0
database/migrations/2026_08_14_000001_add_type_to_partner_agreement_services_table.php

@@ -0,0 +1,30 @@
+<?php
+
+use App\Enums\PartnerAgreementServiceTypeEnum;
+use App\Enums\PartnerAgreementTypeEnum;
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    public function up(): void
+    {
+        Schema::table('partner_agreement_services', function (Blueprint $table) {
+            $table->string('type')->nullable()->after('description');
+
+            $table->index(['partner_agreement_id', 'type']);
+        });
+
+        $this->backfill();
+    }
+
+    public function down(): void
+    {
+        Schema::table('partner_agreement_services', function (Blueprint $table) {
+            $table->dropIndex(['partner_agreement_id', 'type']);
+            $table->dropColumn('type');
+        });
+    }
+};

+ 38 - 0
database/migrations/2026_08_14_000002_add_type_and_acceptance_to_appointments_table.php

@@ -0,0 +1,38 @@
+<?php
+
+use App\Enums\AppointmentTypeEnum;
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    public function up(): void
+    {
+        Schema::table('appointments', function (Blueprint $table) {
+            $table->string('type')->default(AppointmentTypeEnum::CONSULTA->value)->after('partner_agreement_id');
+
+            $table->unsignedBigInteger('partner_agreement_service_id')->nullable()->change();
+
+            $table->timestamp('accepted_at')->nullable()->after('requested_at');
+            $table->timestamp('refused_at')->nullable()->after('accepted_at');
+            $table->foreignId('refused_by_user_id')->nullable()->after('refused_at')
+                ->constrained('users')->nullOnDelete();
+            $table->text('refusal_reason')->nullable()->after('refused_by_user_id');
+
+            $table->index('type');
+        });
+
+        DB::table('appointments')->update(['type' => AppointmentTypeEnum::CONSULTA->value]);
+    }
+
+    public function down(): void
+    {
+        Schema::table('appointments', function (Blueprint $table) {
+            $table->dropConstrainedForeignId('refused_by_user_id');
+            $table->dropIndex(['type']);
+            $table->dropColumn(['type', 'accepted_at', 'refused_at', 'refusal_reason']);
+        });
+    }
+};

+ 31 - 0
database/migrations/2026_08_14_000003_create_appointment_exams_table.php

@@ -0,0 +1,31 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    public function up(): void
+    {
+        Schema::create('appointment_exams', function (Blueprint $table) {
+            $table->id();
+
+            $table->foreignId('appointment_id')->constrained('appointments')->cascadeOnDelete();
+            $table->foreignId('partner_agreement_service_id')->constrained('partner_agreement_services')->cascadeOnDelete();
+
+            $table->decimal('service_price', 10, 2)->nullable();
+
+            $table->timestamps();
+
+            $table->unique(['appointment_id', 'partner_agreement_service_id'], 'appointment_exams_unique');
+            $table->index('appointment_id');
+            $table->index('partner_agreement_service_id');
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::dropIfExists('appointment_exams');
+    }
+};

+ 6 - 0
database/seeders/PermissionSeeder.php

@@ -99,6 +99,12 @@ class PermissionSeeder extends Seeder
                         "bits" => Permission::CRUD,
                         "children" => [],
                     ],
+                    [
+                        "scope" => "parceiro.exame",
+                        "description" => "Guias de Exame do Convênio Médico",
+                        "bits" => Permission::CRUD,
+                        "children" => [],
+                    ],
                     [
                         "scope" => "parceiro.dados",
                         "description" => "Dados dos Parceiros",

+ 1 - 0
database/seeders/UserTypePermissionSeeder.php

@@ -55,6 +55,7 @@ class UserTypePermissionSeeder extends Seeder
                         ['scope' => 'parceiro',             'bits' => Permission::VIEW | Permission::EDIT | Permission::MENU],
                         ['scope' => 'parceiro.carteirinha', 'bits' => Permission::VIEW | Permission::MENU],
                         ['scope' => 'parceiro.agendamento', 'bits' => Permission::VIEW | Permission::EDIT | Permission::MENU],
+                        ['scope' => 'parceiro.exame',       'bits' => Permission::VIEW | Permission::ADD | Permission::MENU],
                         ['scope' => 'parceiro.dados',       'bits' => Permission::VIEW | Permission::EDIT | Permission::MENU],
                         ['scope' => 'parceiro.servico',     'bits' => Permission::VIEW | Permission::ADD | Permission::EDIT | Permission::DELETE],
                         ['scope' => 'parceiro.notificacao', 'bits' => Permission::VIEW | Permission::MENU],

+ 5 - 1
lang/en/messages.php

@@ -17,7 +17,11 @@ return [
     'appointment_created'     => 'Appointment created successfully',
     'appointment_cancelled'   => 'Appointment cancelled successfully',
     'appointment_confirmed'   => 'Appointment confirmed successfully',
-    'guide_unavailable'       => 'The guide can only be generated for confirmed appointments with a defined date and time',
+    'guide_unavailable'       => 'The consultation guide can only be generated for confirmed appointments with a defined date and time. The exam guide is only available after the member accepts it.',
+    'appointment_frozen'      => 'This appointment can no longer be changed. To correct a refused guide, issue a new one.',
+    'exam_accepted'           => 'Exam guide accepted successfully',
+    'exam_refused'            => 'Exam guide refused',
+    'exam_decision_unavailable' => 'This exam guide is no longer waiting for your decision',
     'not_found'               => 'Record not found',
     'unauthorized'            => 'Unauthorized action',
     'landing'                 => [

+ 2 - 0
lang/en/validation.php

@@ -193,6 +193,8 @@ return [
     |
     */
 
+    'exact_advance_days' => 'The appointment must be scheduled exactly :days days in advance. The only available date is :date.',
+
     'attributes' => [
         'name'         => 'name',
         'last_name'    => 'last name',

+ 5 - 1
lang/es/messages.php

@@ -17,7 +17,11 @@ return [
     'appointment_created'     => 'Cita creada exitosamente',
     'appointment_cancelled'   => 'Cita cancelada exitosamente',
     'appointment_confirmed'   => 'Cita confirmada exitosamente',
-    'guide_unavailable'       => 'La guía solo puede generarse para citas confirmadas con fecha y hora definidas',
+    'guide_unavailable'       => 'La guía de consulta solo puede generarse para citas confirmadas con fecha y hora definidas. La guía de exámenes solo está disponible tras la aceptación del asociado.',
+    'appointment_frozen'      => 'Esta cita ya no puede modificarse. Para corregir una guía rechazada, genere una nueva.',
+    'exam_accepted'           => 'Guía de exámenes aceptada con éxito',
+    'exam_refused'            => 'Guía de exámenes rechazada',
+    'exam_decision_unavailable' => 'Esta guía de exámenes ya no está esperando su decisión',
     'not_found'               => 'Registro no encontrado',
     'unauthorized'            => 'Acción no autorizada',
     'landing'                 => [

+ 2 - 0
lang/es/validation.php

@@ -193,6 +193,8 @@ return [
     |
     */
 
+    'exact_advance_days' => 'La cita debe agendarse con exactamente :days días de antelación. La única fecha disponible es :date.',
+
     'attributes' => [
         'name'         => 'nombre',
         'last_name'    => 'apellido',

+ 5 - 1
lang/pt/messages.php

@@ -17,7 +17,11 @@ return [
     'appointment_created'     => 'Agendamento criado com sucesso',
     'appointment_cancelled'   => 'Agendamento cancelado com sucesso',
     'appointment_confirmed'   => 'Agendamento confirmado com sucesso',
-    'guide_unavailable'       => 'A guia só pode ser gerada para agendamentos confirmados com data e horário definidos',
+    'guide_unavailable'       => 'A guia de consulta só pode ser gerada para agendamentos confirmados com data e horário definidos. A guia de exames só fica disponível após o aceite do associado.',
+    'appointment_frozen'      => 'Este agendamento não pode mais ser alterado. Para corrigir uma guia recusada, gere uma nova guia.',
+    'exam_accepted'           => 'Guia de exames aceita com sucesso',
+    'exam_refused'            => 'Guia de exames recusada',
+    'exam_decision_unavailable' => 'Esta guia de exames não está mais aguardando o seu aceite',
     'not_found'               => 'Registro não encontrado',
     'unauthorized'            => 'Ação não autorizada',
     'landing'                 => [

+ 2 - 0
lang/pt/validation.php

@@ -194,6 +194,8 @@ return [
     |
     */
 
+    'exact_advance_days' => 'O agendamento deve ser feito com exatamente :days dias de antecedência. A única data disponível é :date.',
+
     'attributes' => [
         'name'         => 'nome',
         'last_name'    => 'sobrenome',

+ 127 - 0
resources/views/pdf/appointment_exam_guide.blade.php

@@ -0,0 +1,127 @@
+<!DOCTYPE html>
+<html lang="pt-BR">
+<head>
+    <meta charset="UTF-8">
+    <title>Guia de Exames — {{ $guide['order_number'] }}</title>
+    @include('pdf.partials.guide_styles')
+</head>
+<body>
+
+@include('pdf.partials.guide_header', ['title' => 'Guia de Exames', 'kind' => 'Exames'])
+
+@include('pdf.partials.guide_beneficiary')
+
+<div class="section-title">Exames autorizados</div>
+<div class="section">
+    <table class="items">
+        <tr>
+            <th class="num">#</th>
+            <th>Exame</th>
+            <th class="amount">Valor</th>
+        </tr>
+        @forelse ($guide['exams'] as $index => $exam)
+            <tr>
+                <td class="num">{{ $index + 1 }}</td>
+                <td>{{ $exam['name'] ?? '—' }}</td>
+                <td class="amount">{{ $exam['price'] ?? '—' }}</td>
+            </tr>
+        @empty
+            <tr>
+                <td colspan="3">Nenhum exame informado.</td>
+            </tr>
+        @endforelse
+        <tr class="total">
+            <td colspan="2">Total</td>
+            <td class="amount">{{ $guide['price'] ?? '—' }}</td>
+        </tr>
+    </table>
+
+    @if ($guide['observations'])
+        <table class="fields">
+            <tr>
+                <td colspan="2">
+                    <div class="label">Observações</div>
+                    <div class="value">{{ $guide['observations'] }}</div>
+                </td>
+            </tr>
+        </table>
+    @endif
+</div>
+
+<div class="section-title">Local de atendimento</div>
+<div class="section">
+    <table class="fields">
+        <tr>
+            <td>
+                <div class="label">Convênio / Laboratório</div>
+                <div class="value">{{ $guide['clinic_name'] ?? '—' }}</div>
+            </td>
+            <td>
+                <div class="label">Telefone</div>
+                <div class="value">{{ $guide['clinic_phone'] ?? '—' }}</div>
+            </td>
+        </tr>
+        <tr>
+            <td colspan="2">
+                <div class="label">Endereço</div>
+                <div class="value">{{ $guide['clinic_address'] ?? '—' }}</div>
+            </td>
+        </tr>
+        @if ($guide['date'] || $guide['time'])
+            <tr>
+                <td>
+                    <div class="label">Data agendada</div>
+                    <div class="value"><strong>{{ $guide['date'] ?? '—' }}</strong></div>
+                </td>
+                <td>
+                    <div class="label">Horário</div>
+                    <div class="value"><strong>{{ $guide['time'] ?? '—' }}</strong></div>
+                </td>
+            </tr>
+        @endif
+    </table>
+</div>
+
+<div class="section-title">Guia</div>
+<div class="section">
+    <table class="fields">
+        <tr>
+            <td>
+                <div class="label">Data de liberação</div>
+                <div class="value">{{ $guide['issued_at'] ?? '—' }}</div>
+            </td>
+            <td rowspan="2" style="vertical-align: middle;">
+                <div class="price-box">
+                    <div class="label">Valor total dos exames</div>
+                    <div class="amount">{{ $guide['price'] ?? '—' }}</div>
+                </div>
+            </td>
+        </tr>
+        <tr>
+            <td>
+                <div class="label">Válida até</div>
+                <div class="value"><strong>{{ $guide['valid_until'] ?? '—' }}</strong></div>
+            </td>
+        </tr>
+    </table>
+
+    <table class="fields signature">
+        <tr>
+            <td>Assinatura do beneficiário</td>
+            <td class="spacer"></td>
+            <td>Carimbo e assinatura do convênio</td>
+        </tr>
+    </table>
+</div>
+
+<div class="footer">
+    Apresente esta guia no atendimento junto com um documento com foto.<br>
+    @if (!$guide['date'])
+        Não é necessário horário marcado — procure o convênio dentro do prazo de validade.<br>
+    @endif
+    Guia válida somente até {{ $guide['valid_until'] ?? '—' }} e exclusivamente para os exames listados acima.<br>
+    &copy; {{ date('Y') }} SerPrati
+</div>
+
+</body>
+</html>

+ 28 - 88
resources/views/pdf/appointment_guide.blade.php

@@ -2,124 +2,64 @@
 <html lang="pt-BR">
 <head>
     <meta charset="UTF-8">
-    <title>Guia de Atendimento — {{ $guide['order_number'] }}</title>
-    <style>
-        @page { margin: 24px 28px; }
-        body { font-family: DejaVu Sans, sans-serif; color: #333; font-size: 12px; margin: 0; }
-
-        .header { background-color: #4d1658; color: #ffffff; padding: 16px 20px; }
-        .header .brand { font-size: 20px; font-weight: bold; letter-spacing: 1px; }
-        .header .title { font-size: 13px; margin-top: 4px; }
-        .header .order { float: right; font-size: 13px; font-weight: bold; }
-
-        .section { border: 1px solid #d9d0dd; border-top: none; padding: 14px 20px; }
-        .section-title {
-            background-color: #f3e5f5; color: #4d1658; font-size: 11px; font-weight: bold;
-            text-transform: uppercase; letter-spacing: 0.5px; padding: 6px 20px;
-            border: 1px solid #d9d0dd; border-top: none;
-        }
-
-        table.fields { width: 100%; border-collapse: collapse; }
-        table.fields td { padding: 5px 0; vertical-align: top; width: 50%; }
-        .label { color: #8a7f8f; font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px; }
-        .value { font-size: 13px; color: #222; }
-        .value strong { color: #4d1658; }
-
-        /* Em bloco próprio: no dompdf a tarja inline invade a linha de cima. */
-        .badge-dependent {
-            display: block; background-color: #f3e5f5; color: #4d1658;
-            border: 1px solid #c9a3dc; border-radius: 3px; padding: 3px 8px; margin-top: 6px;
-            font-size: 10px; font-weight: bold; text-transform: uppercase; width: 200px;
-        }
-
-        .price-box {
-            background-color: #f3e5f5; border: 1px solid #c9a3dc; border-radius: 4px;
-            padding: 10px 14px; text-align: right;
-        }
-        .price-box .amount { font-size: 20px; font-weight: bold; color: #4d1658; }
-
-        .footer { margin-top: 18px; color: #8a7f8f; font-size: 10px; text-align: center; line-height: 1.5; }
-        .signature { margin-top: 42px; }
-        .signature td { width: 45%; padding-top: 6px; border-top: 1px solid #999; text-align: center; font-size: 10px; color: #666; }
-        .signature .spacer { width: 10%; border: none; }
-    </style>
+    <title>Guia de Consulta — {{ $guide['order_number'] }}</title>
+    @include('pdf.partials.guide_styles')
 </head>
 <body>
 
-<div class="header">
-    <span class="order">Nº {{ $guide['order_number'] }}</span>
-    <div class="brand">SerPrati</div>
-    <div class="title">Guia de Atendimento</div>
-</div>
+@include('pdf.partials.guide_header', ['title' => 'Guia de Atendimento', 'kind' => 'Consulta'])
+
+@include('pdf.partials.guide_beneficiary')
 
-<div class="section-title">Beneficiário</div>
+<div class="section-title">Consulta</div>
 <div class="section">
     <table class="fields">
+        <tr>
+            <td colspan="2">
+                <div class="label">Médico / Especialidade</div>
+                <div class="value"><strong>{{ $guide['doctor_name'] ?? '—' }}</strong></div>
+            </td>
+        </tr>
         <tr>
             <td>
-                <div class="label">Titular</div>
-                <div class="value">{{ $guide['holder_name'] ?? '—' }}</div>
+                <div class="label">Data da consulta</div>
+                <div class="value"><strong>{{ $guide['date'] ?? '—' }}</strong></div>
             </td>
             <td>
-                <div class="label">Crachá</div>
-                <div class="value"><strong>{{ $guide['holder_badge'] ?? '—' }}</strong></div>
+                <div class="label">Horário</div>
+                <div class="value"><strong>{{ $guide['time'] ?? '—' }}</strong></div>
             </td>
         </tr>
-        @if ($guide['dependent_name'])
+        @if ($guide['observations'])
             <tr>
                 <td colspan="2">
-                    <div class="label">Dependente</div>
-                    <div class="value">{{ $guide['dependent_name'] }}</div>
-                    <div class="badge-dependent">Atendimento para dependente</div>
+                    <div class="label">Observações</div>
+                    <div class="value">{{ $guide['observations'] }}</div>
                 </td>
             </tr>
         @endif
     </table>
 </div>
 
-<div class="section-title">Atendimento</div>
+<div class="section-title">Local de atendimento</div>
 <div class="section">
     <table class="fields">
         <tr>
-            <td>
-                <div class="label">Médico / Serviço</div>
-                <div class="value">{{ $guide['doctor_name'] ?? '—' }}</div>
-            </td>
             <td>
                 <div class="label">Clínica</div>
                 <div class="value">{{ $guide['clinic_name'] ?? '—' }}</div>
             </td>
-        </tr>
-        <tr>
             <td>
-                <div class="label">Data da consulta</div>
-                <div class="value"><strong>{{ $guide['date'] ?? '—' }}</strong></div>
+                <div class="label">Telefone</div>
+                <div class="value">{{ $guide['clinic_phone'] ?? '—' }}</div>
             </td>
-            <td>
-                <div class="label">Horário</div>
-                <div class="value"><strong>{{ $guide['time'] ?? '—' }}</strong></div>
+        </tr>
+        <tr>
+            <td colspan="2">
+                <div class="label">Endereço</div>
+                <div class="value">{{ $guide['clinic_address'] ?? '—' }}</div>
             </td>
         </tr>
-        @if ($guide['clinic_address'] || $guide['clinic_phone'])
-            <tr>
-                <td>
-                    <div class="label">Endereço</div>
-                    <div class="value">{{ $guide['clinic_address'] ?? '—' }}</div>
-                </td>
-                <td>
-                    <div class="label">Telefone</div>
-                    <div class="value">{{ $guide['clinic_phone'] ?? '—' }}</div>
-                </td>
-            </tr>
-        @endif
-        @if ($guide['observations'])
-            <tr>
-                <td colspan="2">
-                    <div class="label">Observações</div>
-                    <div class="value">{{ $guide['observations'] }}</div>
-                </td>
-            </tr>
-        @endif
     </table>
 </div>
 
@@ -158,7 +98,7 @@
 <div class="footer">
     Apresente esta guia no atendimento junto com um documento com foto.<br>
     Guia válida somente até {{ $guide['valid_until'] ?? '—' }} e exclusivamente para o atendimento descrito acima.<br>
-    © {{ date('Y') }} SerPrati
+    &copy; {{ date('Y') }} SerPrati
 </div>
 
 </body>

+ 31 - 0
resources/views/pdf/partials/guide_beneficiary.blade.php

@@ -0,0 +1,31 @@
+<div class="section-title">Beneficiário</div>
+<div class="section">
+    <table class="fields">
+        {{-- O CPF só entra quando cadastrado; sem ele o bloco mantém as duas colunas de sempre. --}}
+        <tr>
+            <td style="width: {{ $guide['holder_cpf'] ? '42%' : '50%' }};">
+                <div class="label">Titular</div>
+                <div class="value">{{ $guide['holder_name'] ?? '—' }}</div>
+            </td>
+            <td style="width: {{ $guide['holder_cpf'] ? '26%' : '50%' }};">
+                <div class="label">Crachá</div>
+                <div class="value"><strong>{{ $guide['holder_badge'] ?? '—' }}</strong></div>
+            </td>
+            @if ($guide['holder_cpf'])
+                <td style="width: 32%;">
+                    <div class="label">CPF</div>
+                    <div class="value">{{ $guide['holder_cpf'] }}</div>
+                </td>
+            @endif
+        </tr>
+        @if ($guide['dependent_name'])
+            <tr>
+                <td colspan="{{ $guide['holder_cpf'] ? 3 : 2 }}">
+                    <div class="label">Dependente</div>
+                    <div class="value">{{ $guide['dependent_name'] }}</div>
+                    <div class="badge-dependent">Atendimento para dependente</div>
+                </td>
+            </tr>
+        @endif
+    </table>
+</div>

+ 15 - 0
resources/views/pdf/partials/guide_header.blade.php

@@ -0,0 +1,15 @@
+{{-- Cabeçalho em tabela: o dompdf não posiciona floats de forma confiável e as tarjas se sobrepõem. --}}
+<div class="header">
+    <table class="header__table">
+        <tr>
+            <td class="header__brand">
+                <div class="brand">SerPrati</div>
+                <div class="title">{{ $title }}</div>
+            </td>
+            <td class="header__meta">
+                <div class="order">Nº {{ $guide['order_number'] }}</div>
+                <div class="kind">{{ $kind }}</div>
+            </td>
+        </tr>
+    </table>
+</div>

+ 59 - 0
resources/views/pdf/partials/guide_styles.blade.php

@@ -0,0 +1,59 @@
+<style>
+    @page { margin: 24px 28px; }
+    body { font-family: DejaVu Sans, sans-serif; color: #333; font-size: 12px; margin: 0; }
+
+    .header { background-color: #4d1658; color: #ffffff; padding: 16px 20px; }
+    .header__table { width: 100%; border-collapse: collapse; }
+    .header__brand { vertical-align: top; }
+    .header__meta { vertical-align: top; width: 160px; text-align: right; }
+    .header .brand { font-size: 20px; font-weight: bold; letter-spacing: 1px; }
+    .header .title { font-size: 13px; margin-top: 4px; }
+    .header .order { font-size: 12px; font-weight: bold; }
+    .header .kind {
+        display: block; margin-top: 8px; padding: 3px 0; font-size: 10px; font-weight: bold;
+        text-transform: uppercase; letter-spacing: 1px; text-align: center;
+        background-color: #ffffff; color: #4d1658; border-radius: 3px;
+    }
+
+    .section { border: 1px solid #d9d0dd; border-top: none; padding: 14px 20px; }
+    .section-title {
+        background-color: #f3e5f5; color: #4d1658; font-size: 11px; font-weight: bold;
+        text-transform: uppercase; letter-spacing: 0.5px; padding: 6px 20px;
+        border: 1px solid #d9d0dd; border-top: none;
+    }
+
+    table.fields { width: 100%; border-collapse: collapse; }
+    table.fields td { padding: 5px 0; vertical-align: top; width: 50%; }
+    .label { color: #8a7f8f; font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px; }
+    .value { font-size: 13px; color: #222; }
+    .value strong { color: #4d1658; }
+
+    .badge-dependent {
+        display: block; background-color: #f3e5f5; color: #4d1658;
+        border: 1px solid #c9a3dc; border-radius: 3px; padding: 3px 8px; margin-top: 6px;
+        font-size: 10px; font-weight: bold; text-transform: uppercase; width: 200px;
+    }
+
+    table.items { width: 100%; border-collapse: collapse; margin-top: 4px; }
+    table.items th {
+        background-color: #f3e5f5; color: #4d1658; font-size: 10px; text-transform: uppercase;
+        letter-spacing: 0.5px; text-align: left; padding: 6px 10px; border: 1px solid #d9d0dd;
+    }
+    table.items td { padding: 7px 10px; border: 1px solid #e8e0eb; font-size: 12px; }
+    table.items td.num { width: 34px; color: #8a7f8f; text-align: center; }
+    table.items td.amount, table.items th.amount { text-align: right; width: 110px; }
+    table.items tr.total td {
+        border: none; padding-top: 10px; font-size: 13px; font-weight: bold; color: #4d1658;
+    }
+
+    .price-box {
+        background-color: #f3e5f5; border: 1px solid #c9a3dc; border-radius: 4px;
+        padding: 10px 14px; text-align: right;
+    }
+    .price-box .amount { font-size: 20px; font-weight: bold; color: #4d1658; }
+
+    .footer { margin-top: 18px; color: #8a7f8f; font-size: 10px; text-align: center; line-height: 1.5; }
+    .signature { margin-top: 42px; }
+    .signature td { width: 45%; padding-top: 6px; border-top: 1px solid #999; text-align: center; font-size: 10px; color: #666; }
+    .signature .spacer { width: 10%; border: none; }
+</style>

+ 1 - 0
routes/authRoutes/appointment.php

@@ -9,6 +9,7 @@ Route::controller(AppointmentController::class)->prefix('appointment')->group(fu
     Route::get('/my', 'myAppointments')->middleware('permission:associado.agendamento,view');
 
     Route::post('/', 'store')->middleware('permission:agendamento,add');
+    Route::post('/exam', 'storeExam')->middleware('permission:agendamento,add');
 
     Route::get('/admin/counters', 'getAdminCounters')->middleware('permission:agendamento,view');
     Route::get('/admin/list', 'getAdminAppointmentsPaginated')->middleware('permission:agendamento,view');

+ 5 - 0
routes/authRoutes/associado_appointment.php

@@ -7,5 +7,10 @@ Route::controller(AppointmentController::class)->prefix('associado/appointment')
     Route::get('/my',    'myAppointments')->middleware('permission:associado.agendamento,view');
     Route::get('/{id}/guide', 'myGuide')   ->middleware('permission:associado.agendamento,view');
     Route::post('/',     'store')         ->middleware('permission:associado.agendamento,add');
+
+    // Decisão do associado sobre a guia de exames montada pelo convênio médico.
+    Route::put('/{id}/accept', 'acceptExam')->middleware('permission:associado.agendamento,edit');
+    Route::put('/{id}/refuse', 'refuseExam')->middleware('permission:associado.agendamento,edit');
+
     Route::put('/{id}',  'update')        ->middleware('permission:associado.agendamento,edit');
 });

+ 2 - 0
routes/authRoutes/associado_partner_agreement.php

@@ -7,6 +7,8 @@ use Illuminate\Support\Facades\Route;
 Route::prefix('associado')->group(function () {
     Route::controller(PartnerAgreementController::class)->prefix('partner-agreement')->group(function () {
         Route::get('/',        'index')     ->middleware('permission:associado.convenio,view');
+        // Antes de /{id}, senão a rota com parâmetro captura "paginated".
+        Route::get('/paginated', 'indexPaginatedForSelect')->middleware('permission:associado.convenio,view');
         Route::get('/{id}',    'show')      ->middleware('permission:associado.convenio,view');
         Route::get('/{id}/dados', 'showDados')->middleware('permission:associado.convenio,view');
     });

+ 12 - 1
routes/authRoutes/parceiro_appointment.php

@@ -4,8 +4,19 @@ use App\Http\Controllers\AppointmentController;
 use Illuminate\Support\Facades\Route;
 
 Route::controller(AppointmentController::class)->prefix('parceiro/appointment')->group(function () {
+    // Guias de exame do convênio médico — antes de /{id} para não serem capturadas por ele.
+    Route::get('/exam',            'partnerExams')      ->middleware('permission:parceiro.exame,view');
+    Route::post('/exam',           'storePartnerExam')  ->middleware('permission:parceiro.exame,add');
+
+    // Selects da guia: o parceiro não tem config.user nem associado.dependente.
+    // Precisam vir antes de /exam/{id}, senão a rota com parâmetro os captura.
+    Route::get('/exam/associado',                       'partnerExamAssociados')  ->middleware('permission:parceiro.exame,view');
+    Route::get('/exam/associado/{userId}/dependente',   'partnerExamDependentes') ->middleware('permission:parceiro.exame,view');
+
+    Route::get('/exam/{id}',       'showPartnerExam')   ->middleware('permission:parceiro.exame,view');
+
     Route::get('/',                'partnerAppointments')->middleware('permission:parceiro.agendamento,view');
-    Route::get('/{id}/guide',      'partnerGuide')       ->middleware('permission:parceiro.agendamento,view');
+    Route::get('/{id}/guide',      'partnerGuide')       ->middleware('permission:parceiro.agendamento|parceiro.exame,view');
     Route::get('/{id}',            'show')               ->middleware('permission:parceiro.agendamento,view');
     Route::put('/{id}/approve',    'approveByPartner')   ->middleware('permission:parceiro.agendamento,edit');
     Route::put('/{id}/reject',     'rejectByPartner')    ->middleware('permission:parceiro.agendamento,edit');

+ 1 - 0
routes/authRoutes/partner_agreement.php

@@ -37,3 +37,4 @@ Route::controller(PartnerAgreementController::class)->prefix('partner-agreement'
 });
 
 Route::get('associado-partner-agreement', [PartnerAgreementController::class, 'index'])->middleware('permission:associado.convenio,view');
+Route::get('associado-partner-agreement/paginated', [PartnerAgreementController::class, 'indexPaginatedForSelect'])->middleware('permission:associado.convenio,view');