build ok,

This commit is contained in:
louiscklaw
2025-04-14 09:26:24 +08:00
commit 6c931c1fe8
770 changed files with 63959 additions and 0 deletions

View File

@@ -0,0 +1,519 @@
'use client';
import * as React from 'react';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Card from '@mui/material/Card';
import CardActions from '@mui/material/CardActions';
import CardContent from '@mui/material/CardContent';
import Divider from '@mui/material/Divider';
import FormControl from '@mui/material/FormControl';
import FormHelperText from '@mui/material/FormHelperText';
import IconButton from '@mui/material/IconButton';
import InputAdornment from '@mui/material/InputAdornment';
import InputLabel from '@mui/material/InputLabel';
import OutlinedInput from '@mui/material/OutlinedInput';
import Select from '@mui/material/Select';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import Grid from '@mui/material/Unstable_Grid2';
import { DatePicker } from '@mui/x-date-pickers/DatePicker';
import { PlusCircle as PlusCircleIcon } from '@phosphor-icons/react/dist/ssr/PlusCircle';
import { Trash as TrashIcon } from '@phosphor-icons/react/dist/ssr/Trash';
import { Controller, useForm } from 'react-hook-form';
import { z as zod } from 'zod';
import { paths } from '@/paths';
import { dayjs } from '@/lib/dayjs';
import { logger } from '@/lib/default-logger';
import { Option } from '@/components/core/option';
import { toast } from '@/components/core/toaster';
interface LineItem {
id: string;
description: string;
service: string;
quantity: number;
unitPrice: number;
}
function calculateSubtotal(lineItems: LineItem[]): number {
const subtotal = lineItems.reduce((acc, lineItem) => acc + lineItem.quantity * lineItem.unitPrice, 0);
return parseFloat(subtotal.toFixed(2));
}
function calculateTotalWithoutTaxes(subtotal: number, discount: number, shippingRate: number): number {
return subtotal - discount + shippingRate;
}
function calculateTax(totalWithoutTax: number, taxRate: number): number {
const tax = totalWithoutTax * (taxRate / 100);
return parseFloat(tax.toFixed(2));
}
function calculateTotal(totalWithoutTax: number, taxes: number): number {
return totalWithoutTax + taxes;
}
const schema = zod
.object({
number: zod.string().max(255),
issueDate: zod.date(),
dueDate: zod.date(),
customer: zod.string().min(1, 'Customer is required').max(255),
taxId: zod.string().max(255).optional(),
lineItems: zod.array(
zod.object({
id: zod.string(),
description: zod.string().min(1, 'Description is required').max(255),
service: zod.string().min(1, 'Service is required').max(255),
quantity: zod.number().min(1, 'Quantity must be greater than or equal to 1'),
unitPrice: zod.number().min(0, 'Unit price must be greater than or equal to 0'),
})
),
discount: zod
.number()
.min(0, 'Discount must be greater than or equal to 0')
.max(100, 'Discount must be less than or equal to 100'),
shippingRate: zod.number().min(0, 'Shipping rate must be greater than or equal to 0'),
taxRate: zod
.number()
.min(0, 'Tax rate must be greater than or equal to 0')
.max(100, 'Tax rate must be less than or equal to 100'),
})
.refine((data) => data.issueDate < data.dueDate, {
message: 'Due date should be greater than issue date',
path: ['dueDate'],
});
type Values = zod.infer<typeof schema>;
const defaultValues = {
number: 'INV-001',
issueDate: new Date(),
dueDate: dayjs().add(1, 'month').toDate(),
customer: '',
taxId: '',
lineItems: [{ id: 'LI-001', description: '', service: '', quantity: 1, unitPrice: 0 }],
discount: 0,
shippingRate: 0,
taxRate: 0,
} satisfies Values;
export function InvoiceCreateForm(): React.JSX.Element {
const router = useRouter();
const {
control,
handleSubmit,
formState: { errors },
getValues,
setValue,
watch,
} = useForm<Values>({ defaultValues, resolver: zodResolver(schema) });
const onSubmit = React.useCallback(
async (_: Values): Promise<void> => {
try {
// Make API request
toast.success('Invoice created');
router.push(paths.dashboard.invoices.list);
} catch (err) {
logger.error(err);
toast.error('Something went wrong!');
}
},
[router]
);
const handleAddLineItem = React.useCallback(() => {
const lineItems = getValues('lineItems');
setValue('lineItems', [
...lineItems,
{ id: `LI-${lineItems.length + 1}`, description: '', service: '', quantity: 1, unitPrice: 0 },
]);
}, [getValues, setValue]);
const handleRemoveLineItem = React.useCallback(
(lineItemId: string) => {
const lineItems = getValues('lineItems');
setValue(
'lineItems',
lineItems.filter((lineItem) => lineItem.id !== lineItemId)
);
},
[getValues, setValue]
);
const lineItems = watch('lineItems');
const discount = watch('discount');
const shippingRate = watch('shippingRate');
const taxRate = watch('taxRate');
const subtotal = calculateSubtotal(lineItems);
const totalWithoutTaxes = calculateTotalWithoutTaxes(subtotal, discount, shippingRate);
const tax = calculateTax(totalWithoutTaxes, taxRate);
const total = calculateTotal(totalWithoutTaxes, tax);
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Card>
<CardContent>
<Stack divider={<Divider />} spacing={4}>
<Stack spacing={3}>
<Typography variant="h6">Basic information</Typography>
<Grid container spacing={3}>
<Grid md={6} xs={12}>
<Controller
control={control}
name="customer"
render={({ field }) => (
<FormControl error={Boolean(errors.customer)} fullWidth>
<InputLabel>Customer</InputLabel>
<OutlinedInput {...field} />
{errors.customer ? <FormHelperText>{errors.customer.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
<Grid md={6} xs={12}>
<Controller
control={control}
name="number"
render={({ field }) => (
<FormControl disabled fullWidth>
<InputLabel>Number</InputLabel>
<OutlinedInput {...field} />
</FormControl>
)}
/>
</Grid>
<Grid md={6} xs={12}>
<Controller
control={control}
name="issueDate"
render={({ field }) => (
<DatePicker
{...field}
format="MMM D, YYYY"
label="Issue date"
onChange={(date) => {
field.onChange(date?.toDate());
}}
slotProps={{
textField: {
error: Boolean(errors.issueDate),
fullWidth: true,
helperText: errors.issueDate?.message,
},
}}
value={dayjs(field.value)}
/>
)}
/>
</Grid>
<Grid md={6} xs={12}>
<Controller
control={control}
name="dueDate"
render={({ field }) => (
<DatePicker
{...field}
format="MMM D, YYYY"
label="Due date"
onChange={(date) => {
field.onChange(date?.toDate());
}}
slotProps={{
textField: {
error: Boolean(errors.dueDate),
fullWidth: true,
helperText: errors.dueDate?.message,
},
}}
value={dayjs(field.value)}
/>
)}
/>
</Grid>
<Grid md={6} xs={12}>
<Controller
control={control}
name="taxId"
render={({ field }) => (
<FormControl error={Boolean(errors.taxId)} fullWidth>
<InputLabel>Tax ID</InputLabel>
<OutlinedInput {...field} placeholder="e.g EU372054390" />
{errors.taxId ? <FormHelperText>{errors.taxId.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
</Grid>
</Stack>
<Stack spacing={3}>
<Typography variant="h6">Line items</Typography>
<Stack divider={<Divider />} spacing={2}>
{lineItems.map((lineItem, index) => (
<Stack direction="row" key={lineItem.id} spacing={3} sx={{ alignItems: 'center', flexWrap: 'wrap' }}>
<Controller
control={control}
name={`lineItems.${index}.description`}
render={({ field }) => (
<FormControl
error={Boolean(errors.lineItems?.[index]?.description)}
sx={{ flex: '1 1 auto', minWidth: '200px' }}
>
<InputLabel>Description</InputLabel>
<OutlinedInput {...field} />
{errors.lineItems?.[index]?.description ? (
<FormHelperText>{errors.lineItems[index]!.description!.message}</FormHelperText>
) : null}
</FormControl>
)}
/>
<Controller
control={control}
name={`lineItems.${index}.service`}
render={({ field }) => (
<FormControl
error={Boolean(errors.lineItems?.[index]?.service)}
sx={{ maxWidth: '100%', width: '200px' }}
>
<InputLabel>Service</InputLabel>
<Select {...field}>
<Option value="">Select a service</Option>
<Option value="design">Design</Option>
<Option value="development">Development</Option>
</Select>
{errors.lineItems?.[index]?.service ? (
<FormHelperText>{errors.lineItems[index]!.service!.message}</FormHelperText>
) : null}
</FormControl>
)}
/>
<Controller
control={control}
name={`lineItems.${index}.quantity`}
render={({ field }) => (
<FormControl error={Boolean(errors.lineItems?.[index]?.quantity)} sx={{ width: '140px' }}>
<InputLabel>Quantity</InputLabel>
<OutlinedInput
{...field}
inputProps={{ step: 1 }}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.target.valueAsNumber;
if (isNaN(value)) {
field.onChange('');
return;
}
if (value > 100) {
return;
}
field.onChange(parseInt(event.target.value));
}}
type="number"
/>
{errors.lineItems?.[index]?.quantity ? (
<FormHelperText>{errors.lineItems[index]!.quantity!.message}</FormHelperText>
) : null}
</FormControl>
)}
/>
<Controller
control={control}
name={`lineItems.${index}.unitPrice`}
render={({ field }) => (
<FormControl error={Boolean(errors.lineItems?.[index]?.unitPrice)} sx={{ width: '140px' }}>
<InputLabel>Unit price</InputLabel>
<OutlinedInput
{...field}
inputProps={{ step: 0.01 }}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.target.valueAsNumber;
if (isNaN(value)) {
field.onChange('');
return;
}
field.onChange(parseFloat(value.toFixed(2)));
}}
startAdornment={<InputAdornment position="start">$</InputAdornment>}
type="number"
/>
{errors.lineItems?.[index]?.unitPrice ? (
<FormHelperText>{errors.lineItems[index]!.unitPrice!.message}</FormHelperText>
) : null}
</FormControl>
)}
/>
<IconButton
onClick={() => {
handleRemoveLineItem(lineItem.id);
}}
sx={{ alignSelf: 'flex-end' }}
>
<TrashIcon />
</IconButton>
</Stack>
))}
<div>
<Button
color="secondary"
onClick={handleAddLineItem}
startIcon={<PlusCircleIcon />}
variant="outlined"
>
Add item
</Button>
</div>
</Stack>
</Stack>
<Grid container spacing={3}>
<Grid md={4} xs={12}>
<Controller
control={control}
name="discount"
render={({ field }) => (
<FormControl error={Boolean(errors.discount)} fullWidth>
<InputLabel>Discount</InputLabel>
<OutlinedInput
{...field}
inputProps={{ step: 0.01 }}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.target.valueAsNumber;
if (isNaN(value)) {
field.onChange('');
return;
}
field.onChange(parseFloat(value.toFixed(2)));
}}
startAdornment={<InputAdornment position="start">$</InputAdornment>}
type="number"
/>
{errors.discount ? <FormHelperText>{errors.discount.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
<Grid md={4} xs={12}>
<Controller
control={control}
name="shippingRate"
render={({ field }) => (
<FormControl error={Boolean(errors.shippingRate)} fullWidth>
<InputLabel>Shipping rate</InputLabel>
<OutlinedInput
{...field}
inputProps={{ step: 0.01 }}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.target.valueAsNumber;
if (isNaN(value)) {
field.onChange('');
return;
}
field.onChange(parseFloat(value.toFixed(2)));
}}
startAdornment={<InputAdornment position="start">$</InputAdornment>}
type="number"
/>
{errors.shippingRate ? <FormHelperText>{errors.shippingRate.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
<Grid md={4} xs={12}>
<Controller
control={control}
name="taxRate"
render={({ field }) => (
<FormControl error={Boolean(errors.taxRate)} fullWidth>
<InputLabel>Tax rate (%)</InputLabel>
<OutlinedInput
{...field}
inputProps={{ step: 0.01 }}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.target.valueAsNumber;
if (isNaN(value)) {
field.onChange('');
return;
}
if (value > 100) {
field.onChange(100);
return;
}
field.onChange(parseFloat(value.toFixed(2)));
}}
type="number"
/>
{errors.taxRate ? <FormHelperText>{errors.taxRate.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
</Grid>
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
<Stack spacing={2} sx={{ width: '300px', maxWidth: '100%' }}>
<Stack direction="row" spacing={3} sx={{ justifyContent: 'space-between' }}>
<Typography variant="body2">Subtotal</Typography>
<Typography variant="body2">
{new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(subtotal)}
</Typography>
</Stack>
<Stack direction="row" spacing={3} sx={{ justifyContent: 'space-between' }}>
<Typography variant="body2">Discount</Typography>
<Typography variant="body2">
{discount
? new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(discount)
: '-'}
</Typography>
</Stack>
<Stack direction="row" spacing={3} sx={{ justifyContent: 'space-between' }}>
<Typography variant="body2">Shipping</Typography>
<Typography variant="body2">
{shippingRate
? new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(shippingRate)
: '-'}
</Typography>
</Stack>
<Stack direction="row" spacing={3} sx={{ justifyContent: 'space-between' }}>
<Typography variant="body2">Taxes</Typography>
<Typography variant="body2">
{tax ? new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(tax) : '-'}
</Typography>
</Stack>
<Stack direction="row" spacing={3} sx={{ justifyContent: 'space-between' }}>
<Typography variant="subtitle1">Total</Typography>
<Typography variant="subtitle1">
{new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(total)}
</Typography>
</Stack>
</Stack>
</Box>
</Stack>
</CardContent>
<CardActions sx={{ justifyContent: 'flex-end' }}>
<Button color="secondary">Cancel</Button>
<Button type="submit" variant="contained">
Create invoice
</Button>
</CardActions>
</Card>
</form>
);
}

View File

@@ -0,0 +1,209 @@
'use client';
import * as React from 'react';
import { Document, Image, Page, StyleSheet, Text, View } from '@react-pdf/renderer';
import { dayjs } from '@/lib/dayjs';
// Invoice data should be received as a prop.
// For the sake of simplicity, we are using a hardcoded data.
export interface LineItem {
id: string;
name: string;
quantity: number;
currency: string;
unitAmount: number;
totalAmount: number;
}
const lineItems = [
{ id: 'LI-001', name: 'Pro Subscription', quantity: 1, currency: 'USD', unitAmount: 14.99, totalAmount: 14.99 },
] satisfies LineItem[];
const styles = StyleSheet.create({
// Utils
fontMedium: { fontWeight: 500 },
fontSemibold: { fontWeight: 600 },
textLg: { fontSize: 10, lineHeight: 1.5 },
textXl: { fontSize: 18, lineHeight: 1.6 },
textRight: { textAlign: 'right' },
uppercase: { textTransform: 'uppercase' },
gutterBottom: { marginBottom: 4 },
flexGrow: { flexGrow: 1 },
flexRow: { flexDirection: 'row' },
flexColumn: { flexDirection: 'column' },
w50: { width: '50%' },
// Components
page: { backgroundColor: '#FFFFFF', gap: 32, padding: 24, fontSize: 10, fontWeight: 400, lineHeight: 1.43 },
header: { flexDirection: 'row', justifyContent: 'space-between' },
brand: { height: 40, width: 40 },
refs: { gap: 8 },
refRow: { flexDirection: 'row' },
refDescription: { fontWeight: 500, width: 100 },
items: { borderWidth: 1, borderStyle: 'solid', borderColor: '#EEEEEE', borderRadius: 4 },
itemRow: { borderBottomWidth: 1, borderBottomStyle: 'solid', borderBottomColor: '#EEEEEE', flexDirection: 'row' },
itemNumber: { padding: 6, width: '10%' },
itemDescription: { padding: 6, width: '50%' },
itemQty: { padding: 6, width: '10%' },
itemUnitAmount: { padding: 6, width: '15%' },
itemTotalAmount: { padding: 6, width: '15%' },
summaryRow: { flexDirection: 'row' },
summaryGap: { padding: 6, width: '70%' },
summaryTitle: { padding: 6, width: '15%' },
summaryValue: { padding: 6, width: '15%' },
});
export interface InvoicePDFDocumentProps {
invoice: unknown;
}
export function InvoicePDFDocument(_: InvoicePDFDocumentProps): React.JSX.Element {
return (
<Document>
<Page size="A4" style={styles.page}>
<View style={styles.header}>
<View style={styles.flexGrow}>
<Text style={[styles.textXl, styles.fontSemibold]}>Invoice</Text>
</View>
<View>
<Image source="/assets/logo-emblem--dark.png" style={styles.brand} />
</View>
</View>
<View style={styles.refs}>
<View style={styles.refRow}>
<Text style={styles.refDescription}>Number:</Text>
<Text>INV-001</Text>
</View>
<View style={styles.refRow}>
<Text style={styles.refDescription}>Due Date:</Text>
<Text>{dayjs().add(15, 'day').format('MMM D, YYYY')}</Text>
</View>
<View style={styles.refRow}>
<Text style={styles.refDescription}>Issue Date:</Text>
<Text>{dayjs().subtract(1, 'hour').format('MMM D, YYYY')}</Text>
</View>
<View style={styles.refRow}>
<Text style={styles.refDescription}>Issuer VAT No:</Text>
<Text>RO4675933</Text>
</View>
</View>
<View style={styles.flexRow}>
<View style={styles.w50}>
<Text style={[styles.fontMedium, styles.gutterBottom]}>Devias IO</Text>
<Text>2674 Alfred Drive</Text>
<Text>Brooklyn, New York, United States</Text>
<Text>11206</Text>
<Text>accounts@devias.io</Text>
<Text>(+1) 757 737 1980</Text>
</View>
<View style={styles.w50}>
<Text style={[styles.fontMedium, styles.gutterBottom]}>Billed To</Text>
<Text>Miron Vitold</Text>
<Text>Acme Inc.</Text>
<Text>1721 Bartlett Avenue</Text>
<Text>Southfield, Michigan, United States</Text>
<Text>48034</Text>
<Text>RO8795621</Text>
</View>
</View>
<View>
<Text style={[styles.textLg, styles.fontSemibold]}>
{new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(19.99)} due{' '}
{dayjs().add(15, 'day').format('MMM D, YYYY')}
</Text>
</View>
<View>
<View style={styles.items}>
<View style={styles.itemRow}>
<View style={styles.itemNumber}>
<Text style={styles.fontSemibold}>#</Text>
</View>
<View style={styles.itemDescription}>
<Text style={styles.fontSemibold}>Name</Text>
</View>
<View style={styles.itemUnitAmount}>
<Text style={styles.fontSemibold}>Unit Price</Text>
</View>
<View style={styles.itemQty}>
<Text style={styles.fontSemibold}>Qty</Text>
</View>
<View style={styles.itemTotalAmount}>
<Text style={[styles.fontSemibold, styles.textRight]}>Amount</Text>
</View>
</View>
{lineItems.map((lineItem, index) => (
<View key={lineItem.id} style={styles.itemRow}>
<View style={styles.itemNumber}>
<Text>{index + 1}</Text>
</View>
<View style={styles.itemDescription}>
<Text>{lineItem.name}</Text>
</View>
<View style={styles.itemUnitAmount}>
<Text>
{new Intl.NumberFormat('en-US', { style: 'currency', currency: lineItem.currency }).format(
lineItem.unitAmount
)}
</Text>
</View>
<View style={styles.itemQty}>
<Text>{lineItem.quantity}</Text>
</View>
<View style={styles.itemTotalAmount}>
<Text style={styles.textRight}>
{new Intl.NumberFormat('en-US', { style: 'currency', currency: lineItem.currency }).format(
lineItem.totalAmount
)}
</Text>
</View>
</View>
))}
</View>
<View>
<View style={styles.summaryRow}>
<View style={styles.summaryGap} />
<View style={styles.summaryTitle}>
<Text>Subtotal</Text>
</View>
<View style={styles.summaryValue}>
<Text style={styles.textRight}>
{new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(14.99)}
</Text>
</View>
</View>
<View style={styles.summaryRow}>
<View style={styles.summaryGap} />
<View style={styles.summaryTitle}>
<Text>Taxes</Text>
</View>
<View style={styles.summaryValue}>
<Text style={styles.textRight}>
{new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(5)}
</Text>
</View>
</View>
<View style={styles.summaryRow}>
<View style={styles.summaryGap} />
<View style={styles.summaryTitle}>
<Text>Total</Text>
</View>
<View style={styles.summaryValue}>
<Text style={styles.textRight}>
{new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(19.99)}
</Text>
</View>
</View>
</View>
</View>
<View>
<Text style={[styles.textLg, styles.fontSemibold, styles.gutterBottom]}>Notes</Text>
<Text>
Please make sure you have the right bank registration number as I had issues before and make sure you cover
transfer expenses.
</Text>
</View>
</Page>
</Document>
);
}

View File

@@ -0,0 +1,22 @@
'use client';
import * as React from 'react';
import { PDFDownloadLink } from '@react-pdf/renderer';
import { InvoicePDFDocument } from '@/components/dashboard/invoice/invoice-pdf-document';
// This component should receive the `invoice` prop from the parent component.
// The invoice data should be passed to the `InvoicePDFDocument` component.
export interface InvoicePDFLinkProps {
children: React.ReactNode;
invoice: unknown;
}
export function InvoicePDFLink({ children }: InvoicePDFLinkProps): React.JSX.Element {
return (
<PDFDownloadLink document={<InvoicePDFDocument invoice={undefined} />} fileName="invoice">
{children}
</PDFDownloadLink>
);
}

View File

@@ -0,0 +1,48 @@
'use client';
import * as React from 'react';
import Button from '@mui/material/Button';
import { Funnel as FunnelIcon } from '@phosphor-icons/react/dist/ssr/Funnel';
import { useMediaQuery } from '@/hooks/use-media-query';
import type { Filters } from './invoices-filters';
import { InvoicesFiltersModal } from './invoices-filters-modal';
interface InvoicesFiltersButtonProps {
filters?: Filters;
sortDir?: 'asc' | 'desc';
view?: 'group' | 'list';
}
export function InvoicesFiltersButton({ filters, sortDir, view }: InvoicesFiltersButtonProps): React.JSX.Element {
const lgDown = useMediaQuery('down', 'lg');
const [open, setOpen] = React.useState<boolean>(false);
const hasFilters = filters?.status || filters?.id || filters?.customer || filters?.startDate || filters?.endDate;
return (
<React.Fragment>
<Button
color={hasFilters ? 'primary' : 'secondary'}
onClick={() => {
setOpen((prevState) => !prevState);
}}
startIcon={<FunnelIcon />}
sx={{ display: { lg: 'none' } }}
>
Filters
</Button>
<InvoicesFiltersModal
filters={filters}
onClose={() => {
setOpen(false);
}}
open={lgDown ? open : false}
sortDir={sortDir}
view={view}
/>
</React.Fragment>
);
}

View File

@@ -0,0 +1,29 @@
import * as React from 'react';
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { InvoicesFilters } from './invoices-filters';
import type { Filters } from './invoices-filters';
interface InvoicesFiltersCardProps {
filters?: Filters;
sortDir?: 'asc' | 'desc';
view?: 'group' | 'list';
}
export function InvoicesFiltersCard({ filters, sortDir, view }: InvoicesFiltersCardProps): React.JSX.Element {
return (
<Card
sx={{ display: { xs: 'none', lg: 'block' }, flex: '0 0 auto', width: '340px', position: 'sticky', top: '80px' }}
>
<CardContent>
<Stack spacing={3}>
<Typography variant="h5">Filters</Typography>
<InvoicesFilters filters={filters} sortDir={sortDir} view={view} />
</Stack>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,55 @@
'use client';
import * as React from 'react';
import Dialog from '@mui/material/Dialog';
import DialogContent from '@mui/material/DialogContent';
import IconButton from '@mui/material/IconButton';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { X as XIcon } from '@phosphor-icons/react/dist/ssr/X';
import { InvoicesFilters } from './invoices-filters';
import type { Filters } from './invoices-filters';
export interface InvoicesFiltersModalProps {
filters?: Filters;
onClose?: () => void;
open: boolean;
sortDir?: 'asc' | 'desc';
view?: 'group' | 'list';
}
export function InvoicesFiltersModal({
filters,
onClose,
open,
sortDir,
view,
}: InvoicesFiltersModalProps): React.JSX.Element {
return (
<Dialog
maxWidth="sm"
onClose={onClose}
open={open}
sx={{ '& .MuiDialog-paper': { height: '100%', width: '100%' } }}
>
<DialogContent sx={{ display: 'flex', flexDirection: 'column', minHeight: 0, p: 0 }}>
<Stack spacing={3} sx={{ p: 3 }}>
<Stack direction="row" sx={{ alignItems: 'center', flex: '0 0 auto', justifyContent: 'space-between' }}>
<Typography variant="h5">Filters</Typography>
<IconButton onClick={onClose}>
<XIcon />
</IconButton>
</Stack>
<InvoicesFilters
filters={filters}
onFiltersApplied={onClose}
onFiltersCleared={onClose}
sortDir={sortDir}
view={view}
/>
</Stack>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,221 @@
'use client';
import * as React from 'react';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import Button from '@mui/material/Button';
import FormControl from '@mui/material/FormControl';
import InputLabel from '@mui/material/InputLabel';
import OutlinedInput from '@mui/material/OutlinedInput';
import Select from '@mui/material/Select';
import Stack from '@mui/material/Stack';
import { DatePicker } from '@mui/x-date-pickers/DatePicker';
import { Controller, useForm } from 'react-hook-form';
import { z as zod } from 'zod';
import { paths } from '@/paths';
import { dayjs } from '@/lib/dayjs';
import { Option } from '@/components/core/option';
export interface Filters {
customer?: string;
endDate?: string;
id?: string;
startDate?: string;
status?: string;
}
export type SortDir = 'asc' | 'desc';
const schema = zod
.object({
customer: zod.string().optional(),
endDate: zod.date().max(new Date('2099-01-01')).nullable().optional(),
id: zod.string().optional(),
startDate: zod.date().max(new Date('2099-01-01')).nullable().optional(),
status: zod.string().optional(),
})
.refine(
(data) => {
if (data.startDate && data.endDate) {
return data.startDate <= data.endDate;
}
return true;
},
{ message: 'End date should be greater than start date', path: ['endDate'] }
);
type Values = zod.infer<typeof schema>;
function getDefaultValues(filters: Filters): Values {
return {
customer: filters.customer ?? '',
endDate: filters.endDate ? dayjs(filters.endDate).toDate() : null,
id: filters.id ?? '',
status: filters.status ?? '',
startDate: filters.startDate ? dayjs(filters.startDate).toDate() : null,
};
}
export interface InvoicesFiltersProps {
filters?: Filters;
onFiltersApplied?: () => void;
onFiltersCleared?: () => void;
sortDir?: SortDir;
view?: 'group' | 'list';
}
export function InvoicesFilters({
filters = {},
onFiltersApplied,
onFiltersCleared,
sortDir = 'desc',
view,
}: InvoicesFiltersProps): React.JSX.Element {
const router = useRouter();
const {
control,
handleSubmit,
formState: { errors, isDirty },
} = useForm<Values>({ values: getDefaultValues(filters), resolver: zodResolver(schema) });
const updateSearchParams = React.useCallback(
(newFilters: Filters) => {
const searchParams = new URLSearchParams();
// Keep view as sortDir search params
if (view) {
searchParams.set('view', view);
}
if (sortDir === 'asc') {
searchParams.set('sortDir', sortDir);
}
if (newFilters.status) {
searchParams.set('status', newFilters.status);
}
if (newFilters.id) {
searchParams.set('id', newFilters.id);
}
if (newFilters.customer) {
searchParams.set('customer', newFilters.customer);
}
if (newFilters.startDate) {
searchParams.set('startDate', newFilters.startDate);
}
if (newFilters.endDate) {
searchParams.set('endDate', newFilters.endDate);
}
router.push(`${paths.dashboard.invoices.list}?${searchParams.toString()}`);
},
[router, sortDir, view]
);
const handleApplyFilters = React.useCallback(
(values: Values): void => {
updateSearchParams({
...values,
startDate: values.startDate ? dayjs(values.startDate).format('YYYY-MM-DD') : undefined,
endDate: values.endDate ? dayjs(values.endDate).format('YYYY-MM-DD') : undefined,
});
onFiltersApplied?.();
},
[updateSearchParams, onFiltersApplied]
);
const handleClearFilters = React.useCallback(() => {
updateSearchParams({});
onFiltersCleared?.();
}, [updateSearchParams, onFiltersCleared]);
const hasFilters = filters.id || filters.customer || filters.status || filters.startDate || filters.endDate;
return (
<form onSubmit={handleSubmit(handleApplyFilters)}>
<Stack spacing={3}>
<Controller
control={control}
name="id"
render={({ field }) => (
<FormControl error={Boolean(errors.id)}>
<InputLabel>Invoice ID</InputLabel>
<OutlinedInput {...field} />
</FormControl>
)}
/>
<Controller
control={control}
name="status"
render={({ field }) => (
<FormControl error={Boolean(errors.status)} fullWidth>
<InputLabel required>Status</InputLabel>
<Select {...field}>
<Option value="">All</Option>
<Option value="pending">Pending</Option>
<Option value="paid">Paid</Option>
<Option value="canceled">Canceled</Option>
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="customer"
render={({ field }) => (
<FormControl error={Boolean(errors.customer)}>
<InputLabel>Customer</InputLabel>
<OutlinedInput {...field} />
</FormControl>
)}
/>
<Controller
control={control}
name="startDate"
render={({ field }) => (
<DatePicker
format="MMM D, YYYY"
label="From"
onChange={(date) => {
field.onChange(date ? date.toDate() : null);
}}
slotProps={{ textField: { error: Boolean(errors.startDate), helperText: errors.startDate?.message } }}
value={field.value ? dayjs(field.value) : null}
/>
)}
/>
<Controller
control={control}
name="endDate"
render={({ field }) => (
<DatePicker
format="MMM D, YYYY"
label="To"
onChange={(date) => {
field.onChange(date ? date.toDate() : null);
}}
slotProps={{ textField: { error: Boolean(errors.endDate), helperText: errors.endDate?.message } }}
value={field.value ? dayjs(field.value) : null}
/>
)}
/>
<Button disabled={!isDirty} type="submit" variant="contained">
Apply
</Button>
{hasFilters ? (
<Button color="secondary" onClick={handleClearFilters}>
Clear filters
</Button>
) : null}
</Stack>
</form>
);
}

View File

@@ -0,0 +1,30 @@
'use client';
import * as React from 'react';
import TablePagination from '@mui/material/TablePagination';
function noop(): void {
return undefined;
}
interface InvoicesPaginationProps {
count: number;
page: number;
}
export function InvoicesPagination({ count, page }: InvoicesPaginationProps): React.JSX.Element {
// You should implement the pagination using a similar logic as the filters.
// Note that when page change, you should keep the filter search params.
return (
<TablePagination
component="div"
count={count}
onPageChange={noop}
onRowsPerPageChange={noop}
page={page}
rowsPerPage={5}
rowsPerPageOptions={[5, 10, 25]}
/>
);
}

View File

@@ -0,0 +1,77 @@
'use client';
import * as React from 'react';
import { useRouter } from 'next/navigation';
import Select from '@mui/material/Select';
import type { SelectChangeEvent } from '@mui/material/Select';
import { paths } from '@/paths';
import { Option } from '@/components/core/option';
import type { Filters } from './invoices-filters';
type SortDir = 'asc' | 'desc';
export interface InvoicesSortProps {
filters?: Filters;
sortDir?: SortDir;
view?: 'group' | 'list';
}
export function InvoicesSort({ filters = {}, sortDir = 'desc', view }: InvoicesSortProps): React.JSX.Element {
const router = useRouter();
const updateSearchParams = React.useCallback(
(newSortDir: SortDir): void => {
const searchParams = new URLSearchParams();
// Make sure to keep the search params when changing the sort.
// For the sake of simplicity, we keep only the view search param on sort change.
if (view) {
searchParams.set('view', view);
}
if (newSortDir === 'asc') {
searchParams.set('sortDir', newSortDir);
}
if (filters.status) {
searchParams.set('status', filters.status);
}
if (filters.id) {
searchParams.set('id', filters.id);
}
if (filters.customer) {
searchParams.set('customer', filters.customer);
}
if (filters.startDate) {
searchParams.set('startDate', filters.startDate);
}
if (filters.endDate) {
searchParams.set('endDate', filters.endDate);
}
router.push(`${paths.dashboard.invoices.list}?${searchParams.toString()}`);
},
[router, view, filters]
);
const handleSortChange = React.useCallback(
(event: SelectChangeEvent) => {
updateSearchParams(event.target.value as SortDir);
},
[updateSearchParams]
);
return (
<Select name="sort" onChange={handleSortChange} sx={{ maxWidth: '100%', width: '120px' }} value={sortDir}>
<Option value="desc">Newest</Option>
<Option value="asc">Oldest</Option>
</Select>
);
}

View File

@@ -0,0 +1,104 @@
import * as React from 'react';
import Avatar from '@mui/material/Avatar';
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import Grid from '@mui/material/Unstable_Grid2';
import { Check as CheckIcon } from '@phosphor-icons/react/dist/ssr/Check';
import { Clock as ClockIcon } from '@phosphor-icons/react/dist/ssr/Clock';
import { Receipt as ReceiptIcon } from '@phosphor-icons/react/dist/ssr/Receipt';
export function InvoicesStats(): React.JSX.Element {
return (
<Grid container spacing={4}>
<Grid md={6} xl={4} xs={12}>
<Card>
<CardContent>
<Stack direction="row" spacing={2} sx={{ alignItems: 'center' }}>
<Avatar
sx={{
'--Avatar-size': '48px',
bgcolor: 'var(--mui-palette-background-paper)',
boxShadow: 'var(--mui-shadows-8)',
color: 'var(--mui-palette-text-primary)',
}}
>
<ReceiptIcon fontSize="var(--icon-fontSize-lg)" />
</Avatar>
<div>
<Typography color="text.secondary" variant="body2">
Total
</Typography>
<Typography variant="h6">
{new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(5300)}
</Typography>
<Typography color="text.secondary" variant="body2">
from {new Intl.NumberFormat('en-US').format(12)} invoices
</Typography>
</div>
</Stack>
</CardContent>
</Card>
</Grid>
<Grid md={6} xl={4} xs={12}>
<Card>
<CardContent>
<Stack direction="row" spacing={2} sx={{ alignItems: 'center' }}>
<Avatar
sx={{
'--Avatar-size': '48px',
bgcolor: 'var(--mui-palette-background-paper)',
boxShadow: 'var(--mui-shadows-8)',
color: 'var(--mui-palette-text-primary)',
}}
>
<CheckIcon fontSize="var(--icon-fontSize-lg)" />
</Avatar>
<div>
<Typography color="text.secondary" variant="body2">
Paid
</Typography>
<Typography variant="h6">
{new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(3860.4)}
</Typography>
<Typography color="text.secondary" variant="body2">
from {new Intl.NumberFormat('en-US').format(3)} invoices
</Typography>
</div>
</Stack>
</CardContent>
</Card>
</Grid>
<Grid md={6} xl={4} xs={12}>
<Card>
<CardContent>
<Stack direction="row" spacing={2} sx={{ alignItems: 'center' }}>
<Avatar
sx={{
'--Avatar-size': '48px',
bgcolor: 'var(--mui-palette-background-paper)',
boxShadow: 'var(--mui-shadows-8)',
color: 'var(--mui-palette-text-primary)',
}}
>
<ClockIcon fontSize="var(--icon-fontSize-lg)" />
</Avatar>
<div>
<Typography color="text.secondary" variant="body2">
Pending
</Typography>
<Typography variant="h6">
{new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(1439.6)}
</Typography>
<Typography color="text.secondary" variant="body2">
from {new Intl.NumberFormat('en-US').format(2)} invoices
</Typography>
</div>
</Stack>
</CardContent>
</Card>
</Grid>
</Grid>
);
}

View File

@@ -0,0 +1,183 @@
'use client';
import * as React from 'react';
import RouterLink from 'next/link';
import Avatar from '@mui/material/Avatar';
import Box from '@mui/material/Box';
import Card from '@mui/material/Card';
import Chip from '@mui/material/Chip';
import IconButton from '@mui/material/IconButton';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { ArrowRight as ArrowRightIcon } from '@phosphor-icons/react/dist/ssr/ArrowRight';
import { CheckCircle as CheckCircleIcon } from '@phosphor-icons/react/dist/ssr/CheckCircle';
import { Clock as ClockIcon } from '@phosphor-icons/react/dist/ssr/Clock';
import { XCircle as XCircleIcon } from '@phosphor-icons/react/dist/ssr/XCircle';
import { paths } from '@/paths';
import { dayjs } from '@/lib/dayjs';
import type { ColumnDef } from '@/components/core/data-table';
import { DataTable } from '@/components/core/data-table';
export interface Invoice {
id: string;
customer: { name: string; avatar?: string };
currency: string;
totalAmount: number;
status: 'pending' | 'paid' | 'canceled';
issueDate: Date;
dueDate: Date;
}
interface GroupedRows {
pending: Invoice[];
paid: Invoice[];
canceled: Invoice[];
}
function groupRows(invoices: Invoice[]): GroupedRows {
return invoices.reduce<GroupedRows>(
(acc, invoice) => {
const { status } = invoice;
return { ...acc, [status]: [...acc[status], invoice] };
},
{ canceled: [], paid: [], pending: [] }
);
}
const groupTitles = { canceled: 'Canceled', paid: 'Paid', pending: 'Pending' } as const;
const columns = [
{
formatter: (row): React.JSX.Element => (
<Stack
component={RouterLink}
direction="row"
href={paths.dashboard.invoices.details('1')}
spacing={2}
sx={{ alignItems: 'center', display: 'inline-flex', textDecoration: 'none', whiteSpace: 'nowrap' }}
>
<Avatar src={row.customer.avatar} />
<div>
<Typography color="text.primary" variant="subtitle2">
{row.id}
</Typography>
<Typography color="text.secondary" variant="body2">
{row.customer.name}
</Typography>
</div>
</Stack>
),
name: 'Customer',
width: '250px',
},
{
formatter: (row): React.JSX.Element => (
<Typography variant="subtitle2">
{new Intl.NumberFormat('en-US', { style: 'currency', currency: row.currency }).format(row.totalAmount)}
</Typography>
),
name: 'Total amount',
width: '150px',
},
{
formatter: (row): React.JSX.Element => (
<div>
<Typography variant="subtitle2">Issued</Typography>
<Typography color="text.secondary" variant="body2">
{dayjs(row.issueDate).format('MMM D, YYYY')}
</Typography>
</div>
),
name: 'Issue Date',
width: '150px',
},
{
formatter: (row): React.JSX.Element => (
<div>
<Typography variant="subtitle2">Due</Typography>
<Typography color="text.secondary" variant="body2">
{row.dueDate ? dayjs(row.dueDate).format('MMM D, YYYY') : undefined}
</Typography>
</div>
),
name: 'Due Date',
width: '150px',
},
{
formatter: (row): React.JSX.Element => {
const mapping = {
pending: { label: 'Pending', icon: <ClockIcon color="var(--mui-palette-warning-main)" weight="fill" /> },
paid: { label: 'Paid', icon: <CheckCircleIcon color="var(--mui-palette-success-main)" weight="fill" /> },
canceled: { label: 'Canceled', icon: <XCircleIcon color="var(--mui-palette-error-main)" weight="fill" /> },
} as const;
const { label, icon } = mapping[row.status] ?? { label: 'Unknown', icon: null };
return <Chip icon={icon} label={label} size="small" variant="outlined" />;
},
name: 'Status',
width: '150px',
},
{
formatter: (): React.JSX.Element => (
<IconButton component={RouterLink} href={paths.dashboard.invoices.details('1')}>
<ArrowRightIcon />
</IconButton>
),
name: 'Actions',
width: '100px',
align: 'right',
},
] satisfies ColumnDef<Invoice>[];
export interface InvoicesTableProps {
rows: Invoice[];
view?: 'group' | 'list';
}
export function InvoicesTable({ rows = [], view = 'group' }: InvoicesTableProps): React.JSX.Element {
if (view === 'group') {
const groups = groupRows(rows);
return (
<Stack spacing={6}>
{(['pending', 'paid', 'canceled'] as (keyof GroupedRows)[]).map((key) => {
const group = groups[key];
return (
<Stack key={groupTitles[key]} spacing={2}>
<Typography color="text.secondary" variant="h6">
{groupTitles[key]} ({group.length})
</Typography>
{group.length ? (
<Card sx={{ overflowX: 'auto' }}>
<DataTable<Invoice> columns={columns} hideHead rows={group} />
</Card>
) : (
<div>
<Typography color="text.secondary" variant="body2">
No invoices found
</Typography>
</div>
)}
</Stack>
);
})}
</Stack>
);
}
return (
<Card sx={{ overflowX: 'auto' }}>
<DataTable<Invoice> columns={columns} hideHead rows={rows} />
{!rows.length ? (
<Box sx={{ p: 3 }}>
<Typography color="text.secondary" sx={{ textAlign: 'center' }} variant="body2">
No invoices found
</Typography>
</Box>
) : null}
</Card>
);
}

View File

@@ -0,0 +1,43 @@
'use client';
import * as React from 'react';
import { DataTable } from '@/components/core/data-table';
import type { ColumnDef } from '@/components/core/data-table';
export interface LineItem {
id: string;
name: string;
quantity: number;
currency: string;
unitAmount: number;
totalAmount: number;
}
const columns = [
{ field: 'name', name: 'Name', width: '250px' },
{
formatter: (row): string => {
return new Intl.NumberFormat('en-US', { style: 'currency', currency: row.currency }).format(row.unitAmount);
},
name: 'Unit Amount',
width: '100px',
},
{ field: 'quantity', name: 'Qty', width: '100px' },
{
formatter: (row): string => {
return new Intl.NumberFormat('en-US', { style: 'currency', currency: row.currency }).format(row.totalAmount);
},
name: 'Amount',
width: '100px',
align: 'right',
},
] satisfies ColumnDef<LineItem>[];
export interface LineItemsTableProps {
rows: LineItem[];
}
export function LineItemsTable({ rows }: LineItemsTableProps): React.JSX.Element {
return <DataTable<LineItem> columns={columns} rows={rows} />;
}

View File

@@ -0,0 +1,56 @@
'use client';
import * as React from 'react';
import { useRouter } from 'next/navigation';
import ToggleButton from '@mui/material/ToggleButton';
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
import { List as ListIcon } from '@phosphor-icons/react/dist/ssr/List';
import { Rows as RowsIcon } from '@phosphor-icons/react/dist/ssr/Rows';
import { paths } from '@/paths';
type ViewMode = 'group' | 'list';
export interface ViewModeButtonProps {
view: ViewMode;
}
export function ViewModeButton({ view }: ViewModeButtonProps): React.JSX.Element {
const router = useRouter();
const handleViewChange = React.useCallback(
(value: ViewMode) => {
// Make sure to keep the search params when changing the view mode.
// For the sake of simplicity, we did not keep the search params on mode change.
if (value) {
router.push(`${paths.dashboard.invoices.list}?view=${value}`);
}
},
[router]
);
return (
<ToggleButtonGroup
color="primary"
exclusive
onChange={(_, value: ViewMode) => {
handleViewChange(value);
}}
onKeyUp={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
handleViewChange(view === 'group' ? 'list' : 'group');
}
}}
tabIndex={0}
value={view}
>
<ToggleButton value="group">
<RowsIcon />
</ToggleButton>
<ToggleButton value="list">
<ListIcon />
</ToggleButton>
</ToggleButtonGroup>
);
}