This commit is contained in:
David Arranz 2024-10-03 16:23:46 +02:00
parent f9afa58b78
commit fbb21c3fd3
42 changed files with 660 additions and 369 deletions

View File

@ -5,6 +5,7 @@
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link href="https://fonts.upset.dev/css2?family=Poppins&display=swap" rel="stylesheet" />
<title>Uecko</title>
</head>

View File

@ -1,42 +0,0 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

View File

@ -1,130 +0,0 @@
import { Container } from "./components/Container";
export function TypographyDemo() {
return (
<Container className="mx-auto mt-8 prose prose-slate lg:prose-lg">
<h1>The Joke Tax Chronicles</h1>
<p className="leading-7">
Once upon a time, in a far-off land, there was a very lazy king who
spent all day lounging on his throne. One day, his advisors came to him
with a problem: the kingdom was running out of money.
</p>
<pre>
<code className="language-html">
&lt;article class="prose"&gt; &lt;h1&gt;Garlic bread with cheese: What
the science tells us&lt;/h1&gt; &lt;p&gt; For years parents have
espoused the health benefits of eating garlic bread with cheese to
their children, with the food earning such an iconic status in our
culture that kids will often dress up as warm, cheesy loaf for
Halloween. &lt;/p&gt; &lt;p&gt; But a recent study shows that the
celebrated appetizer may be linked to a series of rabies cases
springing up around the country. &lt;/p&gt; &lt;!-- ... --&gt;
&lt;/article&gt;
</code>
</pre>
<h2>The King's Plan</h2>
<p>
The king thought long and hard, and finally came up with{" "}
<a
href="#"
className="font-medium underline text-primary underline-offset-4"
>
a brilliant plan
</a>
: he would tax the jokes in the kingdom.
</p>
<blockquote className="pl-6 mt-6 italic border-l-2">
"After all," he said, "everyone enjoys a good joke, so it's only fair
that they should pay for the privilege."
</blockquote>
<h3 className="mt-8 text-2xl font-semibold tracking-tight scroll-m-20">
The Joke Tax
</h3>
<p className="leading-7 [&:not(:first-child)]:mt-6">
The king's subjects were not amused. They grumbled and complained, but
the king was firm:
</p>
<ul className="my-6 ml-6 list-disc [&>li]:mt-2">
<li>1st level of puns: 5 gold coins</li>
<li>2nd level of jokes: 10 gold coins</li>
<li>3rd level of one-liners : 20 gold coins</li>
</ul>
<p className="leading-7 [&:not(:first-child)]:mt-6">
As a result, people stopped telling jokes, and the kingdom fell into a
gloom. But there was one person who refused to let the king's
foolishness get him down: a court jester named Jokester.
</p>
<h3 className="mt-8 text-2xl font-semibold tracking-tight scroll-m-20">
Jokester's Revolt
</h3>
<p className="leading-7 [&:not(:first-child)]:mt-6">
Jokester began sneaking into the castle in the middle of the night and
leaving jokes all over the place: under the king's pillow, in his soup,
even in the royal toilet. The king was furious, but he couldn't seem to
stop Jokester.
</p>
<p className="leading-7 [&:not(:first-child)]:mt-6">
And then, one day, the people of the kingdom discovered that the jokes
left by Jokester were so funny that they couldn't help but laugh. And
once they started laughing, they couldn't stop.
</p>
<h3 className="mt-8 text-2xl font-semibold tracking-tight scroll-m-20">
The People's Rebellion
</h3>
<p className="leading-7 [&:not(:first-child)]:mt-6">
The people of the kingdom, feeling uplifted by the laughter, started to
tell jokes and puns again, and soon the entire kingdom was in on the
joke.
</p>
<div className="w-full my-6 overflow-y-auto">
<table className="w-full">
<thead>
<tr className="p-0 m-0 border-t even:bg-muted">
<th className="border px-4 py-2 text-left font-bold [&[align=center]]:text-center [&[align=right]]:text-right">
King's Treasury
</th>
<th className="border px-4 py-2 text-left font-bold [&[align=center]]:text-center [&[align=right]]:text-right">
People's happiness
</th>
</tr>
</thead>
<tbody>
<tr className="p-0 m-0 border-t even:bg-muted">
<td className="border px-4 py-2 text-left [&[align=center]]:text-center [&[align=right]]:text-right">
Empty
</td>
<td className="border px-4 py-2 text-left [&[align=center]]:text-center [&[align=right]]:text-right">
Overflowing
</td>
</tr>
<tr className="p-0 m-0 border-t even:bg-muted">
<td className="border px-4 py-2 text-left [&[align=center]]:text-center [&[align=right]]:text-right">
Modest
</td>
<td className="border px-4 py-2 text-left [&[align=center]]:text-center [&[align=right]]:text-right">
Satisfied
</td>
</tr>
<tr className="p-0 m-0 border-t even:bg-muted">
<td className="border px-4 py-2 text-left [&[align=center]]:text-center [&[align=right]]:text-right">
Full
</td>
<td className="border px-4 py-2 text-left [&[align=center]]:text-center [&[align=right]]:text-right">
Ecstatic
</td>
</tr>
</tbody>
</table>
</div>
<p className="leading-7 [&:not(:first-child)]:mt-6">
The king, seeing how much happier his subjects were, realized the error
of his ways and repealed the joke tax. Jokester was declared a hero, and
the kingdom lived happily ever after.
</p>
<p className="leading-7 [&:not(:first-child)]:mt-6">
The moral of the story is: never underestimate the power of a good laugh
and always be careful of bad ideas.
</p>
</Container>
);
}

View File

@ -20,12 +20,13 @@ import {
TooltipTrigger,
} from "@/ui";
import { useToast } from "@/ui/use-toast";
import { formatDateToYYYYMMDD } from "@shared/utilities/helpers";
import { t } from "i18next";
import { useCallback, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { useQuotes } from "../hooks";
import { DownloadQuoteDialog } from "./DownloadQuoteDialog";
import { QuoteStatusEditor } from "./editors";
import { QuoteSentToEditor, QuoteStatusEditor } from "./editors";
import { QuotePDFPreview } from "./QuotePDFPreview";
type QuoteResumeProps = {
@ -37,9 +38,10 @@ export const QuoteResume = ({ quoteId, className }: QuoteResumeProps) => {
const navigate = useNavigate();
const { toast } = useToast();
const { useOne, useSetStatus, useDownloader, getQuotePDFFilename } = useQuotes();
const { useOne, useSetStatus, useSentTo, useDownloader, getQuotePDFFilename } = useQuotes();
const { data, status } = useOne(quoteId);
const { mutate: setStatusMutation } = useSetStatus(quoteId);
const { mutate: sentToMutation } = useSentTo(quoteId);
const { download, ...downloadProps } = useDownloader();
const { formatCurrency, formatNumber } = useCustomLocalization({
@ -75,6 +77,9 @@ export const QuoteResume = ({ quoteId, className }: QuoteResumeProps) => {
};
}, [data]);
const allowToSent = useMemo(() => data?.status === "accepted" && !data?.date_sent, [data]);
const isSent = useMemo(() => data?.status === "accepted" && data?.date_sent, [data]);
const handleOnChangeStatus = (_: string, newStatus: string) => {
setStatusMutation(
{ newStatus },
@ -89,6 +94,19 @@ export const QuoteResume = ({ quoteId, className }: QuoteResumeProps) => {
);
};
const handleOnSentTo = (_: string) => {
sentToMutation(
{ sent_date: formatDateToYYYYMMDD(new Date()) },
{
onSuccess: () => {
toast({
description: t("quotes.quote_sent_to_editor.toast_status_changed"),
});
},
}
);
};
const handleFinishDownload = useCallback(() => {
toast({
description: t("quotes.downloading_dialog.toast_success"),
@ -129,21 +147,33 @@ export const QuoteResume = ({ quoteId, className }: QuoteResumeProps) => {
</CardTitle>
<div className='flex mr-auto text-foreground'>
<div className='flex items-center gap-1'>
<Button
size='sm'
variant='default'
className='h-8 gap-1'
onClick={(e) => {
e.preventDefault();
navigate(`/quotes/edit/${data.id}`, { relative: "path" });
}}
>
<FilePenLineIcon className='h-3.5 w-3.5' />
<span className='sr-only md:not-sr-only md:whitespace-nowrap'>
{t("quotes.list.columns.actions.edit")}
</span>
</Button>
<QuoteStatusEditor quote={data} onChangeStatus={handleOnChangeStatus} />
{allowToSent && !isSent && (
<>
<QuoteSentToEditor quote={data} onSentTo={handleOnSentTo} />
<QuoteStatusEditor quote={data} onChangeStatus={handleOnChangeStatus} />
</>
)}
{!allowToSent && !isSent && (
<>
<Button
size='sm'
variant='default'
className='h-8 gap-1'
onClick={(e) => {
e.preventDefault();
navigate(`/quotes/edit/${data.id}`, { relative: "path" });
}}
>
<FilePenLineIcon className='h-3.5 w-3.5' />
<span className='sr-only md:not-sr-only md:whitespace-nowrap'>
{t("quotes.list.columns.actions.edit")}
</span>
</Button>
<QuoteStatusEditor quote={data} onChangeStatus={handleOnChangeStatus} />
</>
)}
<Tooltip>
<TooltipTrigger asChild>
@ -154,7 +184,9 @@ export const QuoteResume = ({ quoteId, className }: QuoteResumeProps) => {
onClick={handleDownload}
>
<DownloadIcon className='h-3.5 w-3.5 ' />
<span className='sr-only'>{t("quotes.list.resume.download_quote")}</span>
<span className={isSent ? "" : "sr-only"}>
{t("quotes.list.resume.download_quote")}
</span>
</Button>
</TooltipTrigger>
<TooltipContent>{t("quotes.list.resume.download_quote")}</TooltipContent>

View File

@ -162,6 +162,22 @@ export const QuotesDataTable = ({
),
size: 600,
},
{
id: "date_sent" as const,
accessor: "date_sent",
header: () => (
<div className='text-right text-ellipsis'>{t("quotes.list.columns.date_sent")}</div>
),
cell: ({ row: { original } }: { row: { original: IListQuotes_Response_DTO } }) => {
const quoteDate = UTCDateValue.create(original.date_sent);
return (
<div className='text-right text-ellipsis'>
{quoteDate.isSuccess}
{quoteDate.isSuccess ? quoteDate.object.toLocaleDateString("es-ES") : "-"}
</div>
);
},
},
/*{
id: "total_price" as const,
accessor: "total_price",
@ -179,27 +195,44 @@ export const QuotesDataTable = ({
{
id: "row-actions",
header: () => null,
cell: ({ row }: { row: Row<IListQuotes_Response_DTO> }) => (
cell: ({ row: { original } }: { row: { original: IListQuotes_Response_DTO } }) => (
<ButtonGroup>
<Tooltip>
<TooltipTrigger asChild>
<Button
size='sm'
variant='outline'
className='h-8 gap-1'
onClick={(e) => {
e.preventDefault();
handleEditQuote(row.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>
{original.status === "accepted" && !original.date_sent ? (
<Button
size='sm'
variant='default'
className='h-8 gap-1'
onClick={(e) => {
e.preventDefault();
//handleSentToUecko(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.sent_to")}
</span>
</Button>
) : (
<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>
<TooltipContent>
<p>{t("quotes.list.columns.actions.edit")}</p>
<p>{t("quotes.list.columns.actions.sent_to_uecko")}</p>
</TooltipContent>
</Tooltip>
@ -213,7 +246,7 @@ export const QuotesDataTable = ({
<DropdownMenuContent align='end'>
<DropdownMenuItem
onClick={() => {
download(row.original.id, getQuotePDFFilename(row.original));
download(original.id, getQuotePDFFilename(original));
}}
>
Download

View File

@ -0,0 +1,50 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
Button,
} from "@/ui";
import { IGetQuote_Response_DTO } from "@shared/contexts";
import { t } from "i18next";
export const QuoteSentToEditor = ({
quote,
onSentTo,
}: {
quote: IGetQuote_Response_DTO;
onSentTo: (quoteId: string) => void;
}) => {
const handleSentTo = () => {
onSentTo(quote.id);
};
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size='sm' variant='default' className='h-8 gap-1'>
{t("quotes.quote_sent_to_editor.trigger_button")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("quotes.quote_sent_to_editor.title")}</AlertDialogTitle>
<AlertDialogDescription>
{t("quotes.quote_sent_to_editor.description")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction asChild>
<Button onClick={handleSentTo}>{t("common.continue")}</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@ -1,4 +1,5 @@
export * from "./QuoteDetailsCardEditor";
export * from "./QuoteDocumentsCardEditor";
export * from "./QuoteGeneralCardEditor";
export * from "./QuoteSentToEditor";
export * from "./QuoteStatusEditor";

View File

@ -1,5 +1,5 @@
import { useDownloader } from "@/lib/hooks";
import { UseListQueryResult, useList, useOne, useSave } from "@/lib/hooks/useDataSource";
import { useList, UseListQueryResult, useOne, useSave } from "@/lib/hooks/useDataSource";
import { IGetListDataProviderParams } from "@/lib/hooks/useDataSource/DataSource";
import { TDataSourceError } from "@/lib/hooks/useDataSource/types";
import { useDataSource } from "@/lib/hooks/useDataSource/useDataSource";
@ -10,6 +10,7 @@ import {
IGetQuote_Response_DTO,
IListQuotes_Response_DTO,
IListResponse_DTO,
ISendQuote_Request_DTO,
ISetStatusQuote_Request_DTO,
IUpdateQuote_Request_DTO,
IUpdateQuote_Response_DTO,
@ -173,17 +174,21 @@ export const useQuotes = () => {
});
},
});
},
/*return useMutation<void, TDataSourceError, ISetStatusQuote_Request_DTO>({
useSentTo: (id?: string) => {
const queryClient = useQueryClient();
return useMutation<void, TDataSourceError, ISendQuote_Request_DTO>({
mutationKey: keys().data().resource("quotes").action("one").id(id).params().get(),
mutationFn: (data) => {
const { newStatus } = data;
const { sent_date } = data;
return dataSource.updateOne({
resource: "quotes",
id,
return dataSource.custom({
url: `${dataSource.getApiUrl()}/quotes/${id}/send`,
method: "put",
data: {
newStatus,
sent_date,
},
});
},
@ -192,7 +197,7 @@ export const useQuotes = () => {
queryKey: ["data", "default", "quotes"],
});
},
});*/
});
},
useOne: (id?: string, params?: UseQuotesGetParamsType) =>

View File

@ -82,6 +82,7 @@
"dealers": "Dealers",
"catalog": "Catalog",
"quotes": "Quotes",
"orders": "Orders",
"search_placeholder": "Type here for search quotes and articles",
"user": {
"user_menu": "User menu",
@ -138,13 +139,15 @@
},
"columns": {
"date": "Date",
"date_sent": "Sent to Uecko",
"reference": "Reference",
"status": "Status",
"customer_reference": "Customer Ref.",
"customer_information": "Customer",
"total_price": "Imp. total",
"actions": {
"edit": "Edit quote"
"edit": "Edit quote",
"sent_to": "Send to Uecko"
}
},

View File

@ -82,6 +82,7 @@
"dealers": "Distribuidores",
"catalog": "Catálogo",
"quotes": "Cotizaciones",
"orders": "Pedidos",
"search_placeholder": "Buscar productos, cotizaciones, etc...",
"user": {
"user_menu": "Menú del usuario",
@ -138,13 +139,15 @@
},
"columns": {
"date": "Fecha",
"date_sent": "Enviado a Uecko",
"reference": "Referencia",
"status": "Estado",
"customer_reference": "Ref. cliente",
"customer_information": "Cliente",
"total_price": "Imp. total",
"actions": {
"edit": "Editar"
"edit": "Editar",
"sent_to": "Enviar a Uecko"
}
},
@ -217,6 +220,14 @@
"description": "Para rellenar su cotización, puede añadir artículos del catálogo.",
"toast_article_added": "Artículo del catálogo añadido:"
},
"quote_sent_to_editor": {
"trigger_button": "Enviar a Uecko",
"title": "Enviar la cotización a Uecko",
"description": "¿Desea enviar esta cotización a Uecko? Esta acción no se puede deshacer.",
"submit_button": "Enviar",
"toast_status_changed": "Cotización enviada a Uecko"
},
"quote_status_editor": {
"trigger_button": "Cambiar el estado",
"title": "Cambiar el estado de la cotización",

View File

@ -1,5 +1,6 @@
/** @type {import('tailwindcss').Config} */
import defaultTheme from "tailwindcss/defaultTheme";
import plugin from "tailwindcss/plugin";
export default {
@ -16,14 +17,14 @@ export default {
},
extend: {
// https://tailwindcss.com/docs/font-family#font-families
/*fontFamily: {
sans: ['"Source Sans Pro"', ...defaultTheme.fontFamily.sans],
},*/
fontFamily: {
sans: ['"Poppins"', ...defaultTheme.fontFamily.sans],
},
/*fontFamily: {
display: "Public Sans, ui-sans-serif",
heading: "Noto Serif, ui-serif",
},
},*/
colors: {
border: "hsl(240, 5.9%, 90%)",

1
dist/client/assets/index-D1RJbR2g.css vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -5,9 +5,10 @@
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link href="https://fonts.upset.dev/css2?family=Poppins&display=swap" rel="stylesheet" />
<title>Uecko</title>
<script type="module" crossorigin src="/assets/index-CPprBq9G.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DSV01hTS.css">
<script type="module" crossorigin src="/assets/index-DA9apz9T.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D1RJbR2g.css">
</head>
<body>

View File

@ -3,5 +3,5 @@ import { NextFunction, Request, Response } from "express";
export const handleRequest =
(controllerFactory: any) => (req: Request, res: Response, next: NextFunction) => {
const context = res.locals["context"];
return controllerFactory(context).execute(req, res, next);
return controllerFactory(context, req, res, next).execute(req, res, next);
};

View File

@ -205,6 +205,13 @@ export class CreateQuoteUseCase
return Result.fail(taxOrError.error);
}
const dateSentOrError = UTCDateValue.create(null);
if (dateSentOrError.isFailure) {
return Result.fail(dateSentOrError.error);
}
// Items
let items: Collection<QuoteItem>;
try {
@ -283,6 +290,7 @@ export class CreateQuoteUseCase
items,
dealerId,
dateSent: dateSentOrError.object,
},
quoteId
);

View File

@ -0,0 +1,106 @@
import {
IUseCase,
IUseCaseError,
IUseCaseRequest,
UseCaseError,
} from "@/contexts/common/application/useCases";
import { IRepositoryManager } from "@/contexts/common/domain";
import { ISequelizeAdapter } from "@/contexts/common/infrastructure/sequelize";
import {
DomainError,
ISendQuote_Request_DTO,
Result,
UniqueID,
UTCDateValue,
} from "@shared/contexts";
import { IQuoteRepository } from "../../domain";
export interface ISendQuoteUseCaseRequest extends IUseCaseRequest {
id: UniqueID;
sentQuoteDTO: ISendQuote_Request_DTO;
}
export type SendQuoteResponseOrError =
| Result<never, IUseCaseError> // Misc errors (value objects)
| Result<void, never>; // Success!
export class SendQuoteUseCase
implements IUseCase<ISendQuoteUseCaseRequest, Promise<SendQuoteResponseOrError>>
{
private _adapter: ISequelizeAdapter;
private _repositoryManager: IRepositoryManager;
constructor(props: { adapter: ISequelizeAdapter; repositoryManager: IRepositoryManager }) {
this._adapter = props.adapter;
this._repositoryManager = props.repositoryManager;
}
async execute(request: ISendQuoteUseCaseRequest): Promise<SendQuoteResponseOrError> {
const {
id,
sentQuoteDTO: { sent_date },
} = request;
const quoteRepository = this._getQuoteRepository();
// Comprobar que existe el Quote
const idExists = await quoteRepository().exists(id);
if (!idExists) {
const message = `Quote ID not found`;
return Result.fail(
UseCaseError.create(UseCaseError.NOT_FOUND_ERROR, message, {
path: "id",
})
);
}
// //
// regla de negocio -> no poder enviar un quote enviado
// regla de negocio -> no poder enviar un quote que no esté aceptado
// Comprobar el status
const sentDateOrError = UTCDateValue.create(sent_date);
if (sentDateOrError.isFailure) {
const { error: domainError } = sentDateOrError;
let errorCode = "";
let message = "";
switch (domainError.code) {
// Errores manuales
case DomainError.INVALID_INPUT_DATA:
errorCode = UseCaseError.INVALID_INPUT_DATA;
message = "La fecha de envío no es correcta";
break;
default:
errorCode = UseCaseError.UNEXCEPTED_ERROR;
message = domainError.message;
break;
}
return Result.fail(UseCaseError.create(errorCode, message, domainError));
}
const transaction = this._adapter.startTransaction();
try {
await transaction.complete(async (t) => {
const quoteRepo = quoteRepository({ transaction: t });
await quoteRepo.updateSentDateById(id, sentDateOrError.object);
});
return Result.ok<void>();
} catch (error: unknown) {
//const _error = error as IInfrastructureError;
return Result.fail(
UseCaseError.create(
UseCaseError.REPOSITORY_ERROR,
"Error al establecer el nuevo estado en la cotización"
)
);
}
}
private _getQuoteRepository() {
return this._repositoryManager.getRepository<IQuoteRepository>("Quote");
}
}

View File

@ -48,6 +48,9 @@ export class SetStatusQuoteUseCase
);
}
// //
// regla de negocio -> no poder modificar un quote enviado
// Comprobar el status
const statusOrError = QuoteStatus.create(newStatus);
if (statusOrError.isFailure) {

View File

@ -83,6 +83,9 @@ export class UpdateQuoteUseCase
);
}
// //
// regla de negocio -> no poder modificar un quote enviado
// Crear quote
const quoteOrError = this._tryCreateQuoteInstance(quoteDTO, id, dealerId);
@ -194,6 +197,13 @@ export class UpdateQuoteUseCase
return Result.fail(taxOrError.error);
}
const dateSentOrError = UTCDateValue.create(null);
if (dateSentOrError.isFailure) {
return Result.fail(dateSentOrError.error);
}
// Items
let items: Collection<QuoteItem>;
try {
@ -272,6 +282,8 @@ export class UpdateQuoteUseCase
items,
dealerId,
dateSent: dateSentOrError.object,
},
quoteId
);

View File

@ -2,5 +2,6 @@ export * from "./CreateQuote.useCase";
export * from "./DeleteQuote.useCase";
export * from "./GetQuote.useCase";
export * from "./ListQuotes.useCase";
export * from "./SendQuote.useCase";
export * from "./SetStatusQuote.useCase";
export * from "./UpdateQuote.useCase";

View File

@ -36,6 +36,8 @@ export interface IQuoteProps {
//totalPrice: MoneyValue;
dealerId: UniqueID;
dateSent: UTCDateValue;
}
export interface IQuote {
@ -67,6 +69,8 @@ export interface IQuote {
items: ICollection<QuoteItem>;
dealerId: UniqueID;
dateSent: UTCDateValue;
}
export class Quote extends AggregateRoot<IQuoteProps> implements IQuote {
@ -183,4 +187,8 @@ export class Quote extends AggregateRoot<IQuoteProps> implements IQuote {
get totalPrice(): MoneyValue {
return this.beforeTaxPrice.add(this.taxPrice);
}
get dateSent() {
return this.props.dateSent;
}
}

View File

@ -1,6 +1,6 @@
/* eslint-disable no-unused-vars */
import { IRepository } from "@/contexts/common/domain/repositories";
import { ICollection, IQueryCriteria, UniqueID } from "@shared/contexts";
import { ICollection, IQueryCriteria, UniqueID, UTCDateValue } from "@shared/contexts";
import { Quote, QuoteReference, QuoteStatus } from "../entities";
export interface IQuoteRepository extends IRepository<Quote> {
@ -19,4 +19,5 @@ export interface IQuoteRepository extends IRepository<Quote> {
findLastReferenceByDealerId(dealerId: UniqueID): Promise<QuoteReference | null>;
updateStatusById(quoteId: UniqueID, newStatus: QuoteStatus): Promise<void>;
updateSentDateById(quoteId: UniqueID, sentDate: UTCDateValue): Promise<void>;
}

View File

@ -1,11 +1,11 @@
import { ISequelizeAdapter, SequelizeRepository } from "@/contexts/common/infrastructure/sequelize";
import { ICollection, IQueryCriteria, UniqueID } from "@shared/contexts";
import { ICollection, IQueryCriteria, UniqueID, UTCDateValue } from "@shared/contexts";
import { ModelDefined, Transaction } from "sequelize";
import { IQuoteRepository } from "../domain";
import { Quote, QuoteReference, QuoteStatus } from "../domain/entities";
import { ISalesContext } from "./Sales.context";
import { IQuoteMapper, createQuoteMapper } from "./mappers/quote.mapper";
import { createQuoteMapper, IQuoteMapper } from "./mappers/quote.mapper";
export type QueryParams = {
pagination: Record<string, any>;
@ -158,6 +158,21 @@ export class QuoteRepository extends SequelizeRepository<Quote> implements IQuot
}
);
}
public async updateSentDateById(quoteId: UniqueID, sentDate: UTCDateValue): Promise<void> {
const quoteSentData = sentDate.toPrimitive(); //this.mapper.mapToPersistence(quote);
const _model = this._adapter.getModel("Quote_Model");
await _model.update(
{
date_sent: quoteSentData,
},
{
where: { id: quoteId.toPrimitive() },
transaction: this._transaction,
}
);
}
}
export const registerQuoteRepository = (context: ISalesContext) => {

View File

@ -1,13 +1,10 @@
import { CreateQuoteUseCase } from "@/contexts/sales/application";
import { NextFunction, Request, Response } from "express";
import { registerQuoteRepository } from "../../../../Quote.repository";
import { ISalesContext } from "../../../../Sales.context";
import { CreateQuoteController } from "./CreateQuote.controller";
import { CreateQuotePresenter } from "./presenter";
export const createQuoteController = (req: Request, res: Response, next: NextFunction) => {
const context: ISalesContext = res.locals.context;
export const createQuoteController = (context: ISalesContext) => {
registerQuoteRepository(context);
return new CreateQuoteController(
{
@ -15,5 +12,5 @@ export const createQuoteController = (req: Request, res: Response, next: NextFun
presenter: CreateQuotePresenter,
},
context
).execute(req, res, next);
);
};

View File

@ -1,13 +1,10 @@
import { GetQuoteUseCase } from "@/contexts/sales/application";
import { NextFunction, Request, Response } from "express";
import { registerQuoteRepository } from "../../../../Quote.repository";
import { ISalesContext } from "../../../../Sales.context";
import { GetQuoteController } from "./GetQuote.controller";
import { GetQuotePresenter } from "./presenter";
export const getQuoteController = (req: Request, res: Response, next: NextFunction) => {
const context: ISalesContext = res.locals.context;
export const getQuoteController = (context: ISalesContext) => {
registerQuoteRepository(context);
return new GetQuoteController(
@ -16,5 +13,5 @@ export const getQuoteController = (req: Request, res: Response, next: NextFuncti
presenter: GetQuotePresenter,
},
context
).execute(req, res, next);
);
};

View File

@ -41,6 +41,8 @@ export const GetQuotePresenter: IGetQuotePresenter = {
items: quoteItemPresenter(quote.items, context),
dealer_id: quote.dealerId.toString(),
date_sent: quote.dateSent.toISO8601(),
};
},
};

View File

@ -1,13 +1,10 @@
import { ListQuotesUseCase } from "@/contexts/sales/application";
import { registerQuoteRepository } from "@/contexts/sales/infrastructure/Quote.repository";
import { ISalesContext } from "@/contexts/sales/infrastructure/Sales.context";
import { NextFunction, Request, Response } from "express";
import { ListQuotesController } from "./ListQuotes.controller";
import { ListQuotesPresenter } from "./presenter";
export const listQuotesController = (req: Request, res: Response, next: NextFunction) => {
const context: ISalesContext = res.locals.context;
export const listQuotesController = (context: ISalesContext) => {
registerQuoteRepository(context);
return new ListQuotesController(
@ -16,5 +13,5 @@ export const listQuotesController = (req: Request, res: Response, next: NextFunc
presenter: ListQuotesPresenter,
},
context
).execute(req, res, next);
);
};

View File

@ -39,6 +39,8 @@ export const ListQuotesPresenter: IListQuotesPresenter = {
total_price: quote.totalPrice.convertScale(2).toObject(),
dealer_id: quote.dealerId.toString(),
date_sent: quote.dateSent.toISO8601(),
};
},

View File

@ -1,13 +1,10 @@
import { GetQuoteUseCase } from "@/contexts/sales/application";
import { NextFunction, Request, Response } from "express";
import { registerQuoteRepository } from "../../../../Quote.repository";
import { ISalesContext } from "../../../../Sales.context";
import { ReportQuotePresenter } from "./reporter/ReportQuote.reporter";
import { ReportQuoteController } from "./ReportQuote.controller";
export const reportQuoteController = (req: Request, res: Response, next: NextFunction) => {
const context: ISalesContext = res.locals.context;
export const reportQuoteController = (context: ISalesContext) => {
registerQuoteRepository(context);
return new ReportQuoteController(
@ -16,5 +13,5 @@ export const reportQuoteController = (req: Request, res: Response, next: NextFun
reporter: ReportQuotePresenter,
},
context
).execute(req, res, next);
);
};

View File

@ -0,0 +1,14 @@
import { SendQuoteUseCase } from "@/contexts/sales/application";
import { registerQuoteRepository } from "../../../../Quote.repository";
import { ISalesContext } from "../../../../Sales.context";
import { SendQuoteController } from "./sendQuote.controller";
export const sendQuoteController = (context: ISalesContext) => {
registerQuoteRepository(context);
return new SendQuoteController(
{
useCase: new SendQuoteUseCase(context),
},
context
);
};

View File

@ -0,0 +1,103 @@
import { IUseCaseError, UseCaseError } from "@/contexts/common/application/useCases";
import { IServerError } from "@/contexts/common/domain/errors";
import { IInfrastructureError, InfrastructureError } from "@/contexts/common/infrastructure";
import { ExpressController } from "@/contexts/common/infrastructure/express";
import { SendQuoteUseCase } from "@/contexts/sales/application";
import {
ensureIdIsValid,
ensureSendQuote_Request_DTOIsValid,
ISendQuote_Request_DTO,
} from "@shared/contexts";
import { ISalesContext } from "../../../../Sales.context";
export class SendQuoteController extends ExpressController {
private useCase: SendQuoteUseCase;
private context: ISalesContext;
constructor(props: { useCase: SendQuoteUseCase }, context: ISalesContext) {
super();
const { useCase } = props;
this.useCase = useCase;
this.context = context;
}
async executeImpl(): Promise<any> {
try {
const { quoteId } = this.req.params;
const newStatusDTO: ISendQuote_Request_DTO = this.req.body;
// Validar ID
const quoteIdOrError = ensureIdIsValid(quoteId);
if (quoteIdOrError.isFailure) {
const errorMessage = "Quote ID is not valid";
const infraError = InfrastructureError.create(
InfrastructureError.INVALID_INPUT_DATA,
errorMessage,
quoteIdOrError.error
);
return this.invalidInputError(errorMessage, infraError);
}
// Validar DTO de datos
const sentQuoteDTOOrError = ensureSendQuote_Request_DTOIsValid(newStatusDTO);
if (sentQuoteDTOOrError.isFailure) {
const errorMessage = "New quote status is not valid";
const infraError = InfrastructureError.create(
InfrastructureError.INVALID_INPUT_DATA,
errorMessage,
sentQuoteDTOOrError.error
);
return this.invalidInputError(errorMessage, infraError);
}
// Llamar al caso de uso
const result = await this.useCase.execute({
id: quoteIdOrError.object,
sentQuoteDTO: sentQuoteDTOOrError.object,
});
if (result.isFailure) {
return this._handleExecuteError(result.error);
}
return this.noContent();
} catch (e: unknown) {
return this.fail(e as IServerError);
}
}
private _handleExecuteError(error: IUseCaseError) {
let errorMessage: string;
let infraError: IInfrastructureError;
switch (error.code) {
case UseCaseError.NOT_FOUND_ERROR:
errorMessage = "Quote not found";
infraError = InfrastructureError.create(
InfrastructureError.RESOURCE_NOT_FOUND_ERROR,
errorMessage,
error
);
return this.notFoundError(errorMessage, infraError);
break;
case UseCaseError.UNEXCEPTED_ERROR:
errorMessage = error.message;
infraError = InfrastructureError.create(
InfrastructureError.UNEXCEPTED_ERROR,
errorMessage,
error
);
return this.internalServerError(errorMessage, infraError);
break;
default:
errorMessage = error.message;
return this.clientError(errorMessage);
}
}
}

View File

@ -1,17 +1,14 @@
import { SetStatusQuoteUseCase } from "@/contexts/sales/application";
import { NextFunction, Request, Response } from "express";
import { registerQuoteRepository } from "../../../../Quote.repository";
import { ISalesContext } from "../../../../Sales.context";
import { SetStatusQuoteController } from "./SetStatusQuote.controller";
export const setStatusQuoteController = (req: Request, res: Response, next: NextFunction) => {
const context: ISalesContext = res.locals.context;
export const setStatusQuoteController = (context: ISalesContext) => {
registerQuoteRepository(context);
return new SetStatusQuoteController(
{
useCase: new SetStatusQuoteUseCase(context),
},
context
).execute(req, res, next);
);
};

View File

@ -95,6 +95,8 @@ class QuoteMapper
),*/
dealerId: this.mapsValue(source, "dealer_id", UniqueID.create),
dateSent: this.mapsValue(source, "date_sent", UTCDateValue.create),
};
const quoteOrError = Quote.create(props, id);
@ -139,6 +141,8 @@ class QuoteMapper
items,
dealer_id: source.dealerId.toPrimitive(),
date_sent: source.dateSent?.toPrimitive(),
};
return quote;

View File

@ -13,7 +13,7 @@ import { QuoteItemCreationAttributes, QuoteItem_Model } from "./quoteItem.model"
export type QuoteCreationAttributes = InferCreationAttributes<
Quote_Model,
{ omit: "items" | "dealer" }
{ omit: "items" | "dealer" | "id_contract" }
> & {
items: QuoteItemCreationAttributes[];
dealer_id: string;
@ -65,6 +65,9 @@ export class Quote_Model extends Model<
declare items: NonAttribute<QuoteItem_Model[]>;
declare dealer: NonAttribute<Dealer_Model>;
declare id_contract: CreationOptional<string | null>;
declare date_sent: CreationOptional<string | null>;
}
export default (sequelize: Sequelize) => {
@ -155,6 +158,16 @@ export default (sequelize: Sequelize) => {
type: new DataTypes.BIGINT(),
allowNull: true,
},
id_contract: {
type: DataTypes.BIGINT().UNSIGNED,
allowNull: true,
},
date_sent: {
type: new DataTypes.DATE(),
allowNull: true,
},
},
{
sequelize,
@ -172,6 +185,8 @@ export default (sequelize: Sequelize) => {
{ name: "status_idx", fields: ["status"] },
{ name: "reference_idx", fields: ["reference"] },
{ name: "deleted_at_idx", fields: ["deleted_at"] },
{ name: "date_sent_idx", fields: ["date_sent"] },
{ name: "id_contract_idx", fields: ["id_contract"] },
],
whereMergeStrategy: "and", // <- cómo tratar el merge de un scope

View File

@ -1,4 +1,5 @@
import { checkUser } from "@/contexts/auth";
import { handleRequest } from "@/contexts/common/infrastructure/express";
import {
createQuoteController,
getQuoteController,
@ -7,6 +8,7 @@ import {
setStatusQuoteController,
updateQuoteController,
} from "@/contexts/sales/infrastructure/express/controllers";
import { sendQuoteController } from "@/contexts/sales/infrastructure/express/controllers/quotes/sendQuote";
import { getDealerMiddleware } from "@/contexts/sales/infrastructure/express/middlewares/Dealer.middleware";
import { Router } from "express";
@ -14,20 +16,30 @@ export const quoteRouter = (appRouter: Router): void => {
const quoteRoutes: Router = Router({ mergeParams: true });
// Users CRUD
quoteRoutes.get("/", checkUser, getDealerMiddleware, listQuotesController);
quoteRoutes.get("/:quoteId", checkUser, getDealerMiddleware, getQuoteController);
quoteRoutes.post("/", checkUser, getDealerMiddleware, createQuoteController);
quoteRoutes.put("/:quoteId", checkUser, getDealerMiddleware, updateQuoteController);
quoteRoutes.get("/", checkUser, getDealerMiddleware, handleRequest(listQuotesController));
quoteRoutes.get("/:quoteId", checkUser, getDealerMiddleware, handleRequest(getQuoteController));
quoteRoutes.post("/", checkUser, getDealerMiddleware, handleRequest(createQuoteController));
quoteRoutes.put(
"/:quoteId",
checkUser,
getDealerMiddleware,
handleRequest(updateQuoteController)
);
// Reports
quoteRoutes.get("/:quoteId/report", checkUser, getDealerMiddleware, reportQuoteController);
quoteRoutes.get(
"/:quoteId/report",
checkUser,
getDealerMiddleware,
handleRequest(reportQuoteController)
);
// Status
quoteRoutes.put(
"/:quoteId/setStatus",
checkUser,
/*getDealerMiddleware, */ setStatusQuoteController
);
quoteRoutes.put("/:quoteId/setStatus", checkUser, handleRequest(setStatusQuoteController));
// Send to Uecko
quoteRoutes.put("/:quoteId/send", checkUser, handleRequest(sendQuoteController));
/*
quoteRoutes.post("/", isAdmin, createQuoteController);

View File

@ -25,6 +25,8 @@ export interface IGetQuote_Response_DTO {
items: IGetQuote_QuoteItem_Response_DTO[];
dealer_id: string;
date_sent: string;
}
export interface IGetQuote_QuoteItem_Response_DTO {

View File

@ -19,4 +19,6 @@ export interface IListQuotes_Response_DTO {
total_price: IMoney_DTO;
dealer_id: string;
date_sent: string;
}

View File

@ -0,0 +1,20 @@
import Joi from "joi";
import { Result, RuleValidator } from "../../../../../common";
export interface ISendQuote_Request_DTO {
sent_date: string;
}
export function ensureSendQuote_Request_DTOIsValid(quoteDTO: ISendQuote_Request_DTO) {
const schema = Joi.object({
sent_date: Joi.string(),
});
const result = RuleValidator.validate<ISendQuote_Request_DTO>(schema, quoteDTO);
if (result.isFailure) {
return Result.fail(result.error);
}
return Result.ok(result.object);
}

View File

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

View File

@ -1,5 +1,6 @@
export * from "./CreateQuote.dto";
export * from "./GetQuote.dto";
export * from "./ListQuotes.dto";
export * from "./SendQuote.dto";
export * from "./SetStatusQuote.dto";
export * from "./UpdateQuote.dto";

View File

@ -2,3 +2,11 @@ export const adjustPrecision = ({ amount, scale }: { amount: number; scale: numb
const factor = 10 ** scale;
return Number(amount) / factor;
};
export const formatDateToYYYYMMDD = (date: Date): string => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0"); // Los meses van de 0 a 11, por lo que sumamos 1
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
};