Quote Status

This commit is contained in:
David Arranz 2024-11-13 17:28:58 +01:00
parent adcc6e3028
commit 1be73865ea
7 changed files with 212 additions and 98 deletions

View File

@ -1,6 +1,6 @@
import { DownloadIcon, FilePenLineIcon } from "lucide-react"; import { DownloadIcon, FilePenLineIcon } from "lucide-react";
import { ColorBadge } from "@/components"; import { QuoteStatusBadge } from "@/components";
import { useCustomLocalization } from "@/lib/hooks"; import { useCustomLocalization } from "@/lib/hooks";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
@ -40,7 +40,7 @@ export const QuoteResume = ({ quoteId, className }: QuoteResumeProps) => {
const { useOne, useSetStatus, useSentTo, useDownloader, getQuotePDFFilename } = useQuotes(); const { useOne, useSetStatus, useSentTo, useDownloader, getQuotePDFFilename } = useQuotes();
const { data, status } = useOne(quoteId); const { data, status } = useOne(quoteId);
const { mutate: setStatusMutation } = useSetStatus(quoteId); const { mutate: setStatusMutation } = useSetStatus();
const { mutate: sentToMutation } = useSentTo(quoteId); const { mutate: sentToMutation } = useSentTo(quoteId);
const { download, ...downloadProps } = useDownloader(); const { download, ...downloadProps } = useDownloader();
@ -80,9 +80,9 @@ export const QuoteResume = ({ quoteId, className }: QuoteResumeProps) => {
const allowToSent = useMemo(() => data?.status === "accepted" && !data?.date_sent, [data]); const allowToSent = useMemo(() => data?.status === "accepted" && !data?.date_sent, [data]);
const isSent = useMemo(() => data?.status === "accepted" && data?.date_sent, [data]); const isSent = useMemo(() => data?.status === "accepted" && data?.date_sent, [data]);
const handleOnChangeStatus = (_: string, newStatus: string) => { const handleOnChangeStatus = (newStatus: string) => {
setStatusMutation( setStatusMutation(
{ newStatus }, { id: data!!.id, newStatus },
{ {
onSuccess: () => { onSuccess: () => {
@ -143,14 +143,14 @@ export const QuoteResume = ({ quoteId, className }: QuoteResumeProps) => {
<CardHeader className='gap-3 border-b bg-accent'> <CardHeader className='gap-3 border-b bg-accent'>
<CardTitle className='flex items-center justify-between text-lg'> <CardTitle className='flex items-center justify-between text-lg'>
<span>{t("quotes.list.resume.title")}</span> <span>{t("quotes.list.resume.title")}</span>
<ColorBadge className='text-sm' label={t(`quotes.status.${data.status}`)} /> <QuoteStatusBadge className='text-sm' status={data.status} />
</CardTitle> </CardTitle>
<div className='flex mr-auto text-foreground'> <div className='flex mr-auto text-foreground'>
<div className='flex items-center gap-1'> <div className='flex items-center gap-1'>
{allowToSent && !isSent && ( {allowToSent && !isSent && (
<> <>
<QuoteSentToEditor quote={data} onSentTo={handleOnSentTo} /> <QuoteSentToEditor quote={data} onSentTo={handleOnSentTo} />
<QuoteStatusEditor quote={data} onChangeStatus={handleOnChangeStatus} /> <QuoteStatusEditor status={data.status} onChangeStatus={handleOnChangeStatus} />
</> </>
)} )}
@ -171,7 +171,7 @@ export const QuoteResume = ({ quoteId, className }: QuoteResumeProps) => {
</span> </span>
</Button> </Button>
<QuoteStatusEditor quote={data} onChangeStatus={handleOnChangeStatus} /> <QuoteStatusEditor status={data.status} onChangeStatus={handleOnChangeStatus} />
</> </>
)} )}

View File

@ -8,29 +8,18 @@ import {
} from "@/components"; } from "@/components";
import { DataTableToolbar } from "@/components/DataTable/DataTableToolbar"; import { DataTableToolbar } from "@/components/DataTable/DataTableToolbar";
import { useDataTable, useDataTableContext } from "@/lib/hooks"; import { useDataTable, useDataTableContext } from "@/lib/hooks";
import { import { Button, Card, CardContent, Tooltip, TooltipContent, TooltipTrigger } from "@/ui";
Button,
Card,
CardContent,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/ui";
import { useToast } from "@/ui/use-toast"; import { useToast } from "@/ui/use-toast";
import { IListQuotes_Response_DTO, UTCDateValue } from "@shared/contexts"; import { IListQuotes_Response_DTO, UTCDateValue } from "@shared/contexts";
import { ColumnDef, Row } from "@tanstack/react-table"; import { ColumnDef, Row } from "@tanstack/react-table";
import { t } from "i18next"; import { t } from "i18next";
import { FilePenLineIcon, MoreVerticalIcon, SendIcon } from "lucide-react"; import { ArchiveIcon, CopyIcon, DownloadIcon, EditIcon } from "lucide-react";
import { useCallback, useEffect, useId, useMemo, useState } from "react"; import { useCallback, useEffect, useId, useMemo, useState } 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";
import { QuoteResume } from "./QuoteResume"; import { QuoteResume } from "./QuoteResume";
import { QuoteStatusEditor } from "./editors";
export const QuotesDataTable = ({ export const QuotesDataTable = ({
status = "all", status = "all",
@ -49,7 +38,9 @@ export const QuotesDataTable = ({
const [activeRow, setActiveRow] = useState<Row<IListQuotes_Response_DTO> | undefined>(undefined); const [activeRow, setActiveRow] = useState<Row<IListQuotes_Response_DTO> | undefined>(undefined);
const { useList, useDownloader, getQuotePDFFilename } = useQuotes(); const { useList, useDownloader, useSetStatus, getQuotePDFFilename } = useQuotes();
const { mutate: setStatusMutation } = useSetStatus();
const { data, isPending, isError, error } = useList({ const { data, isPending, isError, error } = useList({
pagination: { pagination: {
@ -79,6 +70,20 @@ export const QuotesDataTable = ({
[navigate, toast] [navigate, toast]
); );
const handleOnChangeStatus = (id: string, newStatus: string) => {
setStatusMutation(
{ id, newStatus },
{
onSuccess: () => {
toast({
description: t("quotes.quote_status_editor.toast_status_changed"),
});
},
}
);
};
const columns = useMemo<ColumnDef<IListQuotes_Response_DTO, unknown>[]>(() => { const columns = useMemo<ColumnDef<IListQuotes_Response_DTO, unknown>[]>(() => {
const columns = [ const columns = [
{ {
@ -118,7 +123,13 @@ export const QuotesDataTable = ({
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 } }: { row: { original: IListQuotes_Response_DTO } }) => ( cell: ({ row: { original } }: { row: { original: IListQuotes_Response_DTO } }) => (
<ColorBadge label={t(`quotes.status.${original.status}`)} /> <QuoteStatusEditor
type='badge'
status={original.status}
onChangeStatus={(newStatus: string, oldStatus: string) =>
handleOnChangeStatus(original.id, newStatus)
}
/>
), ),
}, },
{ {
@ -206,72 +217,86 @@ export const QuotesDataTable = ({
header: () => null, header: () => null,
cell: ({ row: { original } }: { row: { original: IListQuotes_Response_DTO } }) => { cell: ({ row: { original } }: { row: { original: IListQuotes_Response_DTO } }) => {
const allowToSent = original?.status === "accepted" && !original?.date_sent; const allowToSent = original?.status === "accepted" && !original?.date_sent;
const isSent = original?.status === "accepted" && original?.date_sent; const isSent = original?.status === "accepted" && !!original?.date_sent;
return ( return (
<ButtonGroup> <ButtonGroup className='gap-0'>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<> <Button
{allowToSent && !isSent && ( variant='ghost'
<Button size='icon'
size='sm' disabled={isSent}
variant='default' onClick={(e) => {
className='h-8 gap-1' e.preventDefault();
onClick={(e) => { handleEditQuote(original);
e.preventDefault(); }}
//handleSentToUecko(original); >
}} <EditIcon className='w-4 h-4' />
> <span className='sr-only'>Editar</span>
<SendIcon className='h-3.5 w-3.5' /> </Button>
<span className='lg:sr-only xl:not-sr-only xl:whitespace-nowrap'>
{t("quotes.list.columns.actions.sent_to")}
</span>
</Button>
)}
{!isSent && (
<Button
size='sm'
variant='outline'
className='h-8 gap-1'
onClick={(e) => {
e.preventDefault();
handleEditQuote(original);
}}
>
<FilePenLineIcon className='h-3.5 w-3.5' />
<span className='lg:sr-only xl:not-sr-only xl:whitespace-nowrap'>
{t("quotes.list.columns.actions.edit")}
</span>
</Button>
)}
</>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p>{t("quotes.list.columns.actions.sent_to_uecko")}</p> <p>Editar</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
<DropdownMenu> <Tooltip>
<DropdownMenuTrigger asChild> <TooltipTrigger asChild>
<Button size='icon' variant='outline' className='w-8 h-8'> <Button
<MoreVerticalIcon className='h-3.5 w-3.5' /> variant='ghost'
<span className='sr-only'>{t("common.more")}</span> size='icon'
onClick={(e) => {
e.preventDefault();
//handleDuplicateQuote(original);
}}
>
<CopyIcon className='w-4 h-4' />
<span className='sr-only'>Duplicar</span>
</Button> </Button>
</DropdownMenuTrigger> </TooltipTrigger>
<DropdownMenuContent align='end'> <TooltipContent>
<DropdownMenuItem <p>Duplicar</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='icon'
onClick={() => { onClick={() => {
download(original.id, getQuotePDFFilename(original)); download(original.id, getQuotePDFFilename(original));
}} }}
> >
Download <DownloadIcon className='w-4 h-4' />
</DropdownMenuItem> <span className='sr-only'>Descargar</span>
<DropdownMenuSeparator /> </Button>
<DropdownMenuItem>{t("common.archive")}</DropdownMenuItem> </TooltipTrigger>
</DropdownMenuContent> <TooltipContent>
</DropdownMenu> <p>Descargar</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='icon'
disabled={isSent}
onClick={(e) => {
e.preventDefault();
//handleArchiveQuote(original)
}}
>
<ArchiveIcon className='w-4 h-4' />
<span className='sr-only'>Archivar</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Archivar</p>
</TooltipContent>
</Tooltip>
</ButtonGroup> </ButtonGroup>
); );
}, },

View File

@ -1,3 +1,4 @@
import { QuoteStatusBadge } from "@/components";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
Button, Button,
@ -12,7 +13,6 @@ import {
Switch, Switch,
} from "@/ui"; } from "@/ui";
import { DialogDescription } from "@radix-ui/react-dialog"; import { DialogDescription } from "@radix-ui/react-dialog";
import { IGetQuote_Response_DTO } from "@shared/contexts";
import { t } from "i18next"; import { t } from "i18next";
import { RefreshCwIcon } from "lucide-react"; import { RefreshCwIcon } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@ -31,37 +31,43 @@ const quoteStatusTransitions: Record<TQuoteStatus, TQuoteStatus[]> = {
}; };
export const QuoteStatusEditor = ({ export const QuoteStatusEditor = ({
quote, type = "button",
status,
onChangeStatus, onChangeStatus,
}: { }: {
quote: IGetQuote_Response_DTO; type?: "button" | "badge";
onChangeStatus: (quoteId: string, newStatus: string) => void; status: string;
onChangeStatus: (newStatus: string, oldStatus: string) => void;
}) => { }) => {
const [newStatus, setNewStatus] = useState<string>(""); const [newStatus, setNewStatus] = useState<string>(status);
const changeStatus = (newStatus: string) => setNewStatus(newStatus); const changeStatus = (newStatus: string) => setNewStatus(newStatus);
useEffect(() => { useEffect(() => {
if (quote) { if (status) {
setNewStatus(quote.status); setNewStatus(status);
} }
}, [quote]); }, [status]);
const handleChangeStatus = () => { const handleChangeStatus = () => {
if (newStatus !== quote.status) { if (newStatus !== status) {
onChangeStatus(quote.id, newStatus); onChangeStatus(newStatus, status);
} }
}; };
return ( return (
<Dialog> <Dialog>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button size='sm' variant='outline' className='h-8 gap-1'> {type === "button" ? (
<RefreshCwIcon className='h-3.5 w-3.5' /> <Button size='sm' variant='outline' className='h-8 gap-1'>
<span className='sr-only md:not-sr-only md:whitespace-nowrap'> <RefreshCwIcon className='h-3.5 w-3.5' />
{t("quotes.quote_status_editor.trigger_button")} <span className='sr-only md:not-sr-only md:whitespace-nowrap'>
</span> {t("quotes.quote_status_editor.trigger_button")}
</Button> </span>
</Button>
) : (
<QuoteStatusBadge status={status} isEditable />
)}
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
@ -71,9 +77,10 @@ export const QuoteStatusEditor = ({
<div className='grid gap-4 py-4'> <div className='grid gap-4 py-4'>
{QuoteStatus.map((_status) => { {QuoteStatus.map((_status) => {
const isDisabled = !quoteStatusTransitions[quote.status as TQuoteStatus].includes( const isDisabled = false;
/*const isDisabled = !quoteStatusTransitions[status as TQuoteStatus].includes(
_status as TQuoteStatus _status as TQuoteStatus
); );*/
return ( return (
<div key={_status} className='flex items-start space-x-4'> <div key={_status} className='flex items-start space-x-4'>
@ -108,7 +115,7 @@ export const QuoteStatusEditor = ({
</DialogClose> </DialogClose>
<DialogClose asChild> <DialogClose asChild>
<Button onClick={handleChangeStatus} disabled={newStatus === quote.status}> <Button onClick={handleChangeStatus} disabled={newStatus === status}>
{t("quotes.quote_status_editor.submit_button")} {t("quotes.quote_status_editor.submit_button")}
</Button> </Button>
</DialogClose> </DialogClose>

View File

@ -147,13 +147,12 @@ export const useQuotes = () => {
}); });
}, },
useSetStatus: (id?: string) => { useSetStatus: () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation<void, TDataSourceError, ISetStatusQuote_Request_DTO>({ return useMutation<void, TDataSourceError, ISetStatusQuote_Request_DTO & { id: string }>({
mutationKey: keys().data().resource("quotes").action("one").id(id).params().get(),
mutationFn: (data) => { mutationFn: (data) => {
const { newStatus } = data; const { id, newStatus } = data;
return dataSource.custom({ return dataSource.custom({
url: `${dataSource.getApiUrl()}/quotes/${id}/setStatus`, url: `${dataSource.getApiUrl()}/quotes/${id}/setStatus`,

View File

@ -0,0 +1,80 @@
import { cn } from "@/lib/utils";
import { Badge } from "@/ui";
import { t } from "i18next";
import { RefreshCwIcon } from "lucide-react";
export type QuoteStatusBadgeProps = {
status: string;
isEditable?: boolean;
className?: string;
};
type QuoteStatus = "draft" | "ready" | "delivered" | "accepted" | "rejected" | "archived";
const statusColorConfig: Record<
QuoteStatus,
{ color: string; bgColor: string; hoverColor: string; hoverBgColor: string }
> = {
draft: {
color: "text-gray-700",
bgColor: "bg-gray-200",
hoverColor: "hover:text-gray-900",
hoverBgColor: "hover:bg-gray-300",
},
ready: {
color: "text-blue-700",
bgColor: "bg-blue-200",
hoverColor: "hover:text-blue-900",
hoverBgColor: "hover:bg-blue-300",
},
delivered: {
color: "text-yellow-700",
bgColor: "bg-yellow-200",
hoverColor: "hover:text-yellow-900",
hoverBgColor: "hover:bg-yellow-300",
},
accepted: {
color: "text-green-700",
bgColor: "bg-green-200",
hoverColor: "hover:text-green-900",
hoverBgColor: "hover:bg-green-300",
},
rejected: {
color: "text-red-700",
bgColor: "bg-red-200",
hoverColor: "hover:text-red-900",
hoverBgColor: "hover:bg-red-300",
},
archived: {
color: "text-purple-700",
bgColor: "bg-purple-200",
hoverColor: "hover:text-purple-900",
hoverBgColor: "hover:bg-purple-300",
},
};
export const QuoteStatusBadge = ({
status,
isEditable,
className,
...props
}: QuoteStatusBadgeProps) => {
return (
<Badge
className={cn(
statusColorConfig[status as QuoteStatus].bgColor,
statusColorConfig[status as QuoteStatus].color,
statusColorConfig[status as QuoteStatus].hoverBgColor,
statusColorConfig[status as QuoteStatus].hoverColor,
"transition-colors duration-200 cursor-pointer flex items-center group",
className
)}
{...props}
>
{t(`quotes.status.${status}`)}
{isEditable && (
<RefreshCwIcon className='w-3 h-3 ml-2 transition-opacity opacity-0 group-hover:opacity-100' />
)}
</Badge>
);
};

View File

@ -0,0 +1 @@
export * from "./QuoteStatusBadge";

View File

@ -13,6 +13,8 @@ export * from "./LoadingOverlay";
export * from "./LoadingSpinner"; export * from "./LoadingSpinner";
export * from "./PDFViewer"; export * from "./PDFViewer";
export * from "./ProtectedRoute"; export * from "./ProtectedRoute";
export * from "./QuoteStatusBadge";
//export * from "./SorteableDataTable"; //export * from "./SorteableDataTable";
export * from "./TailwindIndicator"; export * from "./TailwindIndicator";