build ok,
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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]}
|
||||
/>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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} />;
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
Reference in New Issue
Block a user