This commit is contained in:
David Arranz 2024-06-06 13:05:54 +02:00
parent 8e13fa18ae
commit 6cb7b831e8
153 changed files with 8506 additions and 5 deletions

View File

@ -9,5 +9,8 @@
"prettier.useEditorConfig": false,
"prettier.useTabs": false,
"prettier.configPath": ".prettierrc",
"asciidoc.antora.enableAntoraSupport": true
"asciidoc.antora.enableAntoraSupport": true,
// other vscode settings
"tailwindCSS.rootFontSize": 16 // <- your root font size here
}

2
client/.env.development Normal file
View File

@ -0,0 +1,2 @@
VITE_API_URL=http://127.0.0.1:4001/api/v1
VITE_API_KEY=e175f809ba71fb2765ad5e60f9d77596-es19

0
client/.env.production Normal file
View File

16
client/.eslintrc.cjs Normal file
View File

@ -0,0 +1,16 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react-hooks/recommended",
],
ignorePatterns: ["dist", ".eslintrc.cjs"],
parser: "@typescript-eslint/parser",
plugins: ["react-refresh"],
rules: {
"@typescript-eslint/no-explicit-any": "warn",
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
},
};

24
client/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

17
client/components.json Normal file
View File

@ -0,0 +1,17 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/index.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "src/",
"utils": "@/lib/utils"
}
}

18
client/index.html Normal file
View File

@ -0,0 +1,18 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Uecko</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="uecko"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

80
client/package.json Normal file
View File

@ -0,0 +1,80 @@
{
"name": "@uecko-presupuestador/client",
"private": true,
"version": "1.0.0",
"author": "Rodax Software <dev@rodax-software.com>",
"type": "module",
"scripts": {
"dev": "vite --host",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@hookform/resolvers": "^3.5.0",
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-alert-dialog": "^1.0.5",
"@radix-ui/react-aspect-ratio": "^1.0.3",
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-collapsible": "^1.0.3",
"@radix-ui/react-context-menu": "^2.1.5",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-hover-card": "^1.0.7",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-menubar": "^1.0.4",
"@radix-ui/react-navigation-menu": "^1.1.4",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-progress": "^1.0.3",
"@radix-ui/react-radio-group": "^1.1.3",
"@radix-ui/react-scroll-area": "^1.0.5",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-separator": "^1.0.3",
"@radix-ui/react-slider": "^1.1.2",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toast": "^1.1.5",
"@radix-ui/react-toggle": "^1.0.3",
"@radix-ui/react-toggle-group": "^1.0.4",
"@radix-ui/react-tooltip": "^1.0.7",
"@tanstack/react-query": "^5.39.0",
"axios": "^1.7.2",
"class-variance-authority": "^0.7.0",
"cmdk": "^1.0.0",
"date-fns": "^3.6.0",
"joi": "^17.13.1",
"lucide-react": "^0.379.0",
"react": "^18.2.0",
"react-beautiful-dnd": "^13.1.1",
"react-day-picker": "^8.10.1",
"react-dom": "^18.2.0",
"react-hook-form": "^7.51.5",
"react-resizable-panels": "^2.0.19",
"react-router-dom": "^6.23.1",
"react-secure-storage": "^1.3.2",
"react-toastify": "^10.0.5",
"react-wrap-balancer": "^1.1.1"
},
"devDependencies": {
"@tanstack/react-query-devtools": "^5.39.0",
"@types/node": "^20.14.0",
"@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22",
"@typescript-eslint/eslint-plugin": "^7.2.0",
"@typescript-eslint/parser": "^7.2.0",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.19",
"clsx": "^2.1.1",
"eslint": "^8.57.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.6",
"postcss": "^8.4.38",
"postcss-import": "^16.1.0",
"tailwind-merge": "^2.3.0",
"tailwindcss": "^3.4.3",
"typescript": "^5.2.2",
"vite": "^5.2.12"
}
}

7
client/postcss.config.js Normal file
View File

@ -0,0 +1,7 @@
export default {
plugins: {
"postcss-import": {},
tailwindcss: {},
autoprefixer: {},
},
};

1
client/public/vite.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

42
client/src/App.css Normal file
View File

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

43
client/src/App.tsx Normal file
View File

@ -0,0 +1,43 @@
import { AuthProvider, ThemeProvider } from "@/lib/hooks";
import { TooltipProvider } from "@/ui";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { Suspense } from "react";
import { ToastContainer } from "react-toastify";
import { Routes } from "./Routes";
import { LoadingOverlay, TailwindIndicator } from "./components";
import { createAxiosDataProvider } from "./lib/axios";
import { createAxiosAuthActions } from "./lib/axios/createAxiosAuthActions";
import { DataSourceProvider } from "./lib/hooks/useDataSource/DataSourceContext";
function App() {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
staleTime: 10000, // Specify a staleTime to only fetch when the data is older than a certain amount of time
},
},
});
return (
<QueryClientProvider client={queryClient}>
<DataSourceProvider dataSource={createAxiosDataProvider(import.meta.env.VITE_API_URL)}>
<AuthProvider authActions={createAxiosAuthActions(import.meta.env.VITE_API_URL)}>
<ThemeProvider defaultTheme='light' storageKey='vite-ui-theme'>
<TooltipProvider delayDuration={0}>
<Suspense fallback={<LoadingOverlay />}>
<Routes />
<ToastContainer />
</Suspense>
</TooltipProvider>
<TailwindIndicator />
<ReactQueryDevtools initialIsOpen={false} />
</ThemeProvider>
</AuthProvider>
</DataSourceProvider>
</QueryClientProvider>
);
}
export default App;

64
client/src/Routes.tsx Normal file
View File

@ -0,0 +1,64 @@
import { RouterProvider, createBrowserRouter } from "react-router-dom";
import { ProtectedRoute } from "./components";
import { AuthContextState, useAuth } from "./lib/hooks";
import { LoginPage } from "./pages/LoginPage";
export const Routes = () => {
const { isLoggedIn } = useAuth() as AuthContextState;
// Define public routes accessible to all users
const routesForPublic = [
{
path: "/service",
element: <div>Service Page</div>,
},
{
path: "/about-us",
element: <div>About Us</div>,
},
];
// Define routes accessible only to authenticated users
const routesForAuthenticatedOnly = [
{
path: "/admin",
element: <ProtectedRoute />, // Wrap the component in ProtectedRoute
children: [
{
path: "",
element: <div>Dashboard</div>,
},
{
path: "profile",
element: <div>User Profile</div>,
},
{
path: "logout",
element: <div>Logout</div>,
},
],
},
];
// Define routes accessible only to non-authenticated users
const routesForNotAuthenticatedOnly = [
{
path: "/",
element: <div>Home Page</div>,
},
{
path: "/login",
Component: LoginPage,
},
];
// Combine and conditionally include routes based on authentication status
const router = createBrowserRouter([
...routesForPublic,
...(!isLoggedIn ? routesForNotAuthenticatedOnly : []),
...routesForAuthenticatedOnly,
]);
// Provide the router configuration using RouterProvider
return <RouterProvider router={router} />;
};

130
client/src/Typography.tsx Normal file
View File

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

View File

@ -0,0 +1,11 @@
import { createAxiosDataProvider } from "@/lib/axios";
import { Outlet } from "react-router-dom";
export const AuthLayout = (): JSX.Element => {
const authDataProvider = createAxiosDataProvider(import.meta.env.VITE_API_URL);
return (
<AuthProvider dataProvider={authDataProvider}>
<Outlet />
</AuthProvider>
);
};

View File

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

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -0,0 +1,31 @@
import { cva, type VariantProps } from "class-variance-authority";
import React from "react";
import { cn } from "@/lib/utils";
export const containerVariants = cva("p-6", {
variants: {
variant: {
full: "w-full",
boxed: "container max-w-3xl lg:max-w-5xl mx-auto",
},
},
defaultVariants: {
variant: "full",
},
});
export interface ContainerProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof containerVariants> {
as?: React.ElementType;
}
export const Container = ({
className,
as: Comp = "article",
variant,
...props
}: ContainerProps) => <Comp className={cn(containerVariants({ variant, className }))} {...props} />;
Container.displayName = "Container";

View File

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

View File

@ -0,0 +1,67 @@
import { cn } from "@/lib/utils";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/ui";
import React, { useMemo } from "react";
interface FormGroupProps
extends React.HTMLAttributes<HTMLHeadingElement>,
React.HTMLAttributes<HTMLDivElement> {
description?: string;
actions?: JSX.Element;
footerActions?: JSX.Element;
}
export const FormGroup = React.forwardRef<HTMLDivElement, FormGroupProps>(
(
{ className, title, description, actions, footerActions, children },
ref
) => {
const id = React.useId();
const hasHeader = useMemo(
() => title || description || actions,
[title, description, actions]
);
return (
<Card
id={id}
className={cn(!hasHeader ? "pt-6" : "", className)}
ref={ref}
>
{hasHeader && (
<CardHeader className="flex flex-row flex-wrap items-center justify-between sm:flex-nowrap">
<div>
{title && (
<CardTitle className="text-lg leading-normal">
{title}
</CardTitle>
)}
{description && (
<CardDescription className="leading-loose">
{description}
</CardDescription>
)}
</div>
{actions && <div className="flex-shrink-0">{actions}</div>}
</CardHeader>
)}
<CardContent className="grid items-start gap-6">{children}</CardContent>
{footerActions && (
<CardFooter
className="px-6 py-4 border-t"
style={{ borderStyle: "inherit" }}
>
{footerActions}
</CardFooter>
)}
</Card>
);
}
);
FormGroup.displayName = "FormGroup";

View File

@ -0,0 +1,26 @@
import * as UI from "@/ui";
import * as LabelPrimitive from "@radix-ui/react-label";
import React from "react";
import { FormInputProps } from "./FormProps";
export interface FormLabelProps {
label: string;
hint?: string;
}
export const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
FormLabelProps &
Pick<FormInputProps, "required">
>(({ label, hint, required, ...props }, ref) => {
const _hint = hint ? hint : required ? "obligatorio" : undefined;
return (
<UI.FormLabel ref={ref} className="flex justify-between" {...props}>
<span className="block font-semibold">{label}</span>
{_hint && <span className="font-normal">{_hint}</span>}
</UI.FormLabel>
);
});
FormLabel.displayName = "FormLabel";

View File

@ -0,0 +1,137 @@
import { cn } from "@/lib/utils";
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
} from "@/ui";
import { createElement } from "react";
import { FormTextFieldProps } from "./FormTextField";
type FormMoneyFieldProps = Omit<FormTextFieldProps, "type">;
// Spanish currency config
const moneyFormatter = Intl.NumberFormat("es-ES", {
currency: "EUR",
currencyDisplay: "symbol",
currencySign: "standard",
style: "currency",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
export function FormMoneyField({
label,
placeholder,
hint,
description,
required,
className,
leadIcon,
trailIcon,
button,
disabled,
errors,
name,
control,
}: FormMoneyFieldProps) {
/*const initialValue = props.form.getValues()[props.name]
? moneyFormatter.format(props.form.getValues()[props.name])
: "";
const [value, setValue] = useReducer((_: any, next: string) => {
const digits = next.replace(/\D/g, "");
return moneyFormatter.format(Number(digits) / 100);
}, initialValue);
function handleChange(realChangeFn: Function, formattedValue: string) {
const digits = formattedValue.replace(/\D/g, "");
const realValue = Number(digits) / 100;
realChangeFn(realValue);
}*/
//const error = Boolean(errors && errors[name]);
const transform = {
input: (value: any) =>
isNaN(value) || value === 0 ? "" : moneyFormatter.format(value),
output: (event: React.ChangeEvent<HTMLInputElement>) => {
const output = parseInt(event.target.value, 10);
return isNaN(output) ? 0 : output;
},
};
return (
<FormField
control={control}
name={name}
rules={{ required }}
disabled={disabled}
render={({ field, fieldState, formState }) => {
return (
<FormItem className={cn(className, "space-y-3")}>
{label && <FormLabel label={label} hint={hint} />}
<div className={cn(button ? "flex" : null)}>
<div
className={cn(
leadIcon
? "relative flex items-stretch flex-grow focus-within:z-10"
: "",
)}
>
{leadIcon && (
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
{createElement(
leadIcon,
{
className: "h-5 w-5 text-muted-foreground",
"aria-hidden": true,
},
null,
)}
</div>
)}
<FormControl
className={cn(
"block",
leadIcon ? "pl-10" : "",
trailIcon ? "pr-10" : "",
)}
>
<Input
type="text"
placeholder={placeholder}
disabled={disabled}
{...field}
onChange={(e) => field.onChange(transform.output(e))}
value={transform.input(field.value)}
/>
</FormControl>
{trailIcon && (
<div className="absolute inset-y-0 right-0 flex items-center pl-3 pointer-events-none">
{createElement(
trailIcon,
{
className: "h-5 w-5 text-muted-foreground",
"aria-hidden": true,
},
null,
)}
</div>
)}
</div>
{button && <>{createElement(button)}</>}
</div>
{description && <FormDescription>{description}</FormDescription>}
<FormMessage />
</FormItem>
);
}}
/>
);
}

View File

@ -0,0 +1,27 @@
import { ReactNode } from "react";
export interface FormInputProps {
placeholder?: string;
description?: string;
required?: boolean;
className?: string;
}
export interface FormInputWithIconProps {
leadIcon?: React.ElementType;
trailIcon?: React.ElementType;
}
export interface FormButtonInputProps {
onClick?: (event: React.SyntheticEvent) => void;
}
export interface FormInputWithButtonProps {
withButton: boolean;
onButtonClick: (event: React.SyntheticEvent) => void;
}
export interface FormDialogInputProps {
content: ReactNode;
onClose?: (event: React.SyntheticEvent, payload: unknown) => void;
}

View File

@ -0,0 +1,70 @@
import { cn } from "@/lib/utils";
import {
AutosizeTextarea,
FormControl,
FormDescription,
FormField,
FormItem,
FormMessage,
Textarea,
} from "@/ui";
import {
FieldErrors,
FieldPath,
FieldValues,
UseControllerProps,
} from "react-hook-form";
import { FormLabel, FormLabelProps } from "./FormLabel";
export const FormTextAreaField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
label,
hint,
placeholder,
description,
autoSize,
className,
...props
}: {
autoSize?: boolean;
placeholder?: string;
description?: string;
className?: string;
} & Partial<FormLabelProps> &
UseControllerProps<TFieldValues, TName> & {
errors?: FieldErrors<TFieldValues>;
}) => {
return (
<FormField
control={props.control}
name={props.name}
render={({ field }) => (
<FormItem className={cn(className, "flex flex-col")}>
{label && <FormLabel label={label} hint={hint} />}
<FormControl>
{autoSize ? (
<AutosizeTextarea
disabled={props.disabled}
placeholder={placeholder}
className="resize-y"
{...field}
/>
) : (
<Textarea
disabled={props.disabled}
placeholder={placeholder}
className="resize-y"
{...field}
/>
)}
</FormControl>
{description && <FormDescription>{description}</FormDescription>}
<FormMessage />
</FormItem>
)}
/>
);
};

View File

@ -0,0 +1,120 @@
import { cn } from "@/lib/utils";
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormMessage,
Input,
InputProps,
} from "@/ui";
import * as React from "react";
import { createElement } from "react";
import {
FieldErrors,
FieldPath,
FieldValues,
UseControllerProps,
} from "react-hook-form";
import { FormLabel, FormLabelProps } from "./FormLabel";
import { FormInputProps, FormInputWithIconProps } from "./FormProps";
export type FormTextFieldProps<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
button?: (props?) => React.ReactNode;
} & InputProps &
FormInputProps &
Partial<FormLabelProps> &
FormInputWithIconProps &
UseControllerProps<TFieldValues, TName> & {
errors?: FieldErrors<TFieldValues>;
};
export function FormTextField({
label,
placeholder,
hint,
description,
required,
className,
leadIcon,
trailIcon,
button,
disabled,
errors,
name,
control,
type,
}: FormTextFieldProps) {
//const error = Boolean(errors && errors[name]);
return (
<FormField
control={control}
name={name}
rules={{ required }}
disabled={disabled}
render={({ field, fieldState, formState }) => (
<FormItem className={cn(className, "space-y-3")}>
{label && <FormLabel label={label} hint={hint} />}
<div className={cn(button ? "flex" : null)}>
<div
className={cn(
leadIcon
? "relative flex items-stretch flex-grow focus-within:z-10"
: "",
)}
>
{leadIcon && (
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
{React.createElement(
leadIcon,
{
className: "h-5 w-5 text-muted-foreground",
"aria-hidden": true,
},
null,
)}
</div>
)}
<FormControl
className={cn(
"block",
leadIcon ? "pl-10" : "",
trailIcon ? "pr-10" : "",
)}
>
<Input
type={type}
placeholder={placeholder}
disabled={disabled}
{...field}
/>
</FormControl>
{trailIcon && (
<div className="absolute inset-y-0 right-0 flex items-center pl-3 pointer-events-none">
{createElement(
trailIcon,
{
className: "h-5 w-5 text-muted-foreground",
"aria-hidden": true,
},
null,
)}
</div>
)}
</div>
{button && <>{createElement(button)}</>}
</div>
{description && <FormDescription>{description}</FormDescription>}
<FormMessage />
</FormItem>
)}
/>
);
}

View File

@ -0,0 +1,5 @@
export * from "./FormGroup";
export * from "./FormLabel";
export * from "./FormMoneyField";
export * from "./FormTextAreaField";
export * from "./FormTextField";

View File

@ -0,0 +1,24 @@
@tailwind components;
@layer components {
.LoadingIndicator {
@apply flex flex-col items-center justify-center max-w-xs;
@apply justify-center w-full h-full mx-auto;
}
.LoadingIndicator__title {
@apply mt-6 text-xl font-semibold text-center text-white;
}
.LoadingIndicator__subtitle {
@apply text-center text-white;
}
.LoadingIndicator__lighttext {
@apply text-white;
}
.LoadingIndicator__darktext {
@apply text-slate-600;
}
}

View File

@ -0,0 +1,55 @@
import { cn } from "@/lib/utils";
import styles from "./LoadingIndicator.module.css";
import { LoadingSpinIcon } from "./LoadingSpinIcon";
export type LoadingIndicatorProps = {
look?: "dark" | string;
size?: number;
active?: boolean;
title?: string;
subtitle?: string;
};
// eslint-disable-next-line no-unused-vars
export const LoadingIndicator = ({
active = true,
look = "dark",
title = "Cargando...",
subtitle = "",
}: LoadingIndicatorProps) => {
const isDark = look === "dark";
const loadingSpinClassName = isDark ? "text-brand" : "text-white";
if (!active) {
return;
}
return (
<div className={styles.LoadingIndicator}>
<LoadingSpinIcon size={12} className={loadingSpinClassName} />
{/*<Spinner {...spinnerProps} />*/}
{title ? (
<h2
className={cn(
styles.LoadingIndicator__title,
isDark ? styles.LoadingIndicator__darktext : styles.LoadingIndicator__lighttext
)}
>
{title}
</h2>
) : null}
{subtitle ? (
<p
className={cn(
styles.LoadingIndicator__subtitle,
isDark ? styles.LoadingIndicator__darktext : styles.LoadingIndicator__lighttext
)}
>
{subtitle}
</p>
) : null}
</div>
);
};
LoadingIndicator.displayName = "LoadingIndicator";

View File

@ -0,0 +1,31 @@
export const LoadingSpinIcon = ({
size = 5,
color = 'brand',
className,
}: {
size?: number;
color?: string;
className?: string;
}): JSX.Element => (
<svg
className={`animate-spin text-${color} w-${size} h-${size} ${className}`}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
);

View File

@ -0,0 +1 @@
export * from './LoadingSpinIcon';

View File

@ -0,0 +1 @@
export * from './LoadingIndicator';

View File

@ -0,0 +1,8 @@
@tailwind components;
@layer components {
.LoadingOverlay {
@apply fixed top-0 bottom-0 left-0 right-0 z-50 w-full h-screen overflow-hidden;
@apply flex justify-center bg-red-700 opacity-75;
}
}

View File

@ -0,0 +1,21 @@
import { LoadingIndicator } from "../LoadingIndicator";
import styles from "./LoadingOverlay.module.css";
export type LoadingOverlayProps = {
title?: string;
subtitle?: string;
};
export const LoadingOverlay = ({
title = "Cargando",
subtitle = "Esto puede tardar unos segundos. Por favor, no cierre esta página.",
...props
}: LoadingOverlayProps) => {
return (
<div className={styles.LoadingOverlay} {...props}>
<LoadingIndicator look='light' title={title} subtitle={subtitle} />
</div>
);
};
LoadingOverlay.displayName = "LoadingOverlay";

View File

@ -0,0 +1 @@
export * from './LoadingOverlay';

View File

@ -0,0 +1,28 @@
import { useIsLoggedIn } from "@/lib/hooks";
import React from "react";
import { Navigate } from "react-router-dom";
type ProctectRouteProps = {
children?: React.ReactNode;
};
export const ProtectedRoute = ({ children }: ProctectRouteProps) => {
const { isSuccess, data } = useIsLoggedIn();
if (isSuccess && data) {
const { authenticated, redirectTo } = data;
if (authenticated) {
return (
<Navigate
to={redirectTo ?? "/login"}
replace
state={{
error: "No authentication, please complete the login process.",
}}
/>
);
}
}
return children;
};

View File

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

View File

@ -0,0 +1,16 @@
export function TailwindIndicator() {
if (import.meta.env.PROD) return null;
return (
<div className="fixed z-50 flex items-center justify-center p-3 font-mono text-xs text-white bg-gray-800 rounded-full bottom-1 left-1 size-6">
<div className="block sm:hidden">xs</div>
<div className="hidden sm:block md:hidden lg:hidden xl:hidden 2xl:hidden">
sm
</div>
<div className="hidden md:block lg:hidden xl:hidden 2xl:hidden">md</div>
<div className="hidden lg:block xl:hidden 2xl:hidden">lg</div>
<div className="hidden xl:block 2xl:hidden">xl</div>
<div className="hidden 2xl:block">2xl</div>
</div>
);
}

View File

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

View File

@ -0,0 +1,9 @@
export const UeckoLogo = ({ className }: { className: string }) => (
<svg viewBox='0 0 336 133' fill='none' xmlns='http://www.w3.org/2000/svg' className={className}>
<path
d='M49.7002 83.0001H66.9002V22.5001H49.7002V56.2001C49.7002 64.3001 45.5002 68.5001 39.0002 68.5001C32.5002 68.5001 28.6002 64.3001 28.6002 56.2001V22.5001H0.700195V33.2001H11.4002V61.6001C11.4002 75.5001 19.0002 84.1001 31.9002 84.1001C40.6002 84.1001 45.7002 79.5001 49.6002 74.4001V83.0001H49.7002ZM120.6 48.0001H94.8002C96.2002 40.2001 100.8 35.1001 107.9 35.1001C115.1 35.2001 119.6 40.3001 120.6 48.0001ZM137.1 58.7001C137.2 57.1001 137.3 56.1001 137.3 54.4001V54.2001C137.3 37.0001 128 21.4001 107.8 21.4001C90.2002 21.4001 77.9002 35.6001 77.9002 52.9001V53.1001C77.9002 71.6001 91.3002 84.4001 109.5 84.4001C120.4 84.4001 128.6 80.1001 134.2 73.1001L124.4 64.4001C119.7 68.8001 115.5 70.6001 109.7 70.6001C102 70.6001 96.6002 66.5001 94.9002 58.7001H137.1ZM162.2 52.9001V52.7001C162.2 43.8001 168.3 36.2001 176.9 36.2001C183 36.2001 186.8 38.8001 190.7 42.9001L201.2 31.6001C195.6 25.3001 188.4 21.4001 177 21.4001C158.5 21.4001 145.3 35.6001 145.3 52.9001V53.1001C145.3 70.4001 158.6 84.4001 176.8 84.4001C188.9 84.4001 195.6 79.8001 201.5 73.3001L191.5 63.1001C187.3 67.1001 183.4 69.5001 177.6 69.5001C168.2 69.6001 162.2 62.1001 162.2 52.9001ZM269.1 83.0001L245.3 46.3001L268.3 22.5001H247.8L227.7 44.5001V0.600098H210.5V83.0001H227.7V64.6001L233.7 58.3001L249.5 83.0001H269.1ZM318.5 53.1001C318.5 62.0001 312.6 69.6001 302.8 69.6001C293.3 69.6001 286.9 61.8001 286.9 52.9001V52.7001C286.9 43.8001 292.8 36.2001 302.6 36.2001C312.1 36.2001 318.5 44.0001 318.5 52.9001V53.1001ZM335.4 52.9001V52.7001C335.4 35.3001 321.5 21.4001 302.8 21.4001C284 21.4001 270 35.5001 270 52.9001V53.1001C270 70.5001 283.9 84.4001 302.6 84.4001C321.4 84.4001 335.4 70.3001 335.4 52.9001Z'
fill='black'
className={className}
></path>
</svg>
);

View File

View File

@ -0,0 +1,6 @@
export * from "./Container";
export * from "./Forms";
export * from "./LoadingIndicator";
export * from "./LoadingOverlay";
export * from "./ProtectedRoute";
export * from "./TailwindIndicator";

64
client/src/index.css Normal file
View File

@ -0,0 +1,64 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* https://ui.jln.dev/ Light Blue */
@layer base {
:root {
--background: 210 40% 96.08%;
--foreground: 334 55% 1%;
--muted: 214.29 31.82% 91.37%;
--muted-foreground: 334 9% 37%;
--popover: 334 62% 100%;
--popover-foreground: 334 55% 1%;
--card: 334 62% 100%;
--card-foreground: 334 55% 1%;
--border: 334 5% 95%;
--input: 334 5% 95%;
--primary: 209.23 58.21% 39.41%;
--primary-foreground: 0 0% 100%;
--secondary: 213.75 20.25% 69.02%;
--secondary-foreground: 334 0% 100%;
--accent: 214.29 31.82% 91.37%;
--accent-foreground: 334 20% 22%;
--destructive: 348.37 78.4% 49.02%;
--destructive-foreground: 18 0% 100%;
--ring: 209.23 58.21% 39.41%;
--radius: 0.5rem;
}
.dark {
--background: 222.22 47.37% 11.18%;
--foreground: 334 34% 98%;
--muted: 215.38 16.32% 46.86%;
--muted-foreground: 334 0% 87.69%;
--popover: 217.24 32.58% 17.45%;
--popover-foreground: 334 34% 98%;
--card: 217.24 32.58% 17.45%;
--card-foreground: 334 34% 98%;
--border: 334 0% 32.31%;
--input: 215.29 25% 26.67%;
--primary: 227.56 53.78% 49.22%;
--primary-foreground: 0 0% 100%;
--secondary: 214.29 5.04% 27.25%;
--secondary-foreground: 334 0% 100%;
--accent: 222.22 47.37% 11.18%;
--accent-foreground: 226.73 0% 100%;
--destructive: 358.82 84.44% 64.71%;
--destructive-foreground: 0 0% 100%;
--ring: 227.56 53.78% 49.22%;
}
}
@layer base {
body {
tab-size: 4;
font-feature-settings: normal;
font-variation-settings: normal;
@apply h-full text-foreground bg-background;
}
#uecko {
@apply flex min-h-screen min-w-[320px] flex-col;
}
}

View File

@ -0,0 +1,207 @@
// https://dev.to/mperon/axios-error-handling-like-a-boss-333d
import axios, { AxiosError } from "axios";
export interface ValidationErrors {
[field: string]:
| string
| string[]
| boolean
| { key: string; message: string };
}
export interface IHttpError extends Record<string, any> {
message: string;
statusCode: number;
errors?: ValidationErrors;
}
export class HttpError extends Error {
constructor(message?: string) {
super(message); // 'Error' breaks prototype chain here
this.name = "HttpError";
Object.setPrototypeOf(this, new.target.prototype); // restore prototype chain
}
}
export const httpErrorHandler = (error: any) => {
if (error === null) throw new Error("Unrecoverable error!! Error is null!");
if (axios.isAxiosError(error)) {
//here we have a type guard check, error inside this if will be treated as AxiosError
const response = error?.response;
const request = error?.request;
const config = error?.config; //here we have access the config used to make the api call (we can make a retry using this conf)
if (error.code === "ERR_NETWORK") {
console.log("connection problems..");
} else if (error.code === "ERR_CANCELED") {
console.log("connection canceled..");
}
if (response) {
//The request was made and the server responded with a status code that falls out of the range of 2xx the http status code mentioned above
const statusCode = response?.status;
if (statusCode === 404) {
console.log(
"The requested resource does not exist or has been deleted",
);
} else if (statusCode === 401) {
console.log("Please login to access this resource");
//redirect user to login
}
} else if (request) {
//The request was made but no response was received, `error.request` is an instance of XMLHttpRequest in the browser and an instance of http.ClientRequest in Node.js
}
}
//Something happened in setting up the request and triggered an Error
console.log(error.message);
};
interface HttpData {
code: string;
description?: string;
status: number;
}
// this is all errrors allowed to receive
type THttpError = Error | AxiosError | null;
// object that can be passed to our registy
interface ErrorHandlerObject {
after?(error?: THttpError, options?: ErrorHandlerObject): void;
before?(error?: THttpError, options?: ErrorHandlerObject): void;
message?: string;
notify?: any; //QNotifyOptions;
}
//signature of error function that can be passed to ours registry
type ErrorHandlerFunction = (
error?: THttpError,
) => ErrorHandlerObject | boolean | undefined;
//type that our registry accepts
type ErrorHandler = ErrorHandlerFunction | ErrorHandlerObject | string;
//interface for register many handlers once (object where key will be presented as search key for error handling
interface ErrorHandlerMany {
[key: string]: ErrorHandler;
}
// type guard to identify that is an ErrorHandlerObject
function isErrorHandlerObject(value: any): value is ErrorHandlerObject {
if (typeof value === "object") {
return ["message", "after", "before", "notify"].some((k) => k in value);
}
return false;
}
class ErrorHandlerRegistry {
private handlers = new Map<string, ErrorHandler>();
private parent: ErrorHandlerRegistry | null = null;
constructor(
parent: ErrorHandlerRegistry = undefined,
input?: ErrorHandlerMany,
) {
if (typeof parent !== "undefined") this.parent = parent;
if (typeof input !== "undefined") this.registerMany(input);
}
// allow to register an handler
register(key: string, handler: ErrorHandler) {
this.handlers.set(key, handler);
return this;
}
// unregister a handler
unregister(key: string) {
this.handlers.delete(key);
return this;
}
// search a valid handler by key
find(seek: string): ErrorHandler | undefined {
const handler = this.handlers.get(seek);
if (handler) return handler;
return this.parent?.find(seek);
}
// pass an object and register all keys/value pairs as handler.
registerMany(input: ErrorHandlerMany) {
for (const [key, value] of Object.entries(input)) {
this.register(key, value);
}
return this;
}
// handle error seeking for key
handleError(
this: ErrorHandlerRegistry,
seek: (string | undefined)[] | string,
error: THttpError,
): boolean {
if (Array.isArray(seek)) {
return seek.some((key) => {
if (key !== undefined) return this.handleError(String(key), error);
});
}
const handler = this.find(String(seek));
if (!handler) {
return false;
} else if (typeof handler === "string") {
return this.handleErrorObject(error, { message: handler });
} else if (typeof handler === "function") {
const result = handler(error);
if (isErrorHandlerObject(result))
return this.handleErrorObject(error, result);
return !!result;
} else if (isErrorHandlerObject(handler)) {
return this.handleErrorObject(error, handler);
}
return false;
}
// if the error is an ErrorHandlerObject, handle here
handleErrorObject(error: THttpError, options: ErrorHandlerObject = {}) {
options?.before?.(error, options);
showToastError(options.message ?? "Unknown Error!!", options, "error");
return true;
}
// this is the function that will be registered in interceptor.
resposeErrorHandler(
this: ErrorHandlerRegistry,
error: THttpError,
direct?: boolean,
) {
if (error === null)
throw new Error("Unrecoverrable error!! Error is null!");
if (axios.isAxiosError(error)) {
const response = error?.response;
const config = error?.config;
const data = response?.data as HttpData;
if (!direct && config?.raw) throw error;
const seekers = [
data?.code,
error.code,
error?.name,
String(data?.status),
String(response?.status),
];
const result = this.handleError(seekers, error);
if (!result) {
if (data?.code && data?.description) {
return this.handleErrorObject(error, {
message: data?.description,
});
}
}
} else if (error instanceof Error) {
return this.handleError(error.name, error);
}
//if nothings works, throw away
throw error;
}
}
// create ours globalHandlers object
const globalHandlers = new ErrorHandlerRegistry();

View File

@ -0,0 +1,120 @@
import axiosClient from "axios";
import { setupInterceptorsTo } from "./setupInterceptors";
// extend the AxiosRequestConfig interface and add two optional options raw and silent. I
// https://dev.to/mperon/axios-error-handling-like-a-boss-333d
declare module "axios" {
export interface AxiosRequestConfig {
raw?: boolean;
silent?: boolean;
}
}
export const defaultAxiosRequestConfig = {
mode: "cors",
cache: "no-cache",
credentials: "same-origin",
headers: {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "no-cache",
//'api-key': SERVER_API_KEY,
},
//timeout: 300,
// `onUploadProgress` allows handling of progress events for uploads
// browser only
//onUploadProgress: function (progressEvent) {
// Do whatever you want with the native progress event
//},
// `onDownloadProgress` allows handling of progress events for downloads
// browser only
//onDownloadProgress: function (progressEvent) {
// Do whatever you want with the native progress event
//},
// `cancelToken` specifies a cancel token that can be used to cancel the request
/*cancelToken: new CancelToken(function (cancel) {
}),*/
};
/**
* Creates an initial 'axios' instance with custom settings.
*/
const axiosInstance = setupInterceptorsTo(axiosClient.create(defaultAxiosRequestConfig));
/**
* Handle all responses.
*/
/*axiosInstance.interceptors.response.use(
(response) => {
if (!response) {
return Promise.resolve({
statusCode: 500,
body: null,
});
}
const { data, headers } = response;
const DTOBody = !isArray(data)
? data
: {
items: data,
totalCount: headers["x-total-count"]
? parseInt(headers["x-total-count"])
: data.length,
};
const result = {
statusCode: response.status,
body: DTOBody,
};
//console.log('Axios OK => ', result);
return Promise.resolve(result);
},
(error) => {
console.group("Axios error:");
if (error.response) {
// La respuesta fue hecha y el servidor respondió con un código de estado
// que esta fuera del rango de 2xx
console.log("1 => El servidor respondió con un código de estado > 200");
console.log(error.response.data);
console.log(error.response.status);
} else if (error.request) {
// La petición fue hecha pero no se recibió respuesta
console.log("2 => El servidor no respondió");
console.log(error.request);
} else {
// Algo paso al preparar la petición que lanzo un Error
console.log("3 => Error desconocido");
console.log("Error", error.message);
}
const customError = {
message: error.response?.data?.message,
statusCode: error.response?.status,
};
console.log("Axios BAD => ", error, customError);
console.groupEnd();
return Promise.reject(customError);
}
);*/
/**
* Replaces main `axios` instance with the custom-one.
*
* @param config - Axios configuration object.
* @returns A promise object of a response of the HTTP request with the 'data' object already
* destructured.
*/
/*const axios = <T>(config: AxiosRequestConfig) =>
axiosInstance.request<any, T>(config);
export default axios;*/
export const createAxiosInstance = () => axiosInstance;

View File

@ -0,0 +1,66 @@
import { ILogin_DTO, ILogin_Response_DTO } from "@shared/contexts";
import secureLocalStorage from "react-secure-storage";
import { IAuthActions } from "../hooks";
import { createAxiosInstance } from "./axiosInstance";
export const createAxiosAuthActions = (
apiUrl: string,
httpClient = createAxiosInstance()
): IAuthActions => ({
login: async ({ email, password }: ILogin_DTO) => {
// eslint-disable-next-line no-useless-catch
try {
const { data } = await httpClient.request<ILogin_Response_DTO>({
url: `${apiUrl}/auth/login`,
method: "POST",
data: {
email,
password,
},
});
secureLocalStorage.setItem("uecko", JSON.stringify(data));
return {
success: true,
data,
redirectTo: "/",
};
} catch (error) {
return {
success: false,
error: {
message: "Login failed",
name: "Invalid email or password",
},
};
}
},
logout: () => {
secureLocalStorage.removeItem("uecko");
return Promise.resolve({
success: true,
redirectTo: "/login",
});
},
check: () => {
const data: ILogin_Response_DTO = JSON.parse(
secureLocalStorage.getItem("uecko")?.toString() || ""
);
return Promise.resolve(
data.token
? {
authenticated: true,
}
: { authenticated: false, redirectTo: "/login" }
);
},
onError: (error: any) => {
console.error(error);
secureLocalStorage.removeItem("uecko");
return Promise.resolve({
error,
logout: true,
});
},
});

View File

@ -0,0 +1,309 @@
import { IListResponse_DTO } from "@shared/contexts";
import {
ICreateOneDataProviderParams,
IDataSource,
IFilterItemDataProviderParam,
IGetListDataProviderParams,
IGetOneDataProviderParams,
IPaginationDataProviderParam,
IRemoveOneDataProviderParams,
ISortItemDataProviderParam,
IUpdateOneDataProviderParams,
} from "../hooks/useDataSource/DataSource";
import { INITIAL_PAGE_INDEX, INITIAL_PAGE_SIZE } from "../hooks/usePagination";
import { createAxiosInstance } from "./axiosInstance";
export const createAxiosDataProvider = (
apiUrl: string,
httpClient = createAxiosInstance()
): IDataSource => ({
name: () => "AxiosDataProvider",
getList: async <R>(params: IGetListDataProviderParams): Promise<IListResponse_DTO<R>> => {
const { resource, quickSearchTerm, pagination, filters, sort } = params;
const url = `${apiUrl}/${resource}`;
const urlParams = new URLSearchParams();
const queryPagination = extractPaginationParams(pagination);
urlParams.append("page", String(queryPagination.page));
urlParams.append("limit", String(queryPagination.limit));
const generatedSort = extractSortParams(sort);
if (generatedSort && generatedSort.length > 0) {
urlParams.append("$sort_by", generatedSort.join(","));
}
const queryQuickSearch = quickSearchTerm || generateQuickSearch(filters);
if (queryQuickSearch) {
urlParams.append("q", queryQuickSearch);
}
const queryFilters = extractFilterParams(filters);
if (queryFilters && queryFilters.length > 0) {
urlParams.append("$filters", queryFilters.join(","));
}
const response = await httpClient.request<IListResponse_DTO<R>>({
url: `${url}?${urlParams.toString()}`,
method: "GET",
});
return response.data;
},
getOne: async <R>(params: IGetOneDataProviderParams): Promise<R> => {
const { resource, id } = params;
const response = await httpClient.request<R>({
url: `${apiUrl}/${resource}/${id}`,
method: "GET",
});
return response.data;
},
/*saveOne: async <P, R>(params: ISaveOneDataProviderParams<P>): Promise<R> => {
const { resource, data, id } = params;
console.log(params);
const result = await httpClient.request<R>({
url: `${apiUrl}/${resource}/${id}`,
method: "PUT",
data,
});
return result.data;
},*/
createOne: async <P, R>(params: ICreateOneDataProviderParams<P>): Promise<R> => {
const { resource, data } = params;
const response = await httpClient.request<R>({
url: `${apiUrl}/${resource}`,
method: "POST",
data,
});
return response.data;
},
updateOne: async <P, R>(params: IUpdateOneDataProviderParams<P>): Promise<R> => {
const { resource, data, id } = params;
const response = await httpClient.request<R>({
url: `${apiUrl}/${resource}/${id}`,
method: "PUT",
data,
});
return response.data;
},
removeOne: async <R>(params: IRemoveOneDataProviderParams) => {
const { resource, id } = params;
await httpClient.request<R>({
url: `${apiUrl}/${resource}/${id}`,
method: "DELETE",
});
return;
},
/*getMany: async ({ resource }) => {
const { body } = await httpClient.request({
url: `${apiUrl}/${resource}`,
method: "GET",
//...defaultRequestConfig,
});
return body;
},*/
/*create: async ({ resource, values }) => {
const url = `${apiUrl}/${resource}`;
const { body } = await httpClient.post(url, values, defaultRequestConfig);
return body;
},*/
/*createMany: async ({ resource, values }) => {
const response = await Promise.all(
values.map(async (param) => {
const { body } = await httpClient.post(
`${apiUrl}/${resource}`,
param
//defaultRequestConfig,
);
return body;
})
);
return response;
},*/
/*update: async ({ resource, id, values }) => {
const url = `${apiUrl}/${resource}/${id}`;
const { body } = await httpClient.patch(url, values, defaultRequestConfig);
return body;
},*/
/*updateMany: async ({ resource, ids, values }) => {
const response = await Promise.all(
ids.map(async (id) => {
const { body } = await httpClient.patch(
`${apiUrl}/${resource}/${id}`,
values
//defaultRequestConfig,
);
return body;
})
);
return response;
},*/
// removeMany: async ({ resource, ids }) => {
// const url = `${apiUrl}/${resource}/bulk-delete`;
// const { body } = await httpClient.request({
// url,
// method: "PATCH",
// data: {
// ids,
// },
// //defaultRequestConfig,
// });
// return body;
// },
// upload: async ({ resource, file, onUploadProgress }) => {
// const url = `${apiUrl}/${resource}`;
// const options = {
// //...defaultRequestConfig,
// onUploadProgress,
// headers: {
// //...defaultRequestConfig.headers,
// "Content-Type": "multipart/form-data",
// },
// };
// const formData = new FormData();
// formData.append("file", file);
// const { body } = await httpClient.post(url, formData, options);
// return body;
// },
/*uploadMany: async ({ resource, values }) => {
const url = `${apiUrl}/${resource}`;
const options = {
//...defaultRequestConfig,
headers: {
...defaultRequestConfig.headers,
'Content-Type': 'multipart/form-data'
}
};
const response = await Promise.all(
values.map(async (value) => {
const { body } = await httpClient.post(
url,
value,
options
);
return body;
}),
);
return response;
},*/
/*custom: async ({ url, method, filters, sort, payload, query, headers }) => {
let requestUrl = `${url}?`;
if (sort) {
const generatedSort = extractSortParams(sort);
if (generatedSort) {
const { _sort, _order } = generatedSort;
const sortQuery = {
_sort: _sort.join(","),
_order: _order.join(","),
};
requestUrl = `${requestUrl}&${queryString.stringify(sortQuery)}`;
}
}
if (filters) {
const filterQuery = extractFilterParams(filters);
requestUrl = `${requestUrl}&${queryString.stringify(filterQuery)}`;
}
if (query) {
requestUrl = `${requestUrl}&${queryString.stringify(query)}`;
}
if (headers) {
httpClient.defaults.headers = {
...httpClient.defaults.headers,
...headers,
};
}
let axiosResponse;
switch (method) {
case "put":
case "post":
case "patch":
axiosResponse = await httpClient[method](url, payload);
break;
case "remove":
axiosResponse = await httpClient.delete(url);
break;
default:
axiosResponse = await httpClient.get(requestUrl);
break;
}
const { data } = axiosResponse;
return Promise.resolve({ data });
},*/
});
const extractSortParams = (sort: ISortItemDataProviderParam[] = []) =>
sort.map((item) => `${item.order === "DESC" ? "-" : "+"}${item.field}`);
const extractFilterParams = (filters?: IFilterItemDataProviderParam[]): string[] => {
let queryFilters: string[] = [];
if (filters) {
queryFilters = filters
.filter((item) => item.field !== "q")
.map(({ field, operator, value }) => `${field}[${operator}]${value}`);
}
return queryFilters;
};
const generateQuickSearch = (filters?: IFilterItemDataProviderParam[]): string | undefined => {
let quickSearch: string | undefined = undefined;
if (filters) {
const qsArray = filters.filter((item) => item.field === "q");
if (qsArray.length > 0) {
quickSearch = qsArray[0].value;
}
}
return quickSearch;
};
const extractPaginationParams = (pagination?: IPaginationDataProviderParam) => {
const { pageIndex = INITIAL_PAGE_INDEX, pageSize = INITIAL_PAGE_SIZE } = pagination || {};
return {
page: pageIndex,
limit: pageSize,
};
};

View File

@ -0,0 +1,65 @@
import { IListResponse_DTO } from "@shared/contexts";
import {
IDataSource,
IGetListDataProviderParams,
IGetOneDataProviderParams,
IPaginationDataProviderParam,
} from "../hooks/useDataSource/DataSource";
import { INITIAL_PAGE_INDEX, INITIAL_PAGE_SIZE } from "../hooks/usePagination";
export const createJSONDataProvider = (jsonData: unknown[]): IDataSource => ({
name: () => "JSONDataProvider",
getList: <R>(params: IGetListDataProviderParams): Promise<IListResponse_DTO<R>> => {
const { resource, quickSearchTerm, pagination, filters, sort } = params;
const queryPagination = extractPaginationParams(pagination);
const items = jsonData.slice(
queryPagination.page * queryPagination.limit,
queryPagination.page * queryPagination.limit + queryPagination.limit
);
const totalItems = jsonData.length;
const totalPages = Math.ceil(totalItems / queryPagination.limit);
const response: IListResponse_DTO<R> = {
page: queryPagination.page,
per_page: queryPagination.limit,
total_pages: totalPages,
total_items: totalItems,
items,
};
return Promise.resolve(response);
},
getOne: async <R>(params: IGetOneDataProviderParams): Promise<R> => {
/*const { resource, id } = params;
const response = await httpClient.request<R>({
url: `${apiUrl}/${resource}/${id}`,
method: "GET",
});
return response.data;*/
},
createOne: <P>(params: ICreateOneDataProviderParams<P>) => {
throw Error;
},
updateOne: <P>(params: IUpdateOneDataProviderParams<P>) => {
throw Error;
},
removeOne: (params: IRemoveOneDataProviderParams) => {
throw Error;
},
});
const extractPaginationParams = (pagination?: IPaginationDataProviderParam) => {
const { pageIndex = INITIAL_PAGE_INDEX, pageSize = INITIAL_PAGE_SIZE } = pagination || {};
return {
page: pageIndex,
limit: pageSize,
};
};

View File

@ -0,0 +1,2 @@
export * from "./HttpError";
export * from "./createAxiosDataProvider";

View File

@ -0,0 +1,104 @@
import {
AxiosError,
AxiosInstance,
AxiosResponse,
InternalAxiosRequestConfig,
} from "axios";
//use(onFulfilled?: ((value: V) => V | Promise<V>) | null,
//onRejected?: ((error: any) => any) | null, options?: AxiosInterceptorOptions): number;
const onRequest = (
request: InternalAxiosRequestConfig
): InternalAxiosRequestConfig => {
/*console.group("[request]");
console.dir(request);
console.groupEnd();*/
return request;
};
const onRequestError = (error: AxiosError): Promise<AxiosError> => {
/*console.group("[request error]");
console.dir(error);
console.groupEnd();*/
return Promise.reject(error);
};
const onResponse = (response: AxiosResponse): AxiosResponse => {
console.group("[response]");
console.dir(response);
console.groupEnd();
const config = response?.config;
if (config.raw) {
return response;
}
/*if (response.status === 200) {
const data = response?.data;
if (!data) {
throw new HttpError("API Error. No data!");
}
return data;
}*/
//throw new HttpError("API Error! Invalid status code!");
return response;
};
const onResponseError = (error: AxiosError): Promise<AxiosError> => {
console.group("[response error]");
console.log(error);
if (error.response) {
// La respuesta fue hecha y el servidor respondió con un código de estado
// que esta fuera del rango de 2xx
console.log("1 => El servidor respondió con un código de estado > 200");
console.log(error.response.data);
console.log(error.response.status);
switch (error.response.status) {
case 400:
console.error("Bad Request");
break;
case 401:
console.error("UnAuthorized");
break;
case 403:
console.error("Forbidden");
break;
/*AppEvents.publish(Events.N_Error, {
message: "Forbidden",
description: "Operation ",
});*/
case 404:
console.error("Not found");
break;
case 422:
console.error("Unprocessable Content");
throw error.response.data;
}
console.error(error.response.status);
} else if (error.request) {
// La petición fue hecha pero no se recibió respuesta
console.log("2 => El servidor no respondió");
console.error(error);
} else {
// Algo paso al preparar la petición que lanzo un Error
console.log("3 => Error desconocido");
console.error(error);
}
console.groupEnd();
throw error;
};
export function setupInterceptorsTo(
axiosInstance: AxiosInstance
): AxiosInstance {
axiosInstance.interceptors.request.use(onRequest, onRequestError);
axiosInstance.interceptors.response.use(onResponse, onResponseError);
return axiosInstance;
}

View File

View File

@ -0,0 +1,15 @@
/*export * from "./useBreadcrumbs";
export * from "./useDataSource";
export * from "./useDataTable";
export * from "./useForm";
export * from "./useLoadingOvertime";
export * from "./useMounted";
export * from "./usePagination";
export * from "./useRemoveAlertDialog";
export * from "./useResizeObserver";
export * from "./useUrlId";
*/
export * from "./useAuth";
export * from "./useTheme";

View File

@ -0,0 +1,41 @@
export type SuccessNotificationResponse = {
message: string;
description?: string;
};
export type PermissionResponse = unknown;
export type IdentityResponse = unknown;
export type AuthActionCheckResponse = {
authenticated: boolean;
redirectTo?: string;
logout?: boolean;
error?: Error;
};
export type AuthActionOnErrorResponse = {
redirectTo?: string;
logout?: boolean;
error?: Error;
};
export type AuthActionResponse = {
success: boolean;
redirectTo?: string;
error?: Error;
[key: string]: unknown;
successNotification?: SuccessNotificationResponse;
};
export interface IAuthActions {
login: (params: any) => Promise<AuthActionResponse>;
logout: (params: any) => Promise<AuthActionResponse>;
check: (params?: any) => Promise<AuthActionCheckResponse>;
onError?: (error: any) => Promise<AuthActionOnErrorResponse>;
register?: (params: unknown) => Promise<AuthActionResponse>;
forgotPassword?: (params: unknown) => Promise<AuthActionResponse>;
updatePassword?: (params: unknown) => Promise<AuthActionResponse>;
getPermissions?: (params?: Record<string, unknown>) => Promise<PermissionResponse>;
getIdentity?: (params?: unknown) => Promise<IdentityResponse>;
}

View File

@ -0,0 +1,51 @@
import { PropsWithChildren, createContext } from "react";
import { IAuthActions } from "./AuthActions";
export interface IAuthContextState extends IAuthActions {}
export const AuthContext = createContext<IAuthContextState | null>(null);
export const AuthProvider = ({
children,
authActions,
}: PropsWithChildren<{ authActions: Partial<IAuthActions> }>) => {
const handleLogin = (params: unknown) => {
try {
return Promise.resolve(authActions.login?.(params));
} catch (error) {
console.error(error);
return Promise.reject(error);
}
};
const handleLogout = (params: unknown) => {
try {
return Promise.resolve(authActions.logout?.(params));
} catch (error) {
console.error(error);
return Promise.reject(error);
}
};
const handleCheck = async (params: unknown) => {
try {
return Promise.resolve(authActions.check?.(params));
} catch (error) {
console.error(error);
return Promise.reject(error);
}
};
return (
<AuthContext.Provider
value={{
...authActions,
login: handleLogin as IAuthActions["login"],
logout: handleLogout as IAuthActions["logout"],
check: handleCheck as IAuthActions["check"],
}}
>
{children}
</AuthContext.Provider>
);
};

View File

@ -0,0 +1,5 @@
export * from "./AuthActions";
export * from "./AuthContext";
export * from "./useAuth";
export * from "./useIsLoggedIn";
export * from "./useLogin";

View File

@ -0,0 +1,8 @@
import { useContext } from "react";
import { AuthContext } from "./AuthContext";
export const useAuth = () => {
const context = useContext(AuthContext);
if (context === null) throw new Error("useAuth must be used within a AuthProvider");
return context;
};

View File

@ -0,0 +1,16 @@
import { AuthActionCheckResponse, useAuth } from "@/lib/hooks";
import { UseMutationOptions, useMutation } from "@tanstack/react-query";
import { useQueryKey } from "../useQueryKey";
export const useIsLoggedIn = (
params?: UseMutationOptions<AuthActionCheckResponse, Error, unknown>
) => {
const keys = useQueryKey();
const { check } = useAuth();
return useMutation({
mutationKey: keys().auth().action("check").get(),
mutationFn: check,
...params,
});
};

View File

@ -0,0 +1,36 @@
import { AuthActionResponse, useAuth } from "@/lib/hooks";
import { ILogin_DTO } from "@shared/contexts";
import { UseMutationOptions, useMutation } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify";
import { useQueryKey } from "../useQueryKey";
export const useLogin = (params?: UseMutationOptions<AuthActionResponse, Error, ILogin_DTO>) => {
const { onSuccess, onError, ...restParams } = params || {};
const keys = useQueryKey();
const { login } = useAuth();
const navigate = useNavigate();
return useMutation({
mutationKey: keys().auth().action("login").get(),
mutationFn: login,
onSuccess: async (data, variables, context) => {
const { success, redirectTo } = data;
if (success && redirectTo) {
navigate(redirectTo);
}
if (onSuccess) {
onSuccess(data, variables, context);
}
},
onError: (error, variables, context) => {
const { message } = error;
toast.error(message);
if (onError) {
onError(error, variables, context);
}
},
...restParams,
});
};

View File

@ -0,0 +1,19 @@
import { useEffect, useState } from "react";
import { useMatches } from "react-router";
export const useBreadcrumbs = (): any[] => {
const [crumbs, setCrumbs] = useState<any[]>([]);
let matches = useMatches();
useEffect(() => {
const _crumbs = matches
// @ts-ignore
.filter((match) => Boolean(match.handle?.crumb))
// @ts-ignore
.map((match) => match.handle?.crumb(match.data));
setCrumbs(_crumbs);
}, matches);
return crumbs;
};

View File

@ -0,0 +1,82 @@
import { IListResponse_DTO } from "@shared/contexts";
export interface IPaginationDataProviderParam {
pageIndex: number;
pageSize: number;
}
export interface ISortItemDataProviderParam {
order: string;
field: string;
}
export interface IFilterItemDataProviderParam {
field: string;
operator?: string;
value?: string;
}
export interface IGetListDataProviderParams {
resource: string;
quickSearchTerm?: string;
pagination?: IPaginationDataProviderParam;
sort?: ISortItemDataProviderParam[];
filters?: IFilterItemDataProviderParam[];
}
export interface IGetOneDataProviderParams {
resource: string;
id: string;
}
export interface ISaveOneDataProviderParams<T> {
resource: string;
data: T;
id: string;
}
export interface ICreateOneDataProviderParams<T> {
resource: string;
data: T;
}
export interface IUpdateOneDataProviderParams<T> {
resource: string;
data: T;
id: string;
}
export interface IRemoveOneDataProviderParams {
resource: string;
id: string;
}
/*export interface ICustomDataProviderParam {
resource: string;
method: string;
params: any;
}*/
export interface IDataSource {
name: () => string;
getList: <R>(params: IGetListDataProviderParams) => Promise<IListResponse_DTO<R>>;
getOne: <R>(params: IGetOneDataProviderParams) => Promise<R>;
//saveOne: <P, R>(params: ISaveOneDataProviderParams<P>) => Promise<R>;
createOne: <P, R>(params: ICreateOneDataProviderParams<P>) => Promise<R>;
updateOne: <P, R>(params: IUpdateOneDataProviderParams<P>) => Promise<R>;
removeOne: (params: IRemoveOneDataProviderParams) => Promise<void>;
//custom: <R>(params: ICustomDataProviderParam) => Promise<R>;
//getApiUrl: () => string;
//create: () => any;
//createMany: () => any;
//removeMany: () => any;
//getMany: () => any;
//update: () => any;
//updateMany: () => any;
//upload: () => any;
//custom: () => any;
//getApiUrl: () => string;
}

View File

@ -0,0 +1,11 @@
import { PropsWithChildren, createContext } from "react";
import { IDataSource } from "./DataSource";
export const DataSourceContext = createContext<IDataSource | null>(null);
export const DataSourceProvider = ({
dataSource,
children,
}: PropsWithChildren<{
dataSource: IDataSource;
}>) => <DataSourceContext.Provider value={dataSource}>{children}</DataSourceContext.Provider>;

View File

@ -0,0 +1,10 @@
// export * from './useApiUrl';
// export * from './useCreateMany';
export * from "./useList";
export * from "./useMany";
export * from "./useOne";
export * from "./useRemove";
export * from "./useRemoveMany";
export * from "./useSave";
// export * from './useUpdateMany';
// export * from './useUpload';

View File

@ -0,0 +1,10 @@
import { useContext } from "react";
import { DataSourceContext } from "./DataSourceContext";
export const useDataSource = () => {
const context = useContext(DataSourceContext);
if (context === undefined)
throw new Error("useDataSource must be used within a DataSourceProvider");
return context;
};

View File

@ -0,0 +1,78 @@
import { IsResponseAListDTO } from "@shared/contexts";
import {
QueryFunctionContext,
QueryKey,
UseQueryResult,
keepPreviousData,
useQuery,
} from "@tanstack/react-query";
import { useEffect, useState } from "react";
import {
UseLoadingOvertimeOptionsProps,
UseLoadingOvertimeReturnType,
useLoadingOvertime,
} from "../useLoadingOvertime/useLoadingOvertime";
const DEFAULT_REFETCH_INTERVAL = 2 * 60 * 1000; // 2 minutes
const DEFAULT_STALE_TIME = 60 * 1000; // 1 minute
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export type UseListQueryOptions<TUseListQueryData, TUseListQueryError> = {
queryKey: QueryKey;
queryFn: (context: QueryFunctionContext) => Promise<TUseListQueryData>;
enabled?: boolean;
select?: (data: TUseListQueryData) => TUseListQueryData;
queryOptions?: Record<string, unknown>;
} & UseLoadingOvertimeOptionsProps;
export type UseListQueryResult<TUseListQueryData, TUseListQueryError> =
UseQueryResult<TUseListQueryData, TUseListQueryError> & {
isEmpty: boolean;
} & UseLoadingOvertimeReturnType;
export const useList = <TUseListQueryData, TUseListQueryError>({
queryKey,
queryFn,
enabled,
select,
queryOptions = {},
overtimeOptions,
}: UseListQueryOptions<
TUseListQueryData,
TUseListQueryError
>): UseListQueryResult<TUseListQueryData, TUseListQueryError> => {
const [isEmpty, setIsEmpty] = useState<boolean>(false);
const queryResponse = useQuery<TUseListQueryData, TUseListQueryError>({
queryKey,
queryFn,
placeholderData: keepPreviousData,
staleTime: DEFAULT_STALE_TIME,
refetchInterval: DEFAULT_REFETCH_INTERVAL,
refetchOnWindowFocus: true,
enabled: enabled && !!queryFn,
select,
...queryOptions,
});
useEffect(() => {
if (queryResponse.isSuccess && IsResponseAListDTO(queryResponse.data)) {
setIsEmpty(queryResponse.data.total_items === 0);
}
}, [queryResponse]);
const { elapsedTime } = useLoadingOvertime({
isPending: queryResponse.isFetching,
interval: overtimeOptions?.interval,
onInterval: overtimeOptions?.onInterval,
});
const result = {
...queryResponse,
overtime: { elapsedTime },
isEmpty,
};
return result;
};

View File

@ -0,0 +1,37 @@
import {
QueryFunction,
QueryKey,
UseQueryResult,
useQuery,
} from '@tanstack/react-query';
export interface IUseManyQueryOptions<
TUseManyQueryData = unknown,
TUseManyQueryError = unknown
> {
queryKey: QueryKey;
queryFn: QueryFunction<TUseManyQueryData, QueryKey>;
enabled?: boolean;
select?: (data: TUseManyQueryData) => TUseManyQueryData;
queryOptions?: any;
}
export function useMany<TUseManyQueryData, TUseManyQueryError>(
options: IUseManyQueryOptions<TUseManyQueryData, TUseManyQueryError>
): UseQueryResult<TUseManyQueryData, TUseManyQueryError> {
const { queryKey, queryFn, enabled, select, queryOptions } = options;
const queryResponse = useQuery<TUseManyQueryData, TUseManyQueryError>({
queryKey,
queryFn,
keepPreviousData: true,
...queryOptions,
enabled,
select,
});
return queryResponse;
}

View File

@ -0,0 +1,47 @@
import {
QueryFunctionContext,
QueryKey,
UseQueryResult,
keepPreviousData,
useQuery,
} from "@tanstack/react-query";
import {
UseLoadingOvertimeOptionsProps,
UseLoadingOvertimeReturnType,
} from "../useLoadingOvertime/useLoadingOvertime";
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export type UseOneQueryOptions<TUseOneQueryData, TUseOneQueryError> = {
queryKey: QueryKey;
queryFn: (context: QueryFunctionContext) => Promise<TUseOneQueryData>;
enabled?: boolean;
autoRefresh?: boolean;
select?: (data: TUseOneQueryData) => TUseOneQueryData;
queryOptions?: Record<string, unknown>;
} & UseLoadingOvertimeOptionsProps;
export type UseOneQueryResult<TUseOneQueryData, TUseOneQueryError> =
UseQueryResult<TUseOneQueryData, TUseOneQueryError> & {
isEmpty: boolean;
} & UseLoadingOvertimeReturnType;
export function useOne<TUseOneQueryData, TUseOneQueryError>({
queryKey,
queryFn,
enabled,
select,
queryOptions = {},
}: UseOneQueryOptions<TUseOneQueryData, TUseOneQueryError>): UseQueryResult<
TUseOneQueryData,
TUseOneQueryError
> {
return useQuery<TUseOneQueryData, TUseOneQueryError>({
queryKey,
queryFn,
placeholderData: keepPreviousData,
enabled,
select,
...queryOptions,
});
}

View File

@ -0,0 +1,30 @@
import { useMutation } from '@tanstack/react-query';
export interface IUseRemoveMutationOptions<
TUseRemoveMutationData,
TUseRemoveMutationError,
TUseRemoveMutationVariables
> {
mutationFn: (
variables: TUseRemoveMutationVariables,
) => Promise<TUseRemoveMutationData>;
}
export function useRemove<
TUseRemoveMutationData,
TUseRemoveMutationError,
TUseRemoveMutationVariables>(options: IUseRemoveMutationOptions<
TUseRemoveMutationData,
TUseRemoveMutationError,
TUseRemoveMutationVariables
>) {
const { mutationFn, ...params } = options;
return useMutation<
TUseRemoveMutationData,
TUseRemoveMutationError,
TUseRemoveMutationVariables>({
mutationFn,
...params
});
}

View File

@ -0,0 +1,13 @@
import { useMutation } from '@tanstack/react-query';
export interface IUseRemoveManyMutationOptions {
mutationFn: any;
}
export function useRemoveMany(options: IUseRemoveManyMutationOptions) {
const { mutationFn } = options;
return useMutation({
mutationFn,
});
}

View File

@ -0,0 +1,43 @@
import {
DefaultError,
UseMutationOptions,
useMutation,
} from "@tanstack/react-query";
export interface IUseSaveMutationOptions<
TUseSaveMutationData = unknown,
TUseSaveMutationError = DefaultError,
TUseSaveMutationVariables = unknown,
TUseSaveMutationContext = unknown,
> extends UseMutationOptions<
TUseSaveMutationData,
TUseSaveMutationError,
TUseSaveMutationVariables,
TUseSaveMutationContext
> {}
export function useSave<
TUseSaveMutationData = unknown,
TUseSaveMutationError = DefaultError,
TUseSaveMutationVariables = unknown,
TUseSaveMutationContext = unknown,
>(
options: IUseSaveMutationOptions<
TUseSaveMutationData,
TUseSaveMutationError,
TUseSaveMutationVariables,
TUseSaveMutationContext
>,
) {
const { mutationFn, ...params } = options;
return useMutation<
TUseSaveMutationData,
TUseSaveMutationError,
TUseSaveMutationVariables,
TUseSaveMutationContext
>({
mutationFn,
...params,
});
}

View File

@ -0,0 +1,192 @@
import { differenceWith, isEmpty, isEqual, reverse, unionWith } from 'lodash';
export const parseQueryString = (queryString: Record<string, string>) => {
// pagination
const pageIndex = parseInt(queryString.page ?? '0', 0) - 1;
const pageSize = Math.min(parseInt(queryString.limit ?? '10', 10), 100);
const pagination =
pageIndex >= 0 && pageSize > 0 ? { pageIndex, pageSize } : undefined;
// pagination
/*let pagination = undefined;
if (page !== undefined && limit !== undefined) {
let parsedPage = toSafeInteger(queryString['page']) - 1;
if (parsedPage < 0) parsedPage = 0;
let parsedPageSize = toSafeInteger(queryString['limit']);
if (parsedPageSize > 100) parsedPageSize = 100;
if (parsedPageSize < 5) parsedPageSize = 5;
pagination = {
pageIndex: parsedPage,
pageSize: parsedPageSize,
};
}*/
// sorter
// sorter
const sorter = (queryString.sort ?? '')
.split(',')
.map((token) => token.match(/([+-]?)([\w_]+)/i))
.slice(0, 3)
.map((item) => (item ? { id: item[2], desc: item[1] === '-' } : null))
.filter(Boolean);
/*let sorter = [];
if (sort !== undefined) {
sorter = sort
.split(',')
.map((token) => token.match(/([+-]?)([\w_]+)/i))
.slice(0, 3)
.map((item) =>
item ? { id: item[2], desc: item[1] === '-' } : null
);
}*/
// filters
const filters = Object.entries(queryString)
.filter(([key]) => key !== 'page' && key !== 'limit' && key !== 'sort')
.map(([key, value]) => {
const [, field, , , operator] =
key.match(/([\w]+)(([\[])([\w]+)([\]]))*/i) ?? [];
const sanitizedOperator = _sanitizeOperator(operator ?? '');
return !isEmpty(value)
? { field, operator: sanitizedOperator, value }
: null;
})
.filter(Boolean);
/*let filters = [];
if (filterCandidates !== undefined) {
Object.keys(filterCandidates).map((token) => {
const [, field, , , operator] = token.match( */
// /([\w]+)(([\[])([\w]+)([\]]))*/i
/* );
const value = filterCandidates[token];
if (!isEmpty(value)) {
filters.push({
field,
operator: _sanitizeOperator(operator),
value,
});
}
});
}*/
return {
pagination,
sorter,
filters,
};
};
export const buildQueryString = ({ pagination, sorter, filters }) => {
const params = new URLSearchParams();
if (
pagination &&
pagination.pageIndex !== undefined &&
pagination.pageSize !== undefined
) {
params.append('page', String(pagination.pageIndex + 1));
params.append('limit', String(pagination.pageSize));
}
if (sorter && Array.isArray(sorter) && sorter.length > 0) {
params.append(
'sort',
sorter.map(({ id, desc }) => `${desc ? '-' : ''}${id}`).toString()
);
}
if (filters && Array.isArray(filters) && filters.length > 0) {
filters.forEach((filterItem) => {
if (filterItem.value !== undefined) {
let operator = _mapFilterOperator(filterItem.operator);
if (operator === 'eq') {
params.append(`${filterItem.field}`, filterItem.value);
} else {
params.append(
`${filterItem.field}[${_mapFilterOperator(
filterItem.operator
)}]`,
filterItem.value
);
}
}
});
}
return params.toString();
};
export const combineFilters = (requiredFilters = [], otherFilters = []) => [
...differenceWith(otherFilters, requiredFilters, isEqual),
...requiredFilters,
];
export const unionFilters = (
permanentFilter = [],
newFilters = [],
prevFilters = []
) =>
reverse(
unionWith(
permanentFilter,
newFilters,
prevFilters,
(left, right) =>
left.field == right.field && left.operator == right.operator
)
).filter(
(crudFilter) =>
crudFilter.value !== undefined && crudFilter.value !== null
);
export const extractTableSortPropertiesFromColumn = (columns) => {
const _extractColumnSortProperies = (column) => {
const { canSort, isSorted, sortedIndex, isSortedDesc } = column;
if (!isSorted || !canSort) {
return undefined;
} else {
return {
index: sortedIndex,
field: column.id,
order: isSortedDesc ? 'DESC' : 'ASC',
};
}
};
return columns
.map((column) => _extractColumnSortProperies(column))
.filter((item) => item)
.sort((a, b) => a.index - b.index);
};
export const extractTableSortProperties = (sorter) => {
return sorter.map((sortItem, index) => ({
index,
field: sortItem.id,
order: sortItem.desc ? 'DESC' : 'ASC',
}));
};
export const extractTableFilterProperties = (filters) =>
filters.filter((item) => !isEmpty(item.value));
const _sanitizeOperator = (operator) =>
['eq', 'ne', 'gte', 'lte', 'like'].includes(operator) ? operator : 'eq';
const _mapFilterOperator = (operator) => {
switch (operator) {
case 'ne':
case 'gte':
case 'lte':
return `[${operator}]`;
case 'contains':
return '[like]';
default:
return 'eq';
}
};

View File

@ -0,0 +1,3 @@
export * from "./useDataTable";
export * from "./useDataTableColumns";
export * from "./useQueryDataTable";

View File

@ -0,0 +1,321 @@
import {
OnChangeFn,
PaginationState,
getCoreRowModel,
getFacetedRowModel,
getFacetedUniqueValues,
getFilteredRowModel,
getSortedRowModel,
useReactTable,
type ColumnDef,
type ColumnFiltersState,
type SortingState,
type VisibilityState,
} from "@tanstack/react-table";
import { getDataTableSelectionColumn } from "@/components";
import React, { useCallback, useMemo, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { DataTableFilterField } from "../../types";
import { usePaginationParams } from "../usePagination";
//import { useDebounce } from "@/hooks/use-debounce";
interface UseDataTableProps<TData, TValue> {
/**
* The data for the table.
* @default []
* @type TData[]
*/
data: TData[];
/**
* The columns of the table.
* @default []
* @type ColumnDef<TData, TValue>[]
*/
columns: ColumnDef<TData, TValue>[];
/**
* The number of pages in the table.
* @type number
*/
pageCount: number;
/**
* Enable sorting columns.
* @default false
* @type boolean
*/
enableSorting?: boolean;
/**
* Enable hiding columns.
* @default false
* @type boolean
*/
enableHiding?: boolean;
/**
* Enable selection rows.
* @default false
* @type boolean
*/
enableRowSelection?: boolean;
/**
* Defines filter fields for the table. Supports both dynamic faceted filters and search filters.
* - Faceted filters are rendered when `options` are provided for a filter field.
* - Otherwise, search filters are rendered.
*
* The indie filter field `value` represents the corresponding column name in the database table.
* @default []
* @type { label: string, value: keyof TData, placeholder?: string, options?: { label: string, value: string, icon?: React.ComponentType<{ className?: string }> }[] }[]
* @example
* ```ts
* // Render a search filter
* const filterFields = [
* { label: "Title", value: "title", placeholder: "Search titles" }
* ];
* // Render a faceted filter
* const filterFields = [
* {
* label: "Status",
* value: "status",
* options: [
* { label: "Todo", value: "todo" },
* { label: "In Progress", value: "in-progress" },
* { label: "Done", value: "done" },
* { label: "Canceled", value: "canceled" }
* ]
* }
* ];
* ```
*/
filterFields?: DataTableFilterField<TData>[];
/**
* Enable notion like column filters.
* Advanced filters and column filters cannot be used at the same time.
* @default false
* @type boolean
*/
enableAdvancedFilter?: boolean;
}
export function useDataTable<TData, TValue>({
data,
columns,
pageCount,
enableSorting = false,
enableHiding = false,
enableRowSelection = false,
filterFields = [],
enableAdvancedFilter = false,
}: UseDataTableProps<TData, TValue>) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [searchParams, setSearchParams] = useSearchParams();
const [pagination, setPagination] = usePaginationParams();
const [sorting, setSorting] = useState<SortingState>([]);
// Memoize computation of searchableColumns and filterableColumns
const { searchableColumns, filterableColumns } = useMemo(() => {
return {
searchableColumns: filterFields.filter((field) => !field.options),
filterableColumns: filterFields.filter((field) => field.options),
};
}, [filterFields]);
// Create query string
/*const createQueryString = useCallback(
(params: Record<string, string | number | null>) => {
const newSearchParams = new URLSearchParams(searchParams?.toString());
for (const [key, value] of Object.entries(params)) {
if (value === null) {
newSearchParams.delete(key);
} else {
newSearchParams.set(key, String(value));
}
}
return newSearchParams.toString();
},
[searchParams]
);*/
// Initial column filters
const initialColumnFilters: ColumnFiltersState = useMemo(() => {
return Array.from(searchParams.entries()).reduce<ColumnFiltersState>(
(filters, [key, value]) => {
const filterableColumn = filterableColumns.find(
(column) => column.value === key
);
const searchableColumn = searchableColumns.find(
(column) => column.value === key
);
if (filterableColumn) {
filters.push({
id: key,
value: value.split("."),
});
} else if (searchableColumn) {
filters.push({
id: key,
value: [value],
});
}
return filters;
},
[]
);
}, [filterableColumns, searchableColumns, searchParams]);
// Table states
const [rowSelection, setRowSelection] = React.useState({});
const [columnVisibility, setColumnVisibility] =
React.useState<VisibilityState>({});
const [columnFilters, setColumnFilters] =
React.useState<ColumnFiltersState>(initialColumnFilters);
const paginationUpdater: OnChangeFn<PaginationState> = (updater) => {
if (typeof updater === "function") {
const newPagination = updater(pagination);
console.log(newPagination);
setPagination(newPagination);
}
};
const sortingUpdater: OnChangeFn<SortingState> = (updater) => {
if (typeof updater === "function") {
setSorting(updater(sorting));
}
};
// Handle server-side filtering
/*const debouncedSearchableColumnFilters = JSON.parse(
useDebounce(
JSON.stringify(
columnFilters.filter((filter) => {
return searchableColumns.find((column) => column.value === filter.id);
})
),
500
)
) as ColumnFiltersState;*/
/*const filterableColumnFilters = columnFilters.filter((filter) => {
return filterableColumns.find((column) => column.value === filter.id);
});
const [mounted, setMounted] = useState(false);*/
/*useEffect(() => {
// Opt out when advanced filter is enabled, because it contains additional params
if (enableAdvancedFilter) return;
// Prevent resetting the page on initial render
if (!mounted) {
setMounted(true);
return;
}
// Initialize new params
const newParamsObject = {
page: 1,
};
// Handle debounced searchable column filters
for (const column of debouncedSearchableColumnFilters) {
if (typeof column.value === "string") {
Object.assign(newParamsObject, {
[column.id]: typeof column.value === "string" ? column.value : null,
});
}
}
// Handle filterable column filters
for (const column of filterableColumnFilters) {
if (typeof column.value === "object" && Array.isArray(column.value)) {
Object.assign(newParamsObject, { [column.id]: column.value.join(".") });
}
}
// Remove deleted values
for (const key of searchParams.keys()) {
if (
(searchableColumns.find((column) => column.value === key) &&
!debouncedSearchableColumnFilters.find(
(column) => column.id === key
)) ||
(filterableColumns.find((column) => column.value === key) &&
!filterableColumnFilters.find((column) => column.id === key))
) {
Object.assign(newParamsObject, { [key]: null });
}
}
// After cumulating all the changes, push new params
navigate(`${location.pathname}?${createQueryString(newParamsObject)}`);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
// eslint-disable-next-line react-hooks/exhaustive-deps
//JSON.stringify(debouncedSearchableColumnFilters),
// eslint-disable-next-line react-hooks/exhaustive-deps
JSON.stringify(filterableColumnFilters),
]);*/
const getTableColumns = useCallback(() => {
const _columns = columns;
if (enableRowSelection) {
_columns.unshift(getDataTableSelectionColumn());
}
return _columns;
}, [columns, enableRowSelection]);
const table = useReactTable({
columns: getTableColumns(),
data,
getCoreRowModel: getCoreRowModel(),
//getPaginationRowModel: getPaginationRowModel(),
state: {
pagination,
sorting,
columnVisibility,
rowSelection,
columnFilters,
},
enableRowSelection,
onRowSelectionChange: setRowSelection,
manualSorting: true,
enableSorting,
getSortedRowModel: getSortedRowModel(),
onSortingChange: sortingUpdater,
enableHiding,
onColumnVisibilityChange: setColumnVisibility,
manualPagination: true,
pageCount: pageCount ?? -1,
onPaginationChange: paginationUpdater,
getFilteredRowModel: getFilteredRowModel(),
manualFiltering: true,
onColumnFiltersChange: setColumnFilters,
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues(),
});
return { table };
}

View File

@ -0,0 +1,42 @@
import { ColumnDef } from "@tanstack/react-table";
import { useEffect, useState } from "react";
// Función para calcular las columnas
export function useDataTableColumns<T extends object>(
data: T[],
extraColumns: ColumnDef<T>[] = []
): ColumnDef<T>[] {
const [columns, setColumns] = useState<ColumnDef<T>[]>([]);
useEffect(() => {
if (!data || data.length === 0) {
setColumns([]);
return;
}
// Obtener las claves de todas las propiedades de todos los elementos
const keys = data.reduce((acc: string[], item: T) => {
return acc.concat(Object.keys(item));
}, []);
// Eliminar claves duplicadas y ordenarlas alfabéticamente
const uniqueKeys = Array.from(new Set(keys)).sort();
// Crear una columna para cada clave
const calculatedColumns: ColumnDef<T>[] = uniqueKeys.map((key) => {
return {
id: key,
header: key,
accessorKey: key.toLowerCase().replace(/\s+/g, "_"),
};
});
const finalColumns = extraColumns
? calculatedColumns.concat(extraColumns)
: calculatedColumns;
setColumns(finalColumns);
}, [data, extraColumns]);
return columns;
}

View File

@ -0,0 +1,127 @@
import {
DataTableColumnProps,
getDataTableSelectionColumn,
} from "@/components";
import { IListResponse_DTO } from "@shared/contexts";
import {
OnChangeFn,
PaginationState,
SortingState,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
import { useCallback, useMemo, useState } from "react";
import { UseListQueryResult } from "../useDataSource";
import { usePaginationParams } from "../usePagination";
type TUseDataTableQueryResult<TData, TError> = UseListQueryResult<
IListResponse_DTO<TData>,
TError
>;
type TUseDataTableQuery<TData, TError> = (params: {
pagination: {
pageIndex: number;
pageSize: number;
};
enabled?: boolean;
}) => TUseDataTableQueryResult<TData, TError>;
type UseDataTableProps<TData, TValue, TError> = {
fetchQuery: TUseDataTableQuery<TData, TError>;
enabled?: boolean;
columnOptions: DataTableColumnsOptionsProps<TData, TValue>;
enableSorting?: boolean;
enableHiding?: boolean;
//initialPage?: number;
//initialPageSize?: number;
};
type DataTableColumnsOptionsProps<TData, TValue> = {
enableSelectionColumn?: boolean;
columns: DataTableColumnsProps<TData, TValue>;
};
type DataTableColumnsProps<TData, TValue> = DataTableColumnProps<
TData,
TValue
>[];
export const useQueryDataTable = <
TData = unknown,
TValue = unknown,
TError = Error
>({
fetchQuery,
enabled = true,
columnOptions,
enableSorting = false,
enableHiding = false,
}: UseDataTableProps<TData, TValue, TError>) => {
const defaultData = useMemo(() => [], []);
const [rowSelection, setRowSelection] = useState({});
const [pagination, setPagination] = usePaginationParams();
const [sorting, setSorting] = useState<SortingState>([]);
const paginationUpdater: OnChangeFn<PaginationState> = (updater) => {
if (typeof updater === "function") {
setPagination(updater(pagination));
}
};
const sortingUpdater: OnChangeFn<SortingState> = (updater) => {
if (typeof updater === "function") {
setSorting(updater(sorting));
}
};
const getTableColumns = useCallback(() => {
const _columns = columnOptions.columns;
if (columnOptions.enableSelectionColumn) {
_columns.unshift(getDataTableSelectionColumn());
}
return _columns;
}, [columnOptions]);
const queryResult: TUseDataTableQueryResult<TData, TError> = fetchQuery({
pagination,
enabled,
});
const table = useReactTable<TData>({
columns: getTableColumns(),
data: queryResult.data?.items ?? defaultData,
getCoreRowModel: getCoreRowModel(),
//getPaginationRowModel: getPaginationRowModel(),
enableRowSelection: columnOptions.enableSelectionColumn,
onRowSelectionChange: setRowSelection,
enableSorting,
onSortingChange: sortingUpdater,
enableHiding,
//onColumnVisibilityChange: columnVisibilityUpdater,
state: {
pagination,
sorting,
rowSelection,
},
manualPagination: true,
//autoResetPageIndex: true,
pageCount: queryResult?.data?.total_pages ?? -1,
onPaginationChange: paginationUpdater,
debugTable: true,
});
return {
table,
queryResult,
};
};

View File

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

View File

@ -0,0 +1,104 @@
import { useEffect, useState } from "react";
export type UseLoadingOvertimeRefineContext = Omit<
UseLoadingOvertimeCoreProps,
"isPending" | "interval"
> &
Required<Pick<UseLoadingOvertimeCoreProps, "interval">>;
export type UseLoadingOvertimeOptionsProps = {
overtimeOptions?: UseLoadingOvertimeCoreOptions;
};
export type UseLoadingOvertimeReturnType = {
overtime: {
elapsedTime?: number;
};
};
type UseLoadingOvertimeCoreOptions = Omit<
UseLoadingOvertimeCoreProps,
"isPending"
>;
type UseLoadingOvertimeCoreReturnType = {
elapsedTime?: number;
};
export type UseLoadingOvertimeCoreProps = {
/**
* The pengind state. If true, the elapsed time will be calculated.
*/
isPending: boolean;
/**
* The interval in milliseconds. If the pending time exceeds this time, the `onInterval` callback will be called.
* If not specified, the `interval` value from the `overtime` option of the `RefineProvider` will be used.
*
* @default: 1000 (1 second)
*/
interval?: number;
/**
* The callback function that will be called when the pending time exceeds the specified time.
* If not specified, the `onInterval` value from the `overtime` option of the `RefineProvider` will be used.
*
* @param elapsedInterval The elapsed time in milliseconds.
*/
onInterval?: (elapsedInterval: number) => void;
};
/**
* if you need to do something when the loading time exceeds the specified time, refine provides the `useLoadingOvertime` hook.
* It returns the elapsed time in milliseconds.
*
* @example
* const { elapsedTime } = useLoadingOvertime({
* isLoading,
* interval: 1000,
* onInterval(elapsedInterval) {
* console.log("loading overtime", elapsedInterval);
* },
* });
*/
export const useLoadingOvertime = ({
isPending,
interval = 1000,
onInterval,
}: UseLoadingOvertimeCoreProps): UseLoadingOvertimeCoreReturnType => {
const [elapsedTime, setElapsedTime] = useState<number | undefined>(undefined);
useEffect(() => {
let intervalFn: ReturnType<typeof setInterval>;
if (isPending) {
intervalFn = setInterval(() => {
// increase elapsed time
setElapsedTime((prevElapsedTime) => {
if (prevElapsedTime === undefined) {
return interval;
}
return prevElapsedTime + interval;
});
}, interval);
}
return () => {
clearInterval(intervalFn);
// reset elapsed time
setElapsedTime(undefined);
};
}, [isPending, interval]);
useEffect(() => {
// call onInterval callback
if (onInterval && elapsedTime) {
onInterval(elapsedTime);
}
}, [elapsedTime]);
return {
elapsedTime,
};
};

View File

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

View File

@ -0,0 +1,13 @@
import * as React from "react";
export const useMounted = () => {
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => {
setMounted(true);
return () => setMounted(false);
}, []);
return mounted;
};

View File

@ -0,0 +1,2 @@
export * from "./usePagination";
export * from "./usePaginationParams";

View File

@ -0,0 +1,50 @@
import { useState } from "react";
export const INITIAL_PAGE_INDEX = 0;
export const INITIAL_PAGE_SIZE = 10;
export const MIN_PAGE_INDEX = 0;
export const MIN_PAGE_SIZE = 1;
export const MAX_PAGE_SIZE = Number.MAX_SAFE_INTEGER;
export const DEFAULT_PAGE_SIZES = [10, 25, 50, 100];
export interface PaginationState {
pageIndex: number;
pageSize: number;
}
export const defaultPaginationState = {
pageIndex: INITIAL_PAGE_INDEX,
pageSize: INITIAL_PAGE_SIZE,
};
export const usePagination = (
initialPageIndex: number = INITIAL_PAGE_INDEX,
initialPageSize: number = INITIAL_PAGE_SIZE
) => {
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: initialPageIndex,
pageSize: initialPageSize,
});
const updatePagination = (newPagination: PaginationState) => {
// Realiza comprobaciones antes de actualizar el estado
if (newPagination.pageIndex < INITIAL_PAGE_INDEX) {
newPagination.pageIndex = INITIAL_PAGE_INDEX;
}
if (
newPagination.pageSize < MIN_PAGE_SIZE ||
newPagination.pageSize > MAX_PAGE_SIZE
) {
return;
}
setPagination(newPagination);
};
return [pagination, updatePagination] as const;
};

View File

@ -0,0 +1,52 @@
import { useEffect, useMemo } from "react";
import { useSearchParams } from "react-router-dom";
import {
INITIAL_PAGE_INDEX,
INITIAL_PAGE_SIZE,
usePagination,
} from "./usePagination";
export const usePaginationParams = (
initialPageIndex: number = INITIAL_PAGE_INDEX,
initialPageSize: number = INITIAL_PAGE_SIZE
) => {
const [urlSearchParams, setUrlSearchParams] = useSearchParams();
const urlParamPageIndex: string | null = urlSearchParams.get("page_index");
const urlParamPageSize: string | null = urlSearchParams.get("page_size");
const calculatedPageIndex = useMemo(() => {
const parsedPageIndex = parseInt(urlParamPageIndex ?? "", 10);
return !isNaN(parsedPageIndex) ? parsedPageIndex : initialPageIndex;
}, [urlParamPageIndex, initialPageIndex]);
const calculatedPageSize = useMemo(() => {
const parsedPageSize = parseInt(urlParamPageSize ?? "", 10);
return !isNaN(parsedPageSize) ? parsedPageSize : initialPageSize;
}, [urlParamPageSize, initialPageSize]);
const [pagination, setPagination] = usePagination(
calculatedPageIndex,
calculatedPageSize
);
useEffect(() => {
// Actualizar la URL cuando cambia la paginación
const actualSearchParam = Object.fromEntries(
new URLSearchParams(urlSearchParams)
);
if (
String(pagination.pageIndex) !== actualSearchParam.page_index ||
String(pagination.pageSize) !== actualSearchParam.page_size
) {
setUrlSearchParams({
...actualSearchParam,
page_index: String(pagination.pageIndex),
page_size: String(pagination.pageSize),
});
}
}, [pagination]);
return [pagination, setPagination] as const;
};

View File

@ -0,0 +1,181 @@
type BaseKey = string | number;
type ParametrizedDataActions = "list" | "infinite";
type IdRequiredDataActions = "one";
type IdsRequiredDataActions = "many";
type DataMutationActions =
| "custom"
| "customMutation"
| "create"
| "createMany"
| "update"
| "updateMany"
| "delete"
| "deleteMany";
type AuthActionType =
| "login"
| "logout"
| "identity"
| "register"
| "forgotPassword"
| "check"
| "onError"
| "permissions"
| "updatePassword";
type AuditActionType = "list" | "log" | "rename";
type IdType = BaseKey;
type IdsType = IdType[];
type ParamsType = any;
type KeySegment = string | IdType | IdsType | ParamsType;
export function arrayFindIndex<T>(array: T[], slice: T[]): number {
return array.findIndex(
(item, index) =>
index <= array.length - slice.length &&
slice.every((sliceItem, sliceIndex) => array[index + sliceIndex] === sliceItem)
);
}
export function arrayReplace<T>(array: T[], partToBeReplaced: T[], newPart: T[]): T[] {
const newArray: T[] = [...array];
const startIndex = arrayFindIndex(array, partToBeReplaced);
if (startIndex !== -1) {
newArray.splice(startIndex, partToBeReplaced.length, ...newPart);
}
return newArray;
}
export function stripUndefined(segments: KeySegment[]) {
return segments.filter((segment) => segment !== undefined);
}
class BaseKeyBuilder {
segments: KeySegment[] = [];
constructor(segments: KeySegment[] = []) {
this.segments = segments;
}
key() {
return this.segments;
}
get() {
return this.segments;
}
}
class ParamsKeyBuilder extends BaseKeyBuilder {
params(paramsValue?: ParamsType) {
return new BaseKeyBuilder([...this.segments, paramsValue]);
}
}
class DataIdRequiringKeyBuilder extends BaseKeyBuilder {
id(idValue?: IdType) {
return new ParamsKeyBuilder([...this.segments, idValue ? String(idValue) : undefined]);
}
}
class DataIdsRequiringKeyBuilder extends BaseKeyBuilder {
ids(...idsValue: IdsType) {
return new ParamsKeyBuilder([
...this.segments,
...(idsValue.length ? [idsValue.map((el) => String(el))] : []),
]);
}
}
class DataResourceKeyBuilder extends BaseKeyBuilder {
action(actionType: ParametrizedDataActions): ParamsKeyBuilder;
action(actionType: IdRequiredDataActions): DataIdRequiringKeyBuilder;
action(actionType: IdsRequiredDataActions): DataIdsRequiringKeyBuilder;
action(
actionType: ParametrizedDataActions | IdRequiredDataActions | IdsRequiredDataActions
): ParamsKeyBuilder | DataIdRequiringKeyBuilder | DataIdsRequiringKeyBuilder {
if (actionType === "one") {
return new DataIdRequiringKeyBuilder([...this.segments, actionType]);
}
if (actionType === "many") {
return new DataIdsRequiringKeyBuilder([...this.segments, actionType]);
}
if (["list", "infinite"].includes(actionType)) {
return new ParamsKeyBuilder([...this.segments, actionType]);
}
throw new Error("Invalid action type");
}
}
class DataKeyBuilder extends BaseKeyBuilder {
resource(resourceName?: string) {
return new DataResourceKeyBuilder([...this.segments, resourceName]);
}
mutation(mutationName: DataMutationActions) {
return new ParamsKeyBuilder([
...(mutationName === "custom" ? this.segments : [this.segments[0]]),
mutationName,
]);
}
}
class AuthKeyBuilder extends BaseKeyBuilder {
action(actionType: AuthActionType) {
return new ParamsKeyBuilder([...this.segments, actionType]);
}
}
class AccessResourceKeyBuilder extends BaseKeyBuilder {
action(resourceName: string) {
return new ParamsKeyBuilder([...this.segments, resourceName]);
}
}
class AccessKeyBuilder extends BaseKeyBuilder {
resource(resourceName?: string) {
return new AccessResourceKeyBuilder([...this.segments, resourceName]);
}
}
class AuditActionKeyBuilder extends BaseKeyBuilder {
action(actionType: Extract<AuditActionType, "list">) {
return new ParamsKeyBuilder([...this.segments, actionType]);
}
}
class AuditKeyBuilder extends BaseKeyBuilder {
resource(resourceName?: string) {
return new AuditActionKeyBuilder([...this.segments, resourceName]);
}
action(actionType: Extract<AuditActionType, "rename" | "log">) {
return new ParamsKeyBuilder([...this.segments, actionType]);
}
}
export class KeyBuilder extends BaseKeyBuilder {
data(name?: string) {
return new DataKeyBuilder(["data", name || "default"]);
}
auth() {
return new AuthKeyBuilder(["auth"]);
}
access() {
return new AccessKeyBuilder(["access"]);
}
audit() {
return new AuditKeyBuilder(["audit"]);
}
}
export const keys = () => new KeyBuilder([]);

View File

@ -0,0 +1,5 @@
import { keys } from "./KeyBuilder";
export const useQueryKey = () => {
return keys;
};

View File

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

View File

@ -0,0 +1,107 @@
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/ui";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/ui/alert-dialog";
import { useCallback, useState } from "react";
interface UseRemoveAlertDialogProps {
isOpen?: boolean;
onOpen?: () => void;
onClose?: () => void;
onCancel?: (event: React.SyntheticEvent) => void;
onRemove?: (payload: any, event: React.SyntheticEvent) => void;
}
interface UseRemoveAlertDialogReturnType {
RemoveAlertDialog: () => JSX.Element;
open: (title: string, description: string, payload?: any) => void;
close: () => void;
}
export const useRemoveAlertDialog = ({
isOpen = false,
onRemove,
onCancel,
}: UseRemoveAlertDialogProps): UseRemoveAlertDialogReturnType => {
const [visible, setVisible] = useState<boolean>(isOpen);
const [title, setTitle] = useState<string>("");
const [description, setDescription] = useState<string>("");
const [payload, setPayload] = useState<any>({});
/*const cancelButtonProps = {
cancelButtonText: "Cancelar",
cancelButtonProps: {},
onCancel,
};
const submitButtonProps = {
submitButtonText: "Eliminar",
submitButtonProps: {},
onSubmit: (event: React.SyntheticEvent) => {
if (onRemove) {
onRemove(payload, event);
}
},
};*/
const open = useCallback(
(title: string, description: string, payload: any = {}) => {
setTitle(title);
setDescription(description);
setPayload(payload);
setVisible(true);
},
[]
);
const close = () => setVisible(false);
const RemoveAlertDialog = (): JSX.Element => (
<AlertDialog open={visible} onOpenChange={setVisible}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={onCancel}>Cancelar</AlertDialogCancel>
<AlertDialogAction
className={cn(
buttonVariants({ variant: "destructive" }),
"mt-2 sm:mt-0"
)}
onClick={(event) => (onRemove ? onRemove(payload, event) : null)}
>
Eliminar
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
return { RemoveAlertDialog, open, close };
};
/*
<ConfirmDialog
dialogType="danger"
title={title}
description={description}
{...cancelButton}
{...continueButton}
{...modalProps}
>
{children}
</ConfirmDialog>
*/

View File

@ -0,0 +1,32 @@
// https://github.com/wojtekmaj/react-hooks/blob/main/src/useResizeObserver.ts
import { useEffect } from 'react';
/**
* Observes a given element using ResizeObserver.
*
* @param {Element} [element] Element to attach ResizeObserver to
* @param {ResizeObserverOptions} [options] ResizeObserver options. WARNING! If you define the
* object in component body, make sure to memoize it.
* @param {ResizeObserverCallback} observerCallback ResizeObserver callback. WARNING! If you define
* the function in component body, make sure to memoize it.
* @returns {void}
*/
export default function useResizeObserver(
element: Element | null,
options: ResizeObserverOptions | undefined,
observerCallback: ResizeObserverCallback,
): void {
useEffect(() => {
if (!element || !('ResizeObserver' in window)) {
return undefined;
}
const observer = new ResizeObserver(observerCallback);
observer.observe(element, options);
return () => {
observer.disconnect();
};
}, [element, options, observerCallback]);
}

View File

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

View File

@ -0,0 +1,72 @@
import { createContext, useContext, useEffect, useState } from "react";
type Theme = "dark" | "light" | "system";
type ThemeProviderProps = {
children: React.ReactNode;
defaultTheme?: Theme;
storageKey?: string;
};
type ThemeProviderState = {
theme: Theme;
setTheme: (theme: Theme) => void;
};
const initialState: ThemeProviderState = {
theme: "system",
setTheme: () => null,
};
const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
export function ThemeProvider({
children,
defaultTheme = "system",
storageKey = "vite-ui-theme",
...props
}: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
);
useEffect(() => {
const root = window.document.documentElement;
root.classList.remove("light", "dark");
if (theme === "system") {
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
.matches
? "dark"
: "light";
root.classList.add(systemTheme);
return;
}
root.classList.add(theme);
}, [theme]);
const value = {
theme,
setTheme: (theme: Theme) => {
localStorage.setItem(storageKey, theme);
setTheme(theme);
},
};
return (
<ThemeProviderContext.Provider {...props} value={value}>
{children}
</ThemeProviderContext.Provider>
);
}
export const useTheme = () => {
const context = useContext(ThemeProviderContext);
if (context === undefined)
throw new Error("useTheme must be used within a ThemeProvider");
return context;
};

View File

@ -0,0 +1 @@
export * from './useUrlId';

View File

@ -0,0 +1,6 @@
import { useParams } from 'react-router-dom';
export const useUrlId = (): string | undefined => {
const { id } = useParams<{ id?: string }>();
return id;
};

View File

@ -0,0 +1,17 @@
import { useEffect } from 'react';
export const useWindowResize = (onWindowResize: (resized: boolean) => any) => {
useEffect(() => {
const onResize = () => {
onWindowResize && onWindowResize(true);
};
window.addEventListener('resize', onResize);
return () => {
window.removeEventListener('resize', onResize);
};
}, []);
return null;
};

6
client/src/lib/utils.ts Normal file
View File

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

10
client/src/main.tsx Normal file
View File

@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import "./index.css";
ReactDOM.createRoot(document.getElementById("uecko")!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@ -0,0 +1,51 @@
// - - - - - ErrorPage.tsx - - - - -
import { Button } from "@/ui";
import { useLocation, useNavigate } from "react-router-dom";
// import Button
const isDev = process.env.REACT_APP_NODE_ENV === "development";
const hostname = `${
isDev ? process.env.REACT_APP_DEV_API_URL : process.env.REACT_APP_PROD_API_URL
}`;
type ErrorPageProps = {
error?: string;
};
export const ErrorPage = (props: ErrorPageProps) => {
const navigate = useNavigate();
const location = useLocation();
const navMsg = location.state?.error;
const msg = navMsg ? (
<p className='text-lg'>{navMsg}</p>
) : props.error ? (
<p className='text-lg'>{props.error}</p>
) : (
<p className='text-lg'>
The targeted page <b>"{location.pathname}"</b> was not found, please confirm the spelling and
try again.
</p>
);
return (
<section id='Error' className='flex flex-col items-center w-full h-full'>
{msg}
<span className='flex flex-row items-center justify-center my-10 space-x-8 '>
<Button id='backButton' onClick={() => navigate(-1)}>
Return to Previous Page
</Button>
<Button id='homeButton' onClick={() => navigate("/home")}>
Return to Home Page
</Button>
<Button
id='logout'
onClick={() => {
const endpoint = `${hostname}/logout`;
window.open(endpoint, "_blank");
}}
>
Reset Authentication
</Button>
</span>
</section>
);
};

View File

@ -0,0 +1,155 @@
import { Container, FormTextField } from "@/components";
import { UeckoLogo } from "@/components/UeckoLogo/UeckoLogo";
import { useLogin } from "@/lib/hooks";
import {
Alert,
AlertDescription,
AlertTitle,
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Form,
} from "@/ui";
import { joiResolver } from "@hookform/resolvers/joi";
import Joi from "joi";
import { AlertCircleIcon } from "lucide-react";
import { useState } from "react";
import { SubmitHandler, useForm } from "react-hook-form";
import { Link } from "react-router-dom";
import SpanishJoiMessages from "../spanish-joi-messages.json";
type LoginDataForm = {
email: string;
password: string;
};
export const LoginPage = () => {
const [loading, setLoading] = useState(false);
const { mutate: login } = useLogin({
onSuccess: (data) => {
const { success, error } = data;
if (!success && error) {
form.setError("root", error);
}
},
});
const form = useForm<LoginDataForm>({
mode: "onBlur",
defaultValues: {
email: "",
password: "",
},
resolver: joiResolver(
Joi.object({
email: Joi.string()
.email({ tlds: { allow: false } })
.required(),
password: Joi.string().min(4).alphanum().required(),
}),
{
messages: SpanishJoiMessages,
}
),
});
const onSubmit: SubmitHandler<LoginDataForm> = async (data) => {
console.log(data);
try {
setLoading(true);
login({ email: data.email, password: data.password });
} finally {
setLoading(false);
}
};
return (
<Container
variant={"full"}
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='mx-auto grid w-[450px] gap-6'>
<Card>
<CardHeader>
<UeckoLogo className='inline-block m-auto mb-6 align-middle max-w-32' />
<CardTitle>Presupuestador para distribuidores</CardTitle>
<CardDescription>Enter your email below to login to your account</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className='grid items-start gap-6'>
<div className='grid gap-6'>
<FormTextField
disabled={loading}
label='Email'
type='email'
placeholder='micorreo@ejemplo.com'
{...form.register("email", {
required: true,
})}
errors={form.formState.errors}
/>
</div>
<div className='grid gap-6'>
<FormTextField
disabled={loading}
label='Contraseña'
type='password'
{...form.register("password", {
required: true,
})}
errors={form.formState.errors}
/>
<div className='mb-4 -mt-2 text-sm'>
¿Has olvidado tu contraseña?
<br />
<Link to='https://uecko.com/distribuidores' className='underline'>
Contacta con nosotros
</Link>
</div>
</div>
{form.formState.errors.root?.message && (
<Alert variant='destructive'>
<AlertCircleIcon className='w-4 h-4' />
<AlertTitle>Heads up!</AlertTitle>
<AlertDescription>{form.formState.errors.root?.message}</AlertDescription>
</Alert>
)}
<Button disabled={loading} type='submit' className='w-full'>
Entrar
</Button>
<div className='mt-4 text-sm text-center'>
¿Quieres ser distribuidor de Uecko?
<br />
<Link to='https://uecko.com/distribuidores' className='underline'>
Contacta con nosotros
</Link>
</div>
</div>
</form>
</Form>
</CardContent>
</Card>
</div>
</div>
<div className='hidden bg-muted lg:block'>
<img
src='https://uecko.com/assets/img/021/nara2.jpg'
alt='Image'
width='1920'
height='1080'
className='h-full w-full object-cover dark:brightness-[0.2] dark:grayscale'
/>
</div>
</Container>
);
};

View File

@ -0,0 +1,93 @@
{
"any.unknown": "{{#label}}: no está permitido",
"any.invalid": "{{#label}}: contiene un valor invalido",
"any.empty": "{{#label}}: no está permitido que sea vacío",
"any.required": "{{#label}}: es un campo requerido",
"any.allowOnly": "{{#label}}: debería ser uno de las siguientes variantes: {{valids}}",
"any.default": "emitió un error cuando se ejecutó el metodo default",
"alternatives.base": "{{#label}}: no coincide con ninguna de las alternativas permitidas",
"array.base": "{{#label}}: debe ser un array",
"array.includes": "{{#label}}: en la posición {{pos}} no coincide con ninguno de los tipos permitidos",
"array.includesSingle": "{{#label}}: el valor de \"{{!key}}\" no coincide con ninguno de los tipos permitidos",
"array.includesOne": "{{#label}}: en la posición {{pos}} falló porque {{reason}}",
"array.includesOneSingle": "{{#label}}: el valor \"{{!key}}\" falló porque {{reason}}",
"array.includesRequiredUnknowns": "{{#label}}: no contiene valor/es requerido/s: {{unknownMisses}} ",
"array.includesRequiredKnowns": "{{#label}}: no contiene: {{knownMisses}}",
"array.includesRequiredBoth": "{{#label}}: no contiene {{knownMisses}} y {{unknownMisses}} otros valores requeridos",
"array.excludes": "{{#label}}: en la posición {{pos}} contiene un valor excluído",
"array.excludesSingle": "{{#label}}: el valor \"{{!key}}\" contiene un valor excluído",
"array.min": "{{#label}}: debe contener al menos {{limit}} items",
"array.max": "{{#label}}: debe contener máximo {{limit}} items",
"array.length": "{{#label}}: debe contener exactamente {{limit}} items",
"array.ordered": "{{#label}}: en la posición {{pos}} falló porque {{reason}}",
"array.orderedLength": "{{#label}}: en la posición {{pos}} falló porque el array debre contener como máximo {{limit}} items",
"array.sparse": "{{#label}}: no debe ser un array esparcido",
"array.unique": "{{#label}}: posición {{pos}} contiene un valor duplicado",
"boolean.base": "{{#label}}: debe ser un valor verdadero/falso o si/no",
"binary.base": "{{#label}}: debe ser un buffer o un string",
"binary.min": "{{#label}}: debe ser como mínimo de {{limit}} bytes",
"binary.max": "{{#label}}: debe ser como máximo de {{limit}} bytes",
"binary.length": "{{#label}}: debe tener exactamente {{limit}} bytes",
"date.base": "{{#label}}: debe ser una cantidad de milisegundos o una fecha en cadena de texto válida",
"date.min": "{{#label}}: debe ser mayor o igual a \"{{limit}}\"",
"date.max": "{{#label}}: debe ser menor o igual que \"{{limit}}\"",
"date.isoDate": "{{#label}}: debe ser una fecha en formato ISO 8601",
"date.ref": "referencia a \"{{ref}}\", que no es una fecha válida",
"function.base": "{{#label}}: debe ser una función",
"object.base": "{{#label}}: debe ser un objeto",
"object.child": "hijo \"{{!key}}\" falló porque {{reason}}",
"object.min": "{{#label}}: debe tener como mínimo {{limit}} hijo",
"object.max": "{{#label}}: debe tener menos o a lo sumo {{limit}} hijo",
"object.length": "{{#label}}: debe tener máximo {{limit}} hijo/s",
"object.allowUnknown": "no está permitido",
"object.with": "peer faltante: \"{{peer}}\"",
"object.without": "conflicto con peer prohibido: \"{{peer}}\"",
"object.missing": "{{#label}}: debe contener al menos uno de: {{peers}}",
"object.xor": "{{#label}}: contiene un conflicto con alguno de: {{peers}}",
"object.or": "{{#label}}: debe contener al menos uno de: {{peers}}",
"object.and": "contiene {{present}} sin el requerido: {{missing}}",
"object.nand": "!!\"{{main}}\" no debe existir simultáneamente con {{peers}}",
"object.assert": "!!\"{{ref}}\" falló validacion porque \"{{ref}}\" falló a {{message}}",
"object.rename.multiple": "{{#label}}: no se puede renombrar el hijo \"{{from}}\" porque múltiples re-nombramientos estan deshabilitados y otra clave fue renombrada a \"{{to}}\"",
"object.rename.override": "{{#label}}: no se puede renombrar el hijo \"{{from}}\" porque la sobre escritura esta deshabilitada y el target \"{{to}}\" existe",
"object.type": "{{#label}}: debe ser una instancia de \"{{type}}\"",
"number.base": "{{#label}}: debe ser un número",
"number.min": "{{#label}}: debe ser mayor o igual que {{limit}}",
"number.max": "{{#label}}: debe ser menor o igual que {{limit}}",
"number.less": "{{#label}}: debe ser menor a {{limit}}",
"number.greater": "{{#label}}: debe ser mayor a {{limit}}",
"number.float": "{{#label}}: debe ser un numero flotante",
"number.integer": "{{#label}}: debe ser un número entero",
"number.negative": "{{#label}}: debe ser un número negativo",
"number.positive": "{{#label}}: debe ser un número positivo",
"number.precision": "{{#label}}: no debe tener mas de {{limit}} decimales",
"number.ref": "{{#label}}: referencia a \"{{ref}}\" que no es un número",
"number.multiple": "{{#label}}: debe ser un múltiplo de {{multiple}}",
"string.base": "{{#label}}: debe ser una cadena de texto",
"string.min": "{{#label}}: debe ser mínimo de {{limit}} caracteres de largo",
"string.max": "{{#label}}: debe ser de máximo {{limit}} caracteres de largo",
"string.length": "{{#label}}: debe ser exactamente de {{limit}} caracteres de largo",
"string.alphanum": "{{#label}}: debe contener solo letras y números",
"string.token": "{{#label}}: debe contener solo letras, números y guines bajos",
"string.regex.base": "{{#label}}: el valor \"{{!value}}\" no coincide con el pattern requerido: {{pattern}}",
"string.regex.name": "{{#label}}: el valor \"{{!value}}\" no coincide con el nombre de pattern {{name}}",
"string.email": "{{#label}}: debe ser un email válido",
"string.uri": "{{#label}}: debe sre una uri válida",
"string.uriCustomScheme": "{{#label}}: debe ser una uri válida con el esquema concidiente con el patrón {{scheme}}",
"string.isoDate": "{{#label}}: debe ser una fecha en formato ISO 8601 válida",
"string.guid": "{{#label}}: debe ser un GUID valido",
"string.hex": "{{#label}}: debe contener solo caracteres hexadecimales",
"string.hostname": "{{#label}}: deber ser un hostname válido",
"string.lowercase": "{{#label}}: solo debe contener minúsculas",
"string.uppercase": "{{#label}}: solo debe contener mayúsculas",
"string.trim": "{{#label}}: no debe tener espacios en blanco delante o atrás",
"string.creditCard": "{{#label}}: debe ser una tarjeta de crédito",
"string.ref": "Referencia \"{{ref}}\" que no es un número",
"string.ip": "{{#label}}: debe ser una dirección ip válida con un CDIR {{cidr}}",
"string.ipVersion": "{{#label}}: debe ser una dirección ip válida de una de las siguientes versiones {{version}} con un CDIR {{cidr}}",
"object.unknown": "{{#label}}: es un campo no es permitido",
"luxon.lt": "{{#label}}: must be before {{#date}}",
"luxon.gt": "{{#label}}: must be after {{#date}}",
"luxon.lte": "{{#label}}: must be same as or before {{#date}}",
"luxon.gte": "{{#label}}: must be same as or after {{#date}}"
}

View File

@ -0,0 +1,56 @@
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
const Accordion = AccordionPrimitive.Root
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn("border-b", className)}
{...props}
/>
))
AccordionItem.displayName = "AccordionItem"
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
))
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
))
AccordionContent.displayName = AccordionPrimitive.Content.displayName
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View File

@ -0,0 +1,139 @@
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import * as React from "react";
import { cn } from "@/lib/utils";
import { buttonVariants } from "./button";
const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogPortal = AlertDialogPrimitive.Portal;
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
));
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
);
AlertDialogHeader.displayName = "AlertDialogHeader";
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
);
AlertDialogFooter.displayName = "AlertDialogFooter";
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
));
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName;
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className
)}
{...props}
/>
));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
};

59
client/src/ui/alert.tsx Normal file
View File

@ -0,0 +1,59 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }

View File

@ -0,0 +1,5 @@
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
const AspectRatio = AspectRatioPrimitive.Root
export { AspectRatio }

View File

@ -0,0 +1,73 @@
"use client";
import { cn } from "@/lib/utils";
import * as React from "react";
import { useImperativeHandle } from "react";
import { useAutosizeTextArea } from "./use-autosize-textarea";
export type AutosizeTextAreaRef = {
textArea: HTMLTextAreaElement;
maxHeight: number;
minHeight: number;
};
type AutosizeTextAreaProps = {
maxHeight?: number;
minHeight?: number;
} & React.TextareaHTMLAttributes<HTMLTextAreaElement>;
export const AutosizeTextarea = React.forwardRef<
AutosizeTextAreaRef,
AutosizeTextAreaProps
>(
(
{
maxHeight = Number.MAX_SAFE_INTEGER,
minHeight = 52,
className,
onChange,
value,
...props
}: AutosizeTextAreaProps,
ref: React.Ref<AutosizeTextAreaRef>,
) => {
const textAreaRef = React.useRef<HTMLTextAreaElement | null>(null);
const [triggerAutoSize, setTriggerAutoSize] = React.useState("");
useAutosizeTextArea({
textAreaRef: textAreaRef.current,
triggerAutoSize: triggerAutoSize,
maxHeight,
minHeight,
});
useImperativeHandle(ref, () => ({
textArea: textAreaRef.current as HTMLTextAreaElement,
focus: () => textAreaRef.current?.focus(),
maxHeight,
minHeight,
}));
React.useEffect(() => {
if (value || props?.defaultValue) {
setTriggerAutoSize(value as string);
}
}, [value || props?.defaultValue]);
return (
<textarea
{...props}
value={value}
ref={textAreaRef}
className={cn(
"flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
onChange={(e) => {
setTriggerAutoSize(e.target.value);
onChange?.(e);
}}
/>
);
},
);
AutosizeTextarea.displayName = "AutosizeTextarea";

Some files were not shown because too many files have changed in this diff Show More