This commit is contained in:
David Arranz 2024-08-28 20:38:20 +02:00
parent 313659689e
commit fe34458899
9 changed files with 217 additions and 171 deletions

View File

@ -1,22 +1,24 @@
import { Button, ButtonProps } from "@/ui"; import { Button, ButtonProps } from "@/ui";
import { t } from "i18next"; import { t } from "i18next";
import { PackagePlusIcon } from "lucide-react"; import { PackagePlusIcon } from "lucide-react";
import { forwardRef } from "react";
export interface AppendCatalogArticleRowButtonProps extends ButtonProps { export interface AppendCatalogArticleRowButtonProps extends ButtonProps {
label?: string; label?: string;
className?: string; className?: string;
} }
export const AppendCatalogArticleRowButton = ({ export const AppendCatalogArticleRowButton = forwardRef(
label = t("common.append_article"), (
className, { label = t("common.append_article"), className, ...props }: AppendCatalogArticleRowButtonProps,
...props ref
}: AppendCatalogArticleRowButtonProps): JSX.Element => ( ): JSX.Element => (
<Button type='button' variant='outline' {...props}> <Button type='button' variant='outline' {...props}>
{" "} {" "}
<PackagePlusIcon className={label ? "w-4 h-4 mr-2" : "w-4 h-4"} /> <PackagePlusIcon className={label ? "w-4 h-4 mr-2" : "w-4 h-4"} />
{label && <>{label}</>} {label && <>{label}</>}
</Button> </Button>
)
); );
AppendCatalogArticleRowButton.displayName = "AddNewRowButton"; AppendCatalogArticleRowButton.displayName = "AddNewRowButton";

View File

@ -1,5 +1,7 @@
import { CreditCard, DownloadIcon, FilePenLineIcon, MoreVertical } from "lucide-react"; import { DownloadIcon, FilePenLineIcon } from "lucide-react";
import { ColorBadge } from "@/components";
import { useCustomLocalization } from "@/lib/hooks";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
Button, Button,
@ -9,10 +11,6 @@ import {
CardFooter, CardFooter,
CardHeader, CardHeader,
CardTitle, CardTitle,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Separator, Separator,
Tabs, Tabs,
TabsContent, TabsContent,
@ -23,8 +21,9 @@ import {
TooltipTrigger, TooltipTrigger,
} from "@/ui"; } from "@/ui";
import { useToast } from "@/ui/use-toast"; import { useToast } from "@/ui/use-toast";
import { CurrencyData } from "@shared/contexts";
import { t } from "i18next"; import { t } from "i18next";
import { useCallback } from "react"; import { useCallback, useMemo } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useQuotes } from "../hooks"; import { useQuotes } from "../hooks";
import { DownloadQuoteDialog } from "./DownloadQuoteDialog"; import { DownloadQuoteDialog } from "./DownloadQuoteDialog";
@ -44,6 +43,33 @@ export const QuoteResume = ({ quoteId, className }: QuoteResumeProps) => {
const { mutate: setStatusMutation } = useSetStatus(quoteId); const { mutate: setStatusMutation } = useSetStatus(quoteId);
const { download, ...downloadProps } = useDownloader(); const { download, ...downloadProps } = useDownloader();
const { formatCurrency } = useCustomLocalization({
locale: data?.lang_code || "ES",
});
const currency_symbol = useMemo(() => {
const currencyOrError = data
? CurrencyData.createFromCode(data?.currency_code)
: CurrencyData.createDefaultCode();
return currencyOrError.isSuccess ? currencyOrError.object.symbol : "";
}, [data]);
const totals = useMemo(() => {
return data
? {
subtotal_price: formatCurrency(data.subtotal_price),
discount_price: formatCurrency(data.discount_price),
tax_price: formatCurrency(data.tax_price),
total_price: formatCurrency(data.total_price),
}
: {
subtotal_price: "0,00",
discount_price: "0,00",
tax_price: "0,00",
total_price: "0,00",
};
}, [data]);
const handleOnChangeStatus = (_: string, newStatus: string) => { const handleOnChangeStatus = (_: string, newStatus: string) => {
setStatusMutation( setStatusMutation(
{ newStatus }, { newStatus },
@ -90,183 +116,150 @@ export const QuoteResume = ({ quoteId, className }: QuoteResumeProps) => {
<> <>
<DownloadQuoteDialog {...downloadProps} onFinishDownload={handleFinishDownload} /> <DownloadQuoteDialog {...downloadProps} onFinishDownload={handleFinishDownload} />
<Tabs defaultValue='resume'> <Tabs defaultValue='resume'>
<Card className='w-full overflow-hidden'> <Card className='w-[390px] overflow-hidden'>
<CardHeader className='gap-3 border-b bg-accent'> <CardHeader className='gap-3 border-b bg-accent'>
<CardTitle className='text-lg'> <CardTitle className='flex items-center justify-between text-lg'>
{`${t("quotes.list.preview.quote")} #${data.reference}`} <span>{t("quotes.list.resume.title")}</span>
<ColorBadge className='text-sm' label={t(`quotes.status.${data.status}`)} />
</CardTitle> </CardTitle>
<CardDescription className='flex items-center gap-1 mr-auto text-foreground'> <CardDescription className='flex mr-auto text-foreground'>
<QuoteStatusEditor quote={data} onChangeStatus={handleOnChangeStatus} /> <div className='flex items-center gap-1'>
<Button <Button
size='sm' size='sm'
variant='outline' variant='outline'
className='h-8 gap-1' className='h-8 gap-1'
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
navigate(`/quotes/edit/${data.id}`, { relative: "path" }); navigate(`/quotes/edit/${data.id}`, { relative: "path" });
}} }}
> >
<FilePenLineIcon className='h-3.5 w-3.5' /> <FilePenLineIcon className='h-3.5 w-3.5' />
<span className='sr-only md:not-sr-only md:whitespace-nowrap'> <span className='sr-only md:not-sr-only md:whitespace-nowrap'>
{t("quotes.list.columns.actions.edit")} {t("quotes.list.columns.actions.edit")}
</span> </span>
</Button> </Button>
<QuoteStatusEditor quote={data} onChangeStatus={handleOnChangeStatus} />
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
size='sm' size='sm'
variant='outline' variant='outline'
className='h-8 gap-1' className='h-8 gap-1'
onClick={handleDownload} onClick={handleDownload}
> >
<DownloadIcon className='h-3.5 w-3.5 ' /> <DownloadIcon className='h-3.5 w-3.5 ' />
<span className='sr-only'>{t("quotes.list.preview.download_quote")}</span> <span className='sr-only'>{t("quotes.list.resume.download_quote")}</span>
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>{t("quotes.list.preview.download_quote")}</TooltipContent> <TooltipContent>{t("quotes.list.resume.download_quote")}</TooltipContent>
</Tooltip> </Tooltip>
<DropdownMenu>
<DropdownMenuTrigger> {/*<DropdownMenu>
<Tooltip> <DropdownMenuTrigger>
<TooltipTrigger asChild> <Tooltip>
<Button size='icon' variant='outline' className='hidden w-8 h-8'> <TooltipTrigger asChild>
<MoreVertical className='h-3.5 w-3.5' /> <Button size='icon' variant='outline' className='w-8 h-8'>
<span className='sr-only'>{t("common.more")}</span> <MoreVertical className='h-3.5 w-3.5' />
</Button> <span className='sr-only'>{t("common.more")}</span>
</TooltipTrigger> </Button>
<TooltipContent>{t("common.more")}</TooltipContent> </TooltipTrigger>
</Tooltip> <TooltipContent>{t("common.more")}</TooltipContent>
</DropdownMenuTrigger> </Tooltip>
<DropdownMenuContent align='end'> </DropdownMenuTrigger>
<DropdownMenuItem className='not-sr-only 2xl:sr-only'> <DropdownMenuContent align='end'>
<DownloadIcon className='h-3.5 w-3.5 mr-2' /> <DropdownMenuItem className='not-sr-only 2xl:sr-only'>
<span>{t("quotes.list.preview.download_quote")}</span> <DownloadIcon className='h-3.5 w-3.5 mr-2' />
</DropdownMenuItem> <span>{t("quotes.list.preview.download_quote")}</span>
</DropdownMenuContent> </DropdownMenuItem>
</DropdownMenu> </DropdownMenuContent>
</DropdownMenu>*/}
</div>
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className='p-6 text-sm'> <CardContent className='p-6 text-sm'>
<TabsList className='grid w-full grid-cols-2'> <TabsList className='grid w-full grid-cols-2'>
<TabsTrigger value='resume'>Resume</TabsTrigger> <TabsTrigger value='resume'>{t("quotes.list.resume.tabs.resume")}</TabsTrigger>
<TabsTrigger value='preview'>Preview</TabsTrigger> <TabsTrigger value='preview'>{t("quotes.list.resume.tabs.preview")}</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value='resume' className='pt-4'> <TabsContent value='resume' className='pt-4'>
<div className='grid gap-3'> <div className='grid gap-3'>
<div className='grid gap-3'> <div className='grid gap-3'>
<div className='font-semibold'>Quote information</div> <div className='font-semibold'>{t("quotes.list.resume.quote_information")}</div>
<dl className='grid gap-3'> <dl className='grid gap-3'>
<div className='flex items-center justify-between'>
<dt className='text-muted-foreground'>
{t("quotes.form_fields.reference.label")}
</dt>
<dd className='font-medium'>{data.reference}</dd>
</div>
<div className='flex items-center justify-between'> <div className='flex items-center justify-between'>
<dt className='text-muted-foreground'> <dt className='text-muted-foreground'>
{t("quotes.form_fields.date.label")} {t("quotes.form_fields.date.label")}
</dt> </dt>
<dd>{new Date(data.date).toLocaleDateString()}</dd> <dd className='font-medium'>{new Date(data.date).toLocaleDateString()}</dd>
</div> </div>
<div className='flex items-center justify-between'> <div className='flex items-start justify-between'>
<dt className='text-muted-foreground'>Email</dt> <dt className='text-muted-foreground whitespace-nowrap'>
<dd> {t("quotes.form_fields.customer_reference.label")}
<a href='mailto:'>liam@acme.com</a> </dt>
</dd> <dd className='font-medium text-right whitespace-break-spaces'>
</div> {data.customer_reference}
<div className='flex items-center justify-between'>
<dt className='text-muted-foreground'>Phone</dt>
<dd>
<a href='tel:'>+1 234 567 890</a>
</dd> </dd>
</div> </div>
</dl> </dl>
</div> </div>
<Separator className='my-4' /> <Separator className='my-4' />
<div className='grid gap-3'> <div className='grid gap-3'>
<div className='font-semibold'>Customer Information</div> <div className='font-semibold'>
Date: {new Date(data.date).toLocaleDateString()} {t("quotes.list.resume.customer_information")}
</div>
<div>{data.customer_information}</div> <div>{data.customer_information}</div>
<dl className='grid gap-3'>
<div className='flex items-center justify-between'>
<dt className='text-muted-foreground'>Customer</dt>
<dd>Liam Johnson</dd>
</div>
<div className='flex items-center justify-between'>
<dt className='text-muted-foreground'>Email</dt>
<dd>
<a href='mailto:'>liam@acme.com</a>
</dd>
</div>
<div className='flex items-center justify-between'>
<dt className='text-muted-foreground'>Phone</dt>
<dd>
<a href='tel:'>+1 234 567 890</a>
</dd>
</div>
</dl>
</div> </div>
<Separator className='my-4' /> <Separator className='my-4' />
<div className='font-semibold'>Order Details</div> <div className='font-semibold'>{t("quotes.list.resume.price_information")}</div>
<ul className='grid gap-3'> <ul className='grid gap-3'>
<li className='flex items-center justify-between'> <li className='flex items-center justify-between'>
<span className='text-muted-foreground'> <span className='text-muted-foreground'>
Glimmer Lamps x <span>2</span> {t("quotes.form_fields.subtotal_price.label")}
</span> </span>
<span>$250.00</span> <span>{totals.subtotal_price}</span>
</li> </li>
<li className='flex items-center justify-between'> <li className='flex items-center justify-between'>
<span className='text-muted-foreground'> <span className='text-muted-foreground'>
Aqua Filters x <span>1</span> {t("quotes.form_fields.discount.label")}
</span> </span>
<span>$49.00</span>
</li>
</ul>
<Separator className='my-2' />
<ul className='grid gap-3'>
<li className='flex items-center justify-between'>
<span className='text-muted-foreground'>Subtotal</span>
<span>$299.00</span>
</li>
<li className='flex items-center justify-between'>
<span className='text-muted-foreground'>Shipping</span>
<span>$5.00</span> <span>$5.00</span>
</li> </li>
<li className='flex items-center justify-between'> <li className='flex items-center justify-between'>
<span className='text-muted-foreground'>Tax</span> <span className='text-muted-foreground'>
{t("quotes.form_fields.discount_price.label")}
</span>
<span>$25.00</span> <span>$25.00</span>
</li> </li>
<li className='flex items-center justify-between'>
<span className='text-muted-foreground'>
{t("quotes.form_fields.tax.label")}
</span>
<span>$5.00</span>
</li>
<li className='flex items-center justify-between'>
<span className='text-muted-foreground'>
{t("quotes.form_fields.tax_price.label")}
</span>
<span>$25.00</span>
</li>
<li className='flex items-center justify-between font-semibold'> <li className='flex items-center justify-between font-semibold'>
<span className='text-muted-foreground'>Total</span> <span className='text-muted-foreground'>
{t("quotes.form_fields.total_price.label")}
</span>
<span>$329.00</span> <span>$329.00</span>
</li> </li>
</ul> </ul>
</div> </div>
<Separator className='my-4' />
<div className='grid grid-cols-2 gap-4'>
<div className='grid gap-3'>
<div className='font-semibold'>Shipping Information</div>
<address className='grid gap-0.5 not-italic text-muted-foreground'>
<span>Liam Johnson</span>
<span>1234 Main St.</span>
<span>Anytown, CA 12345</span>
</address>
</div>
<div className='grid gap-3 auto-rows-max'>
<div className='font-semibold'>Billing Information</div>
<div className='text-muted-foreground'>Same as shipping address</div>
</div>
</div>
<Separator className='my-4' />
<div className='grid gap-3'>
<div className='font-semibold'>Payment Information</div>
<dl className='grid gap-3'>
<div className='flex items-center justify-between'>
<dt className='flex items-center gap-1 text-muted-foreground'>
<CreditCard className='w-4 h-4' />
Visa
</dt>
<dd>**** **** **** 4532</dd>
</div>
</dl>
</div>
</TabsContent> </TabsContent>
<TabsContent value='preview'></TabsContent> <TabsContent value='preview'></TabsContent>
</CardContent> </CardContent>

View File

@ -88,7 +88,9 @@ export const QuotesDataTable = ({
accessorKey: "status", accessorKey: "status",
header: () => <>{t("quotes.list.columns.status")}</>, header: () => <>{t("quotes.list.columns.status")}</>,
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
cell: ({ row: { original } }) => <ColorBadge label={original.status} />, cell: ({ row: { original } }) => (
<ColorBadge label={t(`quotes.status.${original.status}`)} />
),
}, },
{ {
id: "date" as const, id: "date" as const,
@ -282,7 +284,7 @@ export const QuotesDataTable = ({
</div> </div>
{preview && ( {preview && (
<div id={previewId} className='flex items-stretch'> <div id={previewId} className='flex items-stretch '>
<QuoteResume quoteId={activeRow?.original.id} /> <QuoteResume quoteId={activeRow?.original.id} />
{/*<QuotePDFPreview quote={activeRow?.original} className='flex-1' />*/} {/*<QuotePDFPreview quote={activeRow?.original} className='flex-1' />*/}
</div> </div>

View File

@ -25,8 +25,8 @@ const quoteStatusTransitions: Record<TQuoteStatus, TQuoteStatus[]> = {
draft: ["draft", "ready", "archived"], draft: ["draft", "ready", "archived"],
ready: ["ready", "delivered", "archived"], ready: ["ready", "delivered", "archived"],
delivered: ["delivered", "accepted", "rejected", "archived"], delivered: ["delivered", "accepted", "rejected", "archived"],
accepted: ["accepted", "archived"], accepted: ["accepted", "rejected", "archived"],
rejected: ["rejected", "archived"], rejected: ["rejected", "accepted", "archived"],
archived: ["archived", "draft", "ready", "delivered", "accepted", "rejected"], archived: ["archived", "draft", "ready", "delivered", "accepted", "rejected"],
}; };
@ -53,8 +53,6 @@ export const QuoteStatusEditor = ({
} }
}; };
console.log(newStatus);
return ( return (
<Dialog> <Dialog>
<DialogTrigger asChild> <DialogTrigger asChild>

View File

@ -1,23 +1,50 @@
import { Badge } from "@/ui"; import { Badge } from "@/ui";
function stringToColor(str: string) { function stringToColorPair(str: string) {
const TEXT_DARKEN_FACTOR = 0.7; // Factor para oscurecer el color del texto
const BACKGROUND_LIGHTEN_FACTOR = 0.7; // Factor para aclarar el color de fondo
let hash = 0; let hash = 0;
for (let i = 0; i < str.length; i++) { for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash); hash = str.charCodeAt(i) + ((hash << 5) - hash);
} }
let color = "#"; let color = "#";
for (let i = 0; i < 3; i++) { for (let i = 0; i < 3; i++) {
const value = (hash >> (i * 8)) & 0xff; const value = (hash >> (i * 8)) & 0xff;
color += ("00" + value.toString(16)).substr(-2); color += ("00" + value.toString(16)).substr(-2);
} }
return color;
// Convert the color from hex to RGB
const r = parseInt(color.substr(1, 2), 16);
const g = parseInt(color.substr(3, 2), 16);
const b = parseInt(color.substr(5, 2), 16);
// Generate a darker shade for the text color
const textColor = `#${((r * TEXT_DARKEN_FACTOR) | 0).toString(16).padStart(2, "0")}${(
(g * TEXT_DARKEN_FACTOR) |
0
)
.toString(16)
.padStart(2, "0")}${((b * TEXT_DARKEN_FACTOR) | 0).toString(16).padStart(2, "0")}`;
// Generate a much lighter shade for the background color
const bgColor = `#${Math.min(255, Math.floor(r + (255 - r) * BACKGROUND_LIGHTEN_FACTOR))
.toString(16)
.padStart(2, "0")}${Math.min(255, Math.floor(g + (255 - g) * BACKGROUND_LIGHTEN_FACTOR))
.toString(16)
.padStart(2, "0")}${Math.min(255, Math.floor(b + (255 - b) * BACKGROUND_LIGHTEN_FACTOR))
.toString(16)
.padStart(2, "0")}`;
return [textColor, bgColor];
} }
export const ColorBadge = ({ label, className }: { label: string; className?: string }) => { export const ColorBadge = ({ label, className }: { label: string; className?: string }) => {
const backgroundColor = stringToColor(label); const [color, backgroundColor] = stringToColorPair(label);
return ( return (
<Badge className={className} style={{ backgroundColor, color: "#fff" }}> <Badge className={className} style={{ backgroundColor, color }}>
{label} {label}
</Badge> </Badge>
); );

View File

@ -1,16 +1,12 @@
/* https://github.com/mayank8aug/use-localization/blob/main/src/index.ts */ /* https://github.com/mayank8aug/use-localization/blob/main/src/index.ts */
import { IMoney, IPercentage, IQuantity } from "@/lib/types"; import { IMoney, IPercentage, IQuantity } from "@/lib/types";
import { adjustPrecision } from "@shared/utilities/helpers";
import { useCallback } from "react"; import { useCallback } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
type UseLocalizationProps = { type UseLocalizationProps = {
locale: string; locale?: string;
};
const adjustPrecision = ({ amount, scale }: { amount: number; scale: number }) => {
const factor = 10 ** scale;
return Number(amount) / factor;
}; };
export const useLocalization = () => { export const useLocalization = () => {
@ -32,10 +28,12 @@ export const useCustomLocalization = (props: UseLocalizationProps) => {
const { amount, scale, currency_code } = value; const { amount, scale, currency_code } = value;
return new Intl.NumberFormat(locale, { return new Intl.NumberFormat(locale ?? "ES", {
style: "currency", style: "currency",
currency: currency_code, currency: currency_code,
currencyDisplay: "symbol", currencyDisplay: "symbol",
useGrouping: true,
maximumFractionDigits: scale, maximumFractionDigits: scale,
}).format(amount === null ? 0 : amount); }).format(amount === null ? 0 : amount);
}, },
@ -53,8 +51,8 @@ export const useCustomLocalization = (props: UseLocalizationProps) => {
const result = new Intl.NumberFormat("es", { const result = new Intl.NumberFormat("es", {
/*minimumSignificantDigits: scale, /*minimumSignificantDigits: scale,
maximumSignificantDigits: scale, maximumSignificantDigits: scale,
minimumFractionDigits: scale, minimumFractionDigits: scale,*/
useGrouping: true,*/ useGrouping: true,
}).format(amount === null ? 0 : adjustPrecision({ amount, scale })); }).format(amount === null ? 0 : adjustPrecision({ amount, scale }));
//console.log(value, result); //console.log(value, result);

View File

@ -138,10 +138,19 @@
"edit": "Edit quote" "edit": "Edit quote"
} }
}, },
"resume": {},
"preview": { "resume": {
"quote": "Quote", "title": "Quote",
"download_quote": "Download quote" "download_quote": "Download quote",
"tabs": {
"resume": "Resume",
"preview": "Preview"
},
"quote_information": "Quote Information",
"customer_information": "Customer Information",
"payment_information": "Payment Information",
"price_information": "Quote totals"
} }
}, },
"create": { "create": {
@ -250,6 +259,11 @@
"desc": "Quote reference", "desc": "Quote reference",
"placeholder": "" "placeholder": ""
}, },
"status": {
"label": "Status",
"desc": "Quote status",
"placeholder": ""
},
"lang_code": { "lang_code": {
"label": "Language", "label": "Language",
"desc": "Quote language", "desc": "Quote language",

View File

@ -4,6 +4,7 @@ import DineroFactory, { Currency, Dinero } from "dinero.js";
import Joi from "joi"; import Joi from "joi";
import { isNull } from "lodash"; import { isNull } from "lodash";
import { NullOr } from "../../../../utilities"; import { NullOr } from "../../../../utilities";
import { adjustPrecision } from "../../../../utilities/helpers";
import { DomainError, handleDomainError } from "../errors"; import { DomainError, handleDomainError } from "../errors";
import { RuleValidator } from "../RuleValidator"; import { RuleValidator } from "../RuleValidator";
import { CurrencyData } from "./CurrencyData"; import { CurrencyData } from "./CurrencyData";
@ -254,6 +255,13 @@ export class MoneyValue extends ValueObject<Dinero> implements IMoneyValue {
return ""; return "";
} }
new Intl.NumberFormat("es", {
/*minimumSignificantDigits: scale,
maximumSignificantDigits: scale,
minimumFractionDigits: scale,*/
useGrouping: true,
}).format(value === null ? 0 : adjustPrecision({ amount: value, scale }));
const factor = Math.pow(10, scale); const factor = Math.pow(10, scale);
const amount = Number(value) / factor; const amount = Number(value) / factor;
return amount.toFixed(scale); return amount.toFixed(scale);

View File

@ -0,0 +1,4 @@
export const adjustPrecision = ({ amount, scale }: { amount: number; scale: number }) => {
const factor = 10 ** scale;
return Number(amount) / factor;
};