1 | import { useEffect, useMemo } from 'react';
|
---|
2 | import * as Yup from 'yup';
|
---|
3 | import { useForm } from 'react-hook-form';
|
---|
4 | import { yupResolver } from '@hookform/resolvers/yup';
|
---|
5 | // @mui
|
---|
6 | import LoadingButton from '@mui/lab/LoadingButton';
|
---|
7 | import Card from '@mui/material/Card';
|
---|
8 | import Stack from '@mui/material/Stack';
|
---|
9 | // routes
|
---|
10 | import { paths } from 'src/routes/paths';
|
---|
11 | import { useRouter } from 'src/routes/hooks';
|
---|
12 | // types
|
---|
13 | import { Invoice } from 'mvpmasters-shared';
|
---|
14 | // hooks
|
---|
15 | import { useBoolean } from 'src/hooks/use-boolean';
|
---|
16 | // components
|
---|
17 | import FormProvider from 'src/components/hook-form';
|
---|
18 | //
|
---|
19 | import { incrementInvoiceNumber } from 'src/utils/increment-invoice-number';
|
---|
20 | import uploadToFirebaseStorage from 'src/utils/upload-to-firebase-storage';
|
---|
21 | import { pdf } from '@react-pdf/renderer';
|
---|
22 | import { useGetSettings } from 'src/api/settings';
|
---|
23 | import {
|
---|
24 | collections,
|
---|
25 | documents,
|
---|
26 | firestoreBatch,
|
---|
27 | generateId,
|
---|
28 | updateDocument,
|
---|
29 | } from 'src/lib/firestore';
|
---|
30 | import { useGetServices } from 'src/api/service';
|
---|
31 | import { mutate } from 'swr';
|
---|
32 | import { Timestamp } from 'firebase/firestore';
|
---|
33 | import InvoiceNewEditStatusDate from './invoice-new-edit-status-date';
|
---|
34 | import InvoiceNewEditAddress from './invoice-new-edit-address';
|
---|
35 | import InvoiceNewEditDetails from './invoice-new-edit-details';
|
---|
36 | import InvoicePDF from './invoice-pdf';
|
---|
37 |
|
---|
38 | // ----------------------------------------------------------------------
|
---|
39 |
|
---|
40 | type Props = {
|
---|
41 | isCopy?: boolean;
|
---|
42 | currentInvoice?: Invoice;
|
---|
43 | };
|
---|
44 |
|
---|
45 | export const monthNames = [
|
---|
46 | 'January',
|
---|
47 | 'February',
|
---|
48 | 'March',
|
---|
49 | 'April',
|
---|
50 | 'May',
|
---|
51 | 'June',
|
---|
52 | 'July',
|
---|
53 | 'August',
|
---|
54 | 'September',
|
---|
55 | 'October',
|
---|
56 | 'November',
|
---|
57 | 'December',
|
---|
58 | ];
|
---|
59 |
|
---|
60 | export default function InvoiceNewEditForm({ isCopy, currentInvoice }: Props) {
|
---|
61 | const router = useRouter();
|
---|
62 |
|
---|
63 | const loadingSave = useBoolean();
|
---|
64 |
|
---|
65 | const loadingSend = useBoolean();
|
---|
66 |
|
---|
67 | const itemSchema = Yup.object({
|
---|
68 | title: Yup.string().required('Title is required'),
|
---|
69 | description: Yup.string().required('Description is required'),
|
---|
70 | service: Yup.string().nullable(),
|
---|
71 | quantity: Yup.number().required('Quantity is required').min(0.5, 'Quantity must be at least 1'),
|
---|
72 | price: Yup.number().required('Price is required').min(0, 'Price must be at least 0'),
|
---|
73 | total: Yup.number().required('Total is required').min(0, 'Total must be at least 0'),
|
---|
74 | });
|
---|
75 |
|
---|
76 | const NewInvoiceSchema = Yup.object().shape({
|
---|
77 | invoiceNumber: Yup.string().nullable().required('Invoice number is required'),
|
---|
78 | createDate: Yup.mixed<any>().nullable().required('Create date is required'),
|
---|
79 | dueDate: Yup.mixed<any>()
|
---|
80 | .required('Due date is required')
|
---|
81 | .test(
|
---|
82 | 'date-min',
|
---|
83 | 'Due date must be later than create date',
|
---|
84 | (value, { parent }) => value.getTime() > parent.createDate.getTime()
|
---|
85 | ),
|
---|
86 | invoiceFrom: Yup.mixed<any>().nullable().required('Invoice from is required'),
|
---|
87 | invoiceTo: Yup.mixed<any>().nullable().required('Invoice to is required'),
|
---|
88 | currency: Yup.string().oneOf(['EUR', 'USD']).required('Currency is required'),
|
---|
89 | quantityType: Yup.string()
|
---|
90 | .oneOf(['Unit', 'Hour', 'Sprint', 'Month'])
|
---|
91 | .required('Quantity type is required'),
|
---|
92 | month: Yup.string().oneOf(monthNames).required('Month is required'),
|
---|
93 | status: Yup.string().required(),
|
---|
94 | totalAmount: Yup.number().required(),
|
---|
95 | // not required
|
---|
96 | taxes: Yup.number(),
|
---|
97 | discount: Yup.number(),
|
---|
98 | items: Yup.array()
|
---|
99 | .of(itemSchema)
|
---|
100 | .required('At least one item is required')
|
---|
101 | .min(1, 'At least one item must be provided'),
|
---|
102 | });
|
---|
103 |
|
---|
104 | const { services: invoiceServices } = useGetServices();
|
---|
105 | const { settings, settingsEmpty, settingsLoading } = useGetSettings();
|
---|
106 |
|
---|
107 | const defaultValues = useMemo(
|
---|
108 | () => ({
|
---|
109 | invoiceNumber:
|
---|
110 | !isCopy && currentInvoice?.invoiceNumber
|
---|
111 | ? currentInvoice?.invoiceNumber
|
---|
112 | : incrementInvoiceNumber(settings?.invoice?.lastInvoiceNumber),
|
---|
113 | createDate: currentInvoice?.createDate?.toDate() || new Date(),
|
---|
114 | dueDate: currentInvoice?.dueDate?.toDate() || null,
|
---|
115 | invoiceFrom: currentInvoice?.invoiceFrom || null,
|
---|
116 | invoiceTo: currentInvoice?.invoiceTo || null,
|
---|
117 | currency: currentInvoice?.currency || 'EUR',
|
---|
118 | quantityType: currentInvoice?.quantityType || 'Unit',
|
---|
119 | month: currentInvoice?.month || monthNames[new Date().getMonth()],
|
---|
120 | // not required
|
---|
121 | taxes: currentInvoice?.taxes || 0,
|
---|
122 | status: !isCopy && currentInvoice?.status ? currentInvoice?.status : 'draft',
|
---|
123 | discount: currentInvoice?.discount || 0,
|
---|
124 | items: currentInvoice?.items.map((item) => ({
|
---|
125 | ...item,
|
---|
126 | service: item.service?.id || null,
|
---|
127 | })) || [
|
---|
128 | {
|
---|
129 | title: '',
|
---|
130 | description: '',
|
---|
131 | service: '',
|
---|
132 | quantity: 1,
|
---|
133 | price: 0,
|
---|
134 | total: 0,
|
---|
135 | },
|
---|
136 | ],
|
---|
137 | subTotal: currentInvoice?.subTotal || 0,
|
---|
138 | totalAmount: currentInvoice?.totalAmount || 0,
|
---|
139 | }),
|
---|
140 | [currentInvoice, isCopy, settings]
|
---|
141 | );
|
---|
142 |
|
---|
143 | const methods = useForm({
|
---|
144 | resolver: yupResolver(NewInvoiceSchema),
|
---|
145 | defaultValues,
|
---|
146 | });
|
---|
147 |
|
---|
148 | const {
|
---|
149 | reset,
|
---|
150 | handleSubmit,
|
---|
151 | formState: { isSubmitting },
|
---|
152 | } = methods;
|
---|
153 |
|
---|
154 | useEffect(() => {
|
---|
155 | if (!settingsEmpty && !settingsLoading) {
|
---|
156 | // eslint-disable-next-line react-hooks/exhaustive-deps
|
---|
157 | reset(defaultValues);
|
---|
158 | }
|
---|
159 | // eslint-disable-next-line react-hooks/exhaustive-deps
|
---|
160 | }, [settingsEmpty, settingsLoading]);
|
---|
161 |
|
---|
162 | const handleSaveAsDraft = handleSubmit(async (data) => {
|
---|
163 | loadingSave.onTrue();
|
---|
164 |
|
---|
165 | try {
|
---|
166 | // generate collection id
|
---|
167 | const id = generateId(collections.invoice);
|
---|
168 |
|
---|
169 | // attach serivce details
|
---|
170 | const items = data.items.map((item) => ({
|
---|
171 | ...item,
|
---|
172 | service: invoiceServices.find((service) => service.id === item.service) || null,
|
---|
173 | }));
|
---|
174 |
|
---|
175 | const currentTime = new Date();
|
---|
176 | const createDateWithCurrentTime = new Date(data.createDate); // This creates a date object using the date from data.createDate
|
---|
177 | // Set the time of createDateWithCurrentTime to the current hour, minutes, and seconds
|
---|
178 | createDateWithCurrentTime.setHours(
|
---|
179 | currentTime.getHours(),
|
---|
180 | currentTime.getMinutes(),
|
---|
181 | currentTime.getSeconds()
|
---|
182 | );
|
---|
183 |
|
---|
184 | // transform data
|
---|
185 | const writeData = {
|
---|
186 | ...data,
|
---|
187 | invoiceNumber: incrementInvoiceNumber(settings?.invoice.lastInvoiceNumber),
|
---|
188 | status: 'draft',
|
---|
189 | createDate: Timestamp.fromDate(createDateWithCurrentTime),
|
---|
190 | dueDate: Timestamp.fromDate(new Date(data.dueDate)),
|
---|
191 | items,
|
---|
192 | };
|
---|
193 |
|
---|
194 | // upload invoice PDF to storage
|
---|
195 | const invoicePdf = <InvoicePDF invoice={writeData as Invoice} currentStatus="pending" />;
|
---|
196 | const blob: Blob = await pdf(invoicePdf).toBlob();
|
---|
197 | const storagePath: string = `invoices/${writeData.invoiceTo.name}/${id}-${writeData.invoiceNumber}.pdf`;
|
---|
198 | await uploadToFirebaseStorage(blob, storagePath);
|
---|
199 |
|
---|
200 | // write to DB
|
---|
201 | await firestoreBatch([
|
---|
202 | {
|
---|
203 | docPath: `${collections.invoice}/${id}`,
|
---|
204 | type: 'set',
|
---|
205 | data: {
|
---|
206 | ...writeData,
|
---|
207 | pdfRef: storagePath,
|
---|
208 | },
|
---|
209 | },
|
---|
210 | {
|
---|
211 | docPath: `${collections.settings}/${documents.settingsInvoice}`,
|
---|
212 | type: 'set',
|
---|
213 | data: { lastInvoiceNumber: writeData.invoiceNumber },
|
---|
214 | },
|
---|
215 | ]);
|
---|
216 |
|
---|
217 | loadingSave.onFalse();
|
---|
218 | router.push(paths.dashboard.invoice.root);
|
---|
219 | // console.info('DATA', JSON.stringify(writeData, null, 2));
|
---|
220 | } catch (error) {
|
---|
221 | console.error(error);
|
---|
222 | loadingSave.onFalse();
|
---|
223 | }
|
---|
224 | });
|
---|
225 |
|
---|
226 | const handleCreateAndUpdate = handleSubmit(async (data) => {
|
---|
227 | loadingSend.onTrue();
|
---|
228 |
|
---|
229 | try {
|
---|
230 | if (currentInvoice) {
|
---|
231 | // attach serivce details
|
---|
232 | const items = data.items.map((item) => ({
|
---|
233 | ...item,
|
---|
234 | service: invoiceServices.find((service) => service.id === item.service) || null,
|
---|
235 | }));
|
---|
236 |
|
---|
237 | // transform data
|
---|
238 | const writeData = {
|
---|
239 | ...data,
|
---|
240 | createDate: Timestamp.fromDate(new Date(data.createDate)),
|
---|
241 | dueDate: Timestamp.fromDate(new Date(data.dueDate)),
|
---|
242 | items,
|
---|
243 | };
|
---|
244 |
|
---|
245 | // upload invoice PDF to storage
|
---|
246 | const invoicePdf = <InvoicePDF invoice={writeData as Invoice} currentStatus="pending" />;
|
---|
247 | const blob: Blob = await pdf(invoicePdf).toBlob();
|
---|
248 | const storagePath: string = `invoices/${data.invoiceTo.name}/${currentInvoice.id}-${data.invoiceNumber}.pdf`;
|
---|
249 | await uploadToFirebaseStorage(blob, storagePath);
|
---|
250 |
|
---|
251 | // update DB
|
---|
252 | await updateDocument(collections.invoice, currentInvoice.id, {
|
---|
253 | ...writeData,
|
---|
254 | pdfRef: storagePath,
|
---|
255 | });
|
---|
256 |
|
---|
257 | // mutate current data
|
---|
258 | mutate([collections.invoice, currentInvoice.id]);
|
---|
259 | } else {
|
---|
260 | // generate collection id
|
---|
261 | const id = generateId(collections.invoice);
|
---|
262 |
|
---|
263 | // attach serivce details
|
---|
264 | const items = data.items.map((item) => ({
|
---|
265 | ...item,
|
---|
266 | service: invoiceServices?.find((service) => service.id === item.service) || null,
|
---|
267 | }));
|
---|
268 |
|
---|
269 | // transform data
|
---|
270 | const writeData = {
|
---|
271 | ...data,
|
---|
272 | createDate: Timestamp.fromDate(new Date(data.createDate)),
|
---|
273 | dueDate: Timestamp.fromDate(new Date(data.dueDate)),
|
---|
274 | items,
|
---|
275 | };
|
---|
276 |
|
---|
277 | // upload invoice PDF to storage
|
---|
278 | const invoicePdf = <InvoicePDF invoice={writeData as Invoice} currentStatus="pending" />;
|
---|
279 | const blob: Blob = await pdf(invoicePdf).toBlob();
|
---|
280 | const storagePath: string = `invoices/${data.invoiceTo.name}/${id}-${data.invoiceNumber}.pdf`;
|
---|
281 | await uploadToFirebaseStorage(blob, storagePath);
|
---|
282 |
|
---|
283 | // write to DB
|
---|
284 | await firestoreBatch([
|
---|
285 | {
|
---|
286 | docPath: `${collections.invoice}/${id}`,
|
---|
287 | type: 'set',
|
---|
288 | data: {
|
---|
289 | ...writeData,
|
---|
290 | pdfRef: storagePath,
|
---|
291 | },
|
---|
292 | },
|
---|
293 | {
|
---|
294 | docPath: `${collections.settings}/${documents.settingsInvoice}`,
|
---|
295 | type: 'set',
|
---|
296 | data: { lastInvoiceNumber: writeData.invoiceNumber },
|
---|
297 | },
|
---|
298 | ]);
|
---|
299 |
|
---|
300 | reset();
|
---|
301 | }
|
---|
302 |
|
---|
303 | loadingSend.onFalse();
|
---|
304 | router.push(paths.dashboard.invoice.root);
|
---|
305 |
|
---|
306 | // console.info('DATA', JSON.stringify(data, null, 2));
|
---|
307 | } catch (error) {
|
---|
308 | console.error(error);
|
---|
309 | loadingSend.onFalse();
|
---|
310 | }
|
---|
311 | });
|
---|
312 |
|
---|
313 | return (
|
---|
314 | <FormProvider methods={methods}>
|
---|
315 | <Card>
|
---|
316 | {settings?.company && <InvoiceNewEditAddress />}
|
---|
317 |
|
---|
318 | <InvoiceNewEditStatusDate isCopy={isCopy} />
|
---|
319 |
|
---|
320 | <InvoiceNewEditDetails />
|
---|
321 | </Card>
|
---|
322 |
|
---|
323 | <Stack justifyContent="flex-end" direction="row" spacing={2} sx={{ mt: 3 }}>
|
---|
324 | {currentInvoice && isCopy && (
|
---|
325 | <LoadingButton
|
---|
326 | color="inherit"
|
---|
327 | size="large"
|
---|
328 | variant="outlined"
|
---|
329 | loading={loadingSave.value && isSubmitting}
|
---|
330 | onClick={handleSaveAsDraft}
|
---|
331 | >
|
---|
332 | Save as Draft
|
---|
333 | </LoadingButton>
|
---|
334 | )}
|
---|
335 |
|
---|
336 | {!isCopy && (
|
---|
337 | <LoadingButton
|
---|
338 | size="large"
|
---|
339 | variant="contained"
|
---|
340 | loading={loadingSend.value && isSubmitting}
|
---|
341 | onClick={handleCreateAndUpdate}
|
---|
342 | >
|
---|
343 | {currentInvoice ? 'Update' : 'Create'}
|
---|
344 | </LoadingButton>
|
---|
345 | )}
|
---|
346 | </Stack>
|
---|
347 | </FormProvider>
|
---|
348 | );
|
---|
349 | }
|
---|