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 (
<Navigate
to={redirectTo ?? "/login"}
replace
state={{
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 (
<Container
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'>
<Card className='px-12 py-6'>
<CardHeader>

View File

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

View File

@ -2,13 +2,16 @@ import { DataTableProvider } from "@/lib/hooks";
import { Trans } from "react-i18next";
import { QuotesDataTable } from "./components";
import { HelpButton } from "@/components";
import {
Button,
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
Label,
ScrollArea,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Tabs,
TabsContent,
TabsList,
@ -17,13 +20,25 @@ import {
} from "@/ui";
import { useToggle } from "@wojtekmaj/react-hooks";
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";
export const QuotesList = () => {
const navigate = useNavigate();
const [status, setStatus] = useState("all");
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 (
<DataTableProvider>
<div className='flex items-center justify-between space-y-2'>
@ -43,83 +58,38 @@ export const QuotesList = () => {
</div>
</div>
<Tabs defaultValue='all'>
<div className='flex items-baseline'>
<TabsList>
<TabsTrigger value='all'>
<Trans i18nKey='quotes.list.tabs.all' />
</TabsTrigger>
<TabsTrigger value='draft'>
<Trans i18nKey='quotes.list.tabs.draft' />
</TabsTrigger>
<TabsTrigger value='ready'>
<Trans i18nKey='quotes.list.tabs.ready' />
</TabsTrigger>
<TabsTrigger value='delivered'>
<Trans i18nKey='quotes.list.tabs.delivered' />
</TabsTrigger>
<TabsTrigger value='accepted'>
<Trans i18nKey='quotes.list.tabs.accepted' />
</TabsTrigger>
<TabsTrigger value='rejected'>
<Trans i18nKey='quotes.list.tabs.rejected' />
</TabsTrigger>
<TabsTrigger value='archived' className='hidden sm:flex'>
<Trans i18nKey='quotes.list.tabs.archived' />
</TabsTrigger>
</TabsList>
<Tabs value={status} onValueChange={setStatus}>
<div className='flex flex-col items-start justify-between mb-4 sm:flex-row sm:items-center'>
<div className='w-full mb-4 sm:w-auto sm:mb-0'>
<TabsList className='hidden sm:flex'>
{quoteStatuses.map((s) => (
<TabsTrigger key={s.value} value={s.value}>
{s.label}
</TabsTrigger>
))}
</TabsList>
<div className='flex items-center w-full space-x-2 sm:hidden'>
<Label>{t("quotes.list.tabs_title")}</Label>
<Select value={status} onValueChange={setStatus}>
<SelectTrigger>
<SelectValue placeholder='Seleccionar estado' />
</SelectTrigger>
<SelectContent>
{quoteStatuses.map((s) => (
<SelectItem key={s.value} value={s.value}>
{s.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className='flex items-baseline justify-center mr-4 font-medium'>
<Dialog>
<DialogTrigger asChild>
<Button variant='link' className='inline-flex items-center font-medium group'>
<Trans
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>
<HelpButton
buttonText='Ayuda'
title='Ayuda sobre el Estado de Cotizaciones'
content={HelpContent}
/>
</div>
<div className='flex items-center gap-2 ml-auto'>
<Toggle
@ -143,28 +113,45 @@ export const QuotesList = () => {
</Toggle>
</div>
</div>
<TabsContent value='all'>
<QuotesDataTable status='all' preview={enabledPreview} />
</TabsContent>
<TabsContent value='draft'>
<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>
{quoteStatuses.map((s) => (
<TabsContent key={s.value} value={s.value}>
<QuotesDataTable status={s.value} preview={enabledPreview} />
</TabsContent>
))}
</Tabs>
</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,
CardTitle,
Form,
Input,
Label,
} from "@/ui";
import { t } from "i18next";
@ -29,7 +31,7 @@ type SettingsDataForm = IUpdateProfile_Request_DTO;
export const SettingsEditor = () => {
const [activeSection, setActiveSection] = useState("profile");
const { useOne, useUpdate } = useSettings();
const { useOne, useUpdate, useUploadLogo } = useSettings();
const { toast } = useToast();
const { data, status, error: queryError } = useOne();
@ -50,6 +52,7 @@ export const SettingsEditor = () => {
);
const { mutate } = useUpdate();
const { upload } = useUploadLogo();
const form = useForm<SettingsDataForm>({
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 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({
isDirty,
});
const onSubmit: SubmitHandler<SettingsDataForm> = async (data) => {
console.log("hola");
mutate(data, {
onError: (error) => {
console.debug(error);
@ -170,6 +204,51 @@ export const SettingsEditor = () => {
</Button>
</CardFooter>
</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 className={cn("grid gap-6", activeSection === "quotes" ? "visible" : "hidden")}>
<Card>

View File

@ -29,6 +29,7 @@ export const useSettings = (params?: UseSettingsGetParamsType) => {
}),
...params,
}),
useUpdate: () => {
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 "./HelpButton";
export * from "./SubmitButton";

View File

@ -1,8 +1,6 @@
import { useGetProfile, useIsLoggedIn } from "@/lib/hooks";
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useIsLoggedIn } from "@/lib/hooks";
import React from "react";
import { Navigate } from "react-router-dom";
import { LoadingOverlay } from "../LoadingOverlay";
type ProctectRouteProps = {
children?: React.ReactNode;
@ -10,9 +8,9 @@ type ProctectRouteProps = {
export const ProtectedRoute = ({ children }: ProctectRouteProps) => {
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);
if (i18n.language !== langCode) {
@ -23,10 +21,10 @@ export const ProtectedRoute = ({ children }: ProctectRouteProps) => {
if (profileStatus.isSuccess && profile && langCode !== profile.lang_code) {
setLangCode(profile.lang_code);
}
}, [profile, profileStatus, i18n]);
}, [profile, profileStatus, i18n]);*/
if (isPending || profileStatus.isPending) {
return <LoadingOverlay />;
if (isPending) {
return null;
}
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
// along to that page after they login, which is a nicer user experience
// 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}</>;

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 components;
@tailwind utilities;
@ -50,63 +52,55 @@
}
}*/
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 221.2 83.2% 53.3%;
--radius: 0.5rem;
--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%;
--background: 0 0% 100%
--foreground: 240 10% 3.9%
--card: 0 0% 100%
--card-foreground: 240 10% 3.9%
--popover: 0 0% 100%
--popover-foreground: 240 10% 3.9%
--primary: 346.8 77.2% 49.8%
--primary-foreground: 355.7 100% 97.3%
--secondary: 240 4.8% 95.9%
--secondary-foreground: 240 5.9% 10%
--muted: 240 4.8% 95.9%
--muted-foreground: 240 3.8% 46.1%
--accent: 240 4.8% 95.9%
--accent-foreground: 240 5.9% 10%
--destructive: 0 84.2% 60.2%
--destructive-foreground: 0 0% 98%
--border: 240 5.9% 90%
--input: 240 5.9% 90%
--ring: 346.8 77.2% 49.8%
--radius: 0.3rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 217.2 91.2% 59.8%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 224.3 76.3% 48%;
--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%;
--background: 20 14.3% 4.1%
--foreground: 0 0% 95%
--card: 24 9.8% 10%
--card-foreground: 0 0% 95%
--popover: 0 0% 9%
--popover-foreground: 0 0% 95%
--primary: 346.8 77.2% 49.8%
--primary-foreground: 355.7 100% 97.3%
--secondary: 240 3.7% 15.9%
--secondary-foreground: 0 0% 98%
--muted: 0 0% 15%
--muted-foreground: 240 5% 64.9%
--accent: 12 6.5% 15.1%
--accent-foreground: 0 0% 98%
--destructive: 0 62.8% 30.6%
--destructive-foreground: 0 85.7% 97.3%
--border: 240 3.7% 15.9%
--input: 240 3.7% 15.9%
--ring: 346.8 77.2% 49.8%
}
}
@layer base {
body {
tab-size: 4;

View File

@ -13,6 +13,7 @@ import {
IRemoveOneDataProviderParams,
ISortItemDataProviderParam,
IUpdateOneDataProviderParams,
IUploadFileDataProviderParam,
} from "../hooks/useDataSource/DataSource";
import { createAxiosInstance } from "./axiosInstance";
@ -119,6 +120,23 @@ export const createAxiosDataProvider = (
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 (
params: IDownloadPDFDataProviderParams
): Promise<IDownloadPDFDataProviderResponse> => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -393,10 +393,14 @@
}
},
"form_fields": {
"image": {
"logo": {
"label": "Logotipo",
"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": {
"label": "Información de contacto",

View File

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

View File

@ -1,6 +1,5 @@
/** @type {import('tailwindcss').Config} */
import defaultTheme from "tailwindcss/defaultTheme";
import plugin from "tailwindcss/plugin";
export default {
@ -18,69 +17,53 @@ export default {
},
extend: {
// https://tailwindcss.com/docs/font-family#font-families
fontFamily: {
/*fontFamily: {
sans: ['"Source Sans Pro"', ...defaultTheme.fontFamily.sans],
},*/
fontFamily: {
display: "Public Sans, ui-sans-serif",
heading: "Noto Serif, ui-serif",
},
colors: {
// https://adevade.github.io/color-scheme-generator/
"brand-light": "#cdd6e7",
brand: "#2a669f",
"brand-dark": "#1e344d",
// 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))",
border: "hsl(240, 5.9%, 90%)",
input: "hsl(240, 5.9%, 90%)",
ring: "hsl(346.8, 77.2%, 49.8%)",
background: "hsl(0, 0%, 100%)",
foreground: "hsl(240, 10%, 3.9%)",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
DEFAULT: "hsl(346.8, 77.2%, 49.8%)",
foreground: "hsl(355.7, 100%, 97.3%)",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
DEFAULT: "hsl(240, 4.8%, 95.9%)",
foreground: "hsl(240, 5.9%, 10%)",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
DEFAULT: "hsl(0, 84.2%, 60.2%)",
foreground: "hsl(0, 0%, 98%)",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
DEFAULT: "hsl(240, 4.8%, 95.9%)",
foreground: "hsl(240, 3.8%, 46.1%)",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
DEFAULT: "hsl(240, 4.8%, 95.9%)",
foreground: "hsl(240, 5.9%, 10%)",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
DEFAULT: "hsl(0, 0%, 100%)",
foreground: "hsl(240, 10%, 3.9%)",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
DEFAULT: "hsl(0, 0%, 100%)",
foreground: "hsl(240, 10%, 3.9%)",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
lg: "0.3rem",
md: "calc(0.3rem - 2px)",
sm: "calc(0.3rem - 4px)",
},
keyframes: {
"accordion-down": {

View File

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

View File

@ -27,7 +27,8 @@
"@types/luxon": "^3.3.1",
"@types/module-alias": "^2.0.1",
"@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-jwt": "^4.0.1",
"@types/passport-local": "^1.0.38",
@ -72,8 +73,10 @@
"jsonwebtoken": "^9.0.2",
"lodash": "^4.17.21",
"luxon": "^3.4.0",
"mime-types": "^2.1.35",
"moment": "^2.29.4",
"morgan": "^1.10.0",
"multer": "^1.4.5-lts.1",
"mysql2": "^3.6.0",
"passport": "^0.7.0",
"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: "*",
},
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: {
name: "Administrador",
email: "darranz@rodax-software.com",

View File

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

View File

@ -26,37 +26,3 @@ export class ContextFactory {
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();
}
public sendFile(filepath: string) {
return this.res.sendFile(filepath);
}
public downloadFile(filepath: string, filename: string, done?: any) {
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 "./UploadProfileLogo.useCase";

View File

@ -23,6 +23,8 @@ export interface IProfileProps {
defaultLegalTerms: Note;
defaultQuoteValidity: Note;
defaultTax: Percentage;
logo: string;
}
export interface IProfile {
@ -39,6 +41,8 @@ export interface IProfile {
defaultQuoteValidity: Note;
defaultTax: Percentage;
logo: string;
}
export class Profile extends AggregateRoot<IProfileProps> implements IProfile {
@ -110,4 +114,12 @@ export class Profile extends AggregateRoot<IProfileProps> implements IProfile {
set defaultTax(newDefaultTax: Percentage) {
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,
createSequelizeAdapter,
} from "@/contexts/common/infrastructure/sequelize";
import { config } from "../../../config";
export interface IProfileContext {
adapter: ISequelizeAdapter;
repositoryManager: IRepositoryManager;
defaults: Record<string, any>;
}
export class ProfileContext {
@ -17,6 +19,7 @@ export class ProfileContext {
ProfileContext.instance = new ProfileContext({
adapter: createSequelizeAdapter(),
repositoryManager: RepositoryManager.getInstance(),
defaults: config.defaults,
});
}

View File

@ -58,7 +58,9 @@ export class GetProfileController extends ExpressController {
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) {
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 { IProfileContext } from "@/contexts/profile/infrastructure/Profile.context";
import { IGetProfileResponse_DTO } from "@shared/contexts";
import fs from "fs/promises";
import mime from "mime-types";
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 = {
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 {
id: user.id.toString(),
name: user.name.toString(),
@ -28,6 +48,8 @@ export const GetProfilePresenter: IGetProfilePresenter = {
default_legal_terms: profile.defaultLegalTerms.toString(),
default_quote_validity: profile.defaultQuoteValidity.toString(),
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 "./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 language = this.mapsValue(source, "lang_code", Language.createFromCode);
const currency = this.mapsValue(source, "currency_code", CurrencyData.createFromCode);
const logo = source.logo;
const contactInformation = this.mapsValue(
source,
@ -70,6 +71,8 @@ class ProfileMapper
defaultLegalTerms,
defaultQuoteValidity,
defaultTax,
logo,
};
const id = this.mapsValue(source, "id", UniqueID.create);
@ -92,6 +95,7 @@ class ProfileMapper
default_legal_terms: source.defaultLegalTerms.toPrimitive(),
default_quote_validity: source.defaultQuoteValidity.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 lang_code: CreationOptional<string>;
declare currency_code: CreationOptional<string>;
declare logo: CreationOptional<string>;
declare user: NonAttribute<User_Model>;
declare quotes: NonAttribute<Quote_Model>;
@ -92,6 +93,11 @@ export default (sequelize: Sequelize) => {
defaultValue: 2100,
},
logo: {
type: DataTypes.STRING,
allowNull: true,
},
lang_code: {
type: DataTypes.STRING(2),
allowNull: false,

View File

@ -2,12 +2,27 @@ import { checkUser } from "@/contexts/auth";
import {
createGetProfileController,
createUpdateProfileController,
ProfileContext,
} 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 { 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) => {
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) =>
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)
);
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);
};

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 morgan from "morgan";
import multer from "multer";
import passport from "passport";
import path from "path";
import { config } from "../../config";
@ -72,6 +73,18 @@ app.set("port", process.env.PORT ?? 3001);
// Public assets
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
app.use("/api/v1", v1Routes());

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

@ -1012,13 +1012,27 @@
dependencies:
"@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"
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.14.10.tgz#a1a218290f1b6428682e3af044785e5874db469a"
integrity sha512-MdiXf+nDuMvY0gJKxyfZ7/6UFsETO7mGKF54MVD/ekJS6HdFtpZFBgrh6Pseu64XTb2MLyFPlbW6hj8HYRQNOQ==
dependencies:
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":
version "4.0.1"
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"
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"
resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a"
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"
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:
version "2.0.1"
resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.1.tgz#168efc2180964e6386d061e094df61afe239b18d"

View File

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

View File

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