.
This commit is contained in:
parent
eb3e2ebc47
commit
247d13ffcf
@ -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"} />;
|
||||
};
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
54
client/src/components/Buttons/HelpButton.tsx
Normal file
54
client/src/components/Buttons/HelpButton.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -1,2 +1,3 @@
|
||||
export * from "./CancelButton";
|
||||
export * from "./HelpButton";
|
||||
export * from "./SubmitButton";
|
||||
|
||||
@ -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}</>;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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> => {
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -59,6 +59,7 @@ services:
|
||||
- NODE_ENV=production
|
||||
volumes:
|
||||
- backend_logs:/var/log
|
||||
- backend_uploads:/api/uploads
|
||||
ports:
|
||||
- 3001:3001
|
||||
networks:
|
||||
|
||||
@ -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",
|
||||
|
||||
138833
server/public/css/tailwind.min.css
vendored
138833
server/public/css/tailwind.min.css
vendored
File diff suppressed because it is too large
Load Diff
@ -1 +0,0 @@
|
||||
<h1>public</h1>
|
||||
138833
server/public/tailwind.min.css
vendored
138833
server/public/tailwind.min.css
vendored
File diff suppressed because it is too large
Load Diff
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}*/
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
@ -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";
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
);
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
@ -0,0 +1 @@
|
||||
export * from "./GetProfileLogo.presenter";
|
||||
@ -1,2 +1,3 @@
|
||||
export * from "./getProfile";
|
||||
export * from "./updateProfile";
|
||||
export * from "./uploadProfileLogo";
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
);
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
};
|
||||
|
||||
55
server/src/infrastructure/express/api/upload.middleware.ts
Normal file
55
server/src/infrastructure/express/api/upload.middleware.ts
Normal 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
|
||||
});
|
||||
}
|
||||
@ -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());
|
||||
|
||||
|
||||
BIN
server/uploads/images/placeholder-200x100.png
Normal file
BIN
server/uploads/images/placeholder-200x100.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.4 KiB |
@ -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"
|
||||
|
||||
@ -18,5 +18,6 @@ export interface IGetProfileResponse_DTO {
|
||||
status: string;
|
||||
lang_code: string;
|
||||
currency_code: string;
|
||||
logo: string;
|
||||
};
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user