Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Integrates Send Invoice button on invoices page #298

Merged
merged 2 commits into from
Apr 21, 2022
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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