Skip to content

Commit

Permalink
Sends invoice to client
Browse files Browse the repository at this point in the history
  • Loading branch information
shivamsinghchahar committed Apr 20, 2022
1 parent ff3a78a commit fb0e29d
Show file tree
Hide file tree
Showing 14 changed files with 328 additions and 12 deletions.
15 changes: 12 additions & 3 deletions app/controllers/internal_api/v1/invoices_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

class InternalApi::V1::InvoicesController < InternalApi::V1::ApplicationController
before_action :load_client, only: [:create, :update]
after_action :ensure_time_entries_billed, only: [:send_invoice]

def index
authorize Invoice
Expand Down Expand Up @@ -46,8 +47,12 @@ def update
def send_invoice
authorize invoice

invoice.send_to_email(subject: invoice_email_params[:subject], recipients: invoice_email_params[:recipients])
invoice.update_timesheet_entry_status!
invoice.send_to_email(
subject: invoice_email_params[:subject],
message: invoice_email_params[:message],
recipients: invoice_email_params[:recipients]
)

render json: { message: "Invoice will be sent!" }, status: :accepted
end

Expand All @@ -69,6 +74,10 @@ def invoice_params
end

def invoice_email_params
params.require(:invoice_email).permit(:subject, :body, recipients: [])
params.require(:invoice_email).permit(:subject, :message, recipients: [])
end

def ensure_time_entries_billed
invoice.update_timesheet_entry_status!
end
end
5 changes: 4 additions & 1 deletion app/javascript/src/apis/invoices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ const post = async (body) => axios.post(`${path}`, body);

const patch = async (id, body) => axios.post(`${path}/${id}`, body);

const invoicesApi = { get, post, patch };
const sendInvoice = async (id, payload) =>
axios.post(`${path}/${id}/send_invoice`, payload);

const invoicesApi = { get, post, patch, sendInvoice };

export default invoicesApi;
242 changes: 242 additions & 0 deletions app/javascript/src/components/Invoices/List/SendInvoice/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
import React, {
FormEvent,
KeyboardEvent,
useEffect,
useRef,
useState
} from "react";

import invoicesApi from "apis/invoices";
import cn from "classnames";
import Toastr from "common/Toastr";
import useOutsideClick from "helpers/outsideClick";
import { X } from "phosphor-react";

import {
isEmailValid,
emailSubject,
emailBody,
isDisabled,
buttonText
} from "./utils";

import { ApiStatus as InvoiceStatus } from "../../../../constants";

interface InvoiceEmail {
subject: string;
message: string;
recipients: string[];
}

const Recipient: React.FC<{ email: string; handleClick: any }> = ({
email,
handleClick
}) => (
<div className="flex items-center px-2 py-1 m-0.5 space-x-2 border rounded-full bg-miru-gray-400 w-fit">
<p>{email}</p>

<button
type="button"
className="text-miru-black-1000 hover:text-miru-red-400"
onClick={handleClick}
>
<X size={14} weight="bold" />
</button>
</div>
);

const SendInvoice: React.FC<any> = ({ invoice, setIsSending, isSending }) => {
const [status, setStatus] = useState<InvoiceStatus>(InvoiceStatus.IDLE);
const [invoiceEmail, setInvoiceEmail] = useState<InvoiceEmail>({
subject: emailSubject(invoice),
message: emailBody(invoice),
recipients: [invoice.client.email]
});
const [newRecipient, setNewRecipient] = useState<string>("");
const [width, setWidth] = useState<string>("10ch");

const modal = useRef();
const input: React.RefObject<HTMLInputElement> = useRef();

useOutsideClick(modal, () => setIsSending(false), isSending);
useEffect(() => {
const length = newRecipient.length;

setWidth(`${length > 10 ? length : 10}ch`);
}, [newRecipient]);

const handleSubmit = async (event: FormEvent) => {
try {
event.preventDefault();
setStatus(InvoiceStatus.LOADING);

const payload = { invoice_email: invoiceEmail };
const {
data: { notice }
} = await invoicesApi.sendInvoice(invoice.id, payload);

Toastr.success(notice);
setStatus(InvoiceStatus.SUCCESS);
} catch (error) {
setStatus(InvoiceStatus.ERROR);
}
};

const handleRemove = (recipient: string) => {
const recipients = invoiceEmail.recipients.filter((r) => r !== recipient);

setInvoiceEmail({
...invoiceEmail,
recipients
});
};

const handleInput = (event: KeyboardEvent) => {
const recipients = invoiceEmail.recipients;

if (isEmailValid(newRecipient) && event.key === "Enter") {
setInvoiceEmail({
...invoiceEmail,
recipients: recipients.concat(newRecipient)
});
setNewRecipient("");
}
};

return (
<div
className="fixed inset-0 z-10 overflow-y-auto"
aria-labelledby="modal-title"
role="dialog"
aria-modal="true"
>
<div className="flex items-end justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<div
className="fixed inset-0 transition-opacity bg-gray-500 bg-opacity-75"
aria-hidden="true"
></div>

<span
className="hidden sm:inline-block sm:align-middle sm:h-screen"
aria-hidden="true"
>
&#8203;
</span>

<div
ref={modal}
className="relative inline-block overflow-hidden text-left align-bottom transition-all transform bg-white rounded-lg shadow-xl sm:my-8 sm:align-middle sm:max-w-lg sm:w-full"
>
<div className="px-4 pt-5 pb-4 bg-white sm:p-6 sm:pb-4">
<div className="flex items-center justify-between mt-2 mb-6">
<h6 className="form__title">
Send Invoice #{invoice.invoiceNumber}
</h6>
<button
type="button"
className="text-miru-gray-1000"
onClick={() => setIsSending(false)}
>
<X size={16} weight="bold" />
</button>
</div>

<form className="space-y-4">
<fieldset className="flex flex-col field_with_errors">
<label htmlFor="to" className="mb-2 form__label">
To
</label>

<div
onClick={() => input.current.focus()}
className={cn(
"p-1.5 rounded bg-miru-gray-100 flex flex-wrap",
{ "h-9": !invoiceEmail.recipients }
)}
>
{invoiceEmail.recipients.map((recipient) => (
<Recipient
key={recipient}
email={recipient}
handleClick={() => handleRemove(recipient)}
/>
))}

<input
ref={input}
style={{ width }}
className={cn(
"py-2 mx-1.5 rounded bg-miru-gray-100 w-fit cursor-text focus:outline-none",
{
"text-miru-red-400": !isEmailValid(newRecipient)
}
)}
type="email"
name="to"
value={newRecipient}
onChange={(e) => setNewRecipient(e.target.value.trim())}
onKeyDown={handleInput}
/>
</div>
</fieldset>

<fieldset className="flex flex-col field_with_errors">
<label htmlFor="subject" className="mb-2 form__label">
Subject
</label>

<input
className="p-1.5 rounded bg-miru-gray-100"
type="text"
name="subject"
value={invoiceEmail.subject}
onChange={(e) =>
setInvoiceEmail({
...invoiceEmail,
subject: e.target.value
})
}
/>
</fieldset>

<fieldset className="flex flex-col field_with_errors">
<label htmlFor="body" className="mb-2 form__label">
Message
</label>

<textarea
name="body"
className="p-1.5 rounded bg-miru-gray-100"
value={invoiceEmail.message}
onChange={(e) =>
setInvoiceEmail({ ...invoiceEmail, message: e.target.value })
}
rows={5}
/>
</fieldset>

<div>
<button
type="button"
onClick={handleSubmit}
className={cn(
"flex justify-center w-full p-3 mt-6 text-lg font-bold text-white uppercase border border-transparent rounded-md shadow-sm bg-miru-han-purple-1000 hover:bg-miru-han-purple-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-miru-han-purple-600",
{
"hover:bg-miru-chart-green-400 bg-miru-chart-green-600":
status === InvoiceStatus.SUCCESS
}
)}
disabled={isDisabled(status)}
>
{buttonText(status)}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
);
};

export default SendInvoice;
29 changes: 29 additions & 0 deletions app/javascript/src/components/Invoices/List/SendInvoice/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import dayjs from "dayjs";
import { currencyFormat } from "helpers/currency";
import * as Yup from "yup";
import { ApiStatus as InvoiceStatus } from "../../../../constants";

export const isEmailValid = (email: string): boolean => {
const schema = Yup.string().email();

return schema.isValidSync(email);
};

export const emailSubject = (invoice: any): string =>
`${invoice.company.name} sent you an invoice (${invoice.invoiceNumber})`;

export const emailBody = (invoice: any): string => {
const formattedAmount = currencyFormat({
baseCurrency: invoice.company.baseCurrency,
amount: invoice.amount
});
const dueDate = dayjs(invoice.dueDate).format(invoice.company.dateFormat);

return `${invoice.company.name} has sent you an invoice (${invoice.invoiceNumber}) for ${formattedAmount} that's due on ${dueDate}.`;
};

export const isDisabled = (status: string): boolean =>
status === InvoiceStatus.LOADING || status === InvoiceStatus.SUCCESS;

export const buttonText = (status: string): string =>
status === InvoiceStatus.SUCCESS ? "🎉 Invoice Sent!" : "Send Invoice";
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ const TableHeader = ({ selectAllInvoices, deselectAllInvoices }) => {
</th>
<th scope="col" className="relative px-6 py-3"></th>
<th scope="col" className="relative px-6 py-3"></th>
<th scope="col" className="relative px-6 py-3"></th>
</tr>
);
};
Expand Down
22 changes: 20 additions & 2 deletions app/javascript/src/components/Invoices/List/Table/TableRow.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
import React from "react";
import React, { useState } from "react";

import CustomCheckbox from "common/CustomCheckbox";
import dayjs from "dayjs";
import { currencyFormat } from "helpers/currency";
import { Pen, Trash } from "phosphor-react";
import { PaperPlaneTilt, Pen, Trash } from "phosphor-react";

import getStatusCssClass from "../../../../utils/getStatusTag";
import SendInvoice from "../SendInvoice";

const TableRow = ({
invoice,
isSelected,
selectInvoices,
deselectInvoices
}) => {
const [isSending, setIsSending] = useState<boolean>(false);

const handleCheckboxChange = () => {
if (isSelected) {
deselectInvoices([invoice.id]);
Expand Down Expand Up @@ -69,6 +72,17 @@ const TableRow = ({
</span>
</td>

<td className="px-2 py-4 text-sm font-medium text-right whitespace-nowrap">
<div className="flex items-center h-full">
<button
className="hidden group-hover:block text-miru-han-purple-1000"
onClick={() => setIsSending(!isSending)}
>
<PaperPlaneTilt size={16} />
</button>
</div>
</td>

<td className="px-2 py-4 text-sm font-medium text-right whitespace-nowrap">
<div className="flex items-center h-full">
<button className="hidden group-hover:block text-miru-han-purple-1000">
Expand All @@ -84,6 +98,10 @@ const TableRow = ({
</button>
</div>
</td>

{isSending && (
<SendInvoice invoice={invoice} setIsSending={setIsSending} isSending />
)}
</tr>
);
};
Expand Down
1 change: 1 addition & 0 deletions app/mailers/invoice_mailer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ def invoice
@invoice = params[:invoice]
recipients = params[:recipients]
subject = params[:subject]
@message = params[:message]

mail(to: recipients, subject:)
end
Expand Down
4 changes: 2 additions & 2 deletions app/models/concerns/invoice_sendable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
module InvoiceSendable
extend ActiveSupport::Concern

def send_to_email(subject:, recipients:)
InvoiceMailer.with(invoice: self, subject:, recipients:).invoice.deliver_later
def send_to_email(subject:, recipients:, message:)
InvoiceMailer.with(invoice: self, subject:, recipients:, message:).invoice.deliver_later
end
end
Loading

0 comments on commit fb0e29d

Please sign in to comment.