source: src/sections/invoice/view/invoice-list-view.tsx@ 87c9f1e

main
Last change on this file since 87c9f1e was 87c9f1e, checked in by Naum Shapkarovski <naumshapkarovski@…>, 5 weeks ago

update the seed script. update the prisma schema, use mapping

  • Property mode set to 100644
File size: 19.4 KB
Line 
1'use client';
2
3import { useState, useCallback, useEffect, useMemo } from 'react';
4// @mui
5import { useTheme, alpha } from '@mui/material/styles';
6import Tab from '@mui/material/Tab';
7import Tabs from '@mui/material/Tabs';
8import Card from '@mui/material/Card';
9import Table from '@mui/material/Table';
10import Stack from '@mui/material/Stack';
11import Button from '@mui/material/Button';
12import Divider from '@mui/material/Divider';
13import Container from '@mui/material/Container';
14import TableBody from '@mui/material/TableBody';
15import TableContainer from '@mui/material/TableContainer';
16// routes
17import { paths } from 'src/routes/paths';
18import { useRouter } from 'src/routes/hooks';
19import { RouterLink } from 'src/routes/components';
20// utils
21import { fTimestamp } from 'src/utils/format-time';
22// components
23import Label from 'src/components/label';
24import Iconify from 'src/components/iconify';
25import Scrollbar from 'src/components/scrollbar';
26import { useSettingsContext } from 'src/components/settings';
27import CustomBreadcrumbs from 'src/components/custom-breadcrumbs';
28import {
29 useTable,
30 getComparator,
31 emptyRows,
32 TableNoData,
33 TableEmptyRows,
34 TableHeadCustom,
35 TablePaginationCustom,
36} from 'src/components/table';
37// types
38import { Invoice, InvoiceStatus, InvoiceTableFilters, InvoiceTableFilterValue } from 'src/schemas';
39//
40import deleteFromFirebaseStorage from 'src/utils/delete-from-firebase-storage';
41// fetch
42import { useDeleteInvoice, useGetInvoices } from 'src/api/invoice';
43import { collections, removeDocument } from 'src/lib/firestore';
44import { mutate } from 'swr';
45import { useBoolean } from 'src/hooks/use-boolean';
46import InvoiceAnalytic from '../invoice-analytic';
47import InvoiceTableRow from '../invoice-table-row';
48import InvoiceTableFiltersResult from '../invoice-table-filters-result';
49import InvoiceTableToolbar from '../invoice-table-toolbar';
50import MailCompose from '../mail-compose';
51import { useFetchAnalytics } from 'src/api/invoice/use-fetch-analytics';
52import { endpoints } from 'src/utils/axios';
53
54// ----------------------------------------------------------------------
55
56const TABLE_HEAD = [
57 { id: 'invoiceNumber', label: 'Customer' },
58 { id: 'price', label: 'Amount' },
59 { id: 'currency', label: 'Currency' },
60 { id: 'invoicePeriod', label: 'Invoice Period' },
61 { id: 'issueDate', label: 'Create' },
62 { id: 'dueDate', label: 'Due' },
63 { id: 'sent', label: 'Sent', align: 'center' },
64 { id: 'status', label: 'Status' },
65 { id: '' },
66];
67
68const defaultFilters: InvoiceTableFilters = {
69 name: '',
70 service: [],
71 status: 'all',
72 startDate: new Date('2024-01-01T00:00:00Z'),
73 endDate: null,
74};
75
76// ----------------------------------------------------------------------
77
78interface StatusTotals {
79 EUR: number;
80 USD: number;
81}
82
83type ResultsType = {
84 [key in 'total' | InvoiceStatus]: StatusTotals;
85};
86
87const getEURtoUSDExchangeRate = async () => {
88 try {
89 // const response = await fetch(
90 // 'http://api.exchangeratesapi.io/v1/latest?access_key=248cc5d9f103fcb183ed4987f2d85b3b&base=EUR&symbols=USD'
91 // );
92 // const data = await response.json();
93 // return data.rates.USD;
94 return 1.05;
95 } catch (error) {
96 console.error('Error fetching the exchange rate:', error);
97 return null;
98 }
99};
100
101const getTotalAmountForAllStatuses = async (invoices: Invoice[]) => {
102 const eurToUsdRate = await getEURtoUSDExchangeRate();
103 if (!eurToUsdRate) {
104 throw new Error("Couldn't fetch the exchange rate.");
105 }
106
107 const results: ResultsType = {
108 total: { EUR: 0, USD: 0 },
109 // excluded
110 processing: { EUR: 0, USD: 0 },
111 paid: { EUR: 0, USD: 0 },
112 pending: { EUR: 0, USD: 0 },
113 overdue: { EUR: 0, USD: 0 },
114 draft: { EUR: 0, USD: 0 },
115 };
116
117 invoices.forEach((invoice) => {
118 const updateAmounts = (key: 'total' | InvoiceStatus) => {
119 const excludeProcessing = key === 'processing' ? 'pending' : key;
120 if (invoice.currency === 'USD') {
121 results[excludeProcessing].USD += invoice.totalAmount;
122 results[excludeProcessing].EUR += invoice.totalAmount / eurToUsdRate; // Convert USD to EUR
123 } else if (invoice.currency === 'EUR') {
124 results[excludeProcessing].EUR += invoice.totalAmount;
125 results[excludeProcessing].USD += invoice.totalAmount * eurToUsdRate; // Convert EUR to USD
126 }
127 };
128
129 updateAmounts('total');
130 updateAmounts(invoice.status as InvoiceStatus);
131 });
132
133 return results;
134};
135
136export default function InvoiceListView() {
137 const theme = useTheme();
138
139 const settings = useSettingsContext();
140
141 const [analytics, setAnalytics] = useState({
142 total: { EUR: 0, USD: 0 },
143 paid: { EUR: 0, USD: 0 },
144 pending: { EUR: 0, USD: 0 },
145 overdue: { EUR: 0, USD: 0 },
146 draft: { EUR: 0, USD: 0 },
147 });
148
149 const [sendingInvoice, setSendingInvoice] = useState<Invoice | null>(null);
150
151 const router = useRouter();
152
153 const table = useTable({
154 defaultOrderBy: 'issueDate',
155 defaultOrder: 'desc',
156 defaultDense: true,
157 defaultRowsPerPage: 25,
158 });
159
160 const [filters, setFilters] = useState(defaultFilters);
161
162 const { invoices: tableData } = useGetInvoices({ startDate: filters.startDate?.toISOString() });
163
164 const invoiceMutationKey = useMemo(
165 () => [
166 collections.invoice,
167 JSON.stringify({
168 where: [['issueDate', '>=', filters.startDate]],
169 orderBy: 'issueDate',
170 direction: 'desc',
171 }),
172 ],
173 [filters]
174 );
175
176 const dateError =
177 filters.startDate && filters.endDate
178 ? filters.startDate.getTime() > filters.endDate.getTime()
179 : false;
180
181 const dataFiltered = applyFilter({
182 inputData: tableData,
183 comparator: getComparator(table.order, table.orderBy),
184 filters,
185 dateError,
186 });
187
188 // const dataInPage = dataFiltered.slice(
189 // table.page * table.rowsPerPage,
190 // table.page * table.rowsPerPage + table.rowsPerPage
191 // );
192
193 const denseHeight = table.dense ? 56 : 76;
194
195 const canReset =
196 !!filters.name ||
197 !!filters.service.length ||
198 filters.status !== 'all' ||
199 (!!filters.startDate && !!filters.endDate);
200
201 const notFound = (!dataFiltered.length && canReset) || !dataFiltered.length;
202
203 const getInvoiceLength = (status: string) =>
204 tableData.filter((item) => item.status === status).length;
205
206 // const getTotalAmount = (status: string) =>
207 // sumBy(
208 // tableData.filter((item) => item.status === status),
209 // 'totalAmount'
210 // );
211
212 const {
213 analytics: analyticsData,
214 isAnalyticsLoading,
215 analyticsError,
216 } = useFetchAnalytics(filters.startDate);
217
218 useEffect(() => {
219 if (analyticsData) {
220 setAnalytics(analyticsData);
221 }
222 }, [analyticsData]);
223
224 useEffect(() => {
225 if (analyticsError) {
226 console.error('Failed to load analytics:', analyticsError);
227 }
228 }, [analyticsError]);
229
230 const getPercentByStatus = (status: string) =>
231 (getInvoiceLength(status) / tableData.length) * 100;
232
233 const TABS = [
234 { value: 'all', label: 'All', color: 'default', count: tableData.length },
235 {
236 value: 'paid',
237 label: 'Paid',
238 color: 'success',
239 count: getInvoiceLength('paid'),
240 },
241 {
242 value: 'pending',
243 label: 'Pending',
244 color: 'warning',
245 count: getInvoiceLength('pending'),
246 },
247 {
248 value: 'overdue',
249 label: 'Overdue',
250 color: 'error',
251 count: getInvoiceLength('overdue'),
252 },
253 {
254 value: 'draft',
255 label: 'Draft',
256 color: 'default',
257 count: getInvoiceLength('draft'),
258 },
259 ] as const;
260
261 const handleFilters = useCallback(
262 (name: string, value: InvoiceTableFilterValue) => {
263 table.onResetPage();
264 setFilters((prevState) => ({
265 ...prevState,
266 [name]: value,
267 }));
268 },
269 [table]
270 );
271
272 const { deleteInvoiceMutation } = useDeleteInvoice();
273
274 const handleDeleteRow = useCallback(
275 async (invoice: Invoice) => {
276 const serializedParams = JSON.stringify({
277 where: [['issueDate', '>=', filters.startDate]],
278 orderBy: 'issueDate',
279 direction: 'desc',
280 });
281
282 await deleteInvoiceMutation(invoice.id);
283 await deleteFromFirebaseStorage(
284 `invoices/${invoice.invoiceTo.name}-${invoice.invoiceNumber}.pdf`
285 );
286
287 mutate(endpoints.invoice);
288 },
289 [filters.startDate, invoiceMutationKey]
290 );
291
292 const openCompose = useBoolean();
293
294 const handleToggleCompose = useCallback(
295 async (invoice: Invoice) => {
296 setSendingInvoice(invoice);
297 openCompose.onToggle();
298 },
299 [openCompose]
300 );
301
302 // const handleDeleteRows = useCallback(() => {
303 // const deleteRows = tableData.filter((row) => !table.selected.includes(row.id));
304 // // setTableData(deleteRows);
305
306 // table.onUpdatePageDeleteRows({
307 // totalRows: tableData.length,
308 // totalRowsInPage: dataInPage.length,
309 // totalRowsFiltered: dataFiltered.length,
310 // });
311 // }, [dataFiltered.length, dataInPage.length, table, tableData]);
312
313 const handleEditRow = useCallback(
314 (id: string) => {
315 router.push(paths.dashboard.invoice.edit(id));
316 },
317 [router]
318 );
319
320 const handleCopyRow = useCallback(
321 (id: string) => {
322 router.push(paths.dashboard.invoice.copy(id));
323 },
324 [router]
325 );
326
327 const handleViewRow = useCallback(
328 (id: string) => {
329 router.push(paths.dashboard.invoice.details(id));
330 },
331 [router]
332 );
333
334 const handleFilterStatus = useCallback(
335 (event: React.SyntheticEvent, newValue: string) => {
336 handleFilters('status', newValue);
337 },
338 [handleFilters]
339 );
340
341 const handleResetFilters = useCallback(() => {
342 setFilters(defaultFilters);
343 }, []);
344
345 if (isAnalyticsLoading) {
346 // Show loading state
347 }
348
349 return (
350 <>
351 <Container maxWidth={settings.themeStretch ? false : 'lg'}>
352 <CustomBreadcrumbs
353 heading="List"
354 links={[
355 {
356 name: 'Dashboard',
357 href: paths.dashboard.root,
358 },
359 {
360 name: 'Invoice',
361 href: paths.dashboard.invoice.root,
362 },
363 {
364 name: 'List',
365 },
366 ]}
367 action={
368 <Button
369 component={RouterLink}
370 href={paths.dashboard.invoice.new}
371 variant="contained"
372 startIcon={<Iconify icon="mingcute:add-line" />}
373 >
374 New Invoice
375 </Button>
376 }
377 sx={{
378 mb: { xs: 3, md: 5 },
379 }}
380 />
381
382 <Card
383 sx={{
384 mb: { xs: 3, md: 5 },
385 }}
386 >
387 <Scrollbar>
388 <Stack
389 direction="row"
390 divider={<Divider orientation="vertical" flexItem sx={{ borderStyle: 'dashed' }} />}
391 sx={{ py: 2 }}
392 >
393 <InvoiceAnalytic
394 title="Total"
395 total={tableData.length}
396 percent={100}
397 price={analytics.total}
398 icon="solar:bill-list-bold-duotone"
399 color={theme.palette.info.main}
400 />
401
402 <InvoiceAnalytic
403 title="Paid"
404 total={getInvoiceLength('paid')}
405 percent={getPercentByStatus('paid')}
406 price={analytics.paid}
407 icon="solar:file-check-bold-duotone"
408 color={theme.palette.success.main}
409 />
410
411 <InvoiceAnalytic
412 title="Pending"
413 total={getInvoiceLength('pending')}
414 percent={getPercentByStatus('pending')}
415 price={analytics.pending}
416 icon="solar:sort-by-time-bold-duotone"
417 color={theme.palette.warning.main}
418 />
419
420 <InvoiceAnalytic
421 title="Overdue"
422 total={getInvoiceLength('overdue')}
423 percent={getPercentByStatus('overdue')}
424 price={analytics.overdue}
425 icon="solar:bell-bing-bold-duotone"
426 color={theme.palette.error.main}
427 />
428
429 <InvoiceAnalytic
430 title="Draft"
431 total={getInvoiceLength('draft')}
432 percent={getPercentByStatus('draft')}
433 price={analytics.draft}
434 icon="solar:file-corrupted-bold-duotone"
435 color={theme.palette.text.secondary}
436 />
437 </Stack>
438 </Scrollbar>
439 </Card>
440
441 <Card>
442 <Tabs
443 value={filters.status}
444 onChange={handleFilterStatus}
445 sx={{
446 px: 2.5,
447 boxShadow: `inset 0 -2px 0 0 ${alpha(theme.palette.grey[500], 0.08)}`,
448 }}
449 >
450 {TABS.map((tab) => (
451 <Tab
452 key={tab.value}
453 value={tab.value}
454 label={tab.label}
455 iconPosition="end"
456 icon={
457 <Label
458 variant={
459 ((tab.value === 'all' || tab.value === filters.status) && 'filled') || 'soft'
460 }
461 color={tab.color}
462 >
463 {tab.count}
464 </Label>
465 }
466 />
467 ))}
468 </Tabs>
469
470 <InvoiceTableToolbar
471 filters={filters}
472 onFilters={handleFilters}
473 //
474 dateError={dateError}
475 serviceOptions={[]}
476 />
477
478 {canReset && (
479 <InvoiceTableFiltersResult
480 filters={filters}
481 onFilters={handleFilters}
482 //
483 onResetFilters={handleResetFilters}
484 //
485 results={dataFiltered.length}
486 sx={{ p: 2.5, pt: 0 }}
487 />
488 )}
489
490 <TableContainer sx={{ position: 'relative', overflow: 'unset' }}>
491 {/* <TableSelectedAction
492 dense={table.dense}
493 numSelected={table.selected.length}
494 rowCount={tableData.length}
495 onSelectAllRows={(checked) =>
496 table.onSelectAllRows(
497 checked,
498 tableData.map((row) => row.id)
499 )
500 }
501 action={
502 <Stack direction="row">
503 <Tooltip title="Sent">
504 <IconButton color="primary">
505 <Iconify icon="iconamoon:send-fill" />
506 </IconButton>
507 </Tooltip>
508
509 <Tooltip title="Download">
510 <IconButton color="primary">
511 <Iconify icon="eva:download-outline" />
512 </IconButton>
513 </Tooltip>
514
515 <Tooltip title="Print">
516 <IconButton color="primary">
517 <Iconify icon="solar:printer-minimalistic-bold" />
518 </IconButton>
519 </Tooltip>
520
521 <Tooltip title="Delete">
522 <IconButton color="primary" onClick={confirm.onTrue}>
523 <Iconify icon="solar:trash-bin-trash-bold" />
524 </IconButton>
525 </Tooltip>
526 </Stack>
527 }
528 /> */}
529
530 <Scrollbar>
531 <Table size={table.dense ? 'small' : 'medium'} sx={{ minWidth: 800 }}>
532 <TableHeadCustom
533 order={table.order}
534 orderBy={table.orderBy}
535 headLabel={TABLE_HEAD}
536 rowCount={tableData.length}
537 numSelected={table.selected.length}
538 onSort={table.onSort}
539 onSelectAllRows={(checked) =>
540 table.onSelectAllRows(
541 checked,
542 tableData.map((row) => row.id)
543 )
544 }
545 />
546
547 <TableBody>
548 {dataFiltered
549 .slice(
550 table.page * table.rowsPerPage,
551 table.page * table.rowsPerPage + table.rowsPerPage
552 )
553 .map((row) => (
554 <InvoiceTableRow
555 key={row.id}
556 row={row}
557 selected={table.selected.includes(row.id)}
558 onSelectRow={() => table.onSelectRow(row.id)}
559 onViewRow={() => handleViewRow(row.id)}
560 onEditRow={() => handleEditRow(row.id)}
561 onCopyRow={() => handleCopyRow(row.id)}
562 onDeleteRow={async () => handleDeleteRow(row)}
563 onSendRow={() => {}}
564 onToggleCompose={() => handleToggleCompose(row)}
565 />
566 ))}
567
568 <TableEmptyRows
569 height={denseHeight}
570 emptyRows={emptyRows(table.page, table.rowsPerPage, tableData.length)}
571 />
572
573 <TableNoData notFound={notFound} />
574 </TableBody>
575 </Table>
576 </Scrollbar>
577 </TableContainer>
578
579 <TablePaginationCustom
580 count={dataFiltered.length}
581 page={table.page}
582 rowsPerPage={table.rowsPerPage}
583 onPageChange={table.onChangePage}
584 onRowsPerPageChange={table.onChangeRowsPerPage}
585 //
586 dense={table.dense}
587 onChangeDense={table.onChangeDense}
588 />
589 </Card>
590 </Container>
591
592 {openCompose.value && (
593 <MailCompose
594 invoice={sendingInvoice}
595 onCloseCompose={openCompose.onFalse}
596 invoiceMutationKey={invoiceMutationKey}
597 />
598 )}
599
600 {/* <ConfirmDialog
601 open={confirm.value}
602 onClose={confirm.onFalse}
603 title="Delete"
604 content={
605 <>
606 Are you sure want to delete <strong> {table.selected.length} </strong> items?
607 </>
608 }
609 action={
610 <Button
611 variant="contained"
612 color="error"
613 onClick={() => {
614 handleDeleteRows();
615 confirm.onFalse();
616 }}
617 >
618 Delete
619 </Button>
620 }
621 /> */}
622 </>
623 );
624}
625
626// ----------------------------------------------------------------------
627
628function applyFilter({
629 inputData,
630 comparator,
631 filters,
632 dateError,
633}: {
634 inputData: Invoice[];
635 comparator: (a: any, b: any) => number;
636 filters: InvoiceTableFilters;
637 dateError: boolean;
638}) {
639 const { name, status, service, startDate, endDate } = filters;
640
641 const stabilizedThis = inputData.map((el, index) => [el, index] as const);
642
643 stabilizedThis.sort((a, b) => {
644 const order = comparator(a[0], b[0]);
645 if (order !== 0) return order;
646 return a[1] - b[1];
647 });
648
649 inputData = stabilizedThis.map((el) => el[0]);
650
651 if (name) {
652 inputData = inputData.filter(
653 (invoice) =>
654 invoice.invoiceNumber.toLowerCase().indexOf(name.toLowerCase()) !== -1 ||
655 invoice.invoiceTo.name.toLowerCase().indexOf(name.toLowerCase()) !== -1
656 );
657 }
658
659 if (status !== 'all') {
660 inputData = inputData.filter((invoice) => invoice.status === status);
661 }
662
663 if (service.length) {
664 inputData = inputData.filter((invoice) =>
665 invoice.items.some((filterItem) => service.includes(filterItem.service.id))
666 );
667 }
668
669 if (!dateError) {
670 if (startDate && endDate) {
671 inputData = inputData.filter(
672 (invoice) =>
673 fTimestamp(invoice.issueDate.getTime()) >= fTimestamp(startDate.getTime()) &&
674 fTimestamp(invoice.issueDate.getTime()) <= fTimestamp(endDate.getTime())
675 );
676 }
677 }
678
679 return inputData;
680}
Note: See TracBrowser for help on using the repository browser.