feat: portal SAM - Next.js 15 + Bootstrap, integración con deployer API

This commit is contained in:
Nurfog committed 2026-09-08 14:06:40 -03:00
1 parent 229cbd074a
commit 7691c01ff1
9 files changed
+1005 -107

No files matched your search

+24
View File
@@ -0,0 +1,24 @@
FROM node:22-alpine AS base
FROM base AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]
+1 -1
View File
@@ -1,7 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
output: "standalone",
};
export default nextConfig;
+262 -5
View File
@@ -8,6 +8,8 @@
"name": "sam-portal",
"version": "0.1.0",
"dependencies": {
"bootstrap": "^5.3.8",
"boxicons": "^2.1.4",
"next": "16.3.4",
"react": "19.2.8",
"react-dom": "19.2.8"
@@ -228,6 +230,15 @@
"node": ">=6.0.0"
}
},
"node_modules/@babel/runtime": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
@@ -1377,6 +1388,17 @@
"node": ">=12.4.0"
}
},
"node_modules/@popperjs/core": {
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/@rtsao/scc": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -2387,6 +2409,12 @@
"win32"
]
},
"node_modules/@webcomponents/webcomponentsjs": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@webcomponents/webcomponentsjs/-/webcomponentsjs-2.8.0.tgz",
"integrity": "sha512-loGD63sacRzOzSJgQnB9ZAhaQGkN7wl2Zuw7tsphI5Isa0irijrRo6EnJii/GgjGefIFO8AIO7UivzRhFaEk9w==",
"license": "BSD-3-Clause"
},
"node_modules/acorn": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
@@ -2692,6 +2720,78 @@
"node": ">=6.0.0"
}
},
"node_modules/bootstrap": {
"version": "5.3.8",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz",
"integrity": "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/twbs"
},
{
"type": "opencollective",
"url": "https://opencollective.com/bootstrap"
}
],
"license": "MIT",
"peerDependencies": {
"@popperjs/core": "^2.11.8"
}
},
"node_modules/boxicons": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/boxicons/-/boxicons-2.1.4.tgz",
"integrity": "sha512-BvJNfYfnE4g9WQ7GL91fftxMOTwAleWlPFwvQJPYb/Ju7aLjlQ/Eu55AH9JLNk/OR82z+ZSq4TbKzbV/e5Rr0A==",
"license": "(CC-BY-4.0 OR OFL-1.1 OR MIT)",
"dependencies": {
"@webcomponents/webcomponentsjs": "^2.0.2",
"prop-types": "^15.6.0",
"react": "^16.0.0",
"react-dom": "^16.0.0",
"react-interactive": "^0.8.1",
"react-router-dom": "^4.2.2"
}
},
"node_modules/boxicons/node_modules/react": {
"version": "16.14.0",
"resolved": "https://registry.npmjs.org/react/-/react-16.14.0.tgz",
"integrity": "sha512-0X2CImDkJGApiAlcf0ODKIneSwBPhqJawOa5wCtKbu7ZECrmS26NvtSILynQ66cgkT/RJ4LidJOc3bUESwmU8g==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0",
"object-assign": "^4.1.1",
"prop-types": "^15.6.2"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/boxicons/node_modules/react-dom": {
"version": "16.14.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.14.0.tgz",
"integrity": "sha512-1gCeQXDLoIqMgqD3IO2Ah9bnf0w9kzhwN5q4FGnHZ67hBm9yePzB5JJAIQCc8x3pFnNlwFq4RidZggNAAkzWWw==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0",
"object-assign": "^4.1.1",
"prop-types": "^15.6.2",
"scheduler": "^0.19.1"
},
"peerDependencies": {
"react": "^16.14.0"
}
},
"node_modules/boxicons/node_modules/scheduler": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.19.1.tgz",
"integrity": "sha512-n/zwRWRYSUj0/3g/otKDRPMh6qv2SYMWNq85IEa8iZyAv8od9zDYpGSnpBEjNgcMNq6Scbu5KfIPxNF72R/2EA==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0",
"object-assign": "^4.1.1"
}
},
"node_modules/brace-expansion": {
"version": "1.1.18",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
@@ -3031,6 +3131,24 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/detect-hover": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/detect-hover/-/detect-hover-1.0.3.tgz",
"integrity": "sha512-HtLoY+tClgYucJNiovNICGWFp9nOGVmHY44s7L62iPqORXM9vujeWFaVcqtA7XRvp/2Y+4RBUfHbDKFGN+xxZQ==",
"license": "MIT"
},
"node_modules/detect-it": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/detect-it/-/detect-it-3.0.7.tgz",
"integrity": "sha512-RxpgcdbatUX6epJE09K16iJqF7x6iEcEdoL18FR2zpBO4JhnL7aMOAUoUEyexdtbWOSfTmoDWmeD6mwRBQyRXg==",
"license": "MIT",
"dependencies": {
"detect-hover": "^1.0.3",
"detect-passive-events": "^1.0.5",
"detect-pointer": "^1.0.3",
"detect-touch-events": "^2.0.2"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -3041,6 +3159,24 @@
"node": ">=8"
}
},
"node_modules/detect-passive-events": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/detect-passive-events/-/detect-passive-events-1.0.5.tgz",
"integrity": "sha512-foW7Q35wwOCxVzW0xLf5XeB5Fhe7oyRgvkBYdiP9IWgLMzjqUqTvsJv9ymuEWGjY6AoDXD3OC294+Z9iuOw0QA==",
"license": "MIT"
},
"node_modules/detect-pointer": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/detect-pointer/-/detect-pointer-1.0.3.tgz",
"integrity": "sha512-d0o/Puo3fiGSCXy6H039h9Kwz+mmYCGKZ/qtPFnpN3WfsumjC1C9b5KKvRu+aYnfdI8peqN/iAe7dPd85qIt2g==",
"license": "MIT"
},
"node_modules/detect-touch-events": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/detect-touch-events/-/detect-touch-events-2.0.2.tgz",
"integrity": "sha512-g8GWBkJLiIDRJfRXEdrd1wMXpNyGId2DkbfuwFahSb4OCvn717hyRJtAcEDISfp3zkwEhZ4Y4woHPA6DeyB3Fw==",
"license": "MIT"
},
"node_modules/doctrine": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
@@ -4175,6 +4311,26 @@
"hermes-estree": "0.25.1"
}
},
"node_modules/history": {
"version": "4.10.1",
"resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz",
"integrity": "sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.1.2",
"loose-envify": "^1.2.0",
"resolve-pathname": "^3.0.0",
"tiny-invariant": "^1.0.2",
"tiny-warning": "^1.0.0",
"value-equal": "^1.0.1"
}
},
"node_modules/hoist-non-react-statics": {
"version": "2.5.5",
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-2.5.5.tgz",
"integrity": "sha512-rqcy4pJo55FTTLWt+bU8ukscqHeE/e9KWvsOW2b/a3afxQZhwkQdT1rPPCJ0rYXdj4vNcasY8zHTH+jF/qStxw==",
"license": "BSD-3-Clause"
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -4227,6 +4383,15 @@
"node": ">= 0.4"
}
},
"node_modules/invariant": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
"integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.0.0"
}
},
"node_modules/is-array-buffer": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
@@ -4704,7 +4869,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/js-yaml": {
@@ -5137,7 +5301,6 @@
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
@@ -5385,7 +5548,6 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -5613,6 +5775,21 @@
"dev": true,
"license": "MIT"
},
"node_modules/path-to-regexp": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.9.0.tgz",
"integrity": "sha512-xIp7/apCFJuUHdDLWe8O1HIkb0kQrOMb/0u6FXQjemHn/ii5LrIzU6bdECnsiTF/GjZkMEKg1xdiZwNqDYlZ6g==",
"license": "MIT",
"dependencies": {
"isarray": "0.0.1"
}
},
"node_modules/path-to-regexp/node_modules/isarray": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz",
"integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==",
"license": "MIT"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -5685,7 +5862,6 @@
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"dev": true,
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
@@ -5745,13 +5921,61 @@
"react": "^19.2.8"
}
},
"node_modules/react-interactive": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/react-interactive/-/react-interactive-0.8.3.tgz",
"integrity": "sha512-mmRvA9aKP7zu9kVfP1AX1egX8tFlnE3DDXq92z0JTZezfOpmeQBzr77O1+mTV54OOmn+M2t6c5kFD5VnuFoM7A==",
"license": "MIT",
"dependencies": {
"detect-it": "^3.0.3",
"object-assign": "^4.1.1",
"prop-types": "^15.6.0"
},
"peerDependencies": {
"react": ">=15"
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true,
"license": "MIT"
},
"node_modules/react-router": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-4.3.1.tgz",
"integrity": "sha512-yrvL8AogDh2X42Dt9iknk4wF4V8bWREPirFfS9gLU1huk6qK41sg7Z/1S81jjTrGHxa3B8R3J6xIkDAA6CVarg==",
"license": "MIT",
"dependencies": {
"history": "^4.7.2",
"hoist-non-react-statics": "^2.5.0",
"invariant": "^2.2.4",
"loose-envify": "^1.3.1",
"path-to-regexp": "^1.7.0",
"prop-types": "^15.6.1",
"warning": "^4.0.1"
},
"peerDependencies": {
"react": ">=15"
}
},
"node_modules/react-router-dom": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-4.3.1.tgz",
"integrity": "sha512-c/MlywfxDdCp7EnB7YfPMOfMD3tOtIjrQlj/CKfNMBxdmpJP8xcz5P/UAFn3JbnQCNUxsHyVVqllF9LhgVyFCA==",
"license": "MIT",
"dependencies": {
"history": "^4.7.2",
"invariant": "^2.2.4",
"loose-envify": "^1.3.1",
"prop-types": "^15.6.1",
"react-router": "^4.3.1",
"warning": "^4.0.1"
},
"peerDependencies": {
"react": ">=15"
}
},
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -5830,6 +6054,12 @@
"node": ">=4"
}
},
"node_modules/resolve-pathname": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/resolve-pathname/-/resolve-pathname-3.0.0.tgz",
"integrity": "sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng==",
"license": "MIT"
},
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
@@ -6394,6 +6624,18 @@
"url": "https://opencollective.com/webpack"
}
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tiny-warning": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz",
"integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==",
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -6734,6 +6976,21 @@
"punycode": "^2.1.0"
}
},
"node_modules/value-equal": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/value-equal/-/value-equal-1.0.1.tgz",
"integrity": "sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw==",
"license": "MIT"
},
"node_modules/warning": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
"integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.0.0"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+2
View File
@@ -9,6 +9,8 @@
"lint": "eslint"
},
"dependencies": {
"bootstrap": "^5.3.8",
"boxicons": "^2.1.4",
"next": "16.3.4",
"react": "19.2.8",
"react-dom": "19.2.8"
+44
View File
@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from "next/server";
const DEPLOYER_URL = process.env.DEPLOYER_URL || "http://127.0.0.1:3010";
const DEPLOYER_TOKEN = process.env.DEPLOYER_TOKEN || "";
function headers() {
const h: Record<string, string> = { "Content-Type": "application/json" };
if (DEPLOYER_TOKEN) h["X-API-Token"] = DEPLOYER_TOKEN;
return h;
}
export async function GET() {
try {
const res = await fetch(`${DEPLOYER_URL}/api/modules`, {
headers: headers(),
cache: "no-store",
});
const data = await res.json();
return NextResponse.json(data, { status: res.status });
} catch (e) {
return NextResponse.json(
{ error: e instanceof Error ? e.message : "Error al conectar con deployer" },
{ status: 502 }
);
}
}
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const res = await fetch(`${DEPLOYER_URL}/api/modules`, {
method: "POST",
headers: headers(),
body: JSON.stringify(body),
});
const data = await res.json();
return NextResponse.json(data, { status: res.status });
} catch (e) {
return NextResponse.json(
{ error: e instanceof Error ? e.message : "Error al registrar módulo" },
{ status: 502 }
);
}
}
+141 -21
View File
@@ -1,26 +1,146 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
--primary: #00285d;
--primary-light: #1a3f7a;
--primary-gradient: linear-gradient(135deg, #00285d 0%, #1a4a8a 100%);
--card-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
--card-shadow-hover: 0 6px 24px rgba(0, 0, 0, 0.14);
--radius: 12px;
--transition: 0.25s ease;
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
font-family: "Fira Sans", sans-serif;
background-color: #f0f2f5;
min-height: 100vh;
}
.sidenav {
background: var(--primary-gradient);
overflow-x: hidden;
padding-top: 20px;
box-shadow: 4px 0 20px rgba(0, 0, 0, 0.15);
}
.login-main-text {
padding: 60px 40px;
}
.login-main-text img {
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3));
width: 80px;
height: 80px;
object-fit: contain;
}
.main {
padding: 0 24px;
}
@media screen and (max-width: 767px) {
.sidenav {
padding: 20px 0 10px;
}
.login-main-text {
padding: 20px;
}
.login-main-text h1 {
font-size: 1.5rem;
}
.main {
padding: 0 12px;
}
}
@media screen and (min-width: 768px) {
.main {
margin-left: 40%;
}
.sidenav {
width: 40%;
position: fixed;
z-index: 1;
top: 0;
left: 0;
height: 100vh;
}
}
.section-header {
border-radius: var(--radius);
border-left: 4px solid var(--primary);
font-weight: 600;
font-size: 1.1rem;
background: #fff;
}
.app-card {
background: #fff;
border-radius: var(--radius);
box-shadow: var(--card-shadow);
transition: transform var(--transition), box-shadow var(--transition),
background-color var(--transition);
border: none;
position: relative;
}
.app-card:hover {
transform: translateY(-3px);
box-shadow: var(--card-shadow-hover);
background-color: #f8faff;
}
.app-card a {
font-weight: 500;
}
.stretched-link ~ * {
pointer-events: none;
}
.app-card table {
font-size: 1.1rem;
}
.app-card-sm table {
font-size: 0.95rem;
}
.app-card .bx-border {
border-radius: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
}
.badge-respaldo {
font-size: 0.7rem;
font-weight: 500;
vertical-align: middle;
letter-spacing: 0.3px;
}
.row-cards > [class*="col-"] {
margin-bottom: 16px;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
}
.status-healthy {
background: #198754;
box-shadow: 0 0 6px rgba(25, 135, 84, 0.5);
}
.status-unhealthy {
background: #dc3545;
}
.status-starting {
background: #ffc107;
}
+20 -19
View File
@@ -1,29 +1,30 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "bootstrap/dist/css/bootstrap.min.css";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "SAM | ICHN - Portal de Módulos",
description: "Sistema de Administración de Matrículas - Portal de integración de módulos",
};
export default function RootLayout({ children }: LayoutProps<"/">) {
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
<html lang="es">
<head>
<link
href="https://unpkg.com/boxicons@2.1.4/css/boxicons.min.css"
rel="stylesheet"
/>
<link
href="https://fonts.googleapis.com/css?family=Fira+Sans:400,500,600,700&display=swap"
rel="stylesheet"
/>
</head>
<body>{children}</body>
</html>
);
}
+280 -61
View File
@@ -1,69 +1,288 @@
import Image from "next/image";
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
interface ModuleState {
name: string;
repoUrl: string;
branch: string;
subdomain: string;
port: number;
active: boolean;
lastDeploy?: {
sha: string;
time: string;
ok: boolean;
log: string;
};
container?: string | null;
}
interface ApiResponse {
backend: {
repoUrl: string;
branch: string;
container: string | null;
lastDeploy: ModuleState["lastDeploy"] | null;
};
modules: ModuleState[];
}
const iconMap: Record<string, { icon: string; bg: string }> = {
ventas: { icon: "bx-money", bg: "bg-success" },
comercial: { icon: "bx-money", bg: "bg-success" },
finanzas: { icon: "bx-detail", bg: "bg-danger" },
contabilidad: { icon: "bx-detail", bg: "bg-danger" },
academico: { icon: "bx-user", bg: "bg-primary" },
"academico-empresas": { icon: "bx-briefcase", bg: "bg-primary" },
profesor: { icon: "bx-pencil", bg: "bg-warning text-dark" },
profesoras: { icon: "bx-pencil", bg: "bg-warning text-dark" },
empresas: { icon: "bx-building", bg: "bg-info" },
pagos: { icon: "bx-credit-card", bg: "bg-success" },
default: { icon: "bx-cube", bg: "bg-secondary" },
};
function getModuleMeta(name: string) {
const key = name.toLowerCase();
return iconMap[key] || iconMap.default;
}
function getStatusInfo(m: ModuleState) {
if (!m.container) return { label: "Detenido", cls: "status-unhealthy" };
if (m.container.includes("healthy")) return { label: "Activo", cls: "status-healthy" };
if (m.container.includes("starting")) return { label: "Iniciando", cls: "status-starting" };
return { label: "Activo", cls: "status-healthy" };
}
export default function Home() {
const [data, setData] = useState<ApiResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchModules = async () => {
try {
setLoading(true);
const res = await fetch("/api/modules", { cache: "no-store" });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
setData(json);
setError(null);
} catch (e) {
setError(e instanceof Error ? e.message : "Error al cargar módulos");
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchModules();
const id = setInterval(fetchModules, 15000);
return () => clearInterval(id);
}, []);
const modules = data?.modules ?? [];
const backend = data?.backend;
return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert h-5 w-[100px]"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the{" "}
<code className="rounded bg-black/[.06] px-1.5 py-0.5 font-mono text-[0.9em] dark:bg-white/[.08]">
page.tsx
</code>{" "}
file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
<>
<div className="sidenav">
<div className="row text-light login-main-text">
<div className="col-12 text-center">
<div
className="mx-auto d-flex align-items-center justify-content-center bg-white rounded-3"
style={{ width: 80, height: 80 }}
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
<i className="bx bx-layer text-primary" style={{ fontSize: 48 }} />
</div>
</div>
<div className="col-12 text-center mt-3">
<h1 className="fw-bold fs-2">Sistema SAM</h1>
</div>
<div className="col-12 text-center">
<h4 className="fw-semibold opacity-75">Aplicaciones</h4>
</div>
<div className="col-12 text-center mt-4">
<Link href="/upload" className="btn btn-light btn-sm px-4">
<i className="bx bx-plus me-1" /> Nuevo Módulo
</Link>
</div>
<div className="col-12 text-center mt-3">
<small className="opacity-50">Portal de integración</small>
</div>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert h-[14px] w-4"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={14}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
<div className="main">
<div className="row" id="modulos">
<div className="col-lg-1" />
<div className="col-lg-10 mt-4">
{loading && (
<div className="text-center py-5">
<div className="spinner-border text-primary" role="status" />
<p className="mt-2 text-muted">Cargando módulos...</p>
</div>
)}
{error && (
<div className="alert alert-danger d-flex align-items-center">
<i className="bx bx-error-circle me-2 fs-5" />
<div>
<strong>Error:</strong> {error}
<button
onClick={fetchModules}
className="btn btn-sm btn-outline-danger ms-3"
>
Reintentar
</button>
</div>
</div>
)}
{!loading && !error && (
<div className="row row-cards">
<div className="col-12">
<div className="alert alert-light section-header mb-3 py-2 px-3 d-flex justify-content-between align-items-center">
<span>
<i className="bx bx-grid-alt me-1" /> Selección de Aplicación
</span>
<span className="badge bg-primary rounded-pill">
{modules.length} módulo{modules.length !== 1 ? "s" : ""}
</span>
</div>
</div>
{modules.length === 0 ? (
<div className="col-12">
<div className="app-card p-4 text-center">
<i className="bx bx-inbox fs-1 text-muted" />
<p className="text-muted mt-2 mb-3">
No hay módulos integrados. ¡Sube tu primer proyecto!
</p>
<Link href="/upload" className="btn btn-primary btn-sm">
<i className="bx bx-upload me-1" /> Subir Proyecto
</Link>
</div>
</div>
) : (
modules.map((m) => {
const meta = getModuleMeta(m.name);
const status = getStatusInfo(m);
const url = `https://${m.subdomain}.devops.norteamericano.cl`;
const displayName =
m.name.charAt(0).toUpperCase() + m.name.slice(1).replace(/-/g, " ");
return (
<div className="col-md-6" key={m.name}>
<div className="app-card p-2">
<table className="table table-borderless mb-0 align-middle">
<tbody>
<tr>
<td className="ps-3" style={{ width: 56 }}>
<i
className={`bx ${meta.icon} bx-sm bx-border text-light ${meta.bg}`}
/>
</td>
<td>
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="text-decoration-none text-dark d-block py-2 stretched-link"
>
{displayName}
</a>
<div className="d-flex align-items-center gap-2">
<span className={`status-dot ${status.cls}`} />
<small className="text-muted" style={{ fontSize: "0.75rem" }}>
{status.label} · :{m.port} · {m.branch}
</small>
</div>
</td>
<td className="text-end pe-3" style={{ width: 40 }}>
<i className="bx bx-chevron-right text-muted" />
</td>
</tr>
</tbody>
</table>
</div>
</div>
);
})
)}
{backend && (
<>
<div className="col-12 mt-4 mb-2">
<div className="alert alert-light section-header mb-3 py-2 px-3">
<i className="bx bx-server me-1" /> Backend & Sistema
</div>
</div>
<div className="col-md-6">
<div className="app-card p-2">
<table className="table table-borderless mb-0 align-middle">
<tbody>
<tr>
<td className="ps-3" style={{ width: 56 }}>
<i className="bx bx-code-alt bx-sm bx-border text-light bg-dark" />
</td>
<td>
<a
href="https://api.devops.norteamericano.cl/health"
target="_blank"
rel="noopener noreferrer"
className="text-decoration-none text-dark d-block py-2 stretched-link"
>
API Backend
</a>
<small className="text-muted" style={{ fontSize: "0.75rem" }}>
{backend.container ? "Activo" : "Detenido"} · {backend.branch}
</small>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div className="col-md-6">
<div className="app-card p-2">
<table className="table table-borderless mb-0 align-middle">
<tbody>
<tr>
<td className="ps-3" style={{ width: 56 }}>
<i className="bx bx-cog bx-sm bx-border text-light bg-secondary" />
</td>
<td>
<Link
href="/upload"
className="text-decoration-none text-dark d-block py-2 stretched-link"
>
Gestionar Módulos
</Link>
<small className="text-muted" style={{ fontSize: "0.75rem" }}>
Subir / actualizar proyectos
</small>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</>
)}
<div className="col-12 mt-4 mb-4 text-center">
<small className="text-muted">
Auto-actualizado cada 15s ·{" "}
<a href="#" onClick={(e) => { e.preventDefault(); fetchModules(); }}>
actualizar ahora
</a>
</small>
</div>
</div>
)}
</div>
</div>
</main>
</div>
</div>
</>
);
}
+231
View File
@@ -0,0 +1,231 @@
"use client";
import { useState } from "react";
import Link from "next/link";
export default function UploadPage() {
const [form, setForm] = useState({
name: "",
repoUrl: "",
branch: "main",
port: "",
subdomain: "",
});
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<{ ok: boolean; message: string } | null>(null);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setForm({ ...form, [e.target.name]: e.target.value });
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setResult(null);
const payload: Record<string, unknown> = {
name: form.name.trim().toLowerCase(),
repoUrl: form.repoUrl.trim(),
branch: form.branch.trim() || "main",
};
if (form.port) payload.port = parseInt(form.port, 10);
if (form.subdomain) payload.subdomain = form.subdomain.trim();
try {
const res = await fetch("/api/modules", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await res.json();
if (res.ok) {
setResult({
ok: true,
message: `Módulo "${payload.name}" registrado. Deploy: ${data.webhook ? "webhook creado" : "sin webhook"}${data.deployLog ? " · " + data.deployLog.slice(0, 80) : ""}`,
});
setForm({ name: "", repoUrl: "", branch: "main", port: "", subdomain: "" });
} else {
setResult({ ok: false, message: data.error || `Error HTTP ${res.status}` });
}
} catch (err) {
setResult({ ok: false, message: err instanceof Error ? err.message : "Error de red" });
} finally {
setLoading(false);
}
};
return (
<>
<div className="sidenav">
<div className="row text-light login-main-text">
<div className="col-12 text-center">
<div
className="mx-auto d-flex align-items-center justify-content-center bg-white rounded-3"
style={{ width: 80, height: 80 }}
>
<i className="bx bx-upload text-primary" style={{ fontSize: 48 }} />
</div>
</div>
<div className="col-12 text-center mt-3">
<h1 className="fw-bold fs-2">Sistema SAM</h1>
</div>
<div className="col-12 text-center">
<h4 className="fw-semibold opacity-75">Subir Proyecto</h4>
</div>
<div className="col-12 text-center mt-4">
<Link href="/" className="btn btn-outline-light btn-sm px-4">
<i className="bx bx-arrow-back me-1" /> Volver
</Link>
</div>
</div>
</div>
<div className="main">
<div className="row">
<div className="col-lg-1" />
<div className="col-lg-10 mt-4">
<div className="alert alert-light section-header mb-3 py-2 px-3">
<i className="bx bx-cloud-upload me-1" /> Integrar Nuevo Módulo
</div>
<div className="app-card p-4">
<p className="text-muted mb-4">
Registra un proyecto <strong>Next.js 15 + Bootstrap</strong> desde un repositorio
Gitea. Se clonará, desplegará automáticamente y quedará disponible en{" "}
<code>https://{"{subdominio}"}.devops.norteamericano.cl</code>. Cada push futuro
actualiza el módulo vía webhook.
</p>
{result && (
<div className={`alert ${result.ok ? "alert-success" : "alert-danger"}`}>
<i className={`bx ${result.ok ? "bx-check-circle" : "bx-error-circle"} me-2`} />
{result.message}
</div>
)}
<form onSubmit={handleSubmit}>
<div className="row g-3">
<div className="col-md-6">
<label className="form-label fw-semibold">
Nombre del módulo <span className="text-danger">*</span>
</label>
<input
name="name"
value={form.name}
onChange={handleChange}
placeholder="ej: ventas"
pattern="^[a-z][a-z0-9-]+$"
required
className="form-control"
/>
<div className="form-text">Minúsculas, guiones, empieza con letra.</div>
</div>
<div className="col-md-6">
<label className="form-label fw-semibold">
Subdominio <small className="text-muted">(opcional)</small>
</label>
<input
name="subdomain"
value={form.subdomain}
onChange={handleChange}
placeholder="ej: ventas (default: nombre)"
className="form-control"
/>
<div className="form-text">https://{form.subdomain || form.name || "subdominio"}.devops.norteamericano.cl</div>
</div>
<div className="col-12">
<label className="form-label fw-semibold">
URL del repositorio Gitea <span className="text-danger">*</span>
</label>
<input
name="repoUrl"
value={form.repoUrl}
onChange={handleChange}
placeholder="https://git.norteamericano.cl/Fimm/mi-frontend.git"
required
className="form-control"
/>
<div className="form-text">Debe ser accesible desde el deployer (https://git.norteamericano.cl).</div>
</div>
<div className="col-md-6">
<label className="form-label fw-semibold">Rama</label>
<input
name="branch"
value={form.branch}
onChange={handleChange}
placeholder="main"
className="form-control"
/>
</div>
<div className="col-md-6">
<label className="form-label fw-semibold">
Puerto <small className="text-muted">(opcional)</small>
</label>
<input
name="port"
type="number"
value={form.port}
onChange={handleChange}
placeholder="auto (3011+)"
min={3000}
max={4000}
className="form-control"
/>
<div className="form-text">Se asigna automáticamente si se deja vacío.</div>
</div>
<div className="col-12 mt-2">
<div className="alert alert-info py-2 small mb-0">
<i className="bx bx-info-circle me-1" />
El repositorio debe ser un proyecto <strong>Next.js 15</strong> (con{" "}
<code>package.json</code> en raíz o en <code>frontend/</code>) y usar{" "}
<strong>Bootstrap</strong>. Si no trae <code>Dockerfile</code>, se genera uno automáticamente.
</div>
</div>
<div className="col-12 mt-3 d-flex gap-2">
<button type="submit" disabled={loading} className="btn btn-primary px-4">
{loading ? (
<>
<span className="spinner-border spinner-border-sm me-2" /> Desplegando...
</>
) : (
<>
<i className="bx bx-rocket me-1" /> Registrar y Desplegar
</>
)}
</button>
<Link href="/" className="btn btn-outline-secondary">
Cancelar
</Link>
</div>
</div>
</form>
</div>
<div className="app-card p-3 mt-3">
<h6 className="fw-semibold mb-2">
<i className="bx bx-book-open me-1" /> Requisitos del repositorio
</h6>
<ul className="small text-muted mb-0">
<li>
Proyecto Next.js 15 con <code>next build</code> / <code>next start</code>
</li>
<li>Bootstrap 5 (via CDN o npm) + Boxicons opcional</li>
<li>
Variable <code>NEXT_PUBLIC_API_URL=https://api.devops.norteamericano.cl</code>{" "}
para consumir el backend
</li>
<li>Branch por defecto: main (configurable)</li>
</ul>
</div>
</div>
</div>
</div>
</>
);
}