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