This commit is contained in:
David Arranz 2024-09-23 17:56:15 +02:00
parent eb3e2ebc47
commit 247d13ffcf
52 changed files with 1020 additions and 277928 deletions

View File

@ -13,7 +13,6 @@ export const StartPage = () => {
return ( return (
<Navigate <Navigate
to={redirectTo ?? "/login"} to={redirectTo ?? "/login"}
replace
state={{ state={{
error: "No authentication, please complete the login process.", error: "No authentication, please complete the login process.",
}} }}
@ -21,5 +20,5 @@ export const StartPage = () => {
); );
} }
return <Navigate to={"/quotes"} replace />; return <Navigate to={"/quotes"} />;
}; };

View File

@ -64,9 +64,9 @@ export const LoginPage = () => {
return ( return (
<Container <Container
variant={"full"} variant={"full"}
className='p-0 lg:grid lg:min-h-[600px] lg:grid-cols-2 xl:min-h-[800px] h-screen' className='p-0 lg:grid lg:min-h-[600px] lg:grid-cols-2 xl:min-h-[800px] h-screen '
> >
<div className='flex items-center justify-center py-12'> <div className='flex items-center justify-center md:py-12'>
<div className='mx-auto grid w-[650px] gap-6'> <div className='mx-auto grid w-[650px] gap-6'>
<Card className='px-12 py-6'> <Card className='px-12 py-6'>
<CardHeader> <CardHeader>

View File

@ -72,7 +72,7 @@ export const QuoteCreate = () => {
onSuccess: (data) => { onSuccess: (data) => {
reset(getValues()); reset(getValues());
toast.success("Cotización guardada"); toast.success("Cotización guardada");
navigate(`/quotes/edit/${data.id}`, { relative: "path", replace: true }); navigate(`/quotes/edit/${data.id}`, { relative: "path" });
}, },
}); });
} finally { } finally {

View File

@ -2,13 +2,16 @@ import { DataTableProvider } from "@/lib/hooks";
import { Trans } from "react-i18next"; import { Trans } from "react-i18next";
import { QuotesDataTable } from "./components"; import { QuotesDataTable } from "./components";
import { HelpButton } from "@/components";
import { import {
Button, Button,
Dialog, Label,
DialogContent, ScrollArea,
DialogHeader, Select,
DialogTitle, SelectContent,
DialogTrigger, SelectItem,
SelectTrigger,
SelectValue,
Tabs, Tabs,
TabsContent, TabsContent,
TabsList, TabsList,
@ -17,13 +20,25 @@ import {
} from "@/ui"; } from "@/ui";
import { useToggle } from "@wojtekmaj/react-hooks"; import { useToggle } from "@wojtekmaj/react-hooks";
import { t } from "i18next"; import { t } from "i18next";
import { EyeIcon, EyeOffIcon, HelpCircleIcon, PlusIcon } from "lucide-react"; import { EyeIcon, EyeOffIcon, PlusIcon } from "lucide-react";
import { useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
export const QuotesList = () => { export const QuotesList = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const [status, setStatus] = useState("all");
const [enabledPreview, toggleEnabledPreview] = useToggle(true); const [enabledPreview, toggleEnabledPreview] = useToggle(true);
const quoteStatuses = [
{ value: "all", label: t("quotes.list.tabs.all") },
{ value: "draft", label: t("quotes.list.tabs.draft") },
{ value: "ready", label: t("quotes.list.tabs.ready") },
{ value: "delivered", label: t("quotes.list.tabs.delivered") },
{ value: "accepted", label: t("quotes.list.tabs.delivered") },
{ value: "rejected", label: t("quotes.list.tabs.rejected") },
{ value: "archived", label: t("quotes.list.tabs.archived") },
];
return ( return (
<DataTableProvider> <DataTableProvider>
<div className='flex items-center justify-between space-y-2'> <div className='flex items-center justify-between space-y-2'>
@ -43,83 +58,38 @@ export const QuotesList = () => {
</div> </div>
</div> </div>
<Tabs defaultValue='all'> <Tabs value={status} onValueChange={setStatus}>
<div className='flex items-baseline'> <div className='flex flex-col items-start justify-between mb-4 sm:flex-row sm:items-center'>
<TabsList> <div className='w-full mb-4 sm:w-auto sm:mb-0'>
<TabsTrigger value='all'> <TabsList className='hidden sm:flex'>
<Trans i18nKey='quotes.list.tabs.all' /> {quoteStatuses.map((s) => (
</TabsTrigger> <TabsTrigger key={s.value} value={s.value}>
<TabsTrigger value='draft'> {s.label}
<Trans i18nKey='quotes.list.tabs.draft' /> </TabsTrigger>
</TabsTrigger> ))}
<TabsTrigger value='ready'> </TabsList>
<Trans i18nKey='quotes.list.tabs.ready' /> <div className='flex items-center w-full space-x-2 sm:hidden'>
</TabsTrigger> <Label>{t("quotes.list.tabs_title")}</Label>
<TabsTrigger value='delivered'> <Select value={status} onValueChange={setStatus}>
<Trans i18nKey='quotes.list.tabs.delivered' /> <SelectTrigger>
</TabsTrigger> <SelectValue placeholder='Seleccionar estado' />
<TabsTrigger value='accepted'> </SelectTrigger>
<Trans i18nKey='quotes.list.tabs.accepted' /> <SelectContent>
</TabsTrigger> {quoteStatuses.map((s) => (
<TabsTrigger value='rejected'> <SelectItem key={s.value} value={s.value}>
<Trans i18nKey='quotes.list.tabs.rejected' /> {s.label}
</TabsTrigger> </SelectItem>
<TabsTrigger value='archived' className='hidden sm:flex'> ))}
<Trans i18nKey='quotes.list.tabs.archived' /> </SelectContent>
</TabsTrigger> </Select>
</TabsList> </div>
</div>
<div className='flex items-baseline justify-center mr-4 font-medium'> <div className='flex items-baseline justify-center mr-4 font-medium'>
<Dialog> <HelpButton
<DialogTrigger asChild> buttonText='Ayuda'
<Button variant='link' className='inline-flex items-center font-medium group'> title='Ayuda sobre el Estado de Cotizaciones'
<Trans content={HelpContent}
i18nKey='quotes.list.tabs_title' />
className='underline-offset-4 group-hover:underline'
/>
<HelpCircleIcon className='w-4 h-4 ml-1 text-muted-foreground' />
</Button>
</DialogTrigger>
<DialogContent className='sm:max-w-[425px]'>
<DialogHeader>
<DialogTitle>Ayuda sobre el Estado de Cotizaciones</DialogTitle>
</DialogHeader>
<div className='grid gap-4 py-4'>
<p>
El estado de una cotización indica su posición actual en el proceso de negocio.
</p>
<p>Los estados posibles son:</p>
<ul className='pl-6 space-y-2 list-disc'>
<li>
<strong>Borrador:</strong> La cotización está en proceso de creación o
edición.
</li>
<li>
<strong>Preparado:</strong> La cotización está lista para ser enviada al
cliente.
</li>
<li>
<strong>Entregado:</strong> La cotización ha sido enviada al cliente.
</li>
<li>
<strong>Aceptado:</strong> El cliente ha aprobado la cotización.
</li>
<li>
<strong>Rechazado:</strong> El cliente no ha aceptado la cotización.
</li>
<li>
<strong>Arcivado:</strong> La cotización ha sido guardada para referencia
futura y ya no está activa.
</li>
</ul>
<p>
Utiliza estos estados para hacer un seguimiento eficiente de tus cotizaciones y
optimizar tu proceso de ventas. El estado <strong>Archivado</strong> es útil
para mantener un historial de cotizaciones pasadas sin que interfieran con las
cotizaciones activas.
</p>
</div>
</DialogContent>
</Dialog>
</div> </div>
<div className='flex items-center gap-2 ml-auto'> <div className='flex items-center gap-2 ml-auto'>
<Toggle <Toggle
@ -143,28 +113,45 @@ export const QuotesList = () => {
</Toggle> </Toggle>
</div> </div>
</div> </div>
<TabsContent value='all'> {quoteStatuses.map((s) => (
<QuotesDataTable status='all' preview={enabledPreview} /> <TabsContent key={s.value} value={s.value}>
</TabsContent> <QuotesDataTable status={s.value} preview={enabledPreview} />
<TabsContent value='draft'> </TabsContent>
<QuotesDataTable status='draft' preview={enabledPreview} /> ))}
</TabsContent>
<TabsContent value='ready'>
<QuotesDataTable status='ready' preview={enabledPreview} />
</TabsContent>
<TabsContent value='delivered'>
<QuotesDataTable status='delivered' preview={enabledPreview} />
</TabsContent>
<TabsContent value='accepted'>
<QuotesDataTable status='accepted' preview={enabledPreview} />
</TabsContent>
<TabsContent value='rejected'>
<QuotesDataTable status='rejected' preview={enabledPreview} />
</TabsContent>
<TabsContent value='archived'>
<QuotesDataTable status='archived' preview={enabledPreview} />
</TabsContent>
</Tabs> </Tabs>
</DataTableProvider> </DataTableProvider>
); );
}; };
const HelpContent = (
<ScrollArea className='grid gap-4 py-4'>
<p>El estado de una cotización indica su posición actual en el proceso de negocio.</p>
<p>Los estados posibles son:</p>
<ul className='pl-6 space-y-2 list-disc'>
<li>
<strong>Borrador:</strong> La cotización está en proceso de creación o edición.
</li>
<li>
<strong>Preparado:</strong> La cotización está lista para ser enviada al cliente.
</li>
<li>
<strong>Entregado:</strong> La cotización ha sido enviada al cliente.
</li>
<li>
<strong>Aceptado:</strong> El cliente ha aprobado la cotización.
</li>
<li>
<strong>Rechazado:</strong> El cliente no ha aceptado la cotización.
</li>
<li>
<strong>Arcivado:</strong> La cotización ha sido guardada para referencia futura y ya no
está activa.
</li>
</ul>
<p>
Utiliza estos estados para hacer un seguimiento eficiente de tus cotizaciones y optimizar tu
proceso de ventas. El estado <strong>Archivado</strong> es útil para mantener un historial de
cotizaciones pasadas sin que interfieran con las cotizaciones activas.
</p>
</ScrollArea>
);

View File

@ -11,6 +11,8 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
Form, Form,
Input,
Label,
} from "@/ui"; } from "@/ui";
import { t } from "i18next"; import { t } from "i18next";
@ -29,7 +31,7 @@ type SettingsDataForm = IUpdateProfile_Request_DTO;
export const SettingsEditor = () => { export const SettingsEditor = () => {
const [activeSection, setActiveSection] = useState("profile"); const [activeSection, setActiveSection] = useState("profile");
const { useOne, useUpdate } = useSettings(); const { useOne, useUpdate, useUploadLogo } = useSettings();
const { toast } = useToast(); const { toast } = useToast();
const { data, status, error: queryError } = useOne(); const { data, status, error: queryError } = useOne();
@ -50,6 +52,7 @@ export const SettingsEditor = () => {
); );
const { mutate } = useUpdate(); const { mutate } = useUpdate();
const { upload } = useUploadLogo();
const form = useForm<SettingsDataForm>({ const form = useForm<SettingsDataForm>({
mode: "onBlur", mode: "onBlur",
@ -70,15 +73,46 @@ export const SettingsEditor = () => {
),*/ ),*/
}); });
const { formState, reset, getValues, handleSubmit } = form; const { formState, reset, getValues, handleSubmit, setValue } = form;
const { isSubmitting, isDirty } = formState; const { isSubmitting, isDirty } = formState;
const handleLogoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
if (file.type !== "image/png" && file.type !== "image/jpeg") {
alert(t("settings.form_fields.logo.invalid_format"));
return;
}
const reader = new FileReader();
reader.onloadend = () => {
const img = new Image();
img.onload = () => {
const width = img.width;
const height = img.height;
// Comprobar que la imagen tiene una proporción de 2:1 (ancho:alto)
const isProportional = width / height === 2;
// Comprobar que las dimensiones son iguales o mayores a 200x100 y proporcionales
if (width < 200 || height < 100 || !isProportional) {
alert(t("settings.form_fields.logo.invalid_dimensions"));
} else {
upload(file);
}
};
img.src = reader.result as string;
};
reader.readAsDataURL(file);
}
};
useUnsavedChangesNotifier({ useUnsavedChangesNotifier({
isDirty, isDirty,
}); });
const onSubmit: SubmitHandler<SettingsDataForm> = async (data) => { const onSubmit: SubmitHandler<SettingsDataForm> = async (data) => {
console.log("hola");
mutate(data, { mutate(data, {
onError: (error) => { onError: (error) => {
console.debug(error); console.debug(error);
@ -170,6 +204,51 @@ export const SettingsEditor = () => {
</Button> </Button>
</CardFooter> </CardFooter>
</Card> </Card>
<Card>
<CardHeader>
<CardTitle>
<Trans i18nKey='settings.form_fields.logo.label' />
</CardTitle>
<CardDescription>
<Trans i18nKey='settings.form_fields.logo.desc' />
</CardDescription>
</CardHeader>
<CardContent>
<div className='flex items-center space-x-4'>
<div className='w-[400px] h-[200px] border border-gray-300 flex items-center justify-center overflow-hidden'>
<img
src={
data.dealer.logo ? data.dealer.logo : "https://via.placeholder.com/200x100"
}
width={400}
height={200}
style={{ objectFit: "contain" }}
/>
</div>
<div>
<p className='mt-2 text-sm text-gray-500'>
<Trans i18nKey='settings.form_fields.logo.requirements' />
</p>
</div>
</div>
</CardContent>
<CardFooter className='px-6 py-4 border-t'>
<Label
htmlFor='logo-upload'
className='inline-flex items-center justify-center h-10 px-4 py-2 text-sm font-medium transition-colors rounded-md cursor-pointer bg-primary text-primary-foreground hover:bg-primary/90 whitespace-nowrap ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50'
>
<Trans i18nKey='settings.form_fields.logo.upload' />
</Label>
<Input
id='logo-upload'
type='file'
accept='.png,.jpg,.jpeg'
onChange={handleLogoChange}
className='hidden'
/>
</CardFooter>
</Card>
</div> </div>
<div className={cn("grid gap-6", activeSection === "quotes" ? "visible" : "hidden")}> <div className={cn("grid gap-6", activeSection === "quotes" ? "visible" : "hidden")}>
<Card> <Card>

View File

@ -29,6 +29,7 @@ export const useSettings = (params?: UseSettingsGetParamsType) => {
}), }),
...params, ...params,
}), }),
useUpdate: () => { useUpdate: () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@ -48,5 +49,40 @@ export const useSettings = (params?: UseSettingsGetParamsType) => {
}, },
}); });
}, },
useUploadLogo: () => {
const queryClient = useQueryClient();
const { mutate, mutateAsync, ...rest } = useMutation<
any, //IUploadProfileLogoResponse_DTO,
TDataSourceError,
any
>({
mutationKey: ["data", "default", "settings"], //keys().data().resource("settings").action("upload").id("me").params().get(),
mutationFn: (data) => {
return dataSource.uploadFile({
path: "profile/logo",
//resource: "profile",
//id: "",
file: data,
key: "logo",
});
},
onError: () => {
return queryClient.invalidateQueries({
queryKey: ["data", "default", "settings"],
});
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["data", "default", "settings"],
});
},
});
return {
upload: mutate,
uploadAsync: mutateAsync,
...rest,
};
},
}; };
}; };

View File

@ -0,0 +1,54 @@
import {
Button,
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
ScrollArea,
} from "@/ui";
import { t } from "i18next";
import { HelpCircleIcon } from "lucide-react";
import React from "react";
interface HelpButtonProps {
buttonText: string;
title?: string;
content: React.ReactNode;
className?: string;
}
export const HelpButton = ({
buttonText,
title = "",
content,
className = "",
}: HelpButtonProps) => {
return (
<div className={`flex items-baseline justify-center mr-4 font-medium ${className}`}>
<Dialog>
<DialogTrigger asChild>
<Button variant='link' className='inline-flex items-center font-medium group'>
<span className='underline-offset-4 group-hover:underline'>{buttonText}</span>
<HelpCircleIcon className='w-4 h-4 ml-1 text-muted-foreground' />
</Button>
</DialogTrigger>
<DialogContent className='sm:max-w-[425px]'>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<ScrollArea className='grid gap-4 py-2'>
{content}
<DialogFooter>
<DialogClose asChild>
<Button type='button'>{t("common.close")}</Button>
</DialogClose>
</DialogFooter>
</ScrollArea>
</DialogContent>
</Dialog>
</div>
);
};

View File

@ -1,2 +1,3 @@
export * from "./CancelButton"; export * from "./CancelButton";
export * from "./HelpButton";
export * from "./SubmitButton"; export * from "./SubmitButton";

View File

@ -1,8 +1,6 @@
import { useGetProfile, useIsLoggedIn } from "@/lib/hooks"; import { useIsLoggedIn } from "@/lib/hooks";
import React, { useEffect, useState } from "react"; import React from "react";
import { useTranslation } from "react-i18next";
import { Navigate } from "react-router-dom"; import { Navigate } from "react-router-dom";
import { LoadingOverlay } from "../LoadingOverlay";
type ProctectRouteProps = { type ProctectRouteProps = {
children?: React.ReactNode; children?: React.ReactNode;
@ -10,9 +8,9 @@ type ProctectRouteProps = {
export const ProtectedRoute = ({ children }: ProctectRouteProps) => { export const ProtectedRoute = ({ children }: ProctectRouteProps) => {
const { isPending, isSuccess, data: { authenticated, redirectTo } = {} } = useIsLoggedIn(); const { isPending, isSuccess, data: { authenticated, redirectTo } = {} } = useIsLoggedIn();
const { data: profile, ...profileStatus } = useGetProfile(); //const { data: profile, ...profileStatus } = useGetProfile();
const { i18n } = useTranslation(); /*const { i18n } = useTranslation();
const [langCode, setLangCode] = useState(i18n.language); const [langCode, setLangCode] = useState(i18n.language);
if (i18n.language !== langCode) { if (i18n.language !== langCode) {
@ -23,10 +21,10 @@ export const ProtectedRoute = ({ children }: ProctectRouteProps) => {
if (profileStatus.isSuccess && profile && langCode !== profile.lang_code) { if (profileStatus.isSuccess && profile && langCode !== profile.lang_code) {
setLangCode(profile.lang_code); setLangCode(profile.lang_code);
} }
}, [profile, profileStatus, i18n]); }, [profile, profileStatus, i18n]);*/
if (isPending || profileStatus.isPending) { if (isPending) {
return <LoadingOverlay />; return null;
} }
if (isSuccess && !authenticated) { if (isSuccess && !authenticated) {
@ -34,7 +32,7 @@ export const ProtectedRoute = ({ children }: ProctectRouteProps) => {
// trying to go to when they were redirected. This allows us to send them // trying to go to when they were redirected. This allows us to send them
// along to that page after they login, which is a nicer user experience // along to that page after they login, which is a nicer user experience
// than dropping them off on the home page. // than dropping them off on the home page.
return <Navigate to={redirectTo ?? "/login"} state={{ from: location }} replace />; return <Navigate to={redirectTo ?? "/login"} state={{ from: location }} />;
} }
return <>{children ?? null}</>; return <>{children ?? null}</>;

View File

@ -1,3 +1,5 @@
/*@import url('https://fonts.googleapis.com/css2?family=Public Sans:wght@100;200;300;400;400;500;600;700;800;900;&family=Noto Serif:wght@100;200;300;400;400;500;600;700;800;900;1,500&display=swap');*/
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
@ -50,63 +52,55 @@
} }
}*/ }*/
@layer base { @layer base {
:root { :root {
--background: 0 0% 100%; --background: 0 0% 100%
--foreground: 222.2 84% 4.9%; --foreground: 240 10% 3.9%
--card: 0 0% 100%; --card: 0 0% 100%
--card-foreground: 222.2 84% 4.9%; --card-foreground: 240 10% 3.9%
--popover: 0 0% 100%; --popover: 0 0% 100%
--popover-foreground: 222.2 84% 4.9%; --popover-foreground: 240 10% 3.9%
--primary: 221.2 83.2% 53.3%; --primary: 346.8 77.2% 49.8%
--primary-foreground: 210 40% 98%; --primary-foreground: 355.7 100% 97.3%
--secondary: 210 40% 96.1%; --secondary: 240 4.8% 95.9%
--secondary-foreground: 222.2 47.4% 11.2%; --secondary-foreground: 240 5.9% 10%
--muted: 210 40% 96.1%; --muted: 240 4.8% 95.9%
--muted-foreground: 215.4 16.3% 46.9%; --muted-foreground: 240 3.8% 46.1%
--accent: 210 40% 96.1%; --accent: 240 4.8% 95.9%
--accent-foreground: 222.2 47.4% 11.2%; --accent-foreground: 240 5.9% 10%
--destructive: 0 84.2% 60.2%; --destructive: 0 84.2% 60.2%
--destructive-foreground: 210 40% 98%; --destructive-foreground: 0 0% 98%
--border: 214.3 31.8% 91.4%; --border: 240 5.9% 90%
--input: 214.3 31.8% 91.4%; --input: 240 5.9% 90%
--ring: 221.2 83.2% 53.3%; --ring: 346.8 77.2% 49.8%
--radius: 0.5rem; --radius: 0.3rem;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
} }
.dark { .dark {
--background: 222.2 84% 4.9%; --background: 20 14.3% 4.1%
--foreground: 210 40% 98%; --foreground: 0 0% 95%
--card: 222.2 84% 4.9%; --card: 24 9.8% 10%
--card-foreground: 210 40% 98%; --card-foreground: 0 0% 95%
--popover: 222.2 84% 4.9%; --popover: 0 0% 9%
--popover-foreground: 210 40% 98%; --popover-foreground: 0 0% 95%
--primary: 217.2 91.2% 59.8%; --primary: 346.8 77.2% 49.8%
--primary-foreground: 222.2 47.4% 11.2%; --primary-foreground: 355.7 100% 97.3%
--secondary: 217.2 32.6% 17.5%; --secondary: 240 3.7% 15.9%
--secondary-foreground: 210 40% 98%; --secondary-foreground: 0 0% 98%
--muted: 217.2 32.6% 17.5%; --muted: 0 0% 15%
--muted-foreground: 215 20.2% 65.1%; --muted-foreground: 240 5% 64.9%
--accent: 217.2 32.6% 17.5%; --accent: 12 6.5% 15.1%
--accent-foreground: 210 40% 98%; --accent-foreground: 0 0% 98%
--destructive: 0 62.8% 30.6%; --destructive: 0 62.8% 30.6%
--destructive-foreground: 210 40% 98%; --destructive-foreground: 0 85.7% 97.3%
--border: 217.2 32.6% 17.5%; --border: 240 3.7% 15.9%
--input: 217.2 32.6% 17.5%; --input: 240 3.7% 15.9%
--ring: 224.3 76.3% 48%; --ring: 346.8 77.2% 49.8%
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
} }
} }
@layer base { @layer base {
body { body {
tab-size: 4; tab-size: 4;

View File

@ -13,6 +13,7 @@ import {
IRemoveOneDataProviderParams, IRemoveOneDataProviderParams,
ISortItemDataProviderParam, ISortItemDataProviderParam,
IUpdateOneDataProviderParams, IUpdateOneDataProviderParams,
IUploadFileDataProviderParam,
} from "../hooks/useDataSource/DataSource"; } from "../hooks/useDataSource/DataSource";
import { createAxiosInstance } from "./axiosInstance"; import { createAxiosInstance } from "./axiosInstance";
@ -119,6 +120,23 @@ export const createAxiosDataProvider = (
return; return;
}, },
uploadFile: async <R>(params: IUploadFileDataProviderParam): Promise<R> => {
const { path, file, key, onUploadProgress } = params;
const url = `${apiUrl}/${path}`;
const formData = new FormData();
formData.append(key || "file", file);
const response = await httpClient.post<R>(url, formData, {
headers: {
"Content-Type": "multipart/form-data",
},
onUploadProgress,
});
return response.data;
},
downloadPDF: async ( downloadPDF: async (
params: IDownloadPDFDataProviderParams params: IDownloadPDFDataProviderParams
): Promise<IDownloadPDFDataProviderResponse> => { ): Promise<IDownloadPDFDataProviderResponse> => {

View File

@ -91,7 +91,7 @@ const onResponseError = (error: AxiosError): Promise<AxiosError> => {
break; break;
case 401: case 401:
console.error("UnAuthorized"); console.error("UnAuthorized");
window.location.href = "/logout"; //window.location.href = "/logout";
break; break;
case 403: case 403:
console.error("Forbidden"); console.error("Forbidden");

View File

@ -19,7 +19,7 @@ export const useLogin = (params?: UseMutationOptions<AuthActionResponse, Error,
onSuccess: (data, variables, context) => { onSuccess: (data, variables, context) => {
const { success, redirectTo } = data; const { success, redirectTo } = data;
if (success && redirectTo) { if (success && redirectTo) {
navigate(redirectTo || "/quotes", { replace: false }); navigate(redirectTo || "/quotes");
} }
if (onSuccess) { if (onSuccess) {
onSuccess(data, variables, context); onSuccess(data, variables, context);

View File

@ -21,7 +21,7 @@ export const useLogout = (params?: UseMutationOptions<AuthActionResponse, Error>
const { success, redirectTo } = data; const { success, redirectTo } = data;
if (success && redirectTo) { if (success && redirectTo) {
navigate(redirectTo || "/", { replace: true }); navigate(redirectTo || "/");
} }
if (onSuccess) { if (onSuccess) {
onSuccess(data, variables, context); onSuccess(data, variables, context);

View File

@ -64,6 +64,15 @@ export interface IDownloadPDFDataProviderResponse {
filedata: Blob; filedata: Blob;
} }
export interface IUploadFileDataProviderParam {
path: string;
//resource: string;
//id: string;
file: File;
key: string;
onUploadProgress?: any;
}
export interface ICustomDataProviderParam { export interface ICustomDataProviderParam {
url: string; url: string;
method: "get" | "delete" | "head" | "options" | "post" | "put" | "patch"; method: "get" | "delete" | "head" | "options" | "post" | "put" | "patch";
@ -86,6 +95,7 @@ export interface IDataSource {
downloadPDF: ( downloadPDF: (
params: IDownloadPDFDataProviderParams params: IDownloadPDFDataProviderParams
) => Promise<IDownloadPDFDataProviderResponse>; ) => Promise<IDownloadPDFDataProviderResponse>;
uploadFile: <R>(params: IUploadFileDataProviderParam) => Promise<R>;
custom: <R>(params: ICustomDataProviderParam) => Promise<R>; custom: <R>(params: ICustomDataProviderParam) => Promise<R>;
getApiUrl: () => string; getApiUrl: () => string;

View File

@ -1,7 +1,7 @@
type BaseKey = string | number; type BaseKey = string | number;
type ParametrizedDataActions = "list" | "infinite"; type ParametrizedDataActions = "list" | "infinite";
type IdRequiredDataActions = "one" | "report"; type IdRequiredDataActions = "one" | "report" | "upload";
type IdsRequiredDataActions = "many"; type IdsRequiredDataActions = "many";
type DataMutationActions = type DataMutationActions =
| "custom" | "custom"

View File

@ -393,10 +393,14 @@
} }
}, },
"form_fields": { "form_fields": {
"image": { "logo": {
"label": "Logotipo", "label": "Logotipo",
"placeholder": "", "placeholder": "",
"desc": "" "desc": "Este logotipo aparecerá en las propuestas exportadas en PDF",
"requirements": "Se permiten ficheros JPG o PNG. Las dimensiones del logotipo deben ser 200x100 píxeles o proporcionales.",
"upload": "Subir logotipo",
"invalid_format": "Sólo se permiten ficheros JPG o PNG",
"invalid_dimensions": "Las dimensiones del logotipo deben ser 200x100 píxeles o proporcionales."
}, },
"contact_information": { "contact_information": {
"label": "Información de contacto", "label": "Información de contacto",

View File

@ -14,7 +14,7 @@ i18n
// init i18next // init i18next
// for all options read: https://www.i18next.com/overview/configuration-options // for all options read: https://www.i18next.com/overview/configuration-options
.init({ .init({
debug: false, debug: true,
fallbackLng: "es", fallbackLng: "es",
interpolation: { interpolation: {
escapeValue: false, escapeValue: false,

View File

@ -1,6 +1,5 @@
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
import defaultTheme from "tailwindcss/defaultTheme";
import plugin from "tailwindcss/plugin"; import plugin from "tailwindcss/plugin";
export default { export default {
@ -18,69 +17,53 @@ export default {
}, },
extend: { extend: {
// https://tailwindcss.com/docs/font-family#font-families // https://tailwindcss.com/docs/font-family#font-families
fontFamily: { /*fontFamily: {
sans: ['"Source Sans Pro"', ...defaultTheme.fontFamily.sans], sans: ['"Source Sans Pro"', ...defaultTheme.fontFamily.sans],
},*/
fontFamily: {
display: "Public Sans, ui-sans-serif",
heading: "Noto Serif, ui-serif",
}, },
colors: { colors: {
// https://adevade.github.io/color-scheme-generator/ border: "hsl(240, 5.9%, 90%)",
"brand-light": "#cdd6e7", input: "hsl(240, 5.9%, 90%)",
brand: "#2a669f", ring: "hsl(346.8, 77.2%, 49.8%)",
"brand-dark": "#1e344d", background: "hsl(0, 0%, 100%)",
foreground: "hsl(240, 10%, 3.9%)",
// https://www.tailwindshades.com/#color=209%2C58%2C39.411764705882355&step-up=12&step-down=7&hue-shift=-59&name=great-blue&base-stop=5&v=1&overrides=e30%3D
denim: {
DEFAULT: "#2A669F",
50: "#E4F7F8",
100: "#CCEEF2",
200: "#9CD7E5",
300: "#6CB9D8",
400: "#3B94CB",
500: "#2A669F",
600: "#234B83",
700: "#1B3366",
800: "#14204A",
900: "#0C102E",
},
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: { primary: {
DEFAULT: "hsl(var(--primary))", DEFAULT: "hsl(346.8, 77.2%, 49.8%)",
foreground: "hsl(var(--primary-foreground))", foreground: "hsl(355.7, 100%, 97.3%)",
}, },
secondary: { secondary: {
DEFAULT: "hsl(var(--secondary))", DEFAULT: "hsl(240, 4.8%, 95.9%)",
foreground: "hsl(var(--secondary-foreground))", foreground: "hsl(240, 5.9%, 10%)",
}, },
destructive: { destructive: {
DEFAULT: "hsl(var(--destructive))", DEFAULT: "hsl(0, 84.2%, 60.2%)",
foreground: "hsl(var(--destructive-foreground))", foreground: "hsl(0, 0%, 98%)",
}, },
muted: { muted: {
DEFAULT: "hsl(var(--muted))", DEFAULT: "hsl(240, 4.8%, 95.9%)",
foreground: "hsl(var(--muted-foreground))", foreground: "hsl(240, 3.8%, 46.1%)",
}, },
accent: { accent: {
DEFAULT: "hsl(var(--accent))", DEFAULT: "hsl(240, 4.8%, 95.9%)",
foreground: "hsl(var(--accent-foreground))", foreground: "hsl(240, 5.9%, 10%)",
}, },
popover: { popover: {
DEFAULT: "hsl(var(--popover))", DEFAULT: "hsl(0, 0%, 100%)",
foreground: "hsl(var(--popover-foreground))", foreground: "hsl(240, 10%, 3.9%)",
}, },
card: { card: {
DEFAULT: "hsl(var(--card))", DEFAULT: "hsl(0, 0%, 100%)",
foreground: "hsl(var(--card-foreground))", foreground: "hsl(240, 10%, 3.9%)",
}, },
}, },
borderRadius: { borderRadius: {
lg: "var(--radius)", lg: "0.3rem",
md: "calc(var(--radius) - 2px)", md: "calc(0.3rem - 2px)",
sm: "calc(var(--radius) - 4px)", sm: "calc(0.3rem - 4px)",
}, },
keyframes: { keyframes: {
"accordion-down": { "accordion-down": {

View File

@ -59,6 +59,7 @@ services:
- NODE_ENV=production - NODE_ENV=production
volumes: volumes:
- backend_logs:/var/log - backend_logs:/var/log
- backend_uploads:/api/uploads
ports: ports:
- 3001:3001 - 3001:3001
networks: networks:

View File

@ -27,7 +27,8 @@
"@types/luxon": "^3.3.1", "@types/luxon": "^3.3.1",
"@types/module-alias": "^2.0.1", "@types/module-alias": "^2.0.1",
"@types/morgan": "^1.9.4", "@types/morgan": "^1.9.4",
"@types/node": "^20.12.11", "@types/multer": "^1.4.12",
"@types/node": "^22.5.5",
"@types/passport": "^1.0.16", "@types/passport": "^1.0.16",
"@types/passport-jwt": "^4.0.1", "@types/passport-jwt": "^4.0.1",
"@types/passport-local": "^1.0.38", "@types/passport-local": "^1.0.38",
@ -72,8 +73,10 @@
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"luxon": "^3.4.0", "luxon": "^3.4.0",
"mime-types": "^2.1.35",
"moment": "^2.29.4", "moment": "^2.29.4",
"morgan": "^1.10.0", "morgan": "^1.10.0",
"multer": "^1.4.5-lts.1",
"mysql2": "^3.6.0", "mysql2": "^3.6.0",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",

File diff suppressed because it is too large Load Diff

View File

@ -1 +0,0 @@
<h1>public</h1>

File diff suppressed because it is too large Load Diff

View File

@ -36,6 +36,13 @@ module.exports = {
cors_origin: "*", cors_origin: "*",
}, },
defaults: {
dealer_logos_upload_path:
"/home/rodax/Documentos/uecko-presupuestos/server/uploads/dealer-logos",
dealer_logo_placeholder:
"/home/rodax/Documentos/uecko-presupuestos/server/uploads/images/placeholder-200x100.png",
},
admin: { admin: {
name: "Administrador", name: "Administrador",
email: "darranz@rodax-software.com", email: "darranz@rodax-software.com",

View File

@ -21,6 +21,11 @@ module.exports = {
cors_origin: "https://presupuestos.uecko.com", cors_origin: "https://presupuestos.uecko.com",
}, },
defaults: {
dealer_logos_upload_path: "uploads/dealer-logos",
dealer_logo_placeholder: "uploads/images/logo-placeholder-200x100.png",
},
admin: { admin: {
name: "Administrador", name: "Administrador",
email: "darranz@rodax-software.com", email: "darranz@rodax-software.com",

View File

@ -26,37 +26,3 @@ export class ContextFactory {
this.context = context; this.context = context;
} }
} }
// ContextFactory.ts
/*export interface IContext {
adapter: ISequelizeAdapter;
repositoryManager: IRepositoryManager;
services: T;
}
export class ContextFactory {
private static instances: Map<string, ContextFactory<any>> = new Map();
public static getInstance(constructor: new () => T): ContextFactory {
const key = constructor.name;
if (!ContextFactory.instances.has(key)) {
ContextFactory.instances.set(key, new ContextFactory(constructor));
}
return ContextFactory.instances.get(key)! as ContextFactory;
}
private context: IContext;
private constructor(constructor: new () => T) {
this.context = {
adapter: createSequelizeAdapter(),
repositoryManager: RepositoryManager.getInstance(),
services: new constructor(),
};
}
public getContext(): IContext {
return this.context;
}
}*/

View File

@ -57,6 +57,10 @@ export abstract class ExpressController implements IController {
return this.res.status(httpStatus.NO_CONTENT).send(); return this.res.status(httpStatus.NO_CONTENT).send();
} }
public sendFile(filepath: string) {
return this.res.sendFile(filepath);
}
public downloadFile(filepath: string, filename: string, done?: any) { public downloadFile(filepath: string, filename: string, done?: any) {
return this.res.download(filepath, filename, done); return this.res.download(filepath, filename, done);
} }

View File

@ -0,0 +1,71 @@
import {
IUseCase,
IUseCaseError,
IUseCaseRequest,
UseCaseError,
} from "@/contexts/common/application/useCases";
import { IRepositoryManager } from "@/contexts/common/domain";
import { ISequelizeAdapter } from "@/contexts/common/infrastructure/sequelize";
import { Result, UniqueID } from "@shared/contexts";
import { IInfrastructureError } from "@/contexts/common/infrastructure";
import { IProfileRepository, Profile } from "../domain";
export interface IGetProfileLogoUseCaseRequest extends IUseCaseRequest {
userId: UniqueID;
}
export type GetProfileLogoResponseOrError =
| Result<never, IUseCaseError> // Misc errors (value objects)
| Result<Profile, never>; // Success!
export class GetProfileLogoUseCase
implements IUseCase<IGetProfileLogoUseCaseRequest, Promise<GetProfileLogoResponseOrError>>
{
private _adapter: ISequelizeAdapter;
private _repositoryManager: IRepositoryManager;
constructor(props: { adapter: ISequelizeAdapter; repositoryManager: IRepositoryManager }) {
this._adapter = props.adapter;
this._repositoryManager = props.repositoryManager;
}
private getRepositoryByName<T>(name: string) {
return this._repositoryManager.getRepository<T>(name);
}
async execute(request: IGetProfileLogoUseCaseRequest): Promise<GetProfileLogoResponseOrError> {
const { userId } = request;
// Validación de datos
// No hay en este caso
return await this._getProfileDealer(userId);
}
private async _getProfileDealer(userId: UniqueID) {
const transaction = this._adapter.startTransaction();
const dealerRepoBuilder = this.getRepositoryByName<IProfileRepository>("Profile");
let profile: Profile | null = null;
try {
await transaction.complete(async (t) => {
const dealerRepo = dealerRepoBuilder({ transaction: t });
profile = await dealerRepo.getByUserId(userId);
});
if (!profile) {
return Result.fail(UseCaseError.create(UseCaseError.NOT_FOUND_ERROR, "Profile not found"));
}
return Result.ok<Profile>(profile!);
} catch (error: unknown) {
const _error = error as IInfrastructureError;
console.error(error);
return Result.fail(
UseCaseError.create(UseCaseError.REPOSITORY_ERROR, "Error al consultar el usuario", _error)
);
}
}
}

View File

@ -0,0 +1,104 @@
import {
IUseCase,
IUseCaseError,
IUseCaseRequest,
UseCaseError,
} from "@/contexts/common/application";
import { IRepositoryManager } from "@/contexts/common/domain";
import { IInfrastructureError } from "@/contexts/common/infrastructure";
import { ISequelizeAdapter } from "@/contexts/common/infrastructure/sequelize";
import { Result, UniqueID } from "@shared/contexts";
import { IProfileRepository, Profile } from "../domain";
export interface IUpdateProfileLogoUseCaseRequest extends IUseCaseRequest {
userId: UniqueID;
file: Express.Multer.File;
}
export type UploadProfileLogoResponseOrError =
| Result<never, IUseCaseError> // Misc errors (value objects)
| Result<Profile, never>; // Success!
export class UploadProfileLogoUseCase
implements IUseCase<IUpdateProfileLogoUseCaseRequest, Promise<UploadProfileLogoResponseOrError>>
{
private _adapter: ISequelizeAdapter;
private _repositoryManager: IRepositoryManager;
constructor(props: { adapter: ISequelizeAdapter; repositoryManager: IRepositoryManager }) {
this._adapter = props.adapter;
this._repositoryManager = props.repositoryManager;
}
async execute(
request: IUpdateProfileLogoUseCaseRequest
): Promise<UploadProfileLogoResponseOrError> {
const { userId, file } = request;
// Comprobar que existe el profile
const exitsOrError = await this._getProfileDealer(userId);
if (exitsOrError.isFailure) {
const message = `Profile not found`;
return Result.fail(
UseCaseError.create(UseCaseError.NOT_FOUND_ERROR, message, {
path: "userId",
})
);
}
const profile = exitsOrError.object;
// Actualizar el perfil con datos actualizados
profile.logo = file.filename;
// Guardar los cambios
return this._saveProfile(profile);
}
private async _saveProfile(updatedProfile: Profile) {
// Guardar el contacto
const transaction = this._adapter.startTransaction();
const profileRepository = this._getProfileRepository();
try {
await transaction.complete(async (t) => {
const profileRepo = profileRepository({ transaction: t });
await profileRepo.update(updatedProfile);
});
return Result.ok<Profile>(updatedProfile);
} catch (error: unknown) {
const _error = error as IInfrastructureError;
return Result.fail(UseCaseError.create(UseCaseError.REPOSITORY_ERROR, _error.message));
}
}
private async _getProfileDealer(userId: UniqueID) {
const transaction = this._adapter.startTransaction();
const dealerRepoBuilder = this._getProfileRepository();
let profile: Profile | null = null;
try {
await transaction.complete(async (t) => {
const dealerRepo = dealerRepoBuilder({ transaction: t });
profile = await dealerRepo.getByUserId(userId);
});
if (!profile) {
return Result.fail(UseCaseError.create(UseCaseError.NOT_FOUND_ERROR, "Profile not found"));
}
return Result.ok<Profile>(profile!);
} catch (error: unknown) {
const _error = error as IInfrastructureError;
return Result.fail(
UseCaseError.create(UseCaseError.REPOSITORY_ERROR, "Error al consultar el usuario", _error)
);
}
}
private _getProfileRepository() {
return this._repositoryManager.getRepository<IProfileRepository>("Profile");
}
}

View File

@ -1 +1,6 @@
export * from "./GetProfile.useCase";
//export * from "./GetProfileByUserId.useCase";
export * from "./GetProfileLogo.useCase";
export * from "./profileServices";
export * from "./UpdateProfile.useCase"; export * from "./UpdateProfile.useCase";
export * from "./UploadProfileLogo.useCase";

View File

@ -23,6 +23,8 @@ export interface IProfileProps {
defaultLegalTerms: Note; defaultLegalTerms: Note;
defaultQuoteValidity: Note; defaultQuoteValidity: Note;
defaultTax: Percentage; defaultTax: Percentage;
logo: string;
} }
export interface IProfile { export interface IProfile {
@ -39,6 +41,8 @@ export interface IProfile {
defaultQuoteValidity: Note; defaultQuoteValidity: Note;
defaultTax: Percentage; defaultTax: Percentage;
logo: string;
} }
export class Profile extends AggregateRoot<IProfileProps> implements IProfile { export class Profile extends AggregateRoot<IProfileProps> implements IProfile {
@ -110,4 +114,12 @@ export class Profile extends AggregateRoot<IProfileProps> implements IProfile {
set defaultTax(newDefaultTax: Percentage) { set defaultTax(newDefaultTax: Percentage) {
this.props.defaultTax = newDefaultTax; this.props.defaultTax = newDefaultTax;
} }
get logo(): string {
return this.props.logo;
}
set logo(newLogo: string) {
this.props.logo = newLogo;
}
} }

View File

@ -3,10 +3,12 @@ import {
ISequelizeAdapter, ISequelizeAdapter,
createSequelizeAdapter, createSequelizeAdapter,
} from "@/contexts/common/infrastructure/sequelize"; } from "@/contexts/common/infrastructure/sequelize";
import { config } from "../../../config";
export interface IProfileContext { export interface IProfileContext {
adapter: ISequelizeAdapter; adapter: ISequelizeAdapter;
repositoryManager: IRepositoryManager; repositoryManager: IRepositoryManager;
defaults: Record<string, any>;
} }
export class ProfileContext { export class ProfileContext {
@ -17,6 +19,7 @@ export class ProfileContext {
ProfileContext.instance = new ProfileContext({ ProfileContext.instance = new ProfileContext({
adapter: createSequelizeAdapter(), adapter: createSequelizeAdapter(),
repositoryManager: RepositoryManager.getInstance(), repositoryManager: RepositoryManager.getInstance(),
defaults: config.defaults,
}); });
} }

View File

@ -58,7 +58,9 @@ export class GetProfileController extends ExpressController {
const profile = profileOrError.object; const profile = profileOrError.object;
return this.ok<IGetProfileResponse_DTO>(this.presenter.map(profile, user, this.context)); return this.ok<IGetProfileResponse_DTO>(
await this.presenter.map(profile, user, this.context)
);
} catch (e: unknown) { } catch (e: unknown) {
return this.fail(e as IServerError); return this.fail(e as IServerError);
} }

View File

@ -2,13 +2,33 @@ import { AuthUser } from "@/contexts/auth/domain";
import { Profile } from "@/contexts/profile/domain"; import { Profile } from "@/contexts/profile/domain";
import { IProfileContext } from "@/contexts/profile/infrastructure/Profile.context"; import { IProfileContext } from "@/contexts/profile/infrastructure/Profile.context";
import { IGetProfileResponse_DTO } from "@shared/contexts"; import { IGetProfileResponse_DTO } from "@shared/contexts";
import fs from "fs/promises";
import mime from "mime-types";
export interface IGetProfilePresenter { export interface IGetProfilePresenter {
map: (profile: Profile, user: AuthUser, context: IProfileContext) => IGetProfileResponse_DTO; map: (
profile: Profile,
user: AuthUser,
context: IProfileContext
) => Promise<IGetProfileResponse_DTO>;
} }
const logoToBase64Image = async (imagePath: string) => {
const data = await fs.readFile(imagePath);
const mimeType = mime.lookup(imagePath);
return `data:${mimeType};base64,${data.toString("base64")}`;
};
export const GetProfilePresenter: IGetProfilePresenter = { export const GetProfilePresenter: IGetProfilePresenter = {
map: (profile: Profile, user: AuthUser, context: IProfileContext): IGetProfileResponse_DTO => { map: async (
profile: Profile,
user: AuthUser,
context: IProfileContext
): Promise<IGetProfileResponse_DTO> => {
const profile_logo = profile.logo
? `${context.defaults.dealer_logos_upload_path}/${profile.logo}`
: context.defaults.dealer_logo_placeholder;
return { return {
id: user.id.toString(), id: user.id.toString(),
name: user.name.toString(), name: user.name.toString(),
@ -28,6 +48,8 @@ export const GetProfilePresenter: IGetProfilePresenter = {
default_legal_terms: profile.defaultLegalTerms.toString(), default_legal_terms: profile.defaultLegalTerms.toString(),
default_quote_validity: profile.defaultQuoteValidity.toString(), default_quote_validity: profile.defaultQuoteValidity.toString(),
default_tax: profile.defaultTax.convertScale(2).toObject(), default_tax: profile.defaultTax.convertScale(2).toObject(),
logo: await logoToBase64Image(profile_logo),
}, },
}; };
}, },

View File

@ -0,0 +1,100 @@
import { AuthUser } from "@/contexts/auth/domain";
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 { GetProfileUseCase as GetProfileLogoUseCase } from "@/contexts/profile/application/GetProfile.useCase";
import { IProfileContext } from "../../../Profile.context";
import { IGetProfileLogoPresenter } from "./presenter";
import { Request } from "express";
interface AuthenticatedRequest extends Request {
user?: AuthUser;
}
export class GetProfileLogoController extends ExpressController {
private useCase: GetProfileLogoUseCase;
private presenter: IGetProfileLogoPresenter;
private context: IProfileContext;
constructor(
props: {
useCase: GetProfileLogoUseCase;
presenter: IGetProfileLogoPresenter;
},
context: IProfileContext
) {
super();
const { useCase, presenter } = props;
this.useCase = useCase;
this.presenter = presenter;
this.context = context;
}
async executeImpl(): Promise<any> {
const req = this.req as AuthenticatedRequest;
const user = <AuthUser>req.user;
if (!user) {
const errorMessage = "Unexpected missing user data";
const infraError = InfrastructureError.create(
InfrastructureError.UNEXCEPTED_ERROR,
errorMessage
);
return this.internalServerError(errorMessage, infraError);
}
try {
const profileOrError = await this.useCase.execute({
userId: user.id,
});
if (profileOrError.isFailure) {
return this._handleExecuteError(profileOrError.error);
}
const profile = profileOrError.object;
const logo = this.presenter.map(profile, this.context);
console.log(logo);
return this.sendFile(logo);
} 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 = "User has no associated profile";
infraError = InfrastructureError.create(
InfrastructureError.RESOURCE_NOT_READY,
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

@ -0,0 +1,17 @@
import { GetProfileUseCase } from "@/contexts/profile/application/GetProfile.useCase";
import { IProfileContext } from "../../../Profile.context";
import { registerProfileRepository } from "../../../Profile.repository";
import { GetProfileLogoController } from "./GetProfileLogo.controller";
import { GetProfileLogoPresenter } from "./presenter";
export const createGetProfileLogoController = (context: IProfileContext) => {
registerProfileRepository(context);
return new GetProfileLogoController(
{
useCase: new GetProfileUseCase(context),
presenter: GetProfileLogoPresenter,
},
context
);
};

View File

@ -0,0 +1,13 @@
import { Profile } from "@/contexts/profile/domain";
import { IProfileContext } from "@/contexts/profile/infrastructure/Profile.context";
export interface IGetProfileLogoPresenter {
map: (profile: Profile, context: IProfileContext) => string;
}
export const GetProfileLogoPresenter: IGetProfileLogoPresenter = {
map: (profile: Profile, context: IProfileContext): string =>
profile.logo
? `${context.defaults.dealer_logos_upload_path}/${profile.logo}`
: context.defaults.dealer_logo_placeholder,
};

View File

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

View File

@ -1,2 +1,3 @@
export * from "./getProfile"; export * from "./getProfile";
export * from "./updateProfile"; export * from "./updateProfile";
export * from "./uploadProfileLogo";

View File

@ -0,0 +1,117 @@
import { IUseCaseError, UseCaseError } from "@/contexts/common/application";
import { IServerError } from "@/contexts/common/domain/errors";
import { IInfrastructureError, InfrastructureError } from "@/contexts/common/infrastructure";
import { ExpressController } from "@/contexts/common/infrastructure/express";
import { UploadProfileLogoUseCase } from "@/contexts/profile/application";
import { User } from "@/contexts/users/domain";
import { IProfileContext } from "../../../Profile.context";
import { Request } from "express";
interface AuthenticatedRequest extends Request {
user?: User;
}
export class UploadProfileLogoController extends ExpressController {
private useCase: UploadProfileLogoUseCase;
private context: IProfileContext;
constructor(
props: {
useCase: UploadProfileLogoUseCase;
},
context: IProfileContext
) {
super();
const { useCase } = props;
this.useCase = useCase;
this.context = context;
}
async executeImpl() {
const req = this.req as AuthenticatedRequest;
const user = <User>req.user;
const file: Express.Multer.File = this.file;
if (!user || !file || !file.filename) {
const errorMessage = "Unexpected missing input data";
const infraError = InfrastructureError.create(
InfrastructureError.UNEXCEPTED_ERROR,
errorMessage
);
return this.internalServerError(errorMessage, infraError);
}
try {
// Llamar al caso de uso
const result = await this.useCase.execute({
userId: user.id,
file,
});
if (result.isFailure) {
return this._handleExecuteError(result.error);
}
return this.ok<void>();
} 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 = "User has no associated profile";
infraError = InfrastructureError.create(
InfrastructureError.RESOURCE_NOT_FOUND_ERROR,
errorMessage,
error
);
return this.notFoundError(errorMessage, infraError);
break;
case UseCaseError.INVALID_INPUT_DATA:
errorMessage = "Profile data not valid";
infraError = InfrastructureError.create(
InfrastructureError.INVALID_INPUT_DATA,
"Datos a actualizar erróneos",
error
);
return this.invalidInputError(errorMessage, infraError);
break;
case UseCaseError.REPOSITORY_ERROR:
errorMessage = "Error updating profile";
infraError = InfrastructureError.create(
InfrastructureError.UNEXCEPTED_ERROR,
errorMessage,
error
);
return this.conflictError(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

@ -0,0 +1,15 @@
import { UploadProfileLogoUseCase } from "@/contexts/profile/application";
import { IProfileContext } from "../../../Profile.context";
import { registerProfileRepository } from "../../../Profile.repository";
import { UploadProfileLogoController } from "./UploadProfileLogo.controller";
export const createUpdloadProfileLogoController = (context: IProfileContext) => {
registerProfileRepository(context);
return new UploadProfileLogoController(
{
useCase: new UploadProfileLogoUseCase(context),
},
context
);
};

View File

@ -32,6 +32,7 @@ class ProfileMapper
const status = this.mapsValue(source, "status", DealerStatus.create); const status = this.mapsValue(source, "status", DealerStatus.create);
const language = this.mapsValue(source, "lang_code", Language.createFromCode); const language = this.mapsValue(source, "lang_code", Language.createFromCode);
const currency = this.mapsValue(source, "currency_code", CurrencyData.createFromCode); const currency = this.mapsValue(source, "currency_code", CurrencyData.createFromCode);
const logo = source.logo;
const contactInformation = this.mapsValue( const contactInformation = this.mapsValue(
source, source,
@ -70,6 +71,8 @@ class ProfileMapper
defaultLegalTerms, defaultLegalTerms,
defaultQuoteValidity, defaultQuoteValidity,
defaultTax, defaultTax,
logo,
}; };
const id = this.mapsValue(source, "id", UniqueID.create); const id = this.mapsValue(source, "id", UniqueID.create);
@ -92,6 +95,7 @@ class ProfileMapper
default_legal_terms: source.defaultLegalTerms.toPrimitive(), default_legal_terms: source.defaultLegalTerms.toPrimitive(),
default_quote_validity: source.defaultQuoteValidity.toPrimitive(), default_quote_validity: source.defaultQuoteValidity.toPrimitive(),
default_tax: source.defaultTax.convertScale(2).toPrimitive(), default_tax: source.defaultTax.convertScale(2).toPrimitive(),
logo: source.logo,
}; };
} }
} }

View File

@ -57,6 +57,7 @@ export class Dealer_Model extends Model<
declare status: CreationOptional<string>; declare status: CreationOptional<string>;
declare lang_code: CreationOptional<string>; declare lang_code: CreationOptional<string>;
declare currency_code: CreationOptional<string>; declare currency_code: CreationOptional<string>;
declare logo: CreationOptional<string>;
declare user: NonAttribute<User_Model>; declare user: NonAttribute<User_Model>;
declare quotes: NonAttribute<Quote_Model>; declare quotes: NonAttribute<Quote_Model>;
@ -92,6 +93,11 @@ export default (sequelize: Sequelize) => {
defaultValue: 2100, defaultValue: 2100,
}, },
logo: {
type: DataTypes.STRING,
allowNull: true,
},
lang_code: { lang_code: {
type: DataTypes.STRING(2), type: DataTypes.STRING(2),
allowNull: false, allowNull: false,

View File

@ -2,12 +2,27 @@ import { checkUser } from "@/contexts/auth";
import { import {
createGetProfileController, createGetProfileController,
createUpdateProfileController, createUpdateProfileController,
ProfileContext,
} from "@/contexts/profile/infrastructure"; } from "@/contexts/profile/infrastructure";
import { createGetProfileLogoController } from "@/contexts/profile/infrastructure/express/controllers/getProfileLogo";
import { createUpdloadProfileLogoController } from "@/contexts/profile/infrastructure/express/controllers/uploadProfileLogo";
import { NextFunction, Request, Response, Router } from "express"; import { NextFunction, Request, Response, Router } from "express";
import { createMulterMiddleware } from "../upload.middleware";
const uploadProfileLogo = createMulterMiddleware({
uploadFolder: "uploads/dealer-logos",
mimeTypes: ["image/jpeg", "image/png"],
maxSize: 1024 * 1024 * 5, // 5Mb
});
export const profileRouter = (appRouter: Router) => { export const profileRouter = (appRouter: Router) => {
const profileRoutes: Router = Router({ mergeParams: true }); const profileRoutes: Router = Router({ mergeParams: true });
profileRoutes.use((req: Request, res: Response, next: NextFunction) => {
res.locals["context"] = ProfileContext.getInstance();
return next();
});
profileRoutes.get("/", checkUser, (req: Request, res: Response, next: NextFunction) => profileRoutes.get("/", checkUser, (req: Request, res: Response, next: NextFunction) =>
createGetProfileController(res.locals["context"]).execute(req, res, next) createGetProfileController(res.locals["context"]).execute(req, res, next)
); );
@ -16,5 +31,17 @@ export const profileRouter = (appRouter: Router) => {
createUpdateProfileController(res.locals["context"]).execute(req, res, next) createUpdateProfileController(res.locals["context"]).execute(req, res, next)
); );
profileRoutes.get("/logo", checkUser, (req: Request, res: Response, next: NextFunction) =>
createGetProfileLogoController(res.locals["context"]).execute(req, res, next)
);
profileRoutes.post(
"/logo",
checkUser,
uploadProfileLogo.single("logo"),
(req: Request, res: Response, next: NextFunction) =>
createUpdloadProfileLogoController(res.locals["context"]).execute(req, res, next)
);
appRouter.use("/profile", profileRoutes); appRouter.use("/profile", profileRoutes);
}; };

View File

@ -0,0 +1,55 @@
import { Request } from "express";
import fs from "fs";
import multer, { FileFilterCallback } from "multer";
import path from "path";
// Tipos personalizados para mejorar la legibilidad
type MimeType = string;
type MaxSize = number;
type UploadFolder = string;
type CreateUploadMiddlewareOptions = {
mimeTypes: MimeType[];
maxSize: MaxSize;
uploadFolder: UploadFolder;
};
export const ensureUploadFolderExists = (folderPath: string) => {
if (!fs.existsSync(folderPath)) {
fs.mkdirSync(folderPath, { recursive: true });
}
};
// Función para configurar Multer
export function createMulterMiddleware(options: CreateUploadMiddlewareOptions) {
const { mimeTypes, maxSize, uploadFolder } = options;
ensureUploadFolderExists(uploadFolder);
// Configuración del almacenamiento3
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, uploadFolder); // Carpeta donde se almacenan los archivos
},
filename: (req, file, cb) => {
const ext = path.extname(file.originalname);
cb(null, `${file.originalname}-${Date.now()}.${ext}`);
},
});
// Filtro de archivos basado en mimetypes
const fileFilter = (req: Request, file: Express.Multer.File, cb: FileFilterCallback) => {
if (mimeTypes.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error("Tipo de archivo no permitido"));
}
};
// Crear y devolver el middleware de Multer con la configuración personalizada
return multer({
storage: storage,
fileFilter: fileFilter,
limits: { fileSize: maxSize }, // Tamaño máximo del archivo
});
}

View File

@ -5,6 +5,7 @@ import responseTime from "response-time";
import { configurePassportAuth } from "@/contexts/auth"; import { configurePassportAuth } from "@/contexts/auth";
import morgan from "morgan"; import morgan from "morgan";
import multer from "multer";
import passport from "passport"; import passport from "passport";
import path from "path"; import path from "path";
import { config } from "../../config"; import { config } from "../../config";
@ -72,6 +73,18 @@ app.set("port", process.env.PORT ?? 3001);
// Public assets // Public assets
app.use("/assets", express.static(path.join(__dirname, "/public"))); app.use("/assets", express.static(path.join(__dirname, "/public")));
// Manejo de errores de multer
app.use((err: any, req: any, res: any, next: any) => {
if (err instanceof multer.MulterError) {
// Error de multer
return res.status(400).json({ error: err.message });
} else if (err) {
// Otro tipo de error
return res.status(500).json({ error: err.message });
}
next();
});
// API // API
app.use("/api/v1", v1Routes()); app.use("/api/v1", v1Routes());

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

@ -1012,13 +1012,27 @@
dependencies: dependencies:
"@types/express" "*" "@types/express" "*"
"@types/node@*", "@types/node@^20.12.11": "@types/multer@^1.4.12":
version "1.4.12"
resolved "https://registry.yarnpkg.com/@types/multer/-/multer-1.4.12.tgz#da67bd0c809f3a63fe097c458c0d4af1fea50ab7"
integrity sha512-pQ2hoqvXiJt2FP9WQVLPRO+AmiIm/ZYkavPlIQnx282u4ZrVdztx0pkh3jjpQt0Kz+YI0YhSG264y08UJKoUQg==
dependencies:
"@types/express" "*"
"@types/node@*":
version "20.14.10" version "20.14.10"
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.14.10.tgz#a1a218290f1b6428682e3af044785e5874db469a" resolved "https://registry.yarnpkg.com/@types/node/-/node-20.14.10.tgz#a1a218290f1b6428682e3af044785e5874db469a"
integrity sha512-MdiXf+nDuMvY0gJKxyfZ7/6UFsETO7mGKF54MVD/ekJS6HdFtpZFBgrh6Pseu64XTb2MLyFPlbW6hj8HYRQNOQ== integrity sha512-MdiXf+nDuMvY0gJKxyfZ7/6UFsETO7mGKF54MVD/ekJS6HdFtpZFBgrh6Pseu64XTb2MLyFPlbW6hj8HYRQNOQ==
dependencies: dependencies:
undici-types "~5.26.4" undici-types "~5.26.4"
"@types/node@^22.5.5":
version "22.5.5"
resolved "https://registry.yarnpkg.com/@types/node/-/node-22.5.5.tgz#52f939dd0f65fc552a4ad0b392f3c466cc5d7a44"
integrity sha512-Xjs4y5UPO/CLdzpgR6GirZJx36yScjh73+2NlLlkFRSoQN8B0DpfXPdZGnvVmLRLOsqDpOfTNv7D9trgGhmOIA==
dependencies:
undici-types "~6.19.2"
"@types/passport-jwt@^4.0.1": "@types/passport-jwt@^4.0.1":
version "4.0.1" version "4.0.1"
resolved "https://registry.yarnpkg.com/@types/passport-jwt/-/passport-jwt-4.0.1.tgz#080fbe934fb9f6954fb88ec4cdf4bb2cc7c4d435" resolved "https://registry.yarnpkg.com/@types/passport-jwt/-/passport-jwt-4.0.1.tgz#080fbe934fb9f6954fb88ec4cdf4bb2cc7c4d435"
@ -4375,7 +4389,7 @@ mime-db@1.52.0:
resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70"
integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==
mime-types@^2.1.12, mime-types@~2.1.24, mime-types@~2.1.34: mime-types@^2.1.12, mime-types@^2.1.35, mime-types@~2.1.24, mime-types@~2.1.34:
version "2.1.35" version "2.1.35"
resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a"
integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==
@ -6047,6 +6061,11 @@ undici-types@~5.26.4:
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617" resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617"
integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA== integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==
undici-types@~6.19.2:
version "6.19.8"
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.19.8.tgz#35111c9d1437ab83a7cdc0abae2f26d88eda0a02"
integrity sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==
universalify@^2.0.0: universalify@^2.0.0:
version "2.0.1" version "2.0.1"
resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.1.tgz#168efc2180964e6386d061e094df61afe239b18d" resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.1.tgz#168efc2180964e6386d061e094df61afe239b18d"

View File

@ -18,5 +18,6 @@ export interface IGetProfileResponse_DTO {
status: string; status: string;
lang_code: string; lang_code: string;
currency_code: string; currency_code: string;
logo: string;
}; };
} }

View File

@ -8,6 +8,7 @@ export interface IUpdateProfile_Request_DTO {
default_legal_terms: string; default_legal_terms: string;
default_quote_validity: string; default_quote_validity: string;
default_tax: IPercentage_DTO; default_tax: IPercentage_DTO;
logo: string;
} }
export function ensureUpdateProfile_Request_DTOIsValid(userDTO: IUpdateProfile_Request_DTO) { export function ensureUpdateProfile_Request_DTOIsValid(userDTO: IUpdateProfile_Request_DTO) {
@ -21,6 +22,7 @@ export function ensureUpdateProfile_Request_DTOIsValid(userDTO: IUpdateProfile_R
amount: Joi.number().allow(null), amount: Joi.number().allow(null),
scale: Joi.number(), scale: Joi.number(),
}).optional(), }).optional(),
logo: Joi.string().optional().allow(null).allow("").default(""),
}).unknown(true); }).unknown(true);
const result = RuleValidator.validate<IUpdateProfile_Request_DTO>(schema, userDTO); const result = RuleValidator.validate<IUpdateProfile_Request_DTO>(schema, userDTO);