[5d6f37a] | 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
|
---|
[057453c] | 13 | import { CreateInvoice, Invoice } from 'src/schemas';
|
---|
[5d6f37a] | 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';
|
---|
[057453c] | 22 | import { useGetTenant } from 'src/api/tenant';
|
---|
| 23 | import { collections, generateId, updateDocument } from 'src/lib/firestore';
|
---|
[5d6f37a] | 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';
|
---|
[057453c] | 30 | import { createInvoice, updateInvoice } from 'src/api/invoice';
|
---|
[5d6f37a] | 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 |
|
---|
[057453c] | 54 | interface InvoiceItem {
|
---|
| 55 | service: string | null;
|
---|
| 56 | title: string;
|
---|
| 57 | price: number;
|
---|
| 58 | total: number;
|
---|
| 59 | quantity: number;
|
---|
| 60 | description: string;
|
---|
| 61 | }
|
---|
| 62 |
|
---|
[5d6f37a] | 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(),
|
---|
[057453c] | 74 | quantity: Yup.number()
|
---|
| 75 | .required('Quantity is required')
|
---|
| 76 | .min(0.5, 'Quantity must be at least 0.5'),
|
---|
[5d6f37a] | 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'),
|
---|
[057453c] | 98 | status: Yup.string().oneOf(['draft', 'processing', 'pending', 'overdue', 'paid']).required(),
|
---|
[5d6f37a] | 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();
|
---|
[057453c] | 110 | console.log('invoiceServices', invoiceServices);
|
---|
| 111 | const { settings: tenant, settingsEmpty, settingsLoading } = useGetTenant();
|
---|
[5d6f37a] | 112 |
|
---|
| 113 | const defaultValues = useMemo(
|
---|
| 114 | () => ({
|
---|
| 115 | invoiceNumber:
|
---|
| 116 | !isCopy && currentInvoice?.invoiceNumber
|
---|
| 117 | ? currentInvoice?.invoiceNumber
|
---|
[057453c] | 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),
|
---|
[5d6f37a] | 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 | }),
|
---|
[057453c] | 148 | [currentInvoice, isCopy, tenant]
|
---|
[5d6f37a] | 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 |
|
---|
[057453c] | 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);
|
---|
[5d6f37a] | 180 |
|
---|
| 181 | const currentTime = new Date();
|
---|
[057453c] | 182 | createDate.setHours(
|
---|
[5d6f37a] | 183 | currentTime.getHours(),
|
---|
| 184 | currentTime.getMinutes(),
|
---|
| 185 | currentTime.getSeconds()
|
---|
| 186 | );
|
---|
| 187 |
|
---|
[057453c] | 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 |
|
---|
[5d6f37a] | 194 | // transform data
|
---|
[057453c] | 195 | const writeData: CreateInvoice = {
|
---|
[5d6f37a] | 196 | ...data,
|
---|
[057453c] | 197 | invoiceNumber: incrementInvoiceNumber(tenant?.lastInvoiceNumber),
|
---|
[5d6f37a] | 198 | status: 'draft',
|
---|
[057453c] | 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!,
|
---|
[5d6f37a] | 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();
|
---|
[299af01] | 211 | const storagePath: string = `invoices/${writeData.invoiceTo.name}-${writeData.invoiceNumber}.pdf`;
|
---|
[5d6f37a] | 212 | await uploadToFirebaseStorage(blob, storagePath);
|
---|
| 213 |
|
---|
| 214 | // write to DB
|
---|
[057453c] | 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 });
|
---|
[5d6f37a] | 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) {
|
---|
[057453c] | 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 |
|
---|
[5d6f37a] | 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,
|
---|
[057453c] | 261 | createDate,
|
---|
| 262 | dueDate,
|
---|
[5d6f37a] | 263 | items,
|
---|
[057453c] | 264 | invoiceFrom: data.invoiceFrom!,
|
---|
| 265 | invoiceTo: data.invoiceTo!,
|
---|
[5d6f37a] | 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();
|
---|
[299af01] | 271 | const storagePath: string = `invoices/${data.invoiceTo.name}-${data.invoiceNumber}.pdf`;
|
---|
[5d6f37a] | 272 | await uploadToFirebaseStorage(blob, storagePath);
|
---|
| 273 |
|
---|
| 274 | // update DB
|
---|
[057453c] | 275 | // await updateDocument(collections.invoice, currentInvoice.id, {
|
---|
| 276 | // ...writeData,
|
---|
| 277 | // pdfRef: storagePath,
|
---|
| 278 | // });
|
---|
| 279 |
|
---|
| 280 | await updateInvoice(currentInvoice.id, {
|
---|
[5d6f37a] | 281 | ...writeData,
|
---|
| 282 | pdfRef: storagePath,
|
---|
[057453c] | 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 | }[],
|
---|
[5d6f37a] | 305 | });
|
---|
| 306 |
|
---|
| 307 | // mutate current data
|
---|
[057453c] | 308 | // mutate([collections.invoice, currentInvoice.id]);
|
---|
[5d6f37a] | 309 | } else {
|
---|
| 310 | // generate collection id
|
---|
| 311 | const id = generateId(collections.invoice);
|
---|
| 312 |
|
---|
[057453c] | 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 |
|
---|
[5d6f37a] | 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,
|
---|
[057453c] | 327 | createDate,
|
---|
| 328 | dueDate,
|
---|
[5d6f37a] | 329 | items,
|
---|
[057453c] | 330 | invoiceFrom: data.invoiceFrom!,
|
---|
| 331 | invoiceTo: data.invoiceTo!,
|
---|
[5d6f37a] | 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();
|
---|
[299af01] | 337 | const storagePath: string = `invoices/${data.invoiceTo.name}-${data.invoiceNumber}.pdf`;
|
---|
[5d6f37a] | 338 | await uploadToFirebaseStorage(blob, storagePath);
|
---|
| 339 |
|
---|
| 340 | // write to DB
|
---|
[057453c] | 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 });
|
---|
[5d6f37a] | 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>
|
---|
[057453c] | 374 | {!!tenant && <InvoiceNewEditAddress />}
|
---|
[5d6f37a] | 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 | }
|
---|