new version

This commit is contained in:
IO420
2026-01-11 20:36:14 -06:00
commit 4eb70fbb1e
33 changed files with 4750 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
VITE_API_URL=https://venus.acatlan.unam.mx/impresiones_test
VITE_PORT=3001
+24
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?
+13
View File
@@ -0,0 +1,13 @@
# React + Vite
Este proyecto es una aplicación web construida con **React + Vite** y está pensada para ser desplegada en **producción usando Nginx** como servidor web.
construimos el build
**npm run build**
este genera una carpeta: dist/
colocamos el dist/ en el servidor y configuramos nuestro Nginx , para https se recomienda Lets Encrypt + Nginx
y reiniciamos Nginx
+2
View File
@@ -0,0 +1,2 @@
VITE_API_URL
VITE_PORT
+29
View File
@@ -0,0 +1,29 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
rules: {
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
},
},
])
+20
View File
@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/printer.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0a3d62" />
<meta
name="description"
content="Sistema académico de bitacora de impresiones desarrollado para Facultad de Estudios Superiores Acatlán, UNAM."
/>
<meta name="author" content="Lino" />
<meta name="keywords" content="bitácora, UNAM, FES Acatlán, impresiones" />
<title>Impresiones</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+3255
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
{
"name": "front-impresiones-vite",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.13.2",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.12.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"vite": "^7.2.4"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+9
View File
@@ -0,0 +1,9 @@
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="-2.4 -2.4 28.80 28.80" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="SVGRepo_bgCarrier" stroke-width="0">
<rect x="-2.4" y="-2.4" width="28.80" height="28.80" rx="0" fill="#ffffff" strokewidth="0"/>
</g>

After

Width:  |  Height:  |  Size: 3.2 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 519 KiB

+1
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

+185
View File
@@ -0,0 +1,185 @@
* {
margin: 0;
padding: 0;
}
header {
display: flex;
align-items: flex-start;
justify-content: flex-start;
width: 100%;
}
.body {
display: grid;
grid-template-rows: auto 1fr;
width: 100vw;
height: 100dvh;
background-color: #f5f5f7;
}
.App {
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
scrollbar-width: none;
z-index: 1;
}
input {
flex: 1;
min-width: 200px;
max-width: 470px;
padding: 0.5rem;
border: 1px solid #cfcfcf;
border-radius: 4px;
font-size: 1rem;
color: black;
}
.messageBox {
position: relative;
padding: 0.5rem 1.25rem;
border-radius: 4px;
font-weight: bold;
font-size: 1rem;
text-align: center;
opacity: 1;
z-index: 100;
width: 90%;
}
.messageBox.hidden {
opacity: 0;
pointer-events: none;
transition: opacity 1s ease-out;
}
.success {
background-color: #d1f7c4;
color: #000000;
border: 1px solid #a5d6a7;
}
.error {
background-color: #ffcdd2;
color: #000000;
border: 1px solid #ef9a9a;
}
.containerHeader {
display: flex;
justify-content: space-between;
width: 100%;
}
.containerButton {
display: flex;
gap: 10px;
justify-content: start;
align-items: center;
width: 100%;
}
.button {
min-height: 34px;
padding: 0.5rem 1.25rem;
font-size: 1rem;
font-weight: bold;
border: none;
color: #ffffff;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s ease;
max-width: 240px;
}
.button-search {
background-color: #2563eb;
}
.button-search:hover {
background-color: #1d4ed8;
}
.button-charge {
background-color: #16a34a;
}
.button-charge:hover {
background-color: #15803d;
}
.button-logout {
background-color: #d32f2f;
position: absolute;
top: 70px;
right: 10px;
}
.button-logout:hover {
background-color: #d12020;
}
@media (max-width: 800px) {
.messageBox {
position: absolute;
left: 50%;
transform: translateX(-50%);
bottom: 10px;
margin-top: 10px;
padding: 0.75rem 1rem;
}
.containerHeader {
max-width: 91%;
gap: 10px;
}
.button {
max-width: 100%;
}
.input {
flex: 0 0 0;
}
.button-logout {
color: transparent;
padding: 1.2rem;
font-size: 0;
top: 20px;
}
.button-logout::after {
content: "";
border: 3px solid white;
border-radius: 2px;
height: 15px;
width: 15px;
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}
.button-logout::before {
content: "";
border: 2px solid white;
border-radius: 1px;
height: 0px;
width: 10px;
position: absolute;
left: 50%;
top: 50%;
transform: translate(0, -50%);
}
input {
min-width: 0px;
}
}
+55
View File
@@ -0,0 +1,55 @@
import { BrowserRouter, Route, Routes } from "react-router-dom";
import "./App.css";
import { Impressions } from "./components/Impressions";
import { Header } from "./components/Header";
import { Login } from "./components/Login";
import { PrivateRoute } from "./components/routes/PrivateRoute";
import { LoginRedirect } from "./components/routes/LoginRedirect";
import NotFound from "./components/NotFound";
function App() {
const session = localStorage.getItem("session");
return (
<section className="body">
<Header />
<div className="App">
<div className="impressions-container">
<header>
<div className="containerHeader lineSeparator">
<h3 className="impressions-title">IMPRESIONES</h3>
<h3 className="impressions-title ">{session}</h3>
</div>
</header>
<BrowserRouter>
<Routes>
<Route
path="/"
element={
<PrivateRoute>
<Impressions />
</PrivateRoute>
}
></Route>
<Route
path="/Login"
element={
<LoginRedirect>
<Login />
</LoginRedirect>
}
></Route>
<Route path="*" element={<NotFound />} />
</Routes>
</BrowserRouter>
<div className="img"></div>
</div>
</div>
</section>
);
}
export default App;
//IO
+8
View File
@@ -0,0 +1,8 @@
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});
+28
View File
@@ -0,0 +1,28 @@
import header from "./header.module.css";
export const Header = () => {
return (
<header className={header.header}>
<a
href="https://www.unam.mx/"
target="_blank"
className={header.center}
style={{
display: "flex",
alignItems: "center",
}}
>
<img
className={header.logo}
src="/logo-blanco.png"
alt="Logo FES"
width={250}
height={70}
/>
</a>
<div className={header.yellowPart}></div>
</header>
);
};
//IO
+242
View File
@@ -0,0 +1,242 @@
.formContainer{
width: 100%;
}
.impressions-container {
position: relative;
padding: 1.5rem;
border: 1px solid #d1d5db;
border-radius: 4px;
width: 90%;
max-width: 1100px;
min-height: 520px;
background-color: white;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
overflow: hidden;
z-index: -2;
}
.impressions-title {
font-size: 1.75rem;
font-weight: bold;
text-align: start;
}
.lineSeparator {
border-bottom: 1px solid #cfcfcf;
padding-bottom: 5px;
margin-bottom: 1rem;
}
.containerInformation {
max-width: 450px;
width: 100%;
}
.input-label {
font-size: 1rem;
font-weight: bold;
display: block;
}
.input-group {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
justify-content: space-between;
}
.inputPages {
max-width: 50px;
}
.selection {
width: 100%;
max-width: 600px;
margin-top: 1rem;
border: 1px solid #e5e7eb;
border-radius: 4px;
background-color: #f9fafb;
}
.selectionButton {
background-color: #e5e7eb;
padding: 0.5rem 1rem;
border-radius: 4px;
font-weight: 600;
border: none;
cursor: pointer;
transition: background-color 0.2s ease;
}
.selectionButton:hover {
background-color: #f9fafb;
}
.toggle-group {
display: flex;
gap: 10px;
background-color: #f3f4f6;
border-radius: 6px;
}
.toggle-button {
flex: 1;
padding: 0.5rem 1rem;
background-color: transparent;
border: none;
cursor: pointer;
border-radius: 4px;
transition: background-color 0.3s, color 0.3s;
font-weight: 500;
}
.toggle-button:hover {
background-color: #e5e7eb;
}
.toggle-button.active {
background-color: #f9fafb;
}
.information {
display: flex;
flex-direction: column;
border: 1px solid #e5e7eb;
padding: 1rem;
border-radius: 4px;
background-color: #f3f4f6;
margin-top: 1rem;
}
.impressions {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
}
.img {
position: absolute;
top: 0;
right: 0;
width: 400px;
height: 615px;
background-image: url("/rock.jpg");
background-size: cover;
background-position: center;
overflow: hidden;
z-index: -1;
opacity: 0.5;
}
.img::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(
to right,
rgb(255, 255, 255),
rgba(128, 128, 128, 0)
);
}
.labelSession {
position: absolute;
font-weight: bold;
font-size: 1.75rem;
top: 15px;
right: 25px;
}
.spinner {
width: 10px;
height: 10px;
border: 3px solid rgba(255, 255, 255, 0.3);
border-top-color: #ffffff;
border-radius: 50%;
animation: spin 0.8s ease-in-out infinite;
margin: 0 20px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.button:disabled {
opacity: 0.7;
cursor: not-allowed;
}
@media (max-width: 800px) {
.impressions-container {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: center;
max-width: 85%;
max-height: 600px;
}
.impressions-title {
font-size: 1.45rem;
}
.lineSeparator {
text-align: center;
padding-bottom: 0;
border-bottom: 1px solid #717171;
width: 100%;
}
.labelSession {
font-size: 1rem;
top: 20px;
right: 60px;
}
.img {
right: 50%;
transform: translateX(50%);
width: 600px;
opacity: 1;
height: 650px;
}
.img::before,
.img::after {
content: "";
position: absolute;
top: 0;
bottom: 0;
width: 100%;
z-index: 1;
}
.img::before {
left: 0;
background: linear-gradient(
to right,
rgb(255, 255, 255),
rgba(128, 128, 128, 0)
);
}
.img::after {
right: 0;
background: linear-gradient(
to left,
rgb(255, 255, 255),
rgba(128, 128, 128, 0)
);
}
}
@media (max-width: 440px) {
.selection {
width: max-content;
}
}
+459
View File
@@ -0,0 +1,459 @@
import axios from "axios";
import { useEffect, useState } from "react";
import { envConfig } from "../envConfigurations/EnvConfigurations";
import "./login.css";
export const Impressions = () => {
const [numAccount, setNumAccount] = useState("");
const [pages, setPages] = useState("");
const [view, setView] = useState("impresiones");
const [folio, setFolio] = useState("");
const [amount, setAmount] = useState("");
const [date, setDate] = useState("");
const [error, setError] = useState("");
const [alert, setAlert] = useState("");
const [errorStudent, setErrorStudent] = useState("");
const [showErrorStudent, setShowErrorStudent] = useState(false);
const [showError, setShowError] = useState(false);
const [showAlert, setShowAlert] = useState(false);
const [studentData, setStudentData] = useState(null);
const [loading, setLoading] = useState(false);
const [loadingstudent, setLoadingStudent] = useState(false);
//restrict this month//
const day = new Date();
const year = day.getFullYear();
const month = day.getMonth();
const today = day.getDate();
const minFecha = new Date(year, month, 1).toISOString().split("T")[0];
const maxFecha = new Date(year, month, today).toISOString().split("T")[0];
//restrict this month//
const url = envConfig().apiUrl;
const token = localStorage.getItem("token");
//fadeOut alert and error
useEffect(() => {
if (alert) {
setShowAlert(true);
const timeout = setTimeout(() => {
setShowAlert(false);
setTimeout(() => setAlert(""), 500);
}, 6000);
return () => clearTimeout(timeout);
}
}, [alert]);
useEffect(() => {
if (error) {
setShowError(true);
const timeout = setTimeout(() => {
setShowError(false);
setTimeout(() => setError(""), 500);
}, 6000);
return () => clearTimeout(timeout);
}
}, [error]);
useEffect(() => {
if (errorStudent) {
setShowErrorStudent(true);
const timeout = setTimeout(() => {
setShowErrorStudent(false);
setTimeout(() => setErrorStudent(""), 500);
}, 6000);
return () => clearTimeout(timeout);
}
}, [errorStudent]);
//fadeOut alert and error//
const headers = {
Authorization: `Bearer ${token}`,
};
const handleLogout = () => {
localStorage.removeItem("token");
localStorage.removeItem("session");
window.location.href = "/Login";
};
const handleSearch = async () => {
setErrorStudent("");
setLoadingStudent(true);
if (!numAccount) {
setErrorStudent("Ingresa un numero de cuenta");
setLoadingStudent(false);
return;
}
try {
const response = await axios.get(`${url}/student/${numAccount}`);
setStudentData(response.data);
} catch (error) {
const errorMessage =
error.response?.data?.error || "No se encontró el estudiante";
if (errorMessage === "Token inválido") {
handleLogout();
}
setErrorStudent(errorMessage);
setStudentData(null);
} finally {
setLoadingStudent(false);
}
};
const handlePayment = async () => {
setAlert("");
setError("");
setLoading(true);
if (!studentData) {
setError("Error busca denuevo al estudiante");
setLoading(false);
return;
}
if (!pages) {
setError("Ingresa el numero de hojas a imprimir");
setLoading(false);
return;
}
try {
await axios.post(
`${url}/impressions`,
{
numAccount: numAccount,
pages: parseInt(pages),
cost: parseInt(pages),
},
{ headers }
);
handleSearch();
setAlert("Cobro realizado correctamente");
setPages("");
} catch (error) {
const errorMessage =
error.response?.data?.error || "No se encontró el estudiante";
if (errorMessage === "Token inválido") {
handleLogout();
}
setError(errorMessage);
} finally {
setLoading(false);
}
};
const handleSaveReceipt = async () => {
setAlert("");
setError("");
setLoading(true);
if (!studentData) {
setError("Primero busca un estudiante");
setLoading(false);
return;
}
if (!folio) {
setError("Ingresa el numero de folio");
setLoading(false);
return;
}
if (!amount) {
setError("Ingresa el monto");
setLoading(false);
return;
}
if (!date) {
setError("Ingresa la fecha del ticket");
setLoading(false);
return;
}
try {
await axios.post(
`${url}/receipt`,
{
fol: folio,
amount: parseFloat(amount),
date: date,
numAccount: numAccount,
},
{ headers }
);
handleSearch();
setAlert("Recibo guardado correctamente");
setFolio("");
setAmount("");
setDate("");
} catch (error) {
const errorMessage =
error.response?.data?.error || "No se encontró el estudiante";
if (errorMessage === "Token inválido") {
handleLogout();
}
setError(errorMessage);
} finally {
setLoading(false);
}
};
return (
<>
<div className="formContainer">
<div className="containerInformation">
<form
onSubmit={(e) => {
e.preventDefault();
handleSearch();
}}
>
<label className="input-label">No.Cuenta</label>
<div className="input-group">
<input
type="text"
value={numAccount}
onChange={(e) => {
const value = e.target.value;
if (/^\d*$/.test(value) && value.length <= 9) {
setNumAccount(value);
}
}}
className="input-field"
placeholder="Coloca un número de cuenta..."
inputMode="numeric"
pattern="[0-9]*"
/>
<button className="button button-search" type="submit" disabled={loadingstudent}>
{loadingstudent ? <div className="spinner"></div> : "Buscar"}
</button>
</div>
{errorStudent && (
<div
className={`messageBox error ${
!showErrorStudent ? "hidden" : ""
}`}
style={{ marginTop: "10px" }}
>
{errorStudent}
</div>
)}
</form>
{studentData && (
<div className="information">
<label>
<b>No.Cuenta: </b>
{studentData[0]?.id_cuenta}
</label>
<label>
<b>Nombre: </b>
{studentData[0]?.nombre}
</label>
<label>
<b>Carrera: </b>
{studentData[0]?.carrera}
</label>
<label>
<b>Crédito: </b>
{studentData[0]?.credito}
</label>
</div>
)}
</div>
</div>
{studentData && (
<div className="selection">
<div className="toggle-group">
<button
className={`toggle-button ${
view === "impresiones" ? "active" : ""
}`}
onClick={() => setView("impresiones")}
>
Impresiones
</button>
<button
className={`toggle-button ${view === "recibo" ? "active" : ""}`}
onClick={() => setView("recibo")}
>
Recibo
</button>
</div>
{view === "impresiones" && (
<form
onSubmit={(e) => {
e.preventDefault();
handlePayment();
}}
>
<div className="impressions">
<div className="groupLabel">
<label className="input-label">Costo: $1.00</label>
</div>
<div className="input-group">
<label className="input-label">Hojas:</label>
<input
type="text"
value={pages}
onChange={(e) => {
const value = e.target.value;
if (/^\d*$/.test(value)) {
setPages(value);
}
}}
className="input-field "
placeholder="Numero de hojas a imprimir..."
inputMode="numeric"
pattern="[0-9]*"
/>
</div>
<div className="groupLabel">
<label className="input-label">
Total: {pages && `$${pages}.00`}
</label>
</div>
<div className="containerButton">
<button
className="button button-charge"
type="submit"
disabled={loading || loadingstudent}
>
{loading ? <div className="spinner"></div> : "Cobrar"}
</button>
{error && (
<div
className={`messageBox error ${
!showError ? "hidden" : ""
}`}
>
{error}
</div>
)}
{alert && (
<div
className={`messageBox success ${
!showAlert ? "hidden" : ""
}`}
>
{alert}
</div>
)}
</div>
</div>
</form>
)}
{view === "recibo" && (
<form
onSubmit={(e) => {
e.preventDefault();
handleSaveReceipt();
}}
>
<div className="impressions">
<div className="input-group">
<label className="input-label">Ticket:</label>
<input
type="text"
value={folio}
onChange={(e) => {
const value = e.target.value;
if (/^\d*$/.test(value)) {
setFolio(value);
}
}}
className="input-field"
placeholder="Numero de folio..."
inputMode="numeric"
pattern="[0-9]*"
minLength={6}
maxLength={10}
/>
</div>
<div className="input-group">
<label className="input-label">Monto:</label>
<input
type="text"
value={amount}
onChange={(e) => {
const value = e.target.value;
if (/^\d*\.?\d*$/.test(value)) {
const numericValue = parseFloat(value);
if (value === "" || numericValue <= 1000) {
setAmount(value);
} else {
setAlert("");
setError("El monto no puede superar $1000.00");
}
}
}}
className="input-field"
placeholder="Monto recibido..."
inputMode="numeric"
pattern="^\d*\.?\d+$"
/>
</div>
<div className="input-group">
<label className="input-label">Fecha de Pago:</label>
<input
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
className="input-field"
min={minFecha}
max={maxFecha}
/>
</div>
<div className="containerButton">
<button
className="button button-search"
type="submit"
disabled={loading || loadingstudent}
>
{loading ? <div className="spinner"></div> : "Guardar"}
</button>
{error && (
<div
className={`messageBox error ${
!showError ? "hidden" : ""
}`}
>
{error}
</div>
)}
{alert && (
<div
className={`messageBox success ${
!showAlert ? "hidden" : ""
}`}
>
{alert}
</div>
)}
</div>
</div>
</form>
)}
</div>
)}
<button onClick={handleLogout} className="button button-logout">
Cerrar sesión
</button>
</>
);
};
//IO
+86
View File
@@ -0,0 +1,86 @@
import axios from 'axios';
import { useState } from 'react';
import { envConfig } from '../envConfigurations/EnvConfigurations';
import './Impressions.css';
export const Login = () => {
const [user, setUser] = useState('');
const [password, setPassword] = useState('');
const [message, setMessage] = useState('');
const handleLogin = async () => {
const url = envConfig().apiUrl;
setMessage('')
try {
const response = await axios.post(`${url}/login`, {
user,
password,
});
if (response.data?.token) {
localStorage.setItem('token', response.data.token);
localStorage.setItem('session', user);
setMessage('Inicio de sesión exitoso');
window.location.href = '/';
} else {
setMessage('Credenciales incorrectas');
}
} catch (error) {
const errorMessage = error.response?.data?.error || 'Error de conexión con la base de datos';
setMessage(errorMessage);
}
};
return (
<section className='containerLogin'>
<form
className='login'
onSubmit={(e) => {
e.preventDefault();
handleLogin();
}}
>
<h2 className='textHeader'>Inicio de sesión</h2>
<div className='containerInput'>
<label className='label'>Usuario</label>
<input
type='text'
value={user}
onChange={(e) => setUser(e.target.value)}
placeholder='Coloca tu usuario...'
/>
</div>
<div className='containerInput relative'>
<label className='label'>Contraseña</label>
<input
type={'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder='Coloca tu contraseña...'
/>
</div>
<button
className='button button-search'
style={{ maxWidth: '100%', width: '100%' }}
type='submit'
>Iniciar sesión
</button>
</form>
{message &&
<div
className={`messageBox ${message.includes('exitoso') ? 'success' : 'error'}`}
style={{ marginTop: '10px' }}
>
{message}
</div>
}
</section>
);
};
//IO
+10
View File
@@ -0,0 +1,10 @@
export default function NotFound() {
return (
<div style={{ padding: 40, textAlign: 'center' }}>
<h1>404 - Página no encontrada</h1>
<p>La ruta que buscas no existe.</p>
<a href='/Login'>Login</a>
</div>
);
}
//IO
+93
View File
@@ -0,0 +1,93 @@
.logo {
position: relative;
z-index: 3;
margin: 5px;
}
.cedetecContainer {
position: absolute;
top: 0;
right: 0;
width: 300px;
height: 100%;
z-index: -1;
}
.cedetecContainer::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(to right, #f9f9f9, rgba(128, 128, 128, 0));
}
.yellowPart {
position: absolute;
content: "";
top: 0;
left: -20px;
width: 100%;
height: 30px;
background: linear-gradient(to right, #bd8c01 40%, #fff 100%);
transform: skew(-45deg);
z-index: 0;
}
.header {
background-color: #fff;
display: grid;
grid-template-columns: auto 1fr;
position: relative;
z-index: 1;
align-items: end;
}
.header::after {
content: "";
top: 0;
left: -60px;
position: absolute;
background-color: #003e79;
height: 100%;
min-width: 400px;
transform: skew(45deg);
z-index: 2;
}
.header::before {
content: "";
top: 0;
left: -40px;
position: absolute;
background-color: rgb(1, 92, 184);
height: 100%;
width: 10%;
min-width: 400px;
transform: skew(45deg);
z-index: 2;
}
@media (max-width: 800px) {
.yellowPart {
left: 0;
background: #bd8c01;
transform: skew(0deg);
}
.header::after {
left: -100px;
}
.header::before {
left: -80px;
}
}
@media (max-width: 400px) {
.header::after {
left: -50px;
min-width: 100%;
transform: skew(20deg);
}
.header::before {
left: -40px;
min-width: 100%;
transform: skew(20deg);
}
}
+57
View File
@@ -0,0 +1,57 @@
.label {
position: block;
margin: 5px;
}
.containerLogin {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 100%;
max-width: 500px;
height: 400px;
border-radius: 4px;
padding: 1.5rem;
}
.login {
width: 300px;
}
.textHeader {
font-weight: bold;
font-size: 1.75rem;
text-align: center;
margin: 5px;
}
.containerInput {
display: flex;
flex-direction: column;
margin-bottom: 10px;
}
.relative {
position: relative;
}
.eyeClose,
.eyeOpen {
position: absolute;
right: 10px;
bottom: -1px;
transform: translateY(-50%);
width: 20px;
height: 20px;
background-size: cover;
cursor: pointer;
}
.eyeClose {
background-image: url('../../img/eye-hidden.svg');
}
.eyeOpen {
background-image: url('../../img/eye-open.svg');
}
+11
View File
@@ -0,0 +1,11 @@
import { Navigate } from 'react-router-dom';
import { useAuth } from '../../services/useAuth';
export const LoginRedirect = ({ children }) => {
const { loading, authenticated } = useAuth();
if (loading) return <p>Cargando...</p>;
return authenticated ? <Navigate to='/' /> : children;
};
//IO
+10
View File
@@ -0,0 +1,10 @@
import { useAuth } from '../../services/useAuth';
export const PrivateRoute = ({ children }) => {
const { loading, authenticated } = useAuth();
if (loading) return <p>Cargando...</p>;
return authenticated ? children : window.location.href = '/Login';
};
//IO
@@ -0,0 +1,4 @@
export const envConfig = () => ({
apiUrl: import.meta.env.VITE_API_URL
});
//IO
+13
View File
@@ -0,0 +1,13 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
+12
View File
@@ -0,0 +1,12 @@
import React from "react";
import ReactDOM from "react-dom/client";
import "./index.css";
import App from "./App";
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
+18
View File
@@ -0,0 +1,18 @@
import axios from 'axios';
import { envConfig } from '../envConfigurations/EnvConfigurations';
const url = envConfig().apiUrl
export const searchUser = async (user, password) => {
try {
const response = await axios.post(url, {
user,
password,
});
return response.data;
} catch (error) {
return error
}
};
//IO
+47
View File
@@ -0,0 +1,47 @@
import { useEffect, useState } from 'react';
import axios from 'axios';
import { envConfig } from '../envConfigurations/EnvConfigurations';
export const useAuth = () => {
const [loading, setLoading] = useState(true);
const [authenticated, setAuthenticated] = useState(false);
useEffect(() => {
const checkAuth = async () => {
const token = localStorage.getItem('token');
if (!token) {
setAuthenticated(false);
setLoading(false);
return;
}
try {
const res = await axios.get(`${envConfig().apiUrl}/validate-token`, {
headers: { Authorization: `Bearer ${token}` },
});
const isValid = res.data.valid;
if (isValid) {
setAuthenticated(true);
} else {
setAuthenticated(false);
localStorage.removeItem('token');
localStorage.removeItem('session');
}
} catch (err) {
setAuthenticated(false);
localStorage.removeItem('token');
localStorage.removeItem('session');
} finally {
setLoading(false);
}
};
checkAuth();
}, []);
return { loading, authenticated };
};
//IO
+5
View File
@@ -0,0 +1,5 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';
+23
View File
@@ -0,0 +1,23 @@
import { defineConfig, loadEnv, } from "vite";
import react from "@vitejs/plugin-react";
// https://vite.dev/config/
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "");
const port = Number(env.VITE_PORT);
if (!port) {
console.error("Not found VITE_PORT, add VITE_PORT in .env");
throw new Error("Not found VITE_PORT, add VITE_PORT in .env");
}
return {
plugins: [react()],
server: {
port,
strictPort: true,
},
};
});