Skip to content

[Quotes]: Send quotes via email for client approval #164

Closed

Description

@nielsdrost7

SMART Plan — [Quotes] Send a quote via email for client approval

Specific

The existing "send email" stub action in QuotesTable is implemented to resolve the prospect's primary contact email via Relation → Contact → Communications, render the email body using a quote-type EmailTemplate, dispatch a queued QuoteMailable, and update the Quote status to Sent. A Filament success or error notification is shown after dispatch.

Measurable

  • Calling the "send email" action dispatches exactly one queued QuoteMailable addressed to the prospect's primary contact email.
  • The Quote record's status column is Sent in the database after a successful dispatch.
  • An error Filament notification is shown (and status does not change) when no contact email can be resolved for the prospect.

Achievable

  1. Service method — implement sendQuoteEmail(Quote $quote): void in Modules/Quotes/Services/QuoteService.php; resolve recipient via $quote->prospect->contacts->first()?->communications->where('type', 'email')->first()?->value; throw a RuntimeException if null.
  2. EmailTemplate lookup — query EmailTemplate for type = 'quote' scoped to the current company; fall back to default subject/body.
  3. Mailable — create Modules/Quotes/Mail/QuoteMailable.php (implements ShouldQueue); accept Quote and template data; render body string into the mail view.
  4. Dispatch & status update — Mail::to($email)->queue(new QuoteMailable($quote, $template)) then $quote->update(['status' => QuoteStatus::Sent]), both inside the service method.
  5. Action wiring — replace the stub ->action() closure in Modules/Quotes/Filament/Company/Resources/Quotes/Tables/QuotesTable.php to call QuoteService::sendQuoteEmail($record), wrap in try/catch, fire Notification.

Relevant

Sending quotes for approval directly from the app closes the loop between quoting and customer sign-off without requiring external email clients.

Time-bound

Sprint 5.


Arrange · Act · Assert

// Modules/Quotes/Tests/Feature/QuotesTest.php

#[Test]
public function it_dispatches_a_queued_mail_and_marks_quote_sent_when_send_email_action_is_called(): void
{
    Mail::fake();

    /* Arrange */
    $relation = \Modules\Clients\Models\Relation::factory()
        ->has(
            \Modules\Clients\Models\Contact::factory()
                ->has(
                    \Modules\Clients\Models\Communication::factory()
                        ->state(['type' => 'email', 'value' => 'prospect@example.com']),
                    'communications'
                ),
            'contacts'
        )
        ->for($this->company)
        ->create();

    $quote = \Modules\Quotes\Models\Quote::factory()
        ->for($this->company)
        ->for($relation, 'prospect')
        ->create(['status' => \Modules\Quotes\Enums\QuoteStatus::Draft]);

    /* Act */
    $component = Livewire::actingAs($this->user)
        ->test(\Modules\Quotes\Filament\Company\Resources\Quotes\Pages\ListQuotes::class)
        ->mountAction(TestAction::make('send-email')->table($quote))
        ->callMountedAction();

    /* Assert */
    $component->assertSuccessful();

    Mail::assertQueued(
        \Modules\Quotes\Mail\QuoteMailable::class,
        fn ($mail) => $mail->hasTo('prospect@example.com')
    );

    $this->assertDatabaseHas('quotes', [
        'id'     => $quote->id,
        'status' => \Modules\Quotes\Enums\QuoteStatus::Sent->value,
    ]);
}

Activity

  1. coderabbitai commented on Dec 28, 2025

    @coderabbitai
    Contributor

    📝 CodeRabbit Plan Mode

    Generate an implementation plan and prompts that you can use with your favorite coding agent.

    • Create Plan
    Examples

    🔗 Similar Issues

    Related Issues

    👤 Suggested Assignees

    🧪 Issue enrichment is currently in open beta.

    You can configure auto-planning by selecting labels in the issue_enrichment configuration.

    To disable automatic issue enrichment, add the following to your .coderabbit.yaml:

    issue_enrichment:
      auto_enrich:
        enabled: false

    💬 Have feedback or questions? Drop into our discord!

  2. added this to the v2.0.0-alpha milestone on Dec 28, 2025
  3. changed the title [-][Quotes] As a user_admin, I want to send quotes via email for client approval.[/-] [+][Quotes]: Send quotes via email for client approval[/+] on Jun 13, 2026
  4. nielsdrost7 commented on Jul 18, 2026

    @nielsdrost7
    CollaboratorAuthor

    SMART Plan — [Quotes] Send a quote via email for client approval

    Specific

    The "send email" stub in Modules/Quotes/Filament/Company/Resources/Quotes/Tables/QuotesTable.php:92-98 becomes a modal action mirroring the invoice pipeline (EmailInvoiceAction + InvoiceService::resolveEmailDefaults/sendInvoiceEmail):

    1. Modal pre-fill — recipient resolved via Relation → Contact → Communications (prefer primary email), subject/body rendered from the company's quote-type EmailTemplate (seeded title quote_sent); all fields editable before send (v1 compose view mailer/views/quote.php).
    2. Dispatch — queued Modules/Quotes/Mail/QuoteMailable, PDF of the quote attached (same email_pdf_attachment setting as [Invoices] Attach invoice PDF automatically when sending an invoice email #526), quote uploads attached.
    3. Side effects — quote status → Sent on success (v1 Mdl_quotes::mark_sent), quote number generated if missing, MailQueue row logged via the quote's mailQueue() morph (pattern from [Invoices] Create/update MailQueue record to log email delivery (timestamp, recipient, status) #527).
    4. Guards — action disabled with tooltip when no prospect email (pattern InvoicesTable.php:198-203); failure shows error notification and changes nothing ([Invoices] Handle invoice email send failures gracefully — show error notification, no status change #531 pattern).

    Measurable

    • One queued QuoteMailable to the prospect's email with PDF attachment.
    • quotes.status = sent after success; unchanged on failure.
    • mail_queue row exists for the quote.
    • Disabled action + tooltip when the prospect has no email.

    Achievable

    1. QuoteService::resolveEmailDefaults(Quote) + QuoteService::sendQuoteEmail(Quote, string $recipient, string $subject, string $body) — port the invoice implementations; EmailTemplateVariableResolver already accepts Invoice|Quote.
    2. Modules/Quotes/Mail/QuoteMailable.php (ShouldQueue), PDF via the quotes PDF pipeline ([Quotes]: Download the PDF for a quote from the quote list #196).
    3. Extract a shared EmailDocumentAction if trivial; otherwise mirror EmailInvoiceAction as EmailQuoteAction and register it in QuotesTable and EditQuote.
    4. Depends on the EmailTemplateType enum fix (invoice/quote/payment/reminder) to filter templates by type.

    Relevant

    Closes the quoting loop in-app; parent of the quotes action-group completion #35. #199 was consolidated here.

    Time-Bound

    Sprint after the invoice email sub-tasks (#526/#527/#529/#531) land, reusing their plumbing.

    Arrange

    Mail::fake(); Relation with contact + email communication prospect@example.com; Draft quote for that relation; company panel test case.

    Act

    Mount the send email table action on ListQuotes for the quote, submit with defaults.

    Assert

    • Mail::assertQueued(QuoteMailable::class, fn ($m) => $m->hasTo('prospect@example.com'))
    • Queued mailable carries a PDF attachment named after the quote number
    • quotes.status = sent
    • mail_queue row exists with mailable_type = Quote morph
    • Quote whose prospect has no email: action disabled, tooltip shown
    • Dispatch failure: error notification, status still draft
  5. nielsdrost7 commented on Jul 24, 2026

    @nielsdrost7
    CollaboratorAuthor

    Test Suggestions

    Added to support future implementation of this request. The issue body contains a complete SMART Plan.

    The body already frames this as a concrete feature: dispatch a QuoteMailable, resolve the recipient email via the relation's primary contact, and update the quote status to Sent. Here are additional edge cases worth covering in PHPUnit:

    • PHPUnit: it_shows_an_error_when_no_contact_email_is_found_on_the_prospect()
    #[Test]
    public function it_shows_an_error_when_no_contact_email_is_found_on_the_prospect(): void
    {
        /* Arrange */
        $relation = Relation::factory()
            ->for($this->company)
            ->customer()
            ->has(Contact::factory()->count(1), 'contacts')
            ->create();
        // Contact has no communications (no email)
    
        $quote = Quote::factory()
            ->for($this->company)
            ->for($relation, 'prospect')
            ->create(['status' => QuoteStatus::Draft]);
    
        /* Act */
        $component = Livewire::actingAs($this->user)
            ->test(ListQuotes::class, ['tenant' => Str::lower($this->company->search_code)])
            ->callAction('send-email', $quote);
    
        /* Assert */
        $component->assertHasNoFormErrors(); // Action itself succeeds
        $this->assertDatabaseHas('quotes', [
            'id'     => $quote->id,
            'status' => QuoteStatus::Draft->value, // Status does NOT change
        ]);
    }
    • PHPUnit: it_uses_the_company_email_template_when_looking_up_the_quote_template()
    #[Test]
    public function it_uses_the_company_email_template_when_looking_up_the_quote_template(): void
    {
        /* Arrange */
        $template = EmailTemplate::factory()
            ->for($this->company)
            ->create(['type' => 'quote']);
    
        $relation = Relation::factory()
            ->has(Contact::factory()->has(Communication::factory()->state(['type' => 'email', 'value' => 'test@example.com']), 'communications'), 'contacts')
            ->for($this->company)
            ->customer()
            ->create();
    
        $quote = Quote::factory()
            ->for($this->company)
            ->for($relation, 'prospect')
            ->create();
    
        Mail::fake();
    
        /* Act */
        app(QuoteService::class)->sendQuoteEmail($quote);
    
        /* Assert */
        Mail::assertQueued(QuoteMailable::class, function ($mail) use ($template) {
            return str_contains($mail->render(), $template->body);
        });
    }
    • Edge case: What happens if a quote is sent twice? Does it re-queue the mail, or does sending an already-Sent quote fail silently?
  6. added a commit that references this issue on Aug 23, 2026
    edd325a
  7. added 2 commits that reference this issue on Aug 24, 2026
    6e0071c
    951e029
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

refinedRefined with SMART plan / test suggestions

Type

No type

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions