Merge branch 'main' of https://github.lci.ulsa.mx/Guillermo.Arrieta/acad-ia-2
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
FROM oven/bun:1 AS build
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
RUN bun install --frozen-lockfile
|
||||
RUN bun install
|
||||
RUN bunx --bun vite build
|
||||
|
||||
FROM nginx:alpine
|
||||
|
||||
43
bun.lock
43
bun.lock
@@ -5,6 +5,7 @@
|
||||
"name": "acad-ia-2",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
@@ -26,10 +27,11 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"lucide-react": "^0.561.0",
|
||||
"lucide-react": "^0.562.0",
|
||||
"motion": "^12.24.7",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"tailwind-merge": "^3.0.2",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss": "^4.0.6",
|
||||
"tw-animate-css": "^1.3.6",
|
||||
},
|
||||
@@ -46,6 +48,7 @@
|
||||
"eslint-import-resolver-typescript": "^4.4.4",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-unused-imports": "^4.3.0",
|
||||
"jsdom": "^27.0.0",
|
||||
"prettier": "^3.5.3",
|
||||
@@ -240,6 +243,8 @@
|
||||
|
||||
"@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.1.11", "", { "dependencies": { "@radix-ui/react-context": "1.1.3", "@radix-ui/react-primitive": "2.1.4", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0Qk603AHGV28BOBO34p7IgD5m+V5Sg/YovfayABkoDDBM5d3NCx0Mp4gGrjzLGes1jV5eNOE1r3itqOR33VC6Q=="],
|
||||
|
||||
"@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw=="],
|
||||
|
||||
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
|
||||
|
||||
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
|
||||
@@ -272,8 +277,6 @@
|
||||
|
||||
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="],
|
||||
|
||||
"@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.10", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A=="],
|
||||
|
||||
"@radix-ui/react-select": ["@radix-ui/react-select@2.2.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ=="],
|
||||
|
||||
"@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g=="],
|
||||
@@ -776,6 +779,8 @@
|
||||
|
||||
"eslint-plugin-n": ["eslint-plugin-n@17.23.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.5.0", "enhanced-resolve": "^5.17.1", "eslint-plugin-es-x": "^7.8.0", "get-tsconfig": "^4.8.1", "globals": "^15.11.0", "globrex": "^0.1.2", "ignore": "^5.3.2", "semver": "^7.6.3", "ts-declaration-location": "^1.0.6" }, "peerDependencies": { "eslint": ">=8.23.0" } }, "sha512-68PealUpYoHOBh332JLLD9Sj7OQUDkFpmcfqt8R9sySfFSeuGJjMTJQvCRRB96zO3A/PELRLkPrzsHmzEFQQ5A=="],
|
||||
|
||||
"eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.0.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA=="],
|
||||
|
||||
"eslint-plugin-unused-imports": ["eslint-plugin-unused-imports@4.3.0", "", { "peerDependencies": { "@typescript-eslint/eslint-plugin": "^8.0.0-0 || ^7.0.0 || ^6.0.0 || ^5.0.0", "eslint": "^9.0.0 || ^8.0.0" }, "optionalPeers": ["@typescript-eslint/eslint-plugin"] }, "sha512-ZFBmXMGBYfHttdRtOG9nFFpmUvMtbHSjsKrS20vdWdbfiVYsO3yA2SGYy9i9XmZJDfMGBflZGBCm70SEnFQtOA=="],
|
||||
|
||||
"eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="],
|
||||
@@ -818,6 +823,8 @@
|
||||
|
||||
"for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="],
|
||||
|
||||
"framer-motion": ["framer-motion@12.24.7", "", { "dependencies": { "motion-dom": "^12.24.3", "motion-utils": "^12.23.28", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-EolFLm7NdEMhWO/VTMZ0LlR4fLHGDiJItTx3i8dlyQooOOBoYAaysK4paGD4PrwqnoDdeDOS+TxnSBIAnNHs3w=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
@@ -868,7 +875,11 @@
|
||||
|
||||
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||
|
||||
"html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="],
|
||||
"hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="],
|
||||
|
||||
"hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
|
||||
|
||||
"html-encoding-sniffer": ["html-encoding-sniffer@4.0.0", "", { "dependencies": { "whatwg-encoding": "^3.1.1" } }, "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ=="],
|
||||
|
||||
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
|
||||
|
||||
@@ -984,7 +995,7 @@ rinsic": "^1.2.6", "is-typed-array": "^1.1.13" } }, "sha512-RKtWF8pGmS87i2D6gqQu
|
||||
|
||||
"lru-cache": ["lru-cache@11.2.4", "", {}, "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg=="],
|
||||
|
||||
"lucide-react": ["lucide-react@0.561.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Y59gMY38tl4/i0qewcqohPdEbieBy7SovpBL9IFebhc2mDd8x4PZSOsiFRkpPcOq6bj1r/mjH/Rk73gSlIJP2A=="],
|
||||
"lucide-react": ["lucide-react@0.562.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw=="],
|
||||
|
||||
"lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="],
|
||||
|
||||
@@ -998,6 +1009,12 @@ rinsic": "^1.2.6", "is-typed-array": "^1.1.13" } }, "sha512-RKtWF8pGmS87i2D6gqQu
|
||||
|
||||
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
||||
|
||||
"motion": ["motion@12.24.7", "", { "dependencies": { "framer-motion": "^12.24.7", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-0jOoqFlQ7JBvAcRhRv28pwUgZ1xw9WS4+tCU6aqYjxgiNVZCVi34ED2cihW3EgjIIWPBoZJis5og1mx/LmQWVQ=="],
|
||||
|
||||
"motion-dom": ["motion-dom@12.24.3", "", { "dependencies": { "motion-utils": "^12.23.28" } }, "sha512-ZjMZCwhTglim0LM64kC1iFdm4o+2P9IKk3rl/Nb4RKsb5p4O9HJ1C2LWZXOFdsRtp6twpqWRXaFKOduF30ntow=="],
|
||||
|
||||
"motion-utils": ["motion-utils@12.23.28", "", {}, "sha512-0W6cWd5Okoyf8jmessVK3spOmbyE0yTdNKujHctHH9XdAE4QDuZ1/LjSXC68rrhsJU+TkzXURC5OdSWh9ibOwQ=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
@@ -1288,6 +1305,8 @@ rinsic": "^1.2.6", "is-typed-array": "^1.1.13" } }, "sha512-RKtWF8pGmS87i2D6gqQu
|
||||
|
||||
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="],
|
||||
|
||||
"@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
|
||||
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
||||
@@ -1296,6 +1315,10 @@ rinsic": "^1.2.6", "is-typed-array": "^1.1.13" } }, "sha512-RKtWF8pGmS87i2D6gqQu
|
||||
|
||||
"@radix-ui/react-arrow/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"@radix-ui/react-checkbox/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
|
||||
|
||||
"@radix-ui/react-checkbox/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"@radix-ui/react-collection/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
|
||||
|
||||
"@radix-ui/react-collection/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
@@ -1328,10 +1351,6 @@ rinsic": "^1.2.6", "is-typed-array": "^1.1.13" } }, "sha512-RKtWF8pGmS87i2D6gqQu
|
||||
|
||||
"@radix-ui/react-roving-focus/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"@radix-ui/react-scroll-area/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
|
||||
|
||||
"@radix-ui/react-scroll-area/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"@radix-ui/react-select/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
|
||||
|
||||
"@radix-ui/react-select/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
@@ -1412,6 +1431,8 @@ rinsic": "^1.2.6", "is-typed-array": "^1.1.13" } }, "sha512-RKtWF8pGmS87i2D6gqQu
|
||||
|
||||
"@radix-ui/react-arrow/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-checkbox/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-dismissable-layer/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-focus-scope/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
@@ -1422,8 +1443,6 @@ rinsic": "^1.2.6", "is-typed-array": "^1.1.13" } }, "sha512-RKtWF8pGmS87i2D6gqQu
|
||||
|
||||
"@radix-ui/react-roving-focus/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-scroll-area/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-tabs/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-visually-hidden/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
@@ -17,5 +17,11 @@
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
"iconLibrary": "lucide",
|
||||
"registries": {
|
||||
"@shadcn-studio": "https://shadcnstudio.com/r/{name}.json",
|
||||
"@ss-components": "https://shadcnstudio.com/r/components/{name}.json",
|
||||
"@ss-blocks": "https://shadcnstudio.com/r/blocks/{name}.json",
|
||||
"@ss-themes": "https://shadcnstudio.com/r/themes/{name}.json"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { tanstackConfig } from '@tanstack/eslint-config'
|
||||
import eslintConfigPrettier from 'eslint-config-prettier'
|
||||
import jsxA11y from 'eslint-plugin-jsx-a11y'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import unusedImports from 'eslint-plugin-unused-imports'
|
||||
|
||||
export default [
|
||||
@@ -24,9 +25,12 @@ export default [
|
||||
|
||||
// 3. TUS REGLAS Y CONFIGURACIÓN "PRO"
|
||||
{
|
||||
// Opcional: Puedes ser explícito sobre dónde aplicar esto
|
||||
files: ['**/*.{ts,tsx,js,jsx}'],
|
||||
plugins: {
|
||||
'jsx-a11y': jsxA11y,
|
||||
'unused-imports': unusedImports,
|
||||
'react-hooks': reactHooks,
|
||||
},
|
||||
// Configuración robusta del Resolver (La versión de Copilot)
|
||||
settings: {
|
||||
@@ -44,7 +48,8 @@ export default [
|
||||
// --- REGLAS DE ACCESIBILIDAD (A11Y) ---
|
||||
// Activamos las recomendadas manualmente
|
||||
...jsxA11y.configs.recommended.rules,
|
||||
|
||||
'react-hooks/rules-of-hooks': 'error',
|
||||
'react-hooks/exhaustive-deps': 'warn',
|
||||
// --- ORDEN DE IMPORTS ---
|
||||
'sort-imports': 'off', // Apagamos el nativo
|
||||
'import/order': [
|
||||
|
||||
12
package.json
12
package.json
@@ -17,8 +17,12 @@
|
||||
"ci:verify": "prettier --check . && eslint . && tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
@@ -28,6 +32,7 @@
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@stepperize/react": "^5.1.9",
|
||||
"@supabase/supabase-js": "^2.90.1",
|
||||
"@tailwindcss/vite": "^4.0.6",
|
||||
"@tanstack/react-devtools": "^0.7.0",
|
||||
"@tanstack/react-query": "^5.66.5",
|
||||
@@ -38,10 +43,12 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"lucide-react": "^0.561.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^0.562.0",
|
||||
"motion": "^12.24.7",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"tailwind-merge": "^3.0.2",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss": "^4.0.6",
|
||||
"tw-animate-css": "^1.3.6"
|
||||
},
|
||||
@@ -58,6 +65,7 @@
|
||||
"eslint-import-resolver-typescript": "^4.4.4",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-unused-imports": "^4.3.0",
|
||||
"jsdom": "^27.0.0",
|
||||
"prettier": "^3.5.3",
|
||||
|
||||
@@ -1,84 +1,291 @@
|
||||
import { useState } from 'react'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Pencil, BookOpen } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useState } from 'react';
|
||||
import { Plus, Search, BookOpen, Trash2, Library, Edit3, Save } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog';
|
||||
import { cn } from '@/lib/utils';
|
||||
//import { toast } from 'sonner';
|
||||
//import { mockLibraryResources } from '@/data/mockMateriaData';
|
||||
|
||||
type Props = {
|
||||
value: string
|
||||
onSave: (value: string) => void
|
||||
export const mockLibraryResources = [
|
||||
{
|
||||
id: 'lib-1',
|
||||
titulo: 'Deep Learning',
|
||||
autor: 'Goodfellow, I., Bengio, Y., & Courville, A.',
|
||||
editorial: 'MIT Press',
|
||||
anio: 2016,
|
||||
isbn: '9780262035613',
|
||||
disponible: true
|
||||
},
|
||||
{
|
||||
id: 'lib-2',
|
||||
titulo: 'Artificial Intelligence: A Modern Approach',
|
||||
autor: 'Russell, S., & Norvig, P.',
|
||||
editorial: 'Pearson',
|
||||
anio: 2020,
|
||||
isbn: '9780134610993',
|
||||
disponible: true
|
||||
},
|
||||
{
|
||||
id: 'lib-3',
|
||||
titulo: 'Hands-On Machine Learning',
|
||||
autor: 'Aurélien Géron',
|
||||
editorial: 'O\'Reilly Media',
|
||||
anio: 2019,
|
||||
isbn: '9781492032649',
|
||||
disponible: false
|
||||
}
|
||||
];
|
||||
|
||||
// --- Interfaces ---
|
||||
export interface BibliografiaEntry {
|
||||
id: string;
|
||||
tipo: 'BASICA' | 'COMPLEMENTARIA';
|
||||
cita: string;
|
||||
fuenteBibliotecaId?: string;
|
||||
fuenteBiblioteca?: any;
|
||||
}
|
||||
|
||||
export function BibliographyItem({ value, onSave }: Props) {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [draft, setDraft] = useState(value)
|
||||
interface BibliografiaTabProps {
|
||||
bibliografia: BibliografiaEntry[];
|
||||
onSave: (bibliografia: BibliografiaEntry[]) => void;
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
setDraft(value)
|
||||
setIsEditing(false)
|
||||
}
|
||||
export function BibliographyItem({ bibliografia, onSave, isSaving }: BibliografiaTabProps) {
|
||||
const [entries, setEntries] = useState<BibliografiaEntry[]>(bibliografia);
|
||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
||||
const [isLibraryDialogOpen, setIsLibraryDialogOpen] = useState(false);
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [newEntryType, setNewEntryType] = useState<'BASICA' | 'COMPLEMENTARIA'>('BASICA');
|
||||
|
||||
function handleSave() {
|
||||
onSave(draft)
|
||||
setIsEditing(false)
|
||||
}
|
||||
const basicaEntries = entries.filter(e => e.tipo === 'BASICA');
|
||||
const complementariaEntries = entries.filter(e => e.tipo === 'COMPLEMENTARIA');
|
||||
|
||||
const handleAddManual = (cita: string) => {
|
||||
const newEntry: BibliografiaEntry = { id: `manual-${Date.now()}`, tipo: newEntryType, cita };
|
||||
setEntries([...entries, newEntry]);
|
||||
setIsAddDialogOpen(false);
|
||||
//toast.success('Referencia manual añadida');
|
||||
};
|
||||
|
||||
const handleAddFromLibrary = (resource: any, tipo: 'BASICA' | 'COMPLEMENTARIA') => {
|
||||
const cita = `${resource.autor} (${resource.anio}). ${resource.titulo}. ${resource.editorial}.`;
|
||||
const newEntry: BibliografiaEntry = {
|
||||
id: `lib-ref-${Date.now()}`,
|
||||
tipo,
|
||||
cita,
|
||||
fuenteBibliotecaId: resource.id,
|
||||
fuenteBiblioteca: resource,
|
||||
};
|
||||
setEntries([...entries, newEntry]);
|
||||
setIsLibraryDialogOpen(false);
|
||||
//toast.success('Añadido desde biblioteca');
|
||||
};
|
||||
|
||||
const handleUpdateCita = (id: string, cita: string) => {
|
||||
setEntries(entries.map(e => e.id === id ? { ...e, cita } : e));
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg border p-4 transition',
|
||||
isEditing
|
||||
? 'border-yellow-400 bg-yellow-50'
|
||||
: 'border-gray-200 bg-white'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<BookOpen className="w-5 h-5 text-yellow-500 mt-1" />
|
||||
|
||||
<div className="flex-1">
|
||||
{!isEditing ? (
|
||||
<>
|
||||
<p className="text-sm leading-relaxed">{value}</p>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="mt-2 text-muted-foreground"
|
||||
onClick={() => setIsEditing(true)}
|
||||
>
|
||||
<Pencil className="w-4 h-4 mr-1" />
|
||||
Editar
|
||||
<div className="max-w-5xl mx-auto py-10 space-y-8 animate-in fade-in duration-500">
|
||||
<div className="flex items-center justify-between border-b pb-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-slate-900 tracking-tight">Bibliografía</h2>
|
||||
<p className="text-sm text-slate-500 mt-1">
|
||||
{basicaEntries.length} básica • {complementariaEntries.length} complementaria
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Dialog open={isLibraryDialogOpen} onOpenChange={setIsLibraryDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" className="border-blue-200 text-blue-700 hover:bg-blue-50">
|
||||
<Library className="w-4 h-4 mr-2" /> Buscar en biblioteca
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Textarea
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
className="min-h-[90px]"
|
||||
/>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<LibrarySearchDialog onSelect={handleAddFromLibrary} existingIds={entries.map(e => e.fuenteBibliotecaId || '')} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<div className="flex justify-end gap-2 mt-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline"><Plus className="w-4 h-4 mr-2" /> Añadir manual</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<AddManualDialog tipo={newEntryType} onTypeChange={setNewEntryType} onAdd={handleAddManual} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
onClick={handleSave}
|
||||
>
|
||||
Guardar
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<Button onClick={() => onSave(entries)} disabled={isSaving} className="bg-blue-600 hover:bg-blue-700">
|
||||
<Save className="w-4 h-4 mr-2" /> {isSaving ? 'Guardando...' : 'Guardar'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-8">
|
||||
{/* BASICA */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-4 w-1 bg-blue-600 rounded-full" />
|
||||
<h3 className="font-semibold text-slate-800">Bibliografía Básica</h3>
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
{basicaEntries.map(entry => (
|
||||
<BibliografiaCard
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
isEditing={editingId === entry.id}
|
||||
onEdit={() => setEditingId(entry.id)}
|
||||
onStopEditing={() => setEditingId(null)}
|
||||
onUpdateCita={handleUpdateCita}
|
||||
onDelete={() => setDeleteId(entry.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* COMPLEMENTARIA */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-4 w-1 bg-slate-400 rounded-full" />
|
||||
<h3 className="font-semibold text-slate-800">Bibliografía Complementaria</h3>
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
{complementariaEntries.map(entry => (
|
||||
<BibliografiaCard
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
isEditing={editingId === entry.id}
|
||||
onEdit={() => setEditingId(entry.id)}
|
||||
onStopEditing={() => setEditingId(null)}
|
||||
onUpdateCita={handleUpdateCita}
|
||||
onDelete={() => setDeleteId(entry.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<AlertDialog open={!!deleteId} onOpenChange={() => setDeleteId(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>¿Eliminar referencia?</AlertDialogTitle>
|
||||
<AlertDialogDescription>La referencia será quitada del plan de estudios.</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancelar</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => { setEntries(entries.filter(e => e.id !== deleteId)); setDeleteId(null); }} className="bg-red-600">Eliminar</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// --- Subcomponentes ---
|
||||
|
||||
function BibliografiaCard({ entry, isEditing, onEdit, onStopEditing, onUpdateCita, onDelete }: any) {
|
||||
const [localCita, setLocalCita] = useState(entry.cita);
|
||||
|
||||
return (
|
||||
<Card className={cn("group transition-all hover:shadow-md", isEditing && "ring-2 ring-blue-500")}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<BookOpen className={cn("w-5 h-5 mt-1", entry.tipo === 'BASICA' ? "text-blue-600" : "text-slate-400")} />
|
||||
<div className="flex-1 min-w-0">
|
||||
{isEditing ? (
|
||||
<div className="space-y-2">
|
||||
<Textarea value={localCita} onChange={(e) => setLocalCita(e.target.value)} className="min-h-[80px]" />
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={onStopEditing}>Cancelar</Button>
|
||||
<Button size="sm" className="bg-emerald-600" onClick={() => { onUpdateCita(entry.id, localCita); onStopEditing(); }}>Guardar</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div onClick={onEdit} className="cursor-pointer">
|
||||
<p className="text-sm leading-relaxed text-slate-700">{entry.cita}</p>
|
||||
{entry.fuenteBiblioteca && (
|
||||
<div className="flex gap-2 mt-2">
|
||||
<Badge variant="secondary" className="text-[10px] bg-slate-100 text-slate-600">Biblioteca</Badge>
|
||||
{entry.fuenteBiblioteca.disponible && <Badge className="text-[10px] bg-emerald-50 text-emerald-700 border-emerald-100">Disponible</Badge>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!isEditing && (
|
||||
<div className="flex opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-blue-600" onClick={onEdit}><Edit3 className="w-4 h-4" /></Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-red-500" onClick={onDelete}><Trash2 className="w-4 h-4" /></Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function AddManualDialog({ tipo, onTypeChange, onAdd }: any) {
|
||||
const [cita, setCita] = useState('');
|
||||
return (
|
||||
<div className="space-y-4 py-4">
|
||||
<DialogHeader><DialogTitle>Referencia Manual</DialogTitle></DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold uppercase text-slate-500">Tipo</label>
|
||||
<Select value={tipo} onValueChange={onTypeChange}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="BASICA">Básica</SelectItem>
|
||||
<SelectItem value="COMPLEMENTARIA">Complementaria</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold uppercase text-slate-500">Cita APA</label>
|
||||
<Textarea value={cita} onChange={(e) => setCita(e.target.value)} placeholder="Autor, A. (Año). Título..." className="min-h-[120px]" />
|
||||
</div>
|
||||
<Button onClick={() => onAdd(cita)} disabled={!cita.trim()} className="w-full bg-blue-600">Añadir a la lista</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LibrarySearchDialog({ onSelect, existingIds }: any) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [tipo, setTipo] = useState<'BASICA' | 'COMPLEMENTARIA'>('BASICA');
|
||||
const filtered = mockLibraryResources.filter(r =>
|
||||
!existingIds.includes(r.id) && r.titulo.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 py-2">
|
||||
<DialogHeader><DialogTitle>Catálogo de Biblioteca</DialogTitle></DialogHeader>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<Input value={search} onChange={(e) => setSearch(e.target.value)} placeholder="Buscar por título o autor..." className="pl-10" />
|
||||
</div>
|
||||
<Select value={tipo} onValueChange={(v:any) => setTipo(v)}><SelectTrigger className="w-36"><SelectValue /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="BASICA">Básica</SelectItem><SelectItem value="COMPLEMENTARIA">Complem.</SelectItem></SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="max-h-[300px] overflow-y-auto pr-2 space-y-2">
|
||||
{filtered.map(res => (
|
||||
<div key={res.id} onClick={() => onSelect(res, tipo)} className="p-3 border rounded-lg hover:bg-slate-50 cursor-pointer flex justify-between items-center group">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-700">{res.titulo}</p>
|
||||
<p className="text-xs text-slate-500">{res.autor}</p>
|
||||
</div>
|
||||
<Plus className="w-4 h-4 text-blue-600 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,40 +1,277 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { UnidadCard } from './contenido-tematico/UnidadCard'
|
||||
import { useState } from 'react';
|
||||
import { Plus, GripVertical, ChevronDown, ChevronRight, Edit3, Trash2, Clock, Save } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { cn } from '@/lib/utils';
|
||||
//import { toast } from 'sonner';
|
||||
|
||||
|
||||
|
||||
export interface Tema {
|
||||
id: string;
|
||||
nombre: string;
|
||||
descripcion?: string;
|
||||
horasEstimadas?: number;
|
||||
}
|
||||
|
||||
export interface UnidadTematica {
|
||||
id: string;
|
||||
nombre: string;
|
||||
numero: number;
|
||||
temas: Tema[];
|
||||
}
|
||||
|
||||
const initialData: UnidadTematica[] = [
|
||||
{
|
||||
id: 'u1',
|
||||
numero: 1,
|
||||
nombre: 'Fundamentos de Inteligencia Artificial',
|
||||
temas: [
|
||||
{ id: 't1', nombre: 'Tipos de IA y aplicaciones', horasEstimadas: 6 },
|
||||
{ id: 't2', nombre: 'Ética en IA', horasEstimadas: 3 },
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
export function ContenidoTematico() {
|
||||
const [unidades, setUnidades] = useState<UnidadTematica[]>(initialData);
|
||||
const [expandedUnits, setExpandedUnits] = useState<Set<string>>(new Set(['u1']));
|
||||
const [deleteDialog, setDeleteDialog] = useState<{ type: 'unidad' | 'tema'; id: string; parentId?: string } | null>(null);
|
||||
const [editingUnit, setEditingUnit] = useState<string | null>(null);
|
||||
const [editingTema, setEditingTema] = useState<{ unitId: string; temaId: string } | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// --- Lógica de Unidades ---
|
||||
const toggleUnit = (id: string) => {
|
||||
const newExpanded = new Set(expandedUnits);
|
||||
newExpanded.has(id) ? newExpanded.delete(id) : newExpanded.add(id);
|
||||
setExpandedUnits(newExpanded);
|
||||
};
|
||||
|
||||
const addUnidad = () => {
|
||||
const newId = `u-${Date.now()}`;
|
||||
const newUnidad: UnidadTematica = {
|
||||
id: newId,
|
||||
nombre: 'Nueva Unidad',
|
||||
numero: unidades.length + 1,
|
||||
temas: [],
|
||||
};
|
||||
setUnidades([...unidades, newUnidad]);
|
||||
setExpandedUnits(new Set([...expandedUnits, newId]));
|
||||
setEditingUnit(newId);
|
||||
};
|
||||
|
||||
const updateUnidadNombre = (id: string, nombre: string) => {
|
||||
setUnidades(unidades.map(u => u.id === id ? { ...u, nombre } : u));
|
||||
};
|
||||
|
||||
// --- Lógica de Temas ---
|
||||
const addTema = (unidadId: string) => {
|
||||
setUnidades(unidades.map(u => {
|
||||
if (u.id === unidadId) {
|
||||
const newTemaId = `t-${Date.now()}`;
|
||||
const newTema: Tema = { id: newTemaId, nombre: 'Nuevo tema', horasEstimadas: 2 };
|
||||
setEditingTema({ unitId: unidadId, temaId: newTemaId });
|
||||
return { ...u, temas: [...u.temas, newTema] };
|
||||
}
|
||||
return u;
|
||||
}));
|
||||
};
|
||||
|
||||
const updateTema = (unidadId: string, temaId: string, updates: Partial<Tema>) => {
|
||||
setUnidades(unidades.map(u => {
|
||||
if (u.id === unidadId) {
|
||||
return { ...u, temas: u.temas.map(t => t.id === temaId ? { ...t, ...updates } : t) };
|
||||
}
|
||||
return u;
|
||||
}));
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!deleteDialog) return;
|
||||
if (deleteDialog.type === 'unidad') {
|
||||
setUnidades(unidades.filter(u => u.id !== deleteDialog.id).map((u, i) => ({ ...u, numero: i + 1 })));
|
||||
} else if (deleteDialog.parentId) {
|
||||
setUnidades(unidades.map(u => u.id === deleteDialog.parentId ? { ...u, temas: u.temas.filter(t => t.id !== deleteDialog.id) } : u));
|
||||
}
|
||||
setDeleteDialog(null);
|
||||
//toast.success("Eliminado correctamente");
|
||||
};
|
||||
|
||||
const totalHoras = unidades.reduce((acc, u) => acc + u.temas.reduce((sum, t) => sum + (t.horasEstimadas || 0), 0), 0);
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto py-10 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="max-w-5xl mx-auto py-10 space-y-6 animate-in fade-in duration-500">
|
||||
<div className="flex items-center justify-between border-b pb-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">Contenido Temático</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Unidades, temas y subtemas de la materia
|
||||
<h2 className="text-2xl font-bold tracking-tight text-slate-900">Contenido Temático</h2>
|
||||
<p className="text-sm text-slate-500 mt-1">
|
||||
{unidades.length} unidades • {totalHoras} horas estimadas totales
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline">Nueva unidad</Button>
|
||||
<Button>Guardar</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={addUnidad} className="gap-2">
|
||||
<Plus className="w-4 h-4" /> Nueva unidad
|
||||
</Button>
|
||||
<Button onClick={() => { setIsSaving(true); setTimeout(() => { setIsSaving(false); /*toast.success("Guardado")*/; }, 1000); }} disabled={isSaving} className="bg-blue-600 hover:bg-blue-700">
|
||||
<Save className="w-4 h-4 mr-2" /> {isSaving ? 'Guardando...' : 'Guardar'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UnidadCard
|
||||
numero={1}
|
||||
titulo="Fundamentos de Inteligencia Artificial"
|
||||
temas={[
|
||||
{
|
||||
id: 't1',
|
||||
titulo: 'Tipos de IA y aplicaciones',
|
||||
horas: 6,
|
||||
},
|
||||
{
|
||||
id: 't2',
|
||||
titulo: 'Ética en IA',
|
||||
horas: 3,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
{unidades.map((unidad) => (
|
||||
<Card key={unidad.id} className="overflow-hidden border-slate-200 shadow-sm">
|
||||
<Collapsible open={expandedUnits.has(unidad.id)} onOpenChange={() => toggleUnit(unidad.id)}>
|
||||
<CardHeader className="bg-slate-50/50 py-3 border-b border-slate-100">
|
||||
<div className="flex items-center gap-3">
|
||||
<GripVertical className="w-4 h-4 text-slate-300 cursor-grab" />
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="p-0 h-auto">
|
||||
{expandedUnits.has(unidad.id) ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<Badge className="bg-blue-600 font-mono">Unidad {unidad.numero}</Badge>
|
||||
|
||||
{editingUnit === unidad.id ? (
|
||||
<Input
|
||||
value={unidad.nombre}
|
||||
onChange={(e) => updateUnidadNombre(unidad.id, e.target.value)}
|
||||
onBlur={() => setEditingUnit(null)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && setEditingUnit(null)}
|
||||
className="max-w-md h-8 bg-white"
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<CardTitle className="text-base font-semibold cursor-pointer hover:text-blue-600 transition-colors" onClick={() => setEditingUnit(unidad.id)}>
|
||||
{unidad.nombre}
|
||||
</CardTitle>
|
||||
)}
|
||||
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
<span className="text-xs font-medium text-slate-400 flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" /> {unidad.temas.reduce((sum, t) => sum + (t.horasEstimadas || 0), 0)}h
|
||||
</span>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-red-500" onClick={() => setDeleteDialog({ type: 'unidad', id: unidad.id })}>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CollapsibleContent>
|
||||
<CardContent className="pt-4 bg-white">
|
||||
<div className="space-y-1 ml-10 border-l-2 border-slate-50 pl-4">
|
||||
{unidad.temas.map((tema, idx) => (
|
||||
<TemaRow
|
||||
key={tema.id}
|
||||
tema={tema}
|
||||
index={idx + 1}
|
||||
isEditing={editingTema?.unitId === unidad.id && editingTema?.temaId === tema.id}
|
||||
onEdit={() => setEditingTema({ unitId: unidad.id, temaId: tema.id })}
|
||||
onStopEditing={() => setEditingTema(null)}
|
||||
onUpdate={(updates) => updateTema(unidad.id, tema.id, updates)}
|
||||
onDelete={() => setDeleteDialog({ type: 'tema', id: tema.id, parentId: unidad.id })}
|
||||
/>
|
||||
))}
|
||||
<Button variant="ghost" size="sm" className="text-blue-600 hover:text-blue-700 hover:bg-blue-50 w-full justify-start mt-2" onClick={() => addTema(unidad.id)}>
|
||||
<Plus className="w-3 h-3 mr-2" /> Añadir subtema
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DeleteConfirmDialog dialog={deleteDialog} setDialog={setDeleteDialog} onConfirm={handleDelete} />
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// --- Componentes Auxiliares ---
|
||||
interface TemaRowProps {
|
||||
tema: Tema;
|
||||
index: number;
|
||||
isEditing: boolean;
|
||||
onEdit: () => void;
|
||||
onStopEditing: () => void;
|
||||
onUpdate: (updates: Partial<Tema>) => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
function TemaRow({ tema, index, isEditing, onEdit, onStopEditing, onUpdate, onDelete }: TemaRowProps) {
|
||||
return (
|
||||
<div className={cn("flex items-center gap-3 p-2 rounded-md group transition-all", isEditing ? "bg-blue-50 ring-1 ring-blue-100" : "hover:bg-slate-50")}>
|
||||
<span className="text-xs font-mono text-slate-400 w-4">{index}.</span>
|
||||
{isEditing ? (
|
||||
<div className="flex-1 flex items-center gap-2 animate-in slide-in-from-left-2">
|
||||
<Input value={tema.nombre} onChange={(e) => onUpdate({ nombre: e.target.value })} className="h-8 flex-1 bg-white" placeholder="Nombre" autoFocus />
|
||||
<Input type="number" value={tema.horasEstimadas} onChange={(e) => onUpdate({ horasEstimadas: parseInt(e.target.value) || 0 })} className="h-8 w-16 bg-white" />
|
||||
<Button size="sm" className="bg-emerald-600 h-8" onClick={onStopEditing}>Listo</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex-1 cursor-pointer" onClick={onEdit}>
|
||||
<p className="text-sm font-medium text-slate-700">{tema.nombre}</p>
|
||||
</div>
|
||||
<Badge variant="secondary" className="text-[10px] opacity-60">{tema.horasEstimadas}h</Badge>
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-blue-600" onClick={onEdit}><Edit3 className="w-3 h-3" /></Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-red-500" onClick={onDelete}><Trash2 className="w-3 h-3" /></Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface DeleteDialogState {
|
||||
type: 'unidad' | 'tema';
|
||||
id: string;
|
||||
parentId?: string;
|
||||
}
|
||||
|
||||
interface DeleteConfirmDialogProps {
|
||||
dialog: DeleteDialogState | null;
|
||||
setDialog: (value: DeleteDialogState | null) => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
|
||||
function DeleteConfirmDialog({
|
||||
dialog,
|
||||
setDialog,
|
||||
onConfirm,
|
||||
}: DeleteConfirmDialogProps) {
|
||||
|
||||
return (
|
||||
<AlertDialog open={!!dialog} onOpenChange={() => setDialog(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>¿Confirmar eliminación?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Estás a punto de borrar un {dialog?.type}. Esta acción no se puede deshacer.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancelar</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={onConfirm} className="bg-red-600 hover:bg-red-700 text-white">Eliminar</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
321
src/components/asignaturas/detalle/DocumentoSEPTab.tsx
Normal file
321
src/components/asignaturas/detalle/DocumentoSEPTab.tsx
Normal file
@@ -0,0 +1,321 @@
|
||||
import { useState } from 'react';
|
||||
import { FileText, Download, RefreshCw, Calendar, FileCheck, AlertTriangle, Loader2 } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import type { DocumentoMateria, Materia, MateriaStructure } from '@/types/materia';
|
||||
import { cn } from '@/lib/utils';
|
||||
//import { toast } from 'sonner';
|
||||
//import { format } from 'date-fns';
|
||||
//import { es } from 'date-fns/locale';
|
||||
|
||||
interface DocumentoSEPTabProps {
|
||||
documento: DocumentoMateria | null;
|
||||
materia: Materia;
|
||||
estructura: MateriaStructure;
|
||||
datosGenerales: Record<string, any>;
|
||||
onRegenerate: () => void;
|
||||
isRegenerating: boolean;
|
||||
}
|
||||
|
||||
export function DocumentoSEPTab({ documento, materia, estructura, datosGenerales, onRegenerate, isRegenerating }: DocumentoSEPTabProps) {
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||
|
||||
// Check completeness
|
||||
const camposObligatorios = estructura.campos.filter(c => c.obligatorio);
|
||||
const camposCompletos = camposObligatorios.filter(c => datosGenerales[c.id]?.trim());
|
||||
const completeness = Math.round((camposCompletos.length / camposObligatorios.length) * 100);
|
||||
const isComplete = completeness === 100;
|
||||
|
||||
const handleRegenerate = () => {
|
||||
setShowConfirmDialog(false);
|
||||
onRegenerate();
|
||||
//toast.success('Regenerando documento...');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-display text-2xl font-semibold text-foreground flex items-center gap-2">
|
||||
<FileCheck className="w-6 h-6 text-accent" />
|
||||
Documento SEP
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Previsualización del documento oficial para la SEP
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{documento?.estado === 'listo' && (
|
||||
<Button variant="outline" onClick={() => console.log("descargando") /*toast.info('Descarga iniciada')*/}>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Descargar
|
||||
</Button>
|
||||
)}
|
||||
<AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button disabled={isRegenerating || !isComplete}>
|
||||
{isRegenerating ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{isRegenerating ? 'Generando...' : 'Regenerar documento'}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>¿Regenerar documento SEP?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Se creará una nueva versión del documento con los datos actuales de la materia.
|
||||
La versión anterior quedará en el historial.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancelar</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleRegenerate}>
|
||||
Regenerar
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Document preview */}
|
||||
<div className="lg:col-span-2">
|
||||
<Card className="card-elevated h-[700px] overflow-hidden">
|
||||
{documento?.estado === 'listo' ? (
|
||||
<div className="h-full bg-muted/30 flex flex-col">
|
||||
{/* Simulated document header */}
|
||||
<div className="bg-card border-b p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="w-5 h-5 text-primary" />
|
||||
<span className="font-medium text-foreground">
|
||||
Programa de Estudios - {materia.clave}
|
||||
</span>
|
||||
</div>
|
||||
<Badge variant="outline">Versión {documento.version}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Document content simulation */}
|
||||
<div className="flex-1 overflow-y-auto p-8">
|
||||
<div className="max-w-2xl mx-auto bg-card rounded-lg shadow-lg p-8 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="text-center border-b pb-6">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-2">
|
||||
Secretaría de Educación Pública
|
||||
</p>
|
||||
<h1 className="font-display text-2xl font-bold text-primary mb-1">
|
||||
{materia.nombre}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Clave: {materia.clave} | Créditos: {materia.creditos || 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Datos de la institución */}
|
||||
<div className="space-y-1 text-sm">
|
||||
<p><strong>Carrera:</strong> {materia.carrera}</p>
|
||||
<p><strong>Facultad:</strong> {materia.facultad}</p>
|
||||
<p><strong>Plan de estudios:</strong> {materia.planNombre}</p>
|
||||
{materia.ciclo && <p><strong>Ciclo:</strong> {materia.ciclo}</p>}
|
||||
</div>
|
||||
|
||||
{/* Campos del documento */}
|
||||
{estructura.campos.map((campo) => {
|
||||
const valor = datosGenerales[campo.id];
|
||||
if (!valor) return null;
|
||||
return (
|
||||
<div key={campo.id} className="space-y-2">
|
||||
<h3 className="font-semibold text-foreground border-b pb-1">
|
||||
{campo.nombre}
|
||||
</h3>
|
||||
<p className="text-sm text-foreground whitespace-pre-wrap leading-relaxed">
|
||||
{valor}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-t pt-6 mt-8 text-center text-xs text-muted-foreground">
|
||||
<p>Documento generado el {/*format(documento.fechaGeneracion, "d 'de' MMMM 'de' yyyy", { locale: es })*/}</p>
|
||||
<p className="mt-1">Universidad La Salle</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : documento?.estado === 'generando' ? (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Loader2 className="w-12 h-12 mx-auto text-accent animate-spin mb-4" />
|
||||
<p className="text-muted-foreground">Generando documento...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="text-center max-w-sm">
|
||||
<FileText className="w-12 h-12 mx-auto text-muted-foreground/50 mb-4" />
|
||||
<p className="text-muted-foreground mb-4">
|
||||
No hay documento generado aún
|
||||
</p>
|
||||
{!isComplete && (
|
||||
<div className="p-4 bg-warning/10 rounded-lg text-sm text-warning-foreground">
|
||||
<AlertTriangle className="w-4 h-4 inline mr-2" />
|
||||
Completa todos los campos obligatorios para generar el documento
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Info sidebar */}
|
||||
<div className="space-y-4">
|
||||
{/* Status */}
|
||||
<Card className="card-elevated">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Estado del documento</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{documento && (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Versión</span>
|
||||
<Badge variant="outline">{documento.version}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Generado</span>
|
||||
<span className="text-sm">
|
||||
{/*format(documento.fechaGeneracion, "d MMM yyyy, HH:mm", { locale: es })*/}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Estado</span>
|
||||
<Badge className={cn(
|
||||
documento.estado === 'listo' && "bg-success text-success-foreground",
|
||||
documento.estado === 'generando' && "bg-info text-info-foreground",
|
||||
documento.estado === 'error' && "bg-destructive text-destructive-foreground"
|
||||
)}>
|
||||
{documento.estado === 'listo' && 'Listo'}
|
||||
{documento.estado === 'generando' && 'Generando'}
|
||||
{documento.estado === 'error' && 'Error'}
|
||||
</Badge>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Completeness */}
|
||||
<Card className="card-elevated">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Completitud de datos</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Campos obligatorios</span>
|
||||
<span className="font-medium">{camposCompletos.length}/{camposObligatorios.length}</span>
|
||||
</div>
|
||||
<div className="h-2 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full transition-all duration-500",
|
||||
completeness === 100 ? "bg-success" : "bg-accent"
|
||||
)}
|
||||
style={{ width: `${completeness}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className={cn(
|
||||
"text-xs",
|
||||
completeness === 100 ? "text-success" : "text-muted-foreground"
|
||||
)}>
|
||||
{completeness === 100
|
||||
? 'Todos los campos obligatorios están completos'
|
||||
: `Faltan ${camposObligatorios.length - camposCompletos.length} campos por completar`
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Missing fields */}
|
||||
{!isComplete && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">Campos faltantes:</p>
|
||||
{camposObligatorios.filter(c => !datosGenerales[c.id]?.trim()).map((campo) => (
|
||||
<div key={campo.id} className="flex items-center gap-2 text-sm">
|
||||
<AlertTriangle className="w-3 h-3 text-warning" />
|
||||
<span className="text-foreground">{campo.nombre}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Requirements */}
|
||||
<Card className="card-elevated">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Requisitos SEP</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li className="flex items-start gap-2">
|
||||
<div className={cn(
|
||||
"w-4 h-4 rounded-full flex items-center justify-center mt-0.5",
|
||||
datosGenerales['objetivo_general'] ? "bg-success/20" : "bg-muted"
|
||||
)}>
|
||||
{datosGenerales['objetivo_general'] && <Check className="w-3 h-3 text-success" />}
|
||||
</div>
|
||||
<span className="text-muted-foreground">Objetivo general definido</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<div className={cn(
|
||||
"w-4 h-4 rounded-full flex items-center justify-center mt-0.5",
|
||||
datosGenerales['competencias'] ? "bg-success/20" : "bg-muted"
|
||||
)}>
|
||||
{datosGenerales['competencias'] && <Check className="w-3 h-3 text-success" />}
|
||||
</div>
|
||||
<span className="text-muted-foreground">Competencias especificadas</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<div className={cn(
|
||||
"w-4 h-4 rounded-full flex items-center justify-center mt-0.5",
|
||||
datosGenerales['evaluacion'] ? "bg-success/20" : "bg-muted"
|
||||
)}>
|
||||
{datosGenerales['evaluacion'] && <Check className="w-3 h-3 text-success" />}
|
||||
</div>
|
||||
<span className="text-muted-foreground">Criterios de evaluación</span>
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Check({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
196
src/components/asignaturas/detalle/HistorialTab.tsx
Normal file
196
src/components/asignaturas/detalle/HistorialTab.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import { useState } from 'react';
|
||||
import { History, FileText, List, BookMarked, Sparkles, FileCheck, User, Filter, Calendar } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import type { CambioMateria } from '@/types/materia';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { format, formatDistanceToNow } from 'date-fns';
|
||||
import { es } from 'date-fns/locale';
|
||||
|
||||
interface HistorialTabProps {
|
||||
historial: CambioMateria[];
|
||||
}
|
||||
|
||||
const tipoConfig: Record<string, { label: string; icon: React.ComponentType<{ className?: string }>; color: string }> = {
|
||||
datos: { label: 'Datos generales', icon: FileText, color: 'text-info' },
|
||||
contenido: { label: 'Contenido temático', icon: List, color: 'text-accent' },
|
||||
bibliografia: { label: 'Bibliografía', icon: BookMarked, color: 'text-success' },
|
||||
ia: { label: 'IA', icon: Sparkles, color: 'text-amber-500' },
|
||||
documento: { label: 'Documento SEP', icon: FileCheck, color: 'text-primary' },
|
||||
};
|
||||
|
||||
export function HistorialTab({ historial }: HistorialTabProps) {
|
||||
const [filtros, setFiltros] = useState<Set<string>>(new Set(['datos', 'contenido', 'bibliografia', 'ia', 'documento']));
|
||||
|
||||
const toggleFiltro = (tipo: string) => {
|
||||
const newFiltros = new Set(filtros);
|
||||
if (newFiltros.has(tipo)) {
|
||||
newFiltros.delete(tipo);
|
||||
} else {
|
||||
newFiltros.add(tipo);
|
||||
}
|
||||
setFiltros(newFiltros);
|
||||
};
|
||||
|
||||
const filteredHistorial = historial.filter(cambio => filtros.has(cambio.tipo));
|
||||
|
||||
// Group by date
|
||||
const groupedHistorial = filteredHistorial.reduce((groups, cambio) => {
|
||||
const dateKey = format(cambio.fecha, 'yyyy-MM-dd');
|
||||
if (!groups[dateKey]) {
|
||||
groups[dateKey] = [];
|
||||
}
|
||||
groups[dateKey].push(cambio);
|
||||
return groups;
|
||||
}, {} as Record<string, CambioMateria[]>);
|
||||
|
||||
const sortedDates = Object.keys(groupedHistorial).sort((a, b) => b.localeCompare(a));
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-display text-2xl font-semibold text-foreground flex items-center gap-2">
|
||||
<History className="w-6 h-6 text-accent" />
|
||||
Historial de cambios
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{historial.length} cambios registrados
|
||||
</p>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline">
|
||||
<Filter className="w-4 h-4 mr-2" />
|
||||
Filtrar ({filtros.size})
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
{Object.entries(tipoConfig).map(([tipo, config]) => {
|
||||
const Icon = config.icon;
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={tipo}
|
||||
checked={filtros.has(tipo)}
|
||||
onCheckedChange={() => toggleFiltro(tipo)}
|
||||
>
|
||||
<Icon className={cn("w-4 h-4 mr-2", config.color)} />
|
||||
{config.label}
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{filteredHistorial.length === 0 ? (
|
||||
<Card className="card-elevated">
|
||||
<CardContent className="py-12 text-center">
|
||||
<History className="w-12 h-12 mx-auto text-muted-foreground/50 mb-4" />
|
||||
<p className="text-muted-foreground">
|
||||
{historial.length === 0
|
||||
? 'No hay cambios registrados aún'
|
||||
: 'No hay cambios con los filtros seleccionados'
|
||||
}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{sortedDates.map((dateKey) => {
|
||||
const cambios = groupedHistorial[dateKey];
|
||||
const date = new Date(dateKey);
|
||||
const isToday = format(new Date(), 'yyyy-MM-dd') === dateKey;
|
||||
const isYesterday = format(new Date(Date.now() - 86400000), 'yyyy-MM-dd') === dateKey;
|
||||
|
||||
return (
|
||||
<div key={dateKey}>
|
||||
{/* Date header */}
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 rounded-lg bg-muted">
|
||||
<Calendar className="w-4 h-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-foreground">
|
||||
{isToday ? 'Hoy' : isYesterday ? 'Ayer' : format(date, "EEEE, d 'de' MMMM", { locale: es })}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{cambios.length} {cambios.length === 1 ? 'cambio' : 'cambios'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
<div className="ml-4 border-l-2 border-border pl-6 space-y-4">
|
||||
{cambios.map((cambio) => {
|
||||
const config = tipoConfig[cambio.tipo];
|
||||
const Icon = config.icon;
|
||||
return (
|
||||
<div key={cambio.id} className="relative">
|
||||
{/* Timeline dot */}
|
||||
<div className={cn(
|
||||
"absolute -left-[31px] w-4 h-4 rounded-full border-2 border-background",
|
||||
`bg-current ${config.color}`
|
||||
)} />
|
||||
|
||||
<Card className="card-interactive">
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className={cn(
|
||||
"p-2 rounded-lg bg-muted flex-shrink-0",
|
||||
config.color
|
||||
)}>
|
||||
<Icon className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
{cambio.descripcion}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{config.label}
|
||||
</Badge>
|
||||
{cambio.detalles?.campo && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Campo: {cambio.detalles.campo}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{format(cambio.fecha, 'HH:mm')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-3 text-xs text-muted-foreground">
|
||||
<User className="w-3 h-3" />
|
||||
<span>{cambio.usuario}</span>
|
||||
<span className="text-muted-foreground/50">•</span>
|
||||
<span>
|
||||
{formatDistanceToNow(cambio.fecha, { addSuffix: true, locale: es })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
357
src/components/asignaturas/detalle/IAMateriaTab.tsx
Normal file
357
src/components/asignaturas/detalle/IAMateriaTab.tsx
Normal file
@@ -0,0 +1,357 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Send, Sparkles, Bot, User, Check, X, RefreshCw, Lightbulb, Wand2 } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command';
|
||||
import type { IAMessage, IASugerencia, CampoEstructura } from '@/types/materia';
|
||||
import { cn } from '@/lib/utils';
|
||||
//import { toast } from 'sonner';
|
||||
|
||||
interface IAMateriaTabProps {
|
||||
campos: CampoEstructura[];
|
||||
datosGenerales: Record<string, any>;
|
||||
messages: IAMessage[];
|
||||
onSendMessage: (message: string, campoId?: string) => void;
|
||||
onAcceptSuggestion: (sugerencia: IASugerencia) => void;
|
||||
onRejectSuggestion: (messageId: string) => void;
|
||||
}
|
||||
|
||||
const quickActions = [
|
||||
{ id: 'mejorar-objetivos', label: 'Mejorar objetivos', icon: Wand2, prompt: 'Mejora el :objetivo_general para que sea más específico y medible' },
|
||||
{ id: 'generar-contenido', label: 'Generar contenido temático', icon: Lightbulb, prompt: 'Sugiere un contenido temático completo basado en los objetivos y competencias' },
|
||||
{ id: 'alinear-perfil', label: 'Alinear con perfil de egreso', icon: RefreshCw, prompt: 'Revisa las :competencias y alinéalas con el perfil de egreso del plan' },
|
||||
{ id: 'ajustar-biblio', label: 'Recomendar bibliografía', icon: Sparkles, prompt: 'Recomienda bibliografía actualizada basándote en el contenido temático' },
|
||||
];
|
||||
|
||||
export function IAMateriaTab({ campos, datosGenerales, messages, onSendMessage, onAcceptSuggestion, onRejectSuggestion }: IAMateriaTabProps) {
|
||||
const [input, setInput] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showFieldSelector, setShowFieldSelector] = useState(false);
|
||||
const [fieldSelectorPosition, setFieldSelectorPosition] = useState({ top: 0, left: 0 });
|
||||
const [cursorPosition, setCursorPosition] = useState(0);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const value = e.target.value;
|
||||
const pos = e.target.selectionStart;
|
||||
setInput(value);
|
||||
setCursorPosition(pos);
|
||||
|
||||
// Check for : character to trigger field selector
|
||||
const lastChar = value.charAt(pos - 1);
|
||||
if (lastChar === ':') {
|
||||
const rect = textareaRef.current?.getBoundingClientRect();
|
||||
if (rect) {
|
||||
setFieldSelectorPosition({ top: rect.bottom + 8, left: rect.left });
|
||||
setShowFieldSelector(true);
|
||||
}
|
||||
} else if (showFieldSelector && (lastChar === ' ' || !value.includes(':'))) {
|
||||
setShowFieldSelector(false);
|
||||
}
|
||||
};
|
||||
|
||||
const insertFieldMention = (campoId: string) => {
|
||||
const beforeCursor = input.slice(0, cursorPosition);
|
||||
const afterCursor = input.slice(cursorPosition);
|
||||
const lastColonIndex = beforeCursor.lastIndexOf(':');
|
||||
const newInput = beforeCursor.slice(0, lastColonIndex) + `:${campoId}` + afterCursor;
|
||||
setInput(newInput);
|
||||
setShowFieldSelector(false);
|
||||
textareaRef.current?.focus();
|
||||
};
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!input.trim() || isLoading) return;
|
||||
|
||||
// Extract field mention if any
|
||||
const fieldMatch = input.match(/:(\w+)/);
|
||||
const campoId = fieldMatch ? fieldMatch[1] : undefined;
|
||||
|
||||
setIsLoading(true);
|
||||
onSendMessage(input, campoId);
|
||||
setInput('');
|
||||
|
||||
// Simulate AI response delay
|
||||
setTimeout(() => {
|
||||
setIsLoading(false);
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
const handleQuickAction = (prompt: string) => {
|
||||
setInput(prompt);
|
||||
textareaRef.current?.focus();
|
||||
};
|
||||
|
||||
const renderMessageContent = (content: string) => {
|
||||
// Render field mentions as styled badges
|
||||
return content.split(/(:[\w_]+)/g).map((part, i) => {
|
||||
if (part.startsWith(':')) {
|
||||
const campo = campos.find(c => c.id === part.slice(1));
|
||||
return (
|
||||
<span key={i} className="field-mention mx-0.5">
|
||||
{campo?.nombre || part}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return part;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-display text-2xl font-semibold text-foreground flex items-center gap-2">
|
||||
<Sparkles className="w-6 h-6 text-accent" />
|
||||
IA de la materia
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Usa <kbd className="px-1.5 py-0.5 bg-muted rounded text-xs font-mono">:</kbd> para mencionar campos específicos
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Chat area */}
|
||||
<Card className="lg:col-span-2 card-elevated flex flex-col h-[600px]">
|
||||
<CardHeader className="pb-2 border-b">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
Conversación
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 flex flex-col p-0">
|
||||
<ScrollArea className="flex-1 p-4" ref={scrollRef}>
|
||||
<div className="space-y-4">
|
||||
{messages.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<Bot className="w-12 h-12 mx-auto text-muted-foreground/50 mb-4" />
|
||||
<p className="text-muted-foreground">
|
||||
Inicia una conversación para mejorar tu materia con IA
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
messages.map((message) => (
|
||||
<div key={message.id} className={cn(
|
||||
"flex gap-3",
|
||||
message.role === 'user' ? "justify-end" : "justify-start"
|
||||
)}>
|
||||
{message.role === 'assistant' && (
|
||||
<div className="w-8 h-8 rounded-full bg-accent/20 flex items-center justify-center flex-shrink-0">
|
||||
<Bot className="w-4 h-4 text-accent" />
|
||||
</div>
|
||||
)}
|
||||
<div className={cn(
|
||||
"max-w-[80%] rounded-lg px-4 py-3",
|
||||
message.role === 'user'
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted"
|
||||
)}>
|
||||
<p className="text-sm whitespace-pre-wrap">
|
||||
{renderMessageContent(message.content)}
|
||||
</p>
|
||||
{message.sugerencia && !message.sugerencia.aceptada && (
|
||||
<div className="mt-3 p-3 bg-background/80 rounded-md border">
|
||||
<p className="text-xs font-medium text-muted-foreground mb-2">
|
||||
Sugerencia para: {message.sugerencia.campoNombre}
|
||||
</p>
|
||||
<div className="text-sm text-foreground bg-accent/10 p-2 rounded mb-3 max-h-32 overflow-y-auto">
|
||||
{message.sugerencia.valorSugerido}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => onAcceptSuggestion(message.sugerencia!)}
|
||||
className="bg-success hover:bg-success/90 text-success-foreground"
|
||||
>
|
||||
<Check className="w-3 h-3 mr-1" />
|
||||
Aplicar
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onRejectSuggestion(message.id)}
|
||||
>
|
||||
<X className="w-3 h-3 mr-1" />
|
||||
Rechazar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{message.sugerencia?.aceptada && (
|
||||
<Badge className="mt-2 badge-library">
|
||||
<Check className="w-3 h-3 mr-1" />
|
||||
Sugerencia aplicada
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{message.role === 'user' && (
|
||||
<div className="w-8 h-8 rounded-full bg-primary flex items-center justify-center flex-shrink-0">
|
||||
<User className="w-4 h-4 text-primary-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{isLoading && (
|
||||
<div className="flex gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-accent/20 flex items-center justify-center flex-shrink-0">
|
||||
<Bot className="w-4 h-4 text-accent animate-pulse" />
|
||||
</div>
|
||||
<div className="bg-muted rounded-lg px-4 py-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 bg-accent rounded-full animate-bounce [animation-delay:-0.3s]" />
|
||||
<div className="w-2 h-2 bg-accent rounded-full animate-bounce [animation-delay:-0.15s]" />
|
||||
<div className="w-2 h-2 bg-accent rounded-full animate-bounce" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Input area */}
|
||||
<div className="p-4 border-t">
|
||||
<div className="relative">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
}}
|
||||
placeholder="Escribe tu mensaje... Usa : para mencionar campos"
|
||||
className="min-h-[80px] pr-12 resize-none"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSend}
|
||||
disabled={!input.trim() || isLoading}
|
||||
className="absolute bottom-3 right-3 h-8 w-8 p-0"
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Field selector popover */}
|
||||
{showFieldSelector && (
|
||||
<div className="absolute z-50 mt-1 w-64 bg-popover border rounded-lg shadow-lg">
|
||||
<Command>
|
||||
<CommandInput placeholder="Buscar campo..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No se encontró el campo</CommandEmpty>
|
||||
<CommandGroup heading="Campos disponibles">
|
||||
{campos.map((campo) => (
|
||||
<CommandItem
|
||||
key={campo.id}
|
||||
value={campo.id}
|
||||
onSelect={() => insertFieldMention(campo.id)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<span className="font-mono text-xs text-accent mr-2">
|
||||
:{campo.id}
|
||||
</span>
|
||||
<span>{campo.nombre}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Sidebar with quick actions and fields */}
|
||||
<div className="space-y-4">
|
||||
{/* Quick actions */}
|
||||
<Card className="card-elevated">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Acciones rápidas</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{quickActions.map((action) => {
|
||||
const Icon = action.icon;
|
||||
return (
|
||||
<Button
|
||||
key={action.id}
|
||||
variant="outline"
|
||||
className="w-full justify-start text-left h-auto py-3"
|
||||
onClick={() => handleQuickAction(action.prompt)}
|
||||
>
|
||||
<Icon className="w-4 h-4 mr-2 text-accent flex-shrink-0" />
|
||||
<span className="text-sm">{action.label}</span>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Available fields */}
|
||||
<Card className="card-elevated">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Campos de la materia</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ScrollArea className="h-[280px]">
|
||||
<div className="space-y-2">
|
||||
{campos.map((campo) => {
|
||||
const hasValue = !!datosGenerales[campo.id];
|
||||
return (
|
||||
<div
|
||||
key={campo.id}
|
||||
className={cn(
|
||||
"p-2 rounded-md border cursor-pointer transition-colors hover:bg-muted/50",
|
||||
hasValue ? "border-success/30" : "border-warning/30"
|
||||
)}
|
||||
onClick={() => {
|
||||
setInput(prev => prev + `:${campo.id} `);
|
||||
textareaRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-mono text-accent">:{campo.id}</span>
|
||||
{hasValue ? (
|
||||
<Badge variant="outline" className="text-xs text-success border-success/30">
|
||||
Completo
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-xs text-warning border-warning/30">
|
||||
Vacío
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-foreground mt-1">{campo.nombre}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,106 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
ArrowLeft,
|
||||
GraduationCap,
|
||||
|
||||
Edit2, Save,
|
||||
Pencil
|
||||
} from 'lucide-react'
|
||||
import { ContenidoTematico } from './ContenidoTematico'
|
||||
import { BibliographyItem } from './BibliographyItem'
|
||||
import { IAMateriaTab } from './IAMateriaTab'
|
||||
import type {
|
||||
CampoEstructura,
|
||||
IAMessage,
|
||||
IASugerencia,
|
||||
UnidadTematica,
|
||||
} from '@/types/materia';
|
||||
import {
|
||||
mockMateria,
|
||||
mockEstructura,
|
||||
mockDocumentoSep,
|
||||
mockHistorial
|
||||
} from '@/data/mockMateriaData';
|
||||
import { DocumentoSEPTab } from './DocumentoSEPTab'
|
||||
import { HistorialTab } from './HistorialTab'
|
||||
|
||||
export interface BibliografiaEntry {
|
||||
id: string;
|
||||
tipo: 'BASICA' | 'COMPLEMENTARIA';
|
||||
cita: string;
|
||||
fuenteBibliotecaId?: string;
|
||||
fuenteBiblioteca?: any;
|
||||
}
|
||||
export interface BibliografiaTabProps {
|
||||
bibliografia: BibliografiaEntry[];
|
||||
onSave: (bibliografia: BibliografiaEntry[]) => void;
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
export default function MateriaDetailPage() {
|
||||
|
||||
// 1. Asegúrate de tener estos estados en tu componente principal
|
||||
const [messages, setMessages] = useState<IAMessage[]>([]);
|
||||
const [datosGenerales, setDatosGenerales] = useState({});
|
||||
const [campos, setCampos] = useState<CampoEstructura[]>([]);
|
||||
|
||||
// 2. Funciones de manejo para la IA
|
||||
const handleSendMessage = (text: string, campoId?: string) => {
|
||||
const newMessage: IAMessage = {
|
||||
id: Date.now().toString(),
|
||||
role: 'user',
|
||||
content: text,
|
||||
timestamp: new Date(),
|
||||
campoAfectado: campoId
|
||||
};
|
||||
setMessages([...messages, newMessage]);
|
||||
|
||||
// Aquí llamarías a tu API de OpenAI/Claude
|
||||
//toast.info("Enviando consulta a la IA...");
|
||||
};
|
||||
|
||||
const handleAcceptSuggestion = (sugerencia: IASugerencia) => {
|
||||
// Lógica para actualizar el valor del campo en tu estado de datosGenerales
|
||||
//toast.success(`Sugerencia aplicada a ${sugerencia.campoNombre}`);
|
||||
};
|
||||
|
||||
// Dentro de tu componente principal (donde están los Tabs)
|
||||
const [bibliografia, setBibliografia] = useState<BibliografiaEntry[]>([
|
||||
{
|
||||
id: '1',
|
||||
tipo: 'BASICA',
|
||||
cita: 'Russell, S., & Norvig, P. (2020). Artificial Intelligence: A Modern Approach. Pearson.'
|
||||
}
|
||||
]);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const handleSaveBibliografia = (data: BibliografiaEntry[]) => {
|
||||
setIsSaving(true);
|
||||
// Aquí iría tu llamada a la API
|
||||
setBibliografia(data);
|
||||
|
||||
// Simulamos un guardado
|
||||
setTimeout(() => {
|
||||
setIsSaving(false);
|
||||
//toast.success("Cambios guardados");
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const [isRegenerating, setIsRegenerating] = useState(false);
|
||||
|
||||
const handleRegenerateDocument = useCallback(() => {
|
||||
setIsRegenerating(true);
|
||||
setTimeout(() => {
|
||||
setIsRegenerating(false);
|
||||
}, 2000);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
@@ -21,7 +108,7 @@ export default function MateriaDetailPage() {
|
||||
<section className="bg-gradient-to-b from-[#0b1d3a] to-[#0e2a5c] text-white">
|
||||
<div className="max-w-7xl mx-auto px-6 py-10">
|
||||
<Link
|
||||
to="/materias"
|
||||
to="/planes"
|
||||
className="flex items-center gap-2 text-sm text-blue-200 hover:text-white mb-4"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
@@ -90,23 +177,36 @@ export default function MateriaDetailPage() {
|
||||
|
||||
<TabsContent value="bibliografia">
|
||||
<BibliographyItem
|
||||
value="Russell, S., & Norvig, P. (2021). Artificial Intelligence: A Modern Approach (4th ed.). Pearson."
|
||||
onSave={(newValue) => {
|
||||
console.log('Guardar en API:', newValue)
|
||||
}}
|
||||
bibliografia={bibliografia}
|
||||
onSave={handleSaveBibliografia}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="ia">
|
||||
<EmptyTab title="IA de la materia" />
|
||||
<IAMateriaTab
|
||||
campos={campos}
|
||||
datosGenerales={datosGenerales}
|
||||
messages={messages}
|
||||
onSendMessage={handleSendMessage}
|
||||
onAcceptSuggestion={handleAcceptSuggestion}
|
||||
onRejectSuggestion={(id) => console.log("Rechazada") /*toast.error("Sugerencia rechazada")*/}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="sep">
|
||||
<EmptyTab title="Documento SEP" />
|
||||
<DocumentoSEPTab
|
||||
documento={mockDocumentoSep}
|
||||
materia={mockMateria}
|
||||
estructura={mockEstructura}
|
||||
datosGenerales={datosGenerales}
|
||||
onRegenerate={handleRegenerateDocument}
|
||||
isRegenerating={isRegenerating}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="historial">
|
||||
<EmptyTab title="Historial" />
|
||||
<HistorialTab historial={mockHistorial} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
@@ -119,105 +219,174 @@ export default function MateriaDetailPage() {
|
||||
|
||||
function DatosGenerales() {
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto py-10 space-y-6">
|
||||
<HeaderTab />
|
||||
|
||||
<InfoCard
|
||||
title="Objetivo General"
|
||||
content="Formar profesionales capaces de diseñar, implementar y evaluar sistemas de inteligencia artificial que resuelvan problemas complejos del mundo real."
|
||||
/>
|
||||
|
||||
<InfoCard
|
||||
title="Competencias a Desarrollar"
|
||||
content={
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
<li>Diseñar algoritmos de machine learning</li>
|
||||
<li>Implementar redes neuronales profundas</li>
|
||||
<li>Evaluar modelos de IA considerando métricas</li>
|
||||
<li>Aplicar principios éticos en sistemas inteligentes</li>
|
||||
</ul>
|
||||
}
|
||||
/>
|
||||
|
||||
<InfoCard
|
||||
title="Justificación"
|
||||
content="La inteligencia artificial es una de las tecnologías más disruptivas del siglo XXI..."
|
||||
/>
|
||||
|
||||
<InfoCard
|
||||
title="Requisitos y Seriación"
|
||||
content="Programación Avanzada (PA-301), Matemáticas Discretas (MAT-201)"
|
||||
/>
|
||||
|
||||
<InfoCard
|
||||
title="Estrategias Didácticas"
|
||||
content={
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
<li>Aprendizaje basado en proyectos</li>
|
||||
<li>Talleres prácticos con datasets reales</li>
|
||||
<li>Estudios de caso</li>
|
||||
</ul>
|
||||
}
|
||||
/>
|
||||
|
||||
<InfoCard
|
||||
title="Sistema de Evaluación"
|
||||
content={
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
<li>Exámenes parciales: 30%</li>
|
||||
<li>Proyecto integrador: 35%</li>
|
||||
<li>Prácticas de laboratorio: 20%</li>
|
||||
<li>Participación: 15%</li>
|
||||
</ul>
|
||||
}
|
||||
/>
|
||||
|
||||
<InfoCard
|
||||
title="Perfil del Docente"
|
||||
content="Profesional con maestría o doctorado en áreas afines a IA, con experiencia mínima de 3 años."
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HeaderTab() {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">Datos Generales</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Información basada en la plantilla SEP Licenciatura
|
||||
</p>
|
||||
<div className="max-w-7xl mx-auto py-8 px-4 space-y-8 animate-in fade-in duration-500">
|
||||
|
||||
{/* Encabezado de la Sección */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 border-b pb-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight text-slate-900">Datos Generales</h2>
|
||||
<p className="text-slate-500 mt-1">
|
||||
Información oficial estructurada bajo los lineamientos de la SEP.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<Edit2 className="w-4 h-4" /> Editar borrador
|
||||
</Button>
|
||||
<Button size="sm" className="gap-2 bg-blue-600 hover:bg-blue-700">
|
||||
<Save className="w-4 h-4" /> Guardar cambios
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button size="sm">Guardar todo</Button>
|
||||
{/* Grid de Información */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
|
||||
{/* Columna Principal (Más ancha) */}
|
||||
<div className="md:col-span-2 space-y-6">
|
||||
<div className="md:col-span-2 space-y-6">
|
||||
<InfoCard
|
||||
title="Competencias a Desarrollar"
|
||||
subtitle="Competencias profesionales que se desarrollarán"
|
||||
isList={true}
|
||||
initialContent={`• Diseñar algoritmos de machine learning para clasificación y predicción\n• Implementar redes neuronales profundas para procesamiento de imágenes\n• Evaluar y optimizar modelos de IA considerando métricas`}
|
||||
/>
|
||||
|
||||
<InfoCard
|
||||
title="Objetivo General"
|
||||
initialContent="Formar profesionales capaces de diseñar, implementar y evaluar sistemas de inteligencia artificial que resuelvan problemas complejos."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<InfoCard
|
||||
title="Justificación"
|
||||
initialContent="La inteligencia artificial es una de las tecnologías más disruptivas..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Columna Lateral (Información Secundaria) */}
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-6">
|
||||
{/* Tarjeta de Requisitos */}
|
||||
<InfoCard
|
||||
title="Requisitos y Seriación"
|
||||
type="requirements"
|
||||
initialContent={[
|
||||
{ type: "Pre-requisito", code: "PA-301", name: "Programación Avanzada" },
|
||||
{ type: "Co-requisito", code: "MAT-201", name: "Matemáticas Discretas" }
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Tarjeta de Evaluación */}
|
||||
<InfoCard
|
||||
title="Sistema de Evaluación"
|
||||
type="evaluation"
|
||||
initialContent={[
|
||||
{ label: "Exámenes parciales", value: "30%" },
|
||||
{ label: "Proyecto integrador", value: "35%" },
|
||||
{ label: "Prácticas de laboratorio", value: "20%" },
|
||||
{ label: "Participación", value: "15%" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InfoCard({
|
||||
title,
|
||||
content,
|
||||
}: {
|
||||
title: string
|
||||
content: React.ReactNode
|
||||
}) {
|
||||
interface InfoCardProps {
|
||||
title: string,
|
||||
subtitle?: string
|
||||
isList?:boolean
|
||||
initialContent: any // Puede ser string o array de objetos
|
||||
type?: 'text' | 'list' | 'requirements' | 'evaluation'
|
||||
}
|
||||
|
||||
function InfoCard({ title, initialContent, type = 'text' }: InfoCardProps) {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [data, setData] = useState(initialContent)
|
||||
// Estado temporal para el área de texto (siempre editamos como texto por simplicidad)
|
||||
const [tempText, setTempText] = useState(
|
||||
type === 'text' || type === 'list'
|
||||
? initialContent
|
||||
: JSON.stringify(initialContent, null, 2) // O un formato legible
|
||||
)
|
||||
|
||||
const handleSave = () => {
|
||||
// Aquí podrías parsear el texto de vuelta si es necesario
|
||||
setData(tempText)
|
||||
setIsEditing(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
{title}
|
||||
<Badge variant="outline">Obligatorio</Badge>
|
||||
</CardTitle>
|
||||
<Card className="transition-all hover:border-slate-300">
|
||||
<CardHeader className="pb-3 flex flex-row items-start justify-between space-y-0">
|
||||
<CardTitle className="text-sm font-bold text-slate-700">{title}</CardTitle>
|
||||
{!isEditing && (
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400" onClick={() => setIsEditing(true)}>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="text-sm text-muted-foreground">
|
||||
{content}
|
||||
<CardContent>
|
||||
{isEditing ? (
|
||||
<div className="space-y-3">
|
||||
<Textarea
|
||||
value={tempText}
|
||||
onChange={(e) => setTempText(e.target.value)}
|
||||
className="text-xs min-h-[100px]"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button size="sm" variant="ghost" onClick={() => setIsEditing(false)}>Cancelar</Button>
|
||||
<Button size="sm" className="bg-[#00a878]" onClick={handleSave}>Guardar</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm">
|
||||
{type === 'requirements' && <RequirementsView items={data} />}
|
||||
{type === 'evaluation' && <EvaluationView items={data} />}
|
||||
{type === 'text' && <p className="text-slate-600">{data}</p>}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// Vista de Requisitos
|
||||
function RequirementsView({ items }: { items: any[] }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{items.map((req, i) => (
|
||||
<div key={i} className="p-3 bg-slate-50 rounded-lg border border-slate-100">
|
||||
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-tight">{req.type}</p>
|
||||
<p className="text-sm font-medium text-slate-700">{req.code} {req.name}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Vista de Evaluación
|
||||
function EvaluationView({ items }: { items: any[] }) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{items.map((item, i) => (
|
||||
<div key={i} className="flex justify-between text-sm border-b border-slate-50 pb-1.5 italic">
|
||||
<span className="text-slate-500">{item.label}</span>
|
||||
<span className="font-bold text-blue-600">{item.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
function EmptyTab({ title }: { title: string }) {
|
||||
return (
|
||||
<div className="py-16 text-center text-muted-foreground">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type {
|
||||
NewSubjectWizardState,
|
||||
TipoAsignatura,
|
||||
} from '@/features/asignaturas/new/types'
|
||||
} from '@/features/asignaturas/nueva/types'
|
||||
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
import {
|
||||
ESTRUCTURAS_SEP,
|
||||
TIPOS_MATERIA,
|
||||
} from '@/features/asignaturas/new/catalogs'
|
||||
} from '@/features/asignaturas/nueva/catalogs'
|
||||
|
||||
export function PasoBasicosForm({
|
||||
wizard,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as Icons from 'lucide-react'
|
||||
|
||||
import type { NewSubjectWizardState } from '@/features/asignaturas/new/types'
|
||||
import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
FACULTADES,
|
||||
MATERIAS_MOCK,
|
||||
PLANES_MOCK,
|
||||
} from '@/features/asignaturas/new/catalogs'
|
||||
} from '@/features/asignaturas/nueva/catalogs'
|
||||
|
||||
export function PasoConfiguracionPanel({
|
||||
wizard,
|
||||
|
||||
@@ -4,7 +4,7 @@ import type {
|
||||
ModoCreacion,
|
||||
NewSubjectWizardState,
|
||||
SubModoClonado,
|
||||
} from '@/features/asignaturas/new/types'
|
||||
} from '@/features/asignaturas/nueva/types'
|
||||
|
||||
import {
|
||||
Card,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as Icons from 'lucide-react'
|
||||
|
||||
import type { NewSubjectWizardState } from '@/features/asignaturas/new/types'
|
||||
import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types'
|
||||
|
||||
import {
|
||||
Card,
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { ESTRUCTURAS_SEP } from '@/features/asignaturas/new/catalogs'
|
||||
import { ESTRUCTURAS_SEP } from '@/features/asignaturas/nueva/catalogs'
|
||||
|
||||
export function PasoResumenCard({ wizard }: { wizard: NewSubjectWizardState }) {
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { NewSubjectWizardState } from '@/features/asignaturas/new/types'
|
||||
import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
import type { CARRERAS } from '@/features/planes/new/catalogs'
|
||||
import type { NewPlanWizardState } from '@/features/planes/new/types'
|
||||
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
FACULTADES,
|
||||
NIVELES,
|
||||
TIPOS_CICLO,
|
||||
} from '@/features/planes/new/catalogs'
|
||||
|
||||
export function PasoBasicosForm({
|
||||
wizard,
|
||||
onChange,
|
||||
carrerasFiltradas,
|
||||
}: {
|
||||
wizard: NewPlanWizardState
|
||||
onChange: React.Dispatch<React.SetStateAction<NewPlanWizardState>>
|
||||
carrerasFiltradas: typeof CARRERAS
|
||||
}) {
|
||||
return (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="grid gap-1 sm:col-span-2">
|
||||
<Label htmlFor="nombrePlan">Nombre del plan</Label>
|
||||
<Input
|
||||
id="nombrePlan"
|
||||
placeholder="Ej. Ingeniería en Sistemas 2026"
|
||||
value={wizard.datosBasicos.nombrePlan}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
datosBasicos: { ...w.datosBasicos, nombrePlan: e.target.value },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="facultad">Facultad</Label>
|
||||
<Select
|
||||
value={wizard.datosBasicos.facultadId}
|
||||
onValueChange={(value) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
datosBasicos: {
|
||||
...w.datosBasicos,
|
||||
facultadId: value,
|
||||
carreraId: '',
|
||||
},
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="facultad"
|
||||
className="w-full min-w-0 [&>span]:block! [&>span]:truncate!"
|
||||
>
|
||||
<SelectValue placeholder="Selecciona facultad…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FACULTADES.map((f) => (
|
||||
<SelectItem key={f.id} value={f.id}>
|
||||
{f.nombre}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="carrera">Carrera</Label>
|
||||
<Select
|
||||
value={wizard.datosBasicos.carreraId}
|
||||
onValueChange={(value) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
datosBasicos: { ...w.datosBasicos, carreraId: value },
|
||||
}))
|
||||
}
|
||||
disabled={!wizard.datosBasicos.facultadId}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="carrera"
|
||||
className="w-full min-w-0 [&>span]:block! [&>span]:truncate!"
|
||||
>
|
||||
<SelectValue placeholder="Selecciona carrera…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{carrerasFiltradas.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
{c.nombre}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="nivel">Nivel</Label>
|
||||
<Select
|
||||
value={wizard.datosBasicos.nivel}
|
||||
onValueChange={(value) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
datosBasicos: { ...w.datosBasicos, nivel: value },
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="nivel"
|
||||
className="w-full min-w-0 [&>span]:block! [&>span]:truncate!"
|
||||
>
|
||||
<SelectValue placeholder="Selecciona nivel…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{NIVELES.map((n) => (
|
||||
<SelectItem key={n} value={n}>
|
||||
{n}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="tipoCiclo">Tipo de ciclo</Label>
|
||||
<Select
|
||||
value={wizard.datosBasicos.tipoCiclo}
|
||||
onValueChange={(value) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
datosBasicos: {
|
||||
...w.datosBasicos,
|
||||
tipoCiclo: value as any,
|
||||
},
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="tipoCiclo"
|
||||
className="w-full min-w-0 [&>span]:block! [&>span]:truncate!"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TIPOS_CICLO.map((t) => (
|
||||
<SelectItem key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="numCiclos">Número de ciclos</Label>
|
||||
<Input
|
||||
id="numCiclos"
|
||||
type="number"
|
||||
min={1}
|
||||
value={wizard.datosBasicos.numCiclos}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
datosBasicos: {
|
||||
...w.datosBasicos,
|
||||
numCiclos: Number(e.target.value || 1),
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
253
src/components/planes/wizard/PasoBasicosForm/PasoBasicosForm.tsx
Normal file
253
src/components/planes/wizard/PasoBasicosForm/PasoBasicosForm.tsx
Normal file
@@ -0,0 +1,253 @@
|
||||
import { TemplateSelectorCard } from './TemplateSelectorCard'
|
||||
|
||||
import type { CARRERAS } from '@/features/planes/nuevo/catalogs'
|
||||
import type { NewPlanWizardState } from '@/features/planes/nuevo/types'
|
||||
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import {
|
||||
FACULTADES,
|
||||
NIVELES,
|
||||
TIPOS_CICLO,
|
||||
PLANTILLAS_ANEXO_1,
|
||||
PLANTILLAS_ANEXO_2,
|
||||
} from '@/features/planes/nuevo/catalogs'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function PasoBasicosForm({
|
||||
wizard,
|
||||
onChange,
|
||||
carrerasFiltradas,
|
||||
}: {
|
||||
wizard: NewPlanWizardState
|
||||
onChange: React.Dispatch<React.SetStateAction<NewPlanWizardState>>
|
||||
carrerasFiltradas: typeof CARRERAS
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="grid gap-1 sm:col-span-2">
|
||||
<Label htmlFor="nombrePlan">
|
||||
Nombre del plan <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="nombrePlan"
|
||||
placeholder="Ej. Ingeniería en Sistemas 2026"
|
||||
value={wizard.datosBasicos.nombrePlan}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
datosBasicos: { ...w.datosBasicos, nombrePlan: e.target.value },
|
||||
}))
|
||||
}
|
||||
className="placeholder:text-muted-foreground/70 font-medium not-italic placeholder:font-normal placeholder:italic"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="facultad">Facultad</Label>
|
||||
<Select
|
||||
value={wizard.datosBasicos.facultadId}
|
||||
onValueChange={(value) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
datosBasicos: {
|
||||
...w.datosBasicos,
|
||||
facultadId: value,
|
||||
carreraId: '',
|
||||
},
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="facultad"
|
||||
className={cn(
|
||||
'w-full min-w-0 [&>span]:block! [&>span]:truncate!',
|
||||
!wizard.datosBasicos.facultadId
|
||||
? 'text-muted-foreground font-normal italic opacity-70' // Es Placeholder
|
||||
: 'font-medium not-italic', // Tiene Valor (Medium)
|
||||
)}
|
||||
>
|
||||
<SelectValue placeholder="Ej. Facultad de Ingeniería" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FACULTADES.map((f) => (
|
||||
<SelectItem key={f.id} value={f.id}>
|
||||
{f.nombre}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="carrera">Carrera</Label>
|
||||
<Select
|
||||
value={wizard.datosBasicos.carreraId}
|
||||
onValueChange={(value) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
datosBasicos: { ...w.datosBasicos, carreraId: value },
|
||||
}))
|
||||
}
|
||||
disabled={!wizard.datosBasicos.facultadId}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="carrera"
|
||||
className={cn(
|
||||
'w-full min-w-0 [&>span]:block! [&>span]:truncate!',
|
||||
!wizard.datosBasicos.carreraId
|
||||
? 'text-muted-foreground font-normal italic opacity-70' // Es Placeholder
|
||||
: 'font-medium not-italic', // Tiene Valor (Medium)
|
||||
)}
|
||||
>
|
||||
<SelectValue placeholder="Ej. Ingeniería en Cibernética y Sistemas Computacionales" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{carrerasFiltradas.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
{c.nombre}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="nivel">Nivel</Label>
|
||||
<Select
|
||||
value={wizard.datosBasicos.nivel}
|
||||
onValueChange={(value) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
datosBasicos: { ...w.datosBasicos, nivel: value },
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="nivel"
|
||||
className={cn(
|
||||
'w-full min-w-0 [&>span]:block! [&>span]:truncate!',
|
||||
!wizard.datosBasicos.nivel
|
||||
? 'text-muted-foreground font-normal italic opacity-70' // Es Placeholder
|
||||
: 'font-medium not-italic', // Tiene Valor (Medium)
|
||||
)}
|
||||
>
|
||||
<SelectValue placeholder="Ej. Licenciatura" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{NIVELES.map((n) => (
|
||||
<SelectItem key={n} value={n}>
|
||||
{n}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="tipoCiclo">Tipo de ciclo</Label>
|
||||
<Select
|
||||
value={wizard.datosBasicos.tipoCiclo}
|
||||
onValueChange={(value) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
datosBasicos: {
|
||||
...w.datosBasicos,
|
||||
tipoCiclo: value as any,
|
||||
},
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="tipoCiclo"
|
||||
className={cn(
|
||||
'w-full min-w-0 [&>span]:block! [&>span]:truncate!',
|
||||
!wizard.datosBasicos.tipoCiclo
|
||||
? 'text-muted-foreground font-normal italic opacity-70' // Es Placeholder
|
||||
: 'font-medium not-italic', // Tiene Valor (Medium)
|
||||
)}
|
||||
>
|
||||
<SelectValue placeholder="Ej. Semestre" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TIPOS_CICLO.map((t) => (
|
||||
<SelectItem key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="numCiclos">Número de ciclos</Label>
|
||||
<Input
|
||||
id="numCiclos"
|
||||
type="number"
|
||||
min={1}
|
||||
value={wizard.datosBasicos.numCiclos ?? ''}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
datosBasicos: {
|
||||
...w.datosBasicos,
|
||||
// Keep undefined when the input is empty so the field stays optional
|
||||
numCiclos:
|
||||
e.target.value === '' ? undefined : Number(e.target.value),
|
||||
},
|
||||
}))
|
||||
}
|
||||
className="placeholder:text-muted-foreground/70 font-medium not-italic placeholder:font-normal placeholder:italic"
|
||||
placeholder="Ej. 8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="my-3" />
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<TemplateSelectorCard
|
||||
cardTitle="Plantilla de plan de estudios"
|
||||
cardDescription="Selecciona el Word para tu nuevo plan."
|
||||
templatesData={PLANTILLAS_ANEXO_1}
|
||||
selectedTemplateId={wizard.datosBasicos.plantillaPlanId || ''}
|
||||
selectedVersion={wizard.datosBasicos.plantillaPlanVersion || ''}
|
||||
onChange={({ templateId, version }) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
datosBasicos: {
|
||||
...w.datosBasicos,
|
||||
plantillaPlanId: templateId,
|
||||
plantillaPlanVersion: version,
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<TemplateSelectorCard
|
||||
cardTitle="Plantilla de mapa curricular"
|
||||
cardDescription="Selecciona el Excel para tu mapa curricular."
|
||||
templatesData={PLANTILLAS_ANEXO_2}
|
||||
selectedTemplateId={wizard.datosBasicos.plantillaMapaId || ''}
|
||||
selectedVersion={wizard.datosBasicos.plantillaMapaVersion || ''}
|
||||
onChange={({ templateId, version }) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
datosBasicos: {
|
||||
...w.datosBasicos,
|
||||
plantillaMapaId: templateId,
|
||||
plantillaMapaVersion: version,
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from '@/components/ui/card'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type TemplateData = {
|
||||
id: string
|
||||
name: string
|
||||
versions: Array<string>
|
||||
}
|
||||
|
||||
// Default data (kept for backward compatibility if caller doesn't pass templates)
|
||||
const DEFAULT_TEMPLATES_DATA: Array<TemplateData> = [
|
||||
{
|
||||
id: 'sep-2025',
|
||||
name: 'Licenciatura RVOE SEP',
|
||||
versions: ['v2025.2 (Vigente)', 'v2025.1', 'v2024.Final'],
|
||||
},
|
||||
{
|
||||
id: 'interno-mix',
|
||||
name: 'Estándar Institucional Mixto',
|
||||
versions: ['v2.0', 'v1.5', 'v1.0-beta'],
|
||||
},
|
||||
{
|
||||
id: 'conacyt',
|
||||
name: 'Formato Posgrado CONAHCYT',
|
||||
versions: ['v3.0 (2025)', 'v2.8'],
|
||||
},
|
||||
]
|
||||
|
||||
interface Props {
|
||||
cardTitle?: string
|
||||
cardDescription?: string
|
||||
templatesData?: Array<TemplateData>
|
||||
// Controlled selection (optional). If not provided, component manages its own state
|
||||
selectedTemplateId?: string
|
||||
selectedVersion?: string
|
||||
onChange?: (sel: { templateId: string; version: string }) => void
|
||||
}
|
||||
|
||||
export function TemplateSelectorCard({
|
||||
cardTitle = 'Configuración del Documento',
|
||||
cardDescription = 'Selecciona la base para tu nuevo plan.',
|
||||
templatesData = DEFAULT_TEMPLATES_DATA,
|
||||
selectedTemplateId,
|
||||
selectedVersion,
|
||||
onChange,
|
||||
}: Props) {
|
||||
const [internalTemplate, setInternalTemplate] = useState<string>('')
|
||||
const [internalVersion, setInternalVersion] = useState<string>('')
|
||||
|
||||
const selectedTemplate = selectedTemplateId ?? internalTemplate
|
||||
const version = selectedVersion ?? internalVersion
|
||||
|
||||
// Buscamos las versiones de la plantilla seleccionada
|
||||
const currentTemplateData = useMemo(
|
||||
() => templatesData.find((t) => t.id === selectedTemplate),
|
||||
[templatesData, selectedTemplate],
|
||||
)
|
||||
const availableVersions = currentTemplateData?.versions || []
|
||||
|
||||
const handleTemplateChange = (value: string) => {
|
||||
const template = templatesData.find((t) => t.id === value)
|
||||
const firstVersion = template?.versions?.[0] ?? ''
|
||||
if (onChange) {
|
||||
onChange({ templateId: value, version: firstVersion })
|
||||
} else {
|
||||
setInternalTemplate(value)
|
||||
setInternalVersion(firstVersion)
|
||||
}
|
||||
}
|
||||
|
||||
const handleVersionChange = (value: string) => {
|
||||
if (onChange) {
|
||||
onChange({ templateId: selectedTemplate, version: value })
|
||||
} else {
|
||||
setInternalVersion(value)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-lg gap-2 overflow-hidden">
|
||||
<CardHeader className="px-4 pb-2 sm:px-6 sm:pb-4">
|
||||
<CardTitle className="text-lg">{cardTitle}</CardTitle>
|
||||
<CardDescription>{cardDescription}</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
{/* SELECT 1: PRIMARIO (Llamativo) */}
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="template-select"
|
||||
className="text-foreground text-base font-semibold"
|
||||
>
|
||||
Plantilla
|
||||
</Label>
|
||||
<Select value={selectedTemplate} onValueChange={handleTemplateChange}>
|
||||
<SelectTrigger
|
||||
id="template-select"
|
||||
className="bg-background border-primary/40 focus:ring-primary/20 focus:border-primary flex h-11 w-full min-w-0 items-center justify-between gap-2 text-base shadow-sm [&>span]:block! [&>span]:truncate! [&>span]:text-left"
|
||||
>
|
||||
<SelectValue placeholder="Selecciona una plantilla..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{templatesData.map((t) => (
|
||||
<SelectItem key={t.id} value={t.id} className="font-medium">
|
||||
{t.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* SELECT 2: SECUNDARIO (Sutil) */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label
|
||||
htmlFor="version-select"
|
||||
className={cn(
|
||||
'text-xs tracking-wider uppercase transition-colors',
|
||||
!selectedTemplate
|
||||
? 'text-muted-foreground/50'
|
||||
: 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
Versión
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
value={version}
|
||||
onValueChange={handleVersionChange}
|
||||
disabled={!selectedTemplate}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="version-select"
|
||||
className={cn(
|
||||
'flex h-9 min-w-0 items-center justify-between gap-2 text-sm transition-all duration-300',
|
||||
/* AQUÍ ESTÁ EL CAMBIO DE ANCHO: */
|
||||
'w-full max-w-full sm:w-55',
|
||||
|
||||
/* Las correcciones vitales para truncado que ya teníamos: */
|
||||
'min-w-0 [&>span]:block! [&>span]:truncate! [&>span]:text-left',
|
||||
'[&>span]:block [&>span]:min-w-0 [&>span]:truncate [&>span]:text-left',
|
||||
|
||||
!selectedTemplate
|
||||
? 'bg-muted/50 cursor-not-allowed border-transparent opacity-50'
|
||||
: 'bg-muted/20 border-border hover:bg-background hover:border-primary/30',
|
||||
)}
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
!selectedTemplate
|
||||
? '— Esperando plantilla —'
|
||||
: 'Selecciona versión'
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableVersions.map((v) => (
|
||||
<SelectItem
|
||||
key={v}
|
||||
value={v}
|
||||
className="text-muted-foreground focus:text-foreground text-sm"
|
||||
>
|
||||
{v}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
205
src/components/planes/wizard/PasoDetallesPanel/FileDropZone.tsx
Normal file
205
src/components/planes/wizard/PasoDetallesPanel/FileDropZone.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import { Upload, File, X, FileText } from 'lucide-react'
|
||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface UploadedFile {
|
||||
id: string
|
||||
name: string
|
||||
size: string
|
||||
type: string
|
||||
}
|
||||
|
||||
interface FileDropzoneProps {
|
||||
onFilesChange?: (files: Array<UploadedFile>) => void
|
||||
acceptedTypes?: string
|
||||
maxFiles?: number
|
||||
title?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export function FileDropzone({
|
||||
onFilesChange,
|
||||
acceptedTypes = '.doc,.docx,.pdf',
|
||||
maxFiles = 5,
|
||||
title = 'Arrastra archivos aquí',
|
||||
description = 'o haz clic para seleccionar',
|
||||
}: FileDropzoneProps) {
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [files, setFiles] = useState<Array<UploadedFile>>([])
|
||||
const onFilesChangeRef = useRef<typeof onFilesChange>(onFilesChange)
|
||||
|
||||
const addFiles = useCallback(
|
||||
(newFiles: Array<File>) => {
|
||||
const toUpload: Array<UploadedFile> = newFiles.map((file) => ({
|
||||
id:
|
||||
typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
||||
? (crypto as any).randomUUID()
|
||||
: `file-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
name: file.name,
|
||||
size: formatFileSize(file.size),
|
||||
type: file.name.split('.').pop() || 'file',
|
||||
}))
|
||||
setFiles((prev) => {
|
||||
const room = Math.max(0, maxFiles - prev.length)
|
||||
const next = [...prev, ...toUpload.slice(0, room)].slice(0, maxFiles)
|
||||
return next
|
||||
})
|
||||
},
|
||||
[maxFiles],
|
||||
)
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(true)
|
||||
}, [])
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
}, [])
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
const droppedFiles = Array.from(e.dataTransfer.files)
|
||||
addFiles(droppedFiles)
|
||||
},
|
||||
[addFiles],
|
||||
)
|
||||
|
||||
const handleFileInput = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files) {
|
||||
const selectedFiles = Array.from(e.target.files)
|
||||
addFiles(selectedFiles)
|
||||
}
|
||||
},
|
||||
[addFiles],
|
||||
)
|
||||
|
||||
const removeFile = useCallback((fileId: string) => {
|
||||
setFiles((prev) => {
|
||||
const next = prev.filter((f) => f.id !== fileId)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Keep latest callback in a ref to avoid retriggering effect on identity change
|
||||
useEffect(() => {
|
||||
onFilesChangeRef.current = onFilesChange
|
||||
}, [onFilesChange])
|
||||
|
||||
// Only emit when files actually change to avoid parent update loops
|
||||
useEffect(() => {
|
||||
if (onFilesChangeRef.current) onFilesChangeRef.current(files)
|
||||
}, [files])
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes < 1024) return bytes + ' B'
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
||||
}
|
||||
|
||||
const getFileIcon = (type: string) => {
|
||||
switch (type.toLowerCase()) {
|
||||
case 'pdf':
|
||||
return <FileText className="text-destructive h-4 w-4" />
|
||||
case 'doc':
|
||||
case 'docx':
|
||||
return <FileText className="text-info h-4 w-4" />
|
||||
default:
|
||||
return <File className="text-muted-foreground h-4 w-4" />
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
className={cn(
|
||||
'border-border hover:border-primary/50 cursor-pointer rounded-xl border-2 border-dashed p-8 text-center transition-all duration-300',
|
||||
isDragging && 'active',
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
accept={acceptedTypes}
|
||||
multiple
|
||||
onChange={handleFileInput}
|
||||
className="hidden"
|
||||
id="file-upload"
|
||||
/>
|
||||
<label
|
||||
htmlFor="file-upload"
|
||||
className="cursor-pointer"
|
||||
aria-label="Seleccionar archivos"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-12 w-12 items-center justify-center rounded-xl transition-colors',
|
||||
isDragging
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-accent text-accent-foreground',
|
||||
)}
|
||||
>
|
||||
<Upload className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-foreground text-sm font-medium">{title}</p>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{description}
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
Formatos:{' '}
|
||||
{acceptedTypes
|
||||
.replace(/\./g, '')
|
||||
.toUpperCase()
|
||||
.replace(/,/g, ', ')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Uploaded files list */}
|
||||
{files.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{files.map((file) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className="bg-accent/50 border-border fade-in flex items-center gap-3 rounded-lg border p-3"
|
||||
>
|
||||
{getFileIcon(file.type)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-foreground truncate text-sm font-medium">
|
||||
{file.name}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">{file.size}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-destructive h-8 w-8"
|
||||
onClick={() => removeFile(file.id)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{files.length >= maxFiles && (
|
||||
<p className="text-warning text-center text-xs">
|
||||
Máximo de {maxFiles} archivos alcanzado
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { NewPlanWizardState } from '@/features/planes/new/types'
|
||||
import { FileDropzone } from './FileDropZone'
|
||||
import ReferenciasParaIA from './ReferenciasParaIA'
|
||||
|
||||
import type { NewPlanWizardState } from '@/features/planes/nuevo/types'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
@@ -14,7 +17,7 @@ import {
|
||||
CARRERAS,
|
||||
FACULTADES,
|
||||
PLANES_EXISTENTES,
|
||||
} from '@/features/planes/new/catalogs'
|
||||
} from '@/features/planes/nuevo/catalogs'
|
||||
|
||||
export function PasoDetallesPanel({
|
||||
wizard,
|
||||
@@ -42,8 +45,8 @@ export function PasoDetallesPanel({
|
||||
|
||||
if (wizard.modoCreacion === 'IA') {
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label htmlFor="desc">Descripción del enfoque</Label>
|
||||
<textarea
|
||||
id="desc"
|
||||
@@ -61,24 +64,8 @@ export function PasoDetallesPanel({
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="poblacion">Población objetivo</Label>
|
||||
<Input
|
||||
id="poblacion"
|
||||
placeholder="Ej. Egresados de bachillerato con perfil STEM"
|
||||
value={wizard.iaConfig?.poblacionObjetivo || ''}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
iaConfig: {
|
||||
...(w.iaConfig || ({} as any)),
|
||||
poblacionObjetivo: e.target.value,
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label htmlFor="notas">Notas adicionales</Label>
|
||||
<textarea
|
||||
id="notas"
|
||||
@@ -96,6 +83,49 @@ export function PasoDetallesPanel({
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<ReferenciasParaIA
|
||||
selectedArchivoIds={wizard.iaConfig?.archivosReferencia || []}
|
||||
selectedRepositorioIds={wizard.iaConfig?.repositoriosReferencia || []}
|
||||
onToggleArchivo={(id, checked) =>
|
||||
onChange((w) => {
|
||||
const prev = w.iaConfig?.archivosReferencia || []
|
||||
const next = checked
|
||||
? [...prev, id]
|
||||
: prev.filter((x) => x !== id)
|
||||
return {
|
||||
...w,
|
||||
iaConfig: {
|
||||
...(w.iaConfig || ({} as any)),
|
||||
archivosReferencia: next,
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
onToggleRepositorio={(id, checked) =>
|
||||
onChange((w) => {
|
||||
const prev = w.iaConfig?.repositoriosReferencia || []
|
||||
const next = checked
|
||||
? [...prev, id]
|
||||
: prev.filter((x) => x !== id)
|
||||
return {
|
||||
...w,
|
||||
iaConfig: {
|
||||
...(w.iaConfig || ({} as any)),
|
||||
repositoriosReferencia: next,
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
onFilesChange={(files) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
iaConfig: {
|
||||
...(w.iaConfig || ({} as any)),
|
||||
archivosAdjuntos: files,
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-muted-foreground text-sm">
|
||||
Opcional: se pueden adjuntar recursos IA más adelante.
|
||||
@@ -144,6 +174,7 @@ export function PasoDetallesPanel({
|
||||
<select
|
||||
id="clonFacultad"
|
||||
className="bg-background text-foreground ring-offset-background focus-visible:ring-ring h-10 w-full rounded-md border px-3 text-sm shadow-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||
aria-label="Facultad"
|
||||
value={wizard.datosBasicos.facultadId}
|
||||
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
|
||||
onChange((w) => ({
|
||||
@@ -168,6 +199,7 @@ export function PasoDetallesPanel({
|
||||
<select
|
||||
id="clonCarrera"
|
||||
className="bg-background text-foreground ring-offset-background focus-visible:ring-ring h-10 w-full rounded-md border px-3 text-sm shadow-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||
aria-label="Carrera"
|
||||
value={wizard.datosBasicos.carreraId}
|
||||
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
|
||||
onChange((w) => ({
|
||||
@@ -242,10 +274,10 @@ export function PasoDetallesPanel({
|
||||
wizard.subModoClonado === 'TRADICIONAL'
|
||||
) {
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label htmlFor="word">Word del plan (obligatorio)</Label>
|
||||
<input
|
||||
{/* <input
|
||||
id="word"
|
||||
type="file"
|
||||
accept=".doc,.docx"
|
||||
@@ -261,6 +293,21 @@ export function PasoDetallesPanel({
|
||||
},
|
||||
}))
|
||||
}
|
||||
/> */}
|
||||
|
||||
<FileDropzone
|
||||
acceptedTypes=".doc,.docx"
|
||||
maxFiles={1}
|
||||
onFilesChange={(files) => {
|
||||
const f = files[0] || null
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
clonTradicional: {
|
||||
...(w.clonTradicional || ({} as any)),
|
||||
archivoWordPlanId: f,
|
||||
},
|
||||
}))
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -269,17 +316,32 @@ export function PasoDetallesPanel({
|
||||
id="mapa"
|
||||
type="file"
|
||||
accept=".xls,.xlsx"
|
||||
title="Subir mapa curricular"
|
||||
className="bg-background text-foreground ring-offset-background focus-visible:ring-ring file:bg-secondary block w-full rounded-md border px-3 py-2 text-sm shadow-sm file:mr-4 file:rounded-md file:border-0 file:px-3 file:py-1.5 file:text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
clonTradicional: {
|
||||
...(w.clonTradicional || ({} as any)),
|
||||
archivoMapaExcelId: e.target.files?.[0]
|
||||
? `file_${e.target.files[0].name}`
|
||||
: null,
|
||||
},
|
||||
}))
|
||||
onChange((w) => {
|
||||
const file = e.target.files?.[0] || null
|
||||
const next = file
|
||||
? {
|
||||
id:
|
||||
typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
||||
? (crypto as any).randomUUID()
|
||||
: `file-${Date.now()}-${Math.random()
|
||||
.toString(36)
|
||||
.substr(2, 9)}`,
|
||||
name: file.name,
|
||||
size: formatFileSize(file.size),
|
||||
type: file.name.split('.').pop() || 'file',
|
||||
}
|
||||
: null
|
||||
return {
|
||||
...w,
|
||||
clonTradicional: {
|
||||
...(w.clonTradicional || ({} as any)),
|
||||
archivoMapaExcelId: next,
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -289,17 +351,32 @@ export function PasoDetallesPanel({
|
||||
id="asignaturas"
|
||||
type="file"
|
||||
accept=".xls,.xlsx,.csv"
|
||||
title="Subir listado de asignaturas"
|
||||
className="bg-background text-foreground ring-offset-background focus-visible:ring-ring file:bg-secondary block w-full rounded-md border px-3 py-2 text-sm shadow-sm file:mr-4 file:rounded-md file:border-0 file:px-3 file:py-1.5 file:text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
clonTradicional: {
|
||||
...(w.clonTradicional || ({} as any)),
|
||||
archivoAsignaturasExcelId: e.target.files?.[0]
|
||||
? `file_${e.target.files[0].name}`
|
||||
: null,
|
||||
},
|
||||
}))
|
||||
onChange((w) => {
|
||||
const file = e.target.files?.[0] || null
|
||||
const next = file
|
||||
? {
|
||||
id:
|
||||
typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
||||
? (crypto as any).randomUUID()
|
||||
: `file-${Date.now()}-${Math.random()
|
||||
.toString(36)
|
||||
.substr(2, 9)}`,
|
||||
name: file.name,
|
||||
size: formatFileSize(file.size),
|
||||
type: file.name.split('.').pop() || 'file',
|
||||
}
|
||||
: null
|
||||
return {
|
||||
...w,
|
||||
clonTradicional: {
|
||||
...(w.clonTradicional || ({} as any)),
|
||||
archivoAsignaturasExcelId: next,
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -321,3 +398,9 @@ export function PasoDetallesPanel({
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return bytes + ' B'
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import { FileText, FolderOpen, Upload } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import BarraBusqueda from '../../BarraBusqueda'
|
||||
|
||||
import { FileDropzone } from './FileDropZone'
|
||||
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
TabsContents,
|
||||
} from '@/components/ui/motion-tabs'
|
||||
import { ARCHIVOS, REPOSITORIOS } from '@/features/planes/nuevo/catalogs'
|
||||
|
||||
const ReferenciasParaIA = ({
|
||||
selectedArchivoIds = [],
|
||||
selectedRepositorioIds = [],
|
||||
onToggleArchivo,
|
||||
onToggleRepositorio,
|
||||
onFilesChange,
|
||||
}: {
|
||||
selectedArchivoIds?: Array<string>
|
||||
selectedRepositorioIds?: Array<string>
|
||||
onToggleArchivo?: (id: string, checked: boolean) => void
|
||||
onToggleRepositorio?: (id: string, checked: boolean) => void
|
||||
onFilesChange?: (
|
||||
files: Array<{ id: string; name: string; size: string; type: string }>,
|
||||
) => void
|
||||
}) => {
|
||||
const [busquedaArchivos, setBusquedaArchivos] = useState('')
|
||||
const [busquedaRepositorios, setBusquedaRepositorios] = useState('')
|
||||
|
||||
const cleanText = (text: string) => {
|
||||
return text
|
||||
.normalize('NFD') // Descompone "á" en "a" + "´"
|
||||
.replace(/[\u0300-\u036f]/g, '') // Elimina los símbolos diacríticos
|
||||
.toLowerCase() // Convierte a minúsculas
|
||||
}
|
||||
|
||||
// Filtrado de archivos y de repositorios
|
||||
const archivosFiltrados = useMemo(() => {
|
||||
// Función helper para limpiar texto (quita acentos y hace minúsculas)
|
||||
|
||||
const term = cleanText(busquedaArchivos)
|
||||
return ARCHIVOS.filter((archivo) =>
|
||||
cleanText(archivo.nombre).includes(term),
|
||||
)
|
||||
}, [busquedaArchivos])
|
||||
|
||||
const repositoriosFiltrados = useMemo(() => {
|
||||
const term = cleanText(busquedaRepositorios)
|
||||
return REPOSITORIOS.filter((repositorio) =>
|
||||
cleanText(repositorio.nombre).includes(term),
|
||||
)
|
||||
}, [busquedaRepositorios])
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
name: 'Archivos existentes',
|
||||
|
||||
value: 'archivos-existentes',
|
||||
|
||||
icon: FileText,
|
||||
|
||||
content: (
|
||||
<div className="flex flex-col">
|
||||
<BarraBusqueda
|
||||
value={busquedaArchivos}
|
||||
onChange={setBusquedaArchivos}
|
||||
placeholder="Buscar archivo existente..."
|
||||
className="m-1 mb-1.5"
|
||||
/>
|
||||
<div className="flex h-72 flex-col gap-0.5 overflow-y-auto">
|
||||
{archivosFiltrados.map((archivo) => (
|
||||
<Label
|
||||
key={archivo.id}
|
||||
className="border-border hover:border-primary/30 hover:bg-accent/50 m-0.5 flex cursor-pointer items-center gap-3 rounded-lg border p-3 transition-colors has-aria-checked:border-blue-600 has-aria-checked:bg-blue-50 dark:has-aria-checked:border-blue-900 dark:has-aria-checked:bg-blue-950"
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedArchivoIds.includes(archivo.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
onToggleArchivo?.(archivo.id, !!checked)
|
||||
}
|
||||
className="peer border-primary ring-offset-background data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground focus-visible:ring-ring h-5 w-5 shrink-0 rounded-sm border focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
|
||||
<FileText className="text-muted-foreground h-4 w-4" />
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-foreground truncate text-sm font-medium">
|
||||
{archivo.nombre}
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{archivo.tamaño}
|
||||
</p>
|
||||
</div>
|
||||
</Label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
||||
{
|
||||
name: 'Repositorios',
|
||||
|
||||
value: 'repositorios',
|
||||
|
||||
icon: FolderOpen,
|
||||
|
||||
content: (
|
||||
<div className="flex flex-col">
|
||||
<BarraBusqueda
|
||||
value={busquedaRepositorios}
|
||||
onChange={setBusquedaRepositorios}
|
||||
placeholder="Buscar repositorio..."
|
||||
className="m-1 mb-1.5"
|
||||
/>
|
||||
<div className="flex h-72 flex-col gap-0.5 overflow-y-auto">
|
||||
{repositoriosFiltrados.map((repositorio) => (
|
||||
<Label
|
||||
key={repositorio.id}
|
||||
className="border-border hover:border-primary/30 hover:bg-accent/50 m-0.5 flex cursor-pointer items-center gap-3 rounded-lg border p-3 transition-colors has-aria-checked:border-blue-600 has-aria-checked:bg-blue-50 dark:has-aria-checked:border-blue-900 dark:has-aria-checked:bg-blue-950"
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedRepositorioIds.includes(repositorio.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
onToggleRepositorio?.(repositorio.id, !!checked)
|
||||
}
|
||||
className="peer border-primary ring-offset-background data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground focus-visible:ring-ring h-5 w-5 shrink-0 rounded-sm border focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
|
||||
<FolderOpen className="text-muted-foreground h-4 w-4" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-foreground text-sm font-medium">
|
||||
{repositorio.nombre}
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{repositorio.descripcion} · {repositorio.cantidadArchivos}{' '}
|
||||
archivos
|
||||
</p>
|
||||
</div>
|
||||
</Label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
||||
{
|
||||
name: 'Subir archivos',
|
||||
|
||||
value: 'subir-archivos',
|
||||
|
||||
icon: Upload,
|
||||
|
||||
content: (
|
||||
<div>
|
||||
<FileDropzone
|
||||
onFilesChange={onFilesChange}
|
||||
title="Sube archivos de referencia"
|
||||
description="Documentos que serán usados como contexto para la generación"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
<Label>Referencias para la IA</Label>
|
||||
|
||||
<Tabs defaultValue="archivos-existentes" className="gap-4">
|
||||
<TabsList className="w-full">
|
||||
{tabs.map(({ icon: Icon, name, value }) => (
|
||||
<TabsTrigger
|
||||
key={value}
|
||||
value={value}
|
||||
className="flex items-center gap-1 px-2.5 sm:px-3"
|
||||
>
|
||||
<Icon />
|
||||
|
||||
<span className="hidden sm:inline">{name}</span>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
<TabsContents className="bg-background mx-1 -mt-2 mb-1 h-full rounded-sm">
|
||||
{tabs.map((tab) => (
|
||||
<TabsContent
|
||||
key={tab.value}
|
||||
value={tab.value}
|
||||
className="animate-in fade-in duration-300 ease-out"
|
||||
>
|
||||
{tab.content}
|
||||
</TabsContent>
|
||||
))}
|
||||
</TabsContents>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ReferenciasParaIA
|
||||
@@ -4,7 +4,7 @@ import type {
|
||||
NewPlanWizardState,
|
||||
ModoCreacion,
|
||||
SubModoClonado,
|
||||
} from '@/features/planes/new/types'
|
||||
} from '@/features/planes/nuevo/types'
|
||||
|
||||
import {
|
||||
Card,
|
||||
@@ -23,6 +23,19 @@ export function PasoModoCardGroup({
|
||||
}) {
|
||||
const isSelected = (m: ModoCreacion) => wizard.modoCreacion === m
|
||||
const isSubSelected = (s: SubModoClonado) => wizard.subModoClonado === s
|
||||
const handleKeyActivate = (e: React.KeyboardEvent, cb: () => void) => {
|
||||
const key = e.key
|
||||
if (
|
||||
key === 'Enter' ||
|
||||
key === ' ' ||
|
||||
key === 'Spacebar' ||
|
||||
key === 'Space'
|
||||
) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
cb()
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<Card
|
||||
@@ -34,6 +47,15 @@ export function PasoModoCardGroup({
|
||||
subModoClonado: undefined,
|
||||
}))
|
||||
}
|
||||
onKeyDown={(e: React.KeyboardEvent) =>
|
||||
handleKeyActivate(e, () =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
modoCreacion: 'MANUAL',
|
||||
subModoClonado: undefined,
|
||||
})),
|
||||
)
|
||||
}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
@@ -54,6 +76,15 @@ export function PasoModoCardGroup({
|
||||
subModoClonado: undefined,
|
||||
}))
|
||||
}
|
||||
onKeyDown={(e: React.KeyboardEvent) =>
|
||||
handleKeyActivate(e, () =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
modoCreacion: 'IA',
|
||||
subModoClonado: undefined,
|
||||
})),
|
||||
)
|
||||
}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
@@ -70,6 +101,11 @@ export function PasoModoCardGroup({
|
||||
<Card
|
||||
className={isSelected('CLONADO') ? 'ring-ring ring-2' : ''}
|
||||
onClick={() => onChange((w) => ({ ...w, modoCreacion: 'CLONADO' }))}
|
||||
onKeyDown={(e: React.KeyboardEvent) =>
|
||||
handleKeyActivate(e, () =>
|
||||
onChange((w) => ({ ...w, modoCreacion: 'CLONADO' })),
|
||||
)
|
||||
}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
@@ -88,6 +124,11 @@ export function PasoModoCardGroup({
|
||||
e.stopPropagation()
|
||||
onChange((w) => ({ ...w, subModoClonado: 'INTERNO' }))
|
||||
}}
|
||||
onKeyDown={(e: React.KeyboardEvent) =>
|
||||
handleKeyActivate(e, () =>
|
||||
onChange((w) => ({ ...w, subModoClonado: 'INTERNO' })),
|
||||
)
|
||||
}
|
||||
className={`hover:border-primary/50 hover:bg-accent flex cursor-pointer flex-row items-center justify-center gap-2 rounded-lg border p-4 text-center transition-all sm:flex-col ${
|
||||
isSubSelected('INTERNO')
|
||||
? 'border-primary bg-primary/5 ring-primary text-primary ring-1'
|
||||
@@ -105,6 +146,11 @@ export function PasoModoCardGroup({
|
||||
e.stopPropagation()
|
||||
onChange((w) => ({ ...w, subModoClonado: 'TRADICIONAL' }))
|
||||
}}
|
||||
onKeyDown={(e: React.KeyboardEvent) =>
|
||||
handleKeyActivate(e, () =>
|
||||
onChange((w) => ({ ...w, subModoClonado: 'TRADICIONAL' })),
|
||||
)
|
||||
}
|
||||
className={`hover:border-primary/50 hover:bg-accent flex cursor-pointer flex-row items-center justify-center gap-2 rounded-lg border p-4 text-center transition-all sm:flex-col ${
|
||||
isSubSelected('TRADICIONAL')
|
||||
? 'border-primary bg-primary/5 ring-primary text-primary ring-1'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { NewPlanWizardState } from '@/features/planes/new/types'
|
||||
import type { NewPlanWizardState } from '@/features/planes/nuevo/types'
|
||||
|
||||
import {
|
||||
Card,
|
||||
@@ -7,10 +7,15 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import {
|
||||
PLANTILLAS_ANEXO_1,
|
||||
PLANTILLAS_ANEXO_2,
|
||||
PLANES_EXISTENTES,
|
||||
ARCHIVOS,
|
||||
REPOSITORIOS,
|
||||
} from '@/features/planes/nuevo/catalogs'
|
||||
|
||||
export function PasoResumenCard({ wizard }: { wizard: NewPlanWizardState }) {
|
||||
const modo = wizard.modoCreacion
|
||||
const sub = wizard.subModoClonado
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -21,53 +26,210 @@ export function PasoResumenCard({ wizard }: { wizard: NewPlanWizardState }) {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-2 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Nombre: </span>
|
||||
<span className="font-medium">
|
||||
{wizard.datosBasicos.nombrePlan || '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Facultad/Carrera: </span>
|
||||
<span className="font-medium">
|
||||
{wizard.datosBasicos.facultadId || '—'} /{' '}
|
||||
{wizard.datosBasicos.carreraId || '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Nivel: </span>
|
||||
<span className="font-medium">
|
||||
{wizard.datosBasicos.nivel || '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Ciclos: </span>
|
||||
<span className="font-medium">
|
||||
{wizard.datosBasicos.numCiclos} ({wizard.datosBasicos.tipoCiclo})
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<span className="text-muted-foreground">Modo: </span>
|
||||
<span className="font-medium">
|
||||
{modo === 'MANUAL' && 'Manual'}
|
||||
{modo === 'IA' && 'Generado con IA'}
|
||||
{modo === 'CLONADO' &&
|
||||
sub === 'INTERNO' &&
|
||||
'Clonado desde plan del sistema'}
|
||||
{modo === 'CLONADO' &&
|
||||
sub === 'TRADICIONAL' &&
|
||||
'Importado desde documentos tradicionales'}
|
||||
</span>
|
||||
</div>
|
||||
{wizard.resumen.previewPlan && (
|
||||
<div className="bg-muted mt-2 rounded-md p-3">
|
||||
<div className="font-medium">Preview IA</div>
|
||||
<div className="text-muted-foreground">
|
||||
Asignaturas aprox.:{' '}
|
||||
{wizard.resumen.previewPlan.numAsignaturasAprox}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(() => {
|
||||
// Precompute common derived values to avoid unnecessary optional chaining warnings
|
||||
const archivosRef = wizard.iaConfig?.archivosReferencia ?? []
|
||||
const repositoriosRef =
|
||||
wizard.iaConfig?.repositoriosReferencia ?? []
|
||||
const adjuntos = wizard.iaConfig?.archivosAdjuntos ?? []
|
||||
const plantillaPlan = PLANTILLAS_ANEXO_1.find(
|
||||
(x) => x.id === wizard.datosBasicos.plantillaPlanId,
|
||||
)
|
||||
const plantillaMapa = PLANTILLAS_ANEXO_2.find(
|
||||
(x) => x.id === wizard.datosBasicos.plantillaMapaId,
|
||||
)
|
||||
const contenido = (
|
||||
<>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Nombre: </span>
|
||||
<span className="font-medium">
|
||||
{wizard.datosBasicos.nombrePlan || '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">
|
||||
Facultad/Carrera:{' '}
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{wizard.datosBasicos.facultadId || '—'} /{' '}
|
||||
{wizard.datosBasicos.carreraId || '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Nivel: </span>
|
||||
<span className="font-medium">
|
||||
{wizard.datosBasicos.nivel || '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Ciclos: </span>
|
||||
<span className="font-medium">
|
||||
{wizard.datosBasicos.numCiclos} (
|
||||
{wizard.datosBasicos.tipoCiclo})
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<span className="text-muted-foreground">
|
||||
Plantilla plan:{' '}
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{(plantillaPlan?.name ||
|
||||
wizard.datosBasicos.plantillaPlanId ||
|
||||
'—') +
|
||||
' · ' +
|
||||
(wizard.datosBasicos.plantillaPlanVersion || '—')}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">
|
||||
Mapa curricular:{' '}
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{(plantillaMapa?.name ||
|
||||
wizard.datosBasicos.plantillaMapaId ||
|
||||
'—') +
|
||||
' · ' +
|
||||
(wizard.datosBasicos.plantillaMapaVersion || '—')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<span className="text-muted-foreground">Modo: </span>
|
||||
<span className="font-medium">
|
||||
{wizard.modoCreacion === 'MANUAL' && 'Manual'}
|
||||
{wizard.modoCreacion === 'IA' && 'Generado con IA'}
|
||||
{wizard.modoCreacion === 'CLONADO' &&
|
||||
wizard.subModoClonado === 'INTERNO' &&
|
||||
'Clonado desde plan del sistema'}
|
||||
{wizard.modoCreacion === 'CLONADO' &&
|
||||
wizard.subModoClonado === 'TRADICIONAL' &&
|
||||
'Importado desde documentos tradicionales'}
|
||||
</span>
|
||||
</div>
|
||||
{wizard.modoCreacion === 'CLONADO' &&
|
||||
wizard.subModoClonado === 'INTERNO' && (
|
||||
<div className="mt-2">
|
||||
<span className="text-muted-foreground">
|
||||
Plan origen:{' '}
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{(() => {
|
||||
const p = PLANES_EXISTENTES.find(
|
||||
(x) => x.id === wizard.clonInterno?.planOrigenId,
|
||||
)
|
||||
return (
|
||||
p?.nombre || wizard.clonInterno?.planOrigenId || '—'
|
||||
)
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{wizard.modoCreacion === 'CLONADO' &&
|
||||
wizard.subModoClonado === 'TRADICIONAL' && (
|
||||
<div className="mt-2">
|
||||
<div className="font-medium">Documentos adjuntos</div>
|
||||
<ul className="text-muted-foreground list-disc pl-5 text-xs">
|
||||
<li>
|
||||
<span className="text-foreground">
|
||||
Word del plan:
|
||||
</span>{' '}
|
||||
{wizard.clonTradicional?.archivoWordPlanId?.name ||
|
||||
'—'}
|
||||
</li>
|
||||
<li>
|
||||
<span className="text-foreground">
|
||||
Mapa curricular:
|
||||
</span>{' '}
|
||||
{wizard.clonTradicional?.archivoMapaExcelId?.name ||
|
||||
'—'}
|
||||
</li>
|
||||
<li>
|
||||
<span className="text-foreground">Asignaturas:</span>{' '}
|
||||
{wizard.clonTradicional?.archivoAsignaturasExcelId
|
||||
?.name || '—'}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{wizard.modoCreacion === 'IA' && (
|
||||
<div className="bg-muted/50 mt-2 rounded-md p-3">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Enfoque: </span>
|
||||
<span className="font-medium">
|
||||
{wizard.iaConfig?.descripcionEnfoque || '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Notas: </span>
|
||||
<span className="font-medium">
|
||||
{wizard.iaConfig?.notasAdicionales || '—'}
|
||||
</span>
|
||||
</div>
|
||||
{archivosRef.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="font-medium">Archivos existentes</div>
|
||||
<ul className="text-muted-foreground list-disc pl-5 text-xs">
|
||||
{archivosRef.map((id) => {
|
||||
const a = ARCHIVOS.find((x) => x.id === id)
|
||||
return (
|
||||
<li key={id}>
|
||||
<span className="text-foreground">
|
||||
{a?.nombre || id}
|
||||
</span>{' '}
|
||||
{a?.tamaño ? <span>· {a.tamaño}</span> : null}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{repositoriosRef.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="font-medium">Repositorios</div>
|
||||
<ul className="text-muted-foreground list-disc pl-5 text-xs">
|
||||
{repositoriosRef.map((id) => {
|
||||
const r = REPOSITORIOS.find((x) => x.id === id)
|
||||
return (
|
||||
<li key={id}>
|
||||
<span className="text-foreground">
|
||||
{r?.nombre || id}
|
||||
</span>{' '}
|
||||
{r?.cantidadArchivos ? (
|
||||
<span>· {r.cantidadArchivos} archivos</span>
|
||||
) : null}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{adjuntos.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="font-medium">Adjuntos</div>
|
||||
<ul className="text-muted-foreground list-disc pl-5 text-xs">
|
||||
{adjuntos.map((f) => (
|
||||
<li key={f.id}>
|
||||
<span className="text-foreground">{f.name}</span>{' '}
|
||||
<span>· {f.size}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{wizard.resumen.previewPlan && (
|
||||
<div className="bg-muted mt-2 rounded-md p-3">
|
||||
<div className="font-medium">Preview IA</div>
|
||||
<div className="text-muted-foreground">
|
||||
Asignaturas aprox.:{' '}
|
||||
{wizard.resumen.previewPlan.numAsignaturasAprox}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
return contenido
|
||||
})()}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
32
src/components/shadcn-studio/checkbox/checkbox-13.tsx
Normal file
32
src/components/shadcn-studio/checkbox/checkbox-13.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
||||
const CheckboxCardDemo = () => {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label className="border-border hover:border-primary/30 hover:bg-accent/50 flex cursor-pointer items-center items-start gap-2 gap-3 rounded-lg border p-3 transition-colors has-[[aria-checked=true]]:border-blue-600 has-[[aria-checked=true]]:bg-blue-50 dark:has-[[aria-checked=true]]:border-blue-900 dark:has-[[aria-checked=true]]:bg-blue-950">
|
||||
<Checkbox
|
||||
defaultChecked
|
||||
className="peer border-primary ring-offset-background data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground focus-visible:ring-ring h-5 w-5 shrink-0 rounded-sm border focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
<div className="grid gap-1.5 font-normal">
|
||||
<p className="text-sm leading-none font-medium">Auto Start</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Starting with your OS.
|
||||
</p>
|
||||
</div>
|
||||
</Label>
|
||||
<Label className="hover:bg-accent/50 flex items-start gap-2 rounded-lg border p-3 has-[[aria-checked=true]]:border-blue-600 has-[[aria-checked=true]]:bg-blue-50 dark:has-[[aria-checked=true]]:border-blue-900 dark:has-[[aria-checked=true]]:bg-blue-950">
|
||||
<Checkbox className="peer border-primary ring-offset-background data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground focus-visible:ring-ring h-5 w-5 shrink-0 rounded-sm border focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50" />
|
||||
<div className="grid gap-1.5 font-normal">
|
||||
<p className="text-sm leading-none font-medium">Auto update</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Download and install new version
|
||||
</p>
|
||||
</div>
|
||||
</Label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CheckboxCardDemo
|
||||
76
src/components/shadcn-studio/tabs/tabs-03.tsx
Normal file
76
src/components/shadcn-studio/tabs/tabs-03.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { BookIcon, GiftIcon, HeartIcon } from 'lucide-react'
|
||||
|
||||
import CheckboxCardDemo from '../checkbox/checkbox-13'
|
||||
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
name: 'Explore',
|
||||
value: 'explore',
|
||||
icon: BookIcon,
|
||||
content: (
|
||||
<>
|
||||
<CheckboxCardDemo />
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'Favorites',
|
||||
value: 'favorites',
|
||||
icon: HeartIcon,
|
||||
content: (
|
||||
<>
|
||||
All your{' '}
|
||||
<span className="text-foreground font-semibold">favorites</span> are
|
||||
saved here. Revisit articles, collections, and moments you love, any
|
||||
time you want a little inspiration.
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'Surprise',
|
||||
value: 'surprise',
|
||||
icon: GiftIcon,
|
||||
content: (
|
||||
<>
|
||||
<span className="text-foreground font-semibold">Surprise!</span>{' '}
|
||||
Here's something unexpected—a fun fact, a quirky tip, or a daily
|
||||
challenge. Come back for a new surprise every day!
|
||||
</>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const TabsWithIconDemo = () => {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Tabs defaultValue="explore" className="gap-4">
|
||||
<TabsList className="w-full">
|
||||
{tabs.map(({ icon: Icon, name, value }) => (
|
||||
<TabsTrigger
|
||||
key={value}
|
||||
value={value}
|
||||
className="flex items-center gap-1 px-2.5 sm:px-3"
|
||||
>
|
||||
<Icon />
|
||||
{name}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{tabs.map((tab) => (
|
||||
<TabsContent
|
||||
key={tab.value}
|
||||
value={tab.value}
|
||||
className="animate-in fade-in duration-300 ease-out"
|
||||
>
|
||||
<p className="text-muted-foreground text-sm">{tab.content}</p>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TabsWithIconDemo
|
||||
72
src/components/shadcn-studio/tabs/tabs-27.tsx
Normal file
72
src/components/shadcn-studio/tabs/tabs-27.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { Tabs, TabsContent, TabsContents, TabsList, TabsTrigger } from '@/components/ui/motion-tabs'
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
name: 'Explore',
|
||||
value: 'explore',
|
||||
content: (
|
||||
<>
|
||||
Discover <span className='text-foreground font-semibold'>fresh ideas</span>, trending topics, and hidden gems
|
||||
curated just for you. Start exploring and let your curiosity lead the way!
|
||||
</>
|
||||
)
|
||||
},
|
||||
{
|
||||
name: 'Favorites',
|
||||
value: 'favorites',
|
||||
content: (
|
||||
<>
|
||||
All your <span className='text-foreground font-semibold'>favorites</span> are saved here. Revisit articles,
|
||||
collections, and moments you love, any time you want a little inspiration.
|
||||
</>
|
||||
)
|
||||
},
|
||||
{
|
||||
name: 'Surprise Me',
|
||||
value: 'surprise',
|
||||
content: (
|
||||
<>
|
||||
<span className='text-foreground font-semibold'>Surprise!</span> Here's something unexpected—a fun fact, a
|
||||
quirky tip, or a daily challenge. Come back for a new surprise every day!
|
||||
</>
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
const AnimatedTabsDemo = () => {
|
||||
return (
|
||||
<div className='w-full max-w-md'>
|
||||
<Tabs defaultValue='explore' className='gap-4'>
|
||||
<TabsList>
|
||||
{tabs.map(tab => (
|
||||
<TabsTrigger key={tab.value} value={tab.value}>
|
||||
{tab.name}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
<TabsContents className='bg-background mx-1 -mt-2 mb-1 h-full rounded-sm'>
|
||||
{tabs.map(tab => (
|
||||
<TabsContent key={tab.value} value={tab.value}>
|
||||
<p className='text-muted-foreground text-sm'>{tab.content}</p>
|
||||
</TabsContent>
|
||||
))}
|
||||
</TabsContents>
|
||||
</Tabs>
|
||||
|
||||
<p className='text-muted-foreground mt-4 text-center text-xs'>
|
||||
Inspired by{' '}
|
||||
<a
|
||||
className='hover:text-foreground underline'
|
||||
href='https://animate-ui.com/docs/components/tabs'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
Animate UI
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AnimatedTabsDemo
|
||||
155
src/components/ui/alert-dialog.tsx
Normal file
155
src/components/ui/alert-dialog.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import * as React from "react"
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
|
||||
function AlertDialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
data-slot="alert-dialog-content"
|
||||
className={cn(
|
||||
"bg-background 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 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot="alert-dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogAction({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Action
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
className={cn(buttonVariants({ variant: "outline" }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
}
|
||||
30
src/components/ui/checkbox.tsx
Normal file
30
src/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Checkbox({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="grid place-content-center text-current transition-none"
|
||||
>
|
||||
<CheckIcon className="size-3.5" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Checkbox }
|
||||
31
src/components/ui/collapsible.tsx
Normal file
31
src/components/ui/collapsible.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
|
||||
|
||||
function Collapsible({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||
}
|
||||
|
||||
function CollapsibleTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleTrigger
|
||||
data-slot="collapsible-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CollapsibleContent({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleContent
|
||||
data-slot="collapsible-content"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||
255
src/components/ui/dropdown-menu.tsx
Normal file
255
src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
549
src/components/ui/motion-highlight.tsx
Normal file
549
src/components/ui/motion-highlight.tsx
Normal file
@@ -0,0 +1,549 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import type { Transition } from 'motion/react'
|
||||
import { AnimatePresence, motion } from 'motion/react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type MotionHighlightMode = 'children' | 'parent'
|
||||
|
||||
type Bounds = {
|
||||
top: number
|
||||
left: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
type MotionHighlightContextType<T extends string> = {
|
||||
mode: MotionHighlightMode
|
||||
activeValue: T | null
|
||||
setActiveValue: (value: T | null) => void
|
||||
setBounds: (bounds: DOMRect) => void
|
||||
clearBounds: () => void
|
||||
id: string
|
||||
hover: boolean
|
||||
className?: string
|
||||
activeClassName?: string
|
||||
setActiveClassName: (className: string) => void
|
||||
transition?: Transition
|
||||
disabled?: boolean
|
||||
enabled?: boolean
|
||||
exitDelay?: number
|
||||
forceUpdateBounds?: boolean
|
||||
}
|
||||
|
||||
const MotionHighlightContext = React.createContext<
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
MotionHighlightContextType<any> | undefined
|
||||
>(undefined)
|
||||
|
||||
function useMotionHighlight<T extends string>(): MotionHighlightContextType<T> {
|
||||
const context = React.useContext(MotionHighlightContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useMotionHighlight must be used within a MotionHighlightProvider')
|
||||
}
|
||||
|
||||
return context as unknown as MotionHighlightContextType<T>
|
||||
}
|
||||
|
||||
type BaseMotionHighlightProps<T extends string> = {
|
||||
mode?: MotionHighlightMode
|
||||
value?: T | null
|
||||
defaultValue?: T | null
|
||||
onValueChange?: (value: T | null) => void
|
||||
className?: string
|
||||
transition?: Transition
|
||||
hover?: boolean
|
||||
disabled?: boolean
|
||||
enabled?: boolean
|
||||
exitDelay?: number
|
||||
}
|
||||
|
||||
type ParentModeMotionHighlightProps = {
|
||||
boundsOffset?: Partial<Bounds>
|
||||
containerClassName?: string
|
||||
forceUpdateBounds?: boolean
|
||||
}
|
||||
|
||||
type ControlledParentModeMotionHighlightProps<T extends string> = BaseMotionHighlightProps<T> &
|
||||
ParentModeMotionHighlightProps & {
|
||||
mode: 'parent'
|
||||
controlledItems: true
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
type ControlledChildrenModeMotionHighlightProps<T extends string> = BaseMotionHighlightProps<T> & {
|
||||
mode?: 'children' | undefined
|
||||
controlledItems: true
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
type UncontrolledParentModeMotionHighlightProps<T extends string> = BaseMotionHighlightProps<T> &
|
||||
ParentModeMotionHighlightProps & {
|
||||
mode: 'parent'
|
||||
controlledItems?: false
|
||||
itemsClassName?: string
|
||||
children: React.ReactElement | React.ReactElement[]
|
||||
}
|
||||
|
||||
type UncontrolledChildrenModeMotionHighlightProps<T extends string> = BaseMotionHighlightProps<T> & {
|
||||
mode?: 'children'
|
||||
controlledItems?: false
|
||||
itemsClassName?: string
|
||||
children: React.ReactElement | React.ReactElement[]
|
||||
}
|
||||
|
||||
type MotionHighlightProps<T extends string> = React.ComponentProps<'div'> &
|
||||
(
|
||||
| ControlledParentModeMotionHighlightProps<T>
|
||||
| ControlledChildrenModeMotionHighlightProps<T>
|
||||
| UncontrolledParentModeMotionHighlightProps<T>
|
||||
| UncontrolledChildrenModeMotionHighlightProps<T>
|
||||
)
|
||||
|
||||
function MotionHighlight<T extends string>({ ref, ...props }: MotionHighlightProps<T>) {
|
||||
const {
|
||||
children,
|
||||
value,
|
||||
defaultValue,
|
||||
onValueChange,
|
||||
className,
|
||||
transition = { type: 'spring', stiffness: 350, damping: 35 },
|
||||
hover = false,
|
||||
enabled = true,
|
||||
controlledItems,
|
||||
disabled = false,
|
||||
exitDelay = 0.2,
|
||||
mode = 'children'
|
||||
} = props
|
||||
|
||||
const localRef = React.useRef<HTMLDivElement>(null)
|
||||
|
||||
React.useImperativeHandle(ref, () => localRef.current as HTMLDivElement)
|
||||
|
||||
const [activeValue, setActiveValue] = React.useState<T | null>(value ?? defaultValue ?? null)
|
||||
const [boundsState, setBoundsState] = React.useState<Bounds | null>(null)
|
||||
const [activeClassNameState, setActiveClassNameState] = React.useState<string>('')
|
||||
|
||||
const safeSetActiveValue = React.useCallback(
|
||||
(id: T | null) => {
|
||||
setActiveValue(prev => (prev === id ? prev : id))
|
||||
if (id !== activeValue) onValueChange?.(id as T)
|
||||
},
|
||||
[activeValue, onValueChange]
|
||||
)
|
||||
|
||||
const safeSetBounds = React.useCallback(
|
||||
(bounds: DOMRect) => {
|
||||
if (!localRef.current) return
|
||||
|
||||
const boundsOffset = (props as ParentModeMotionHighlightProps)?.boundsOffset ?? {
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: 0,
|
||||
height: 0
|
||||
}
|
||||
|
||||
const containerRect = localRef.current.getBoundingClientRect()
|
||||
|
||||
const newBounds: Bounds = {
|
||||
top: bounds.top - containerRect.top + (boundsOffset.top ?? 0),
|
||||
left: bounds.left - containerRect.left + (boundsOffset.left ?? 0),
|
||||
width: bounds.width + (boundsOffset.width ?? 0),
|
||||
height: bounds.height + (boundsOffset.height ?? 0)
|
||||
}
|
||||
|
||||
setBoundsState(prev => {
|
||||
if (
|
||||
prev &&
|
||||
prev.top === newBounds.top &&
|
||||
prev.left === newBounds.left &&
|
||||
prev.width === newBounds.width &&
|
||||
prev.height === newBounds.height
|
||||
) {
|
||||
return prev
|
||||
}
|
||||
|
||||
return newBounds
|
||||
})
|
||||
},
|
||||
[props]
|
||||
)
|
||||
|
||||
const clearBounds = React.useCallback(() => {
|
||||
setBoundsState(prev => (prev === null ? prev : null))
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (value !== undefined) setActiveValue(value)
|
||||
else if (defaultValue !== undefined) setActiveValue(defaultValue)
|
||||
}, [value, defaultValue])
|
||||
|
||||
const id = React.useId()
|
||||
|
||||
React.useEffect(() => {
|
||||
if (mode !== 'parent') return
|
||||
const container = localRef.current
|
||||
|
||||
if (!container) return
|
||||
|
||||
const onScroll = () => {
|
||||
if (!activeValue) return
|
||||
const activeEl = container.querySelector<HTMLElement>(`[data-value="${activeValue}"][data-highlight="true"]`)
|
||||
|
||||
if (activeEl) safeSetBounds(activeEl.getBoundingClientRect())
|
||||
}
|
||||
|
||||
container.addEventListener('scroll', onScroll, { passive: true })
|
||||
|
||||
return () => container.removeEventListener('scroll', onScroll)
|
||||
}, [mode, activeValue, safeSetBounds])
|
||||
|
||||
const render = React.useCallback(
|
||||
(children: React.ReactNode) => {
|
||||
if (mode === 'parent') {
|
||||
return (
|
||||
<div
|
||||
ref={localRef}
|
||||
data-slot='motion-highlight-container'
|
||||
className={cn('relative', (props as ParentModeMotionHighlightProps)?.containerClassName)}
|
||||
>
|
||||
<AnimatePresence initial={false}>
|
||||
{boundsState && (
|
||||
<motion.div
|
||||
data-slot='motion-highlight'
|
||||
animate={{
|
||||
top: boundsState.top,
|
||||
left: boundsState.left,
|
||||
width: boundsState.width,
|
||||
height: boundsState.height,
|
||||
opacity: 1
|
||||
}}
|
||||
initial={{
|
||||
top: boundsState.top,
|
||||
left: boundsState.left,
|
||||
width: boundsState.width,
|
||||
height: boundsState.height,
|
||||
opacity: 0
|
||||
}}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
transition: {
|
||||
...transition,
|
||||
delay: (transition?.delay ?? 0) + (exitDelay ?? 0)
|
||||
}
|
||||
}}
|
||||
transition={transition}
|
||||
className={cn('bg-muted absolute z-0', className, activeClassNameState)}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return children
|
||||
},
|
||||
[mode, props, boundsState, transition, exitDelay, className, activeClassNameState]
|
||||
)
|
||||
|
||||
return (
|
||||
<MotionHighlightContext.Provider
|
||||
value={{
|
||||
mode,
|
||||
activeValue,
|
||||
setActiveValue: safeSetActiveValue,
|
||||
id,
|
||||
hover,
|
||||
className,
|
||||
transition,
|
||||
disabled,
|
||||
enabled,
|
||||
exitDelay,
|
||||
setBounds: safeSetBounds,
|
||||
clearBounds,
|
||||
activeClassName: activeClassNameState,
|
||||
setActiveClassName: setActiveClassNameState,
|
||||
forceUpdateBounds: (props as ParentModeMotionHighlightProps)?.forceUpdateBounds
|
||||
}}
|
||||
>
|
||||
{enabled
|
||||
? controlledItems
|
||||
? render(children)
|
||||
: render(
|
||||
React.Children.map(children, (child, index) => (
|
||||
<MotionHighlightItem key={index} className={props?.itemsClassName}>
|
||||
{child}
|
||||
</MotionHighlightItem>
|
||||
))
|
||||
)
|
||||
: children}
|
||||
</MotionHighlightContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function getNonOverridingDataAttributes(
|
||||
element: React.ReactElement,
|
||||
dataAttributes: Record<string, unknown>
|
||||
): Record<string, unknown> {
|
||||
return Object.keys(dataAttributes).reduce<Record<string, unknown>>((acc, key) => {
|
||||
if ((element.props as Record<string, unknown>)[key] === undefined) {
|
||||
acc[key] = dataAttributes[key]
|
||||
}
|
||||
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
|
||||
type ExtendedChildProps = React.ComponentProps<'div'> & {
|
||||
id?: string
|
||||
ref?: React.Ref<HTMLElement>
|
||||
'data-active'?: string
|
||||
'data-value'?: string
|
||||
'data-disabled'?: boolean
|
||||
'data-highlight'?: boolean
|
||||
'data-slot'?: string
|
||||
}
|
||||
|
||||
type MotionHighlightItemProps = React.ComponentProps<'div'> & {
|
||||
children: React.ReactElement
|
||||
id?: string
|
||||
value?: string
|
||||
className?: string
|
||||
transition?: Transition
|
||||
activeClassName?: string
|
||||
disabled?: boolean
|
||||
exitDelay?: number
|
||||
asChild?: boolean
|
||||
forceUpdateBounds?: boolean
|
||||
}
|
||||
|
||||
function MotionHighlightItem({
|
||||
ref,
|
||||
children,
|
||||
id,
|
||||
value,
|
||||
className,
|
||||
transition,
|
||||
disabled = false,
|
||||
activeClassName,
|
||||
exitDelay,
|
||||
asChild = false,
|
||||
forceUpdateBounds,
|
||||
...props
|
||||
}: MotionHighlightItemProps) {
|
||||
const itemId = React.useId()
|
||||
|
||||
const {
|
||||
activeValue,
|
||||
setActiveValue,
|
||||
mode,
|
||||
setBounds,
|
||||
clearBounds,
|
||||
hover,
|
||||
enabled,
|
||||
className: contextClassName,
|
||||
transition: contextTransition,
|
||||
id: contextId,
|
||||
disabled: contextDisabled,
|
||||
exitDelay: contextExitDelay,
|
||||
forceUpdateBounds: contextForceUpdateBounds,
|
||||
setActiveClassName
|
||||
} = useMotionHighlight()
|
||||
|
||||
const element = children as React.ReactElement<ExtendedChildProps>
|
||||
const childValue = id ?? value ?? element.props?.['data-value'] ?? element.props?.id ?? itemId
|
||||
const isActive = activeValue === childValue
|
||||
const isDisabled = disabled === undefined ? contextDisabled : disabled
|
||||
const itemTransition = transition ?? contextTransition
|
||||
|
||||
const localRef = React.useRef<HTMLDivElement>(null)
|
||||
|
||||
React.useImperativeHandle(ref, () => localRef.current as HTMLDivElement)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (mode !== 'parent') return
|
||||
let rafId: number
|
||||
let previousBounds: Bounds | null = null
|
||||
const shouldUpdateBounds = forceUpdateBounds === true || (contextForceUpdateBounds && forceUpdateBounds !== false)
|
||||
|
||||
const updateBounds = () => {
|
||||
if (!localRef.current) return
|
||||
|
||||
const bounds = localRef.current.getBoundingClientRect()
|
||||
|
||||
if (shouldUpdateBounds) {
|
||||
if (
|
||||
previousBounds &&
|
||||
previousBounds.top === bounds.top &&
|
||||
previousBounds.left === bounds.left &&
|
||||
previousBounds.width === bounds.width &&
|
||||
previousBounds.height === bounds.height
|
||||
) {
|
||||
rafId = requestAnimationFrame(updateBounds)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
previousBounds = bounds
|
||||
rafId = requestAnimationFrame(updateBounds)
|
||||
}
|
||||
|
||||
setBounds(bounds)
|
||||
}
|
||||
|
||||
if (isActive) {
|
||||
updateBounds()
|
||||
setActiveClassName(activeClassName ?? '')
|
||||
} else if (!activeValue) clearBounds()
|
||||
|
||||
if (shouldUpdateBounds) return () => cancelAnimationFrame(rafId)
|
||||
}, [
|
||||
mode,
|
||||
isActive,
|
||||
activeValue,
|
||||
setBounds,
|
||||
clearBounds,
|
||||
activeClassName,
|
||||
setActiveClassName,
|
||||
forceUpdateBounds,
|
||||
contextForceUpdateBounds
|
||||
])
|
||||
|
||||
if (!React.isValidElement(children)) return children
|
||||
|
||||
const dataAttributes = {
|
||||
'data-active': isActive ? 'true' : 'false',
|
||||
'aria-selected': isActive,
|
||||
'data-disabled': isDisabled,
|
||||
'data-value': childValue,
|
||||
'data-highlight': true
|
||||
}
|
||||
|
||||
const commonHandlers = hover
|
||||
? {
|
||||
onMouseEnter: (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
setActiveValue(childValue)
|
||||
element.props.onMouseEnter?.(e)
|
||||
},
|
||||
onMouseLeave: (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
setActiveValue(null)
|
||||
element.props.onMouseLeave?.(e)
|
||||
}
|
||||
}
|
||||
: {
|
||||
onClick: (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
setActiveValue(childValue)
|
||||
element.props.onClick?.(e)
|
||||
}
|
||||
}
|
||||
|
||||
if (asChild) {
|
||||
if (mode === 'children') {
|
||||
return React.cloneElement(
|
||||
element,
|
||||
{
|
||||
key: childValue,
|
||||
ref: localRef,
|
||||
className: cn('relative', element.props.className),
|
||||
...getNonOverridingDataAttributes(element, {
|
||||
...dataAttributes,
|
||||
'data-slot': 'motion-highlight-item-container'
|
||||
}),
|
||||
...commonHandlers,
|
||||
...props
|
||||
},
|
||||
<>
|
||||
<AnimatePresence initial={false}>
|
||||
{isActive && !isDisabled && (
|
||||
<motion.div
|
||||
layoutId={`transition-background-${contextId}`}
|
||||
data-slot='motion-highlight'
|
||||
className={cn('bg-muted absolute inset-0 z-0', contextClassName, activeClassName)}
|
||||
transition={itemTransition}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
transition: {
|
||||
...itemTransition,
|
||||
delay: (itemTransition?.delay ?? 0) + (exitDelay ?? contextExitDelay ?? 0)
|
||||
}
|
||||
}}
|
||||
{...dataAttributes}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div data-slot='motion-highlight-item' className={cn('relative z-[1]', className)} {...dataAttributes}>
|
||||
{children}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return React.cloneElement(element, {
|
||||
ref: localRef,
|
||||
...getNonOverridingDataAttributes(element, {
|
||||
...dataAttributes,
|
||||
'data-slot': 'motion-highlight-item'
|
||||
}),
|
||||
...commonHandlers
|
||||
})
|
||||
}
|
||||
|
||||
return enabled ? (
|
||||
<div
|
||||
key={childValue}
|
||||
ref={localRef}
|
||||
data-slot='motion-highlight-item-container'
|
||||
className={cn(mode === 'children' && 'relative', className)}
|
||||
{...dataAttributes}
|
||||
{...props}
|
||||
{...commonHandlers}
|
||||
>
|
||||
{mode === 'children' && (
|
||||
<AnimatePresence initial={false}>
|
||||
{isActive && !isDisabled && (
|
||||
<motion.div
|
||||
layoutId={`transition-background-${contextId}`}
|
||||
data-slot='motion-highlight'
|
||||
className={cn('bg-muted absolute inset-0 z-0', contextClassName, activeClassName)}
|
||||
transition={itemTransition}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
transition: {
|
||||
...itemTransition,
|
||||
delay: (itemTransition?.delay ?? 0) + (exitDelay ?? contextExitDelay ?? 0)
|
||||
}
|
||||
}}
|
||||
{...dataAttributes}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)}
|
||||
|
||||
{React.cloneElement(element, {
|
||||
className: cn('relative z-[1]', element.props.className),
|
||||
...getNonOverridingDataAttributes(element, {
|
||||
...dataAttributes,
|
||||
'data-slot': 'motion-highlight-item'
|
||||
})
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
children
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
MotionHighlight,
|
||||
MotionHighlightItem,
|
||||
useMotionHighlight,
|
||||
type MotionHighlightProps,
|
||||
type MotionHighlightItemProps
|
||||
}
|
||||
261
src/components/ui/motion-tabs.tsx
Normal file
261
src/components/ui/motion-tabs.tsx
Normal file
@@ -0,0 +1,261 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
|
||||
import { motion, type Transition, type HTMLMotionProps } from 'motion/react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { MotionHighlight, MotionHighlightItem } from '@/components/ui/motion-highlight'
|
||||
|
||||
type TabsContextType<T extends string> = {
|
||||
activeValue: T
|
||||
handleValueChange: (value: T) => void
|
||||
registerTrigger: (value: T, node: HTMLElement | null) => void
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const TabsContext = React.createContext<TabsContextType<any> | undefined>(undefined)
|
||||
|
||||
function useTabs<T extends string = string>(): TabsContextType<T> {
|
||||
const context = React.useContext(TabsContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useTabs must be used within a TabsProvider')
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
type BaseTabsProps = React.ComponentProps<'div'> & {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
type UnControlledTabsProps<T extends string = string> = BaseTabsProps & {
|
||||
defaultValue?: T
|
||||
value?: never
|
||||
onValueChange?: never
|
||||
}
|
||||
|
||||
type ControlledTabsProps<T extends string = string> = BaseTabsProps & {
|
||||
value: T
|
||||
onValueChange?: (value: T) => void
|
||||
defaultValue?: never
|
||||
}
|
||||
|
||||
type TabsProps<T extends string = string> = UnControlledTabsProps<T> | ControlledTabsProps<T>
|
||||
|
||||
function Tabs<T extends string = string>({
|
||||
defaultValue,
|
||||
value,
|
||||
onValueChange,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: TabsProps<T>) {
|
||||
const [activeValue, setActiveValue] = React.useState<T | undefined>(defaultValue ?? undefined)
|
||||
const triggersRef = React.useRef(new Map<string, HTMLElement>())
|
||||
const initialSet = React.useRef(false)
|
||||
const isControlled = value !== undefined
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isControlled && activeValue === undefined && triggersRef.current.size > 0 && !initialSet.current) {
|
||||
const firstTab = Array.from(triggersRef.current.keys())[0]
|
||||
|
||||
setActiveValue(firstTab as T)
|
||||
initialSet.current = true
|
||||
}
|
||||
}, [activeValue, isControlled])
|
||||
|
||||
const registerTrigger = (value: string, node: HTMLElement | null) => {
|
||||
if (node) {
|
||||
triggersRef.current.set(value, node)
|
||||
|
||||
if (!isControlled && activeValue === undefined && !initialSet.current) {
|
||||
setActiveValue(value as T)
|
||||
initialSet.current = true
|
||||
}
|
||||
} else {
|
||||
triggersRef.current.delete(value)
|
||||
}
|
||||
}
|
||||
|
||||
const handleValueChange = (val: T) => {
|
||||
if (!isControlled) setActiveValue(val)
|
||||
else onValueChange?.(val)
|
||||
}
|
||||
|
||||
return (
|
||||
<TabsContext.Provider
|
||||
value={{
|
||||
activeValue: (value ?? activeValue)!,
|
||||
handleValueChange,
|
||||
registerTrigger
|
||||
}}
|
||||
>
|
||||
<div data-slot='tabs' className={cn('flex flex-col gap-2', className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
</TabsContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
type TabsListProps = React.ComponentProps<'div'> & {
|
||||
children: React.ReactNode
|
||||
activeClassName?: string
|
||||
transition?: Transition
|
||||
}
|
||||
|
||||
function TabsList({
|
||||
children,
|
||||
className,
|
||||
activeClassName,
|
||||
transition = {
|
||||
type: 'spring',
|
||||
stiffness: 200,
|
||||
damping: 25
|
||||
},
|
||||
...props
|
||||
}: TabsListProps) {
|
||||
const { activeValue } = useTabs()
|
||||
|
||||
return (
|
||||
<MotionHighlight
|
||||
controlledItems
|
||||
className={cn('bg-background rounded-sm shadow-sm', activeClassName)}
|
||||
value={activeValue}
|
||||
transition={transition}
|
||||
>
|
||||
<div
|
||||
role='tablist'
|
||||
data-slot='tabs-list'
|
||||
className={cn(
|
||||
'bg-muted text-muted-foreground inline-flex h-10 w-fit items-center justify-center rounded-lg p-[4px]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</MotionHighlight>
|
||||
)
|
||||
}
|
||||
|
||||
type TabsTriggerProps = HTMLMotionProps<'button'> & {
|
||||
value: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
function TabsTrigger({ ref, value, children, className, ...props }: TabsTriggerProps) {
|
||||
const { activeValue, handleValueChange, registerTrigger } = useTabs()
|
||||
|
||||
const localRef = React.useRef<HTMLButtonElement | null>(null)
|
||||
|
||||
React.useImperativeHandle(ref, () => localRef.current as HTMLButtonElement)
|
||||
|
||||
React.useEffect(() => {
|
||||
registerTrigger(value, localRef.current)
|
||||
|
||||
return () => registerTrigger(value, null)
|
||||
}, [value, registerTrigger])
|
||||
|
||||
return (
|
||||
<MotionHighlightItem value={value} className='size-full'>
|
||||
<motion.button
|
||||
ref={localRef}
|
||||
data-slot='tabs-trigger'
|
||||
role='tab'
|
||||
onClick={() => handleValueChange(value)}
|
||||
data-state={activeValue === value ? 'active' : 'inactive'}
|
||||
className={cn(
|
||||
'ring-offset-background focus-visible:ring-ring data-[state=active]:text-foreground z-[1] inline-flex size-full cursor-pointer items-center justify-center rounded-sm px-2 py-1 text-sm font-medium whitespace-nowrap transition-transform focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</motion.button>
|
||||
</MotionHighlightItem>
|
||||
)
|
||||
}
|
||||
|
||||
type TabsContentsProps = React.ComponentProps<'div'> & {
|
||||
children: React.ReactNode
|
||||
transition?: Transition
|
||||
}
|
||||
|
||||
function TabsContents({
|
||||
children,
|
||||
className,
|
||||
transition = {
|
||||
type: 'spring',
|
||||
stiffness: 300,
|
||||
damping: 30,
|
||||
bounce: 0,
|
||||
restDelta: 0.01
|
||||
},
|
||||
...props
|
||||
}: TabsContentsProps) {
|
||||
const { activeValue } = useTabs()
|
||||
const childrenArray = React.Children.toArray(children)
|
||||
|
||||
const activeIndex = childrenArray.findIndex(
|
||||
(child): child is React.ReactElement<{ value: string }> =>
|
||||
React.isValidElement(child) &&
|
||||
typeof child.props === 'object' &&
|
||||
child.props !== null &&
|
||||
'value' in child.props &&
|
||||
child.props.value === activeValue
|
||||
)
|
||||
|
||||
return (
|
||||
<div data-slot='tabs-contents' className={cn('overflow-hidden', className)} {...props}>
|
||||
<motion.div className='-mx-2 flex' animate={{ x: activeIndex * -100 + '%' }} transition={transition}>
|
||||
{childrenArray.map((child, index) => (
|
||||
<div key={index} className='w-full shrink-0 px-2'>
|
||||
{child}
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type TabsContentProps = HTMLMotionProps<'div'> & {
|
||||
value: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
function TabsContent({ children, value, className, ...props }: TabsContentProps) {
|
||||
const { activeValue } = useTabs()
|
||||
const isActive = activeValue === value
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
role='tabpanel'
|
||||
data-slot='tabs-content'
|
||||
className={cn('overflow-hidden', className)}
|
||||
initial={{ filter: 'blur(0px)' }}
|
||||
animate={{ filter: isActive ? 'blur(0px)' : 'blur(2px)' }}
|
||||
exit={{ filter: 'blur(0px)' }}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Tabs,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
TabsContents,
|
||||
TabsContent,
|
||||
useTabs,
|
||||
type TabsContextType,
|
||||
type TabsProps,
|
||||
type TabsListProps,
|
||||
type TabsTriggerProps,
|
||||
type TabsContentsProps,
|
||||
type TabsContentProps
|
||||
}
|
||||
45
src/data/api/_helpers.ts
Normal file
45
src/data/api/_helpers.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { PostgrestError, AuthError, SupabaseClient } from "@supabase/supabase-js";
|
||||
import type { Database } from "../types/database";
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly code?: string,
|
||||
public readonly details?: unknown,
|
||||
public readonly hint?: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
}
|
||||
}
|
||||
|
||||
export function throwIfError(error: PostgrestError | AuthError | null): void {
|
||||
if (!error) return;
|
||||
|
||||
const anyErr = error as any;
|
||||
throw new ApiError(
|
||||
anyErr.message ?? "Error inesperado",
|
||||
anyErr.code,
|
||||
anyErr.details,
|
||||
anyErr.hint
|
||||
);
|
||||
}
|
||||
|
||||
export function requireData<T>(data: T | null | undefined, message = "Respuesta vacía"): T {
|
||||
if (data === null || data === undefined) throw new ApiError(message);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getUserIdOrThrow(supabase: SupabaseClient<Database>): Promise<string> {
|
||||
const { data, error } = await supabase.auth.getUser();
|
||||
throwIfError(error);
|
||||
if (!data?.user?.id) throw new ApiError("No hay sesión activa (auth).");
|
||||
return data.user.id;
|
||||
}
|
||||
|
||||
export function buildRange(limit?: number, offset?: number): { from?: number; to?: number } {
|
||||
if (!limit) return {};
|
||||
const from = Math.max(0, offset ?? 0);
|
||||
const to = from + Math.max(1, limit) - 1;
|
||||
return { from, to };
|
||||
}
|
||||
81
src/data/api/ai.api.ts
Normal file
81
src/data/api/ai.api.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { invokeEdge } from "../supabase/invokeEdge";
|
||||
import type { InteraccionIA, UUID } from "../types/domain";
|
||||
|
||||
const EDGE = {
|
||||
ai_plan_improve: "ai_plan_improve",
|
||||
ai_plan_chat: "ai_plan_chat",
|
||||
ai_subject_improve: "ai_subject_improve",
|
||||
ai_subject_chat: "ai_subject_chat",
|
||||
|
||||
library_search: "library_search",
|
||||
} as const;
|
||||
|
||||
export async function ai_plan_improve(payload: {
|
||||
planId: UUID;
|
||||
sectionKey: string; // ej: "perfil_de_egreso" o tu key interna
|
||||
prompt: string;
|
||||
context?: Record<string, any>;
|
||||
fuentes?: {
|
||||
archivosIds?: UUID[];
|
||||
vectorStoresIds?: UUID[];
|
||||
usarMCP?: boolean;
|
||||
conversacionId?: string;
|
||||
};
|
||||
}): Promise<{ interaccion: InteraccionIA; propuesta: any }> {
|
||||
return invokeEdge<{ interaccion: InteraccionIA; propuesta: any }>(EDGE.ai_plan_improve, payload);
|
||||
}
|
||||
|
||||
export async function ai_plan_chat(payload: {
|
||||
planId: UUID;
|
||||
messages: Array<{ role: "system" | "user" | "assistant"; content: string }>;
|
||||
fuentes?: {
|
||||
archivosIds?: UUID[];
|
||||
vectorStoresIds?: UUID[];
|
||||
usarMCP?: boolean;
|
||||
conversacionId?: string;
|
||||
};
|
||||
}): Promise<{ interaccion: InteraccionIA; reply: string; meta?: any }> {
|
||||
return invokeEdge<{ interaccion: InteraccionIA; reply: string; meta?: any }>(EDGE.ai_plan_chat, payload);
|
||||
}
|
||||
|
||||
export async function ai_subject_improve(payload: {
|
||||
subjectId: UUID;
|
||||
sectionKey: string;
|
||||
prompt: string;
|
||||
context?: Record<string, any>;
|
||||
fuentes?: {
|
||||
archivosIds?: UUID[];
|
||||
vectorStoresIds?: UUID[];
|
||||
usarMCP?: boolean;
|
||||
conversacionId?: string;
|
||||
};
|
||||
}): Promise<{ interaccion: InteraccionIA; propuesta: any }> {
|
||||
return invokeEdge<{ interaccion: InteraccionIA; propuesta: any }>(EDGE.ai_subject_improve, payload);
|
||||
}
|
||||
|
||||
export async function ai_subject_chat(payload: {
|
||||
subjectId: UUID;
|
||||
messages: Array<{ role: "system" | "user" | "assistant"; content: string }>;
|
||||
fuentes?: {
|
||||
archivosIds?: UUID[];
|
||||
vectorStoresIds?: UUID[];
|
||||
usarMCP?: boolean;
|
||||
conversacionId?: string;
|
||||
};
|
||||
}): Promise<{ interaccion: InteraccionIA; reply: string; meta?: any }> {
|
||||
return invokeEdge<{ interaccion: InteraccionIA; reply: string; meta?: any }>(EDGE.ai_subject_chat, payload);
|
||||
}
|
||||
|
||||
/** Biblioteca (Edge; adapta a tu API real) */
|
||||
export type LibraryItem = {
|
||||
id: string;
|
||||
titulo: string;
|
||||
autor?: string;
|
||||
isbn?: string;
|
||||
citaSugerida?: string;
|
||||
disponibilidad?: string;
|
||||
};
|
||||
|
||||
export async function library_search(payload: { query: string; limit?: number }): Promise<LibraryItem[]> {
|
||||
return invokeEdge<LibraryItem[]>(EDGE.library_search, payload);
|
||||
}
|
||||
37
src/data/api/files.api.ts
Normal file
37
src/data/api/files.api.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { supabaseBrowser } from "../supabase/client";
|
||||
import { invokeEdge } from "../supabase/invokeEdge";
|
||||
import { throwIfError } from "./_helpers";
|
||||
import type { AppFile } from "./openaiFiles.api";
|
||||
|
||||
const EDGE = {
|
||||
signedUrl: "files_signed_url", // Edge: recibe archivoId o ruta_storage y devuelve URL
|
||||
} as const;
|
||||
|
||||
export async function files_list(params?: {
|
||||
temporal?: boolean;
|
||||
search?: string;
|
||||
limit?: number;
|
||||
}): Promise<AppFile[]> {
|
||||
const supabase = supabaseBrowser();
|
||||
|
||||
let q = supabase
|
||||
.from("archivos")
|
||||
.select("id,openai_file_id,nombre,mime_type,bytes,ruta_storage,temporal,notas,subido_en")
|
||||
.order("subido_en", { ascending: false });
|
||||
|
||||
if (typeof params?.temporal === "boolean") q = q.eq("temporal", params.temporal);
|
||||
if (params?.search?.trim()) q = q.ilike("nombre", `%${params.search.trim()}%`);
|
||||
if (params?.limit) q = q.limit(params.limit);
|
||||
|
||||
const { data, error } = await q;
|
||||
throwIfError(error);
|
||||
return (data ?? []) as AppFile[];
|
||||
}
|
||||
|
||||
/** Para preview/descarga desde espejo — SIN tocar storage directo en el cliente */
|
||||
export async function files_get_signed_url(payload: {
|
||||
archivoId: string; // id interno (tabla archivos)
|
||||
expiresIn?: number; // segundos
|
||||
}): Promise<{ signedUrl: string }> {
|
||||
return invokeEdge<{ signedUrl: string }>(EDGE.signedUrl, payload);
|
||||
}
|
||||
66
src/data/api/meta.api.ts
Normal file
66
src/data/api/meta.api.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { supabaseBrowser } from "../supabase/client";
|
||||
import { throwIfError } from "./_helpers";
|
||||
import type { Carrera, EstadoPlan, EstructuraAsignatura, EstructuraPlan, Facultad } from "../types/domain";
|
||||
|
||||
export async function facultades_list(): Promise<Facultad[]> {
|
||||
const supabase = supabaseBrowser();
|
||||
const { data, error } = await supabase
|
||||
.from("facultades")
|
||||
.select("id,nombre,nombre_corto,color,icono,creado_en,actualizado_en")
|
||||
.order("nombre", { ascending: true });
|
||||
|
||||
throwIfError(error);
|
||||
return data ?? [];
|
||||
}
|
||||
|
||||
export async function carreras_list(params?: { facultadId?: string | null }): Promise<Carrera[]> {
|
||||
const supabase = supabaseBrowser();
|
||||
|
||||
let q = supabase
|
||||
.from("carreras")
|
||||
.select(
|
||||
"id,facultad_id,nombre,nombre_corto,clave_sep,activa,creado_en,actualizado_en, facultades(id,nombre,nombre_corto,color,icono)"
|
||||
)
|
||||
.order("nombre", { ascending: true });
|
||||
|
||||
if (params?.facultadId) q = q.eq("facultad_id", params.facultadId);
|
||||
|
||||
const { data, error } = await q;
|
||||
throwIfError(error);
|
||||
return data ?? [];
|
||||
}
|
||||
|
||||
export async function estructuras_plan_list(params?: { nivel?: string | null }): Promise<EstructuraPlan[]> {
|
||||
const supabase = supabaseBrowser();
|
||||
|
||||
// Nota: en tu DDL no hay "nivel" en estructuras_plan; si luego lo agregas, filtra aquí.
|
||||
const { data, error } = await supabase
|
||||
.from("estructuras_plan")
|
||||
.select("id,nombre,tipo,version,definicion")
|
||||
.order("nombre", { ascending: true });
|
||||
|
||||
throwIfError(error);
|
||||
return data ?? [];
|
||||
}
|
||||
|
||||
export async function estructuras_asignatura_list(): Promise<EstructuraAsignatura[]> {
|
||||
const supabase = supabaseBrowser();
|
||||
const { data, error } = await supabase
|
||||
.from("estructuras_asignatura")
|
||||
.select("id,nombre,version,definicion")
|
||||
.order("nombre", { ascending: true });
|
||||
|
||||
throwIfError(error);
|
||||
return data ?? [];
|
||||
}
|
||||
|
||||
export async function estados_plan_list(): Promise<EstadoPlan[]> {
|
||||
const supabase = supabaseBrowser();
|
||||
const { data, error } = await supabase
|
||||
.from("estados_plan")
|
||||
.select("id,clave,etiqueta,orden,es_final")
|
||||
.order("orden", { ascending: true });
|
||||
|
||||
throwIfError(error);
|
||||
return data ?? [];
|
||||
}
|
||||
31
src/data/api/notifications.api.ts
Normal file
31
src/data/api/notifications.api.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { supabaseBrowser } from "../supabase/client";
|
||||
import { throwIfError, getUserIdOrThrow, requireData } from "./_helpers";
|
||||
import type { Notificacion, UUID } from "../types/domain";
|
||||
|
||||
export async function notificaciones_mias_list(): Promise<Notificacion[]> {
|
||||
const supabase = supabaseBrowser();
|
||||
const userId = await getUserIdOrThrow(supabase);
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("notificaciones")
|
||||
.select("id,usuario_id,tipo,payload,leida,creado_en,leida_en")
|
||||
.eq("usuario_id", userId as UUID)
|
||||
.order("creado_en", { ascending: false });
|
||||
|
||||
throwIfError(error);
|
||||
return data ?? [];
|
||||
}
|
||||
|
||||
export async function notificaciones_marcar_leida(notificacionId: UUID): Promise<Notificacion> {
|
||||
const supabase = supabaseBrowser();
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("notificaciones")
|
||||
.update({ leida: true, leida_en: new Date().toISOString() })
|
||||
.eq("id", notificacionId)
|
||||
.select("id,usuario_id,tipo,payload,leida,creado_en,leida_en")
|
||||
.single();
|
||||
|
||||
throwIfError(error);
|
||||
return requireData(data, "No se pudo marcar notificación.");
|
||||
}
|
||||
64
src/data/api/openaiFiles.api.ts
Normal file
64
src/data/api/openaiFiles.api.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { invokeEdge } from "../supabase/invokeEdge";
|
||||
import type { UUID } from "../types/domain";
|
||||
|
||||
/**
|
||||
* Metadata “canónica” para UI (archivo OpenAI + espejo en Supabase)
|
||||
* Se apoya en tu tabla `archivos`.
|
||||
*/
|
||||
export type AppFile = {
|
||||
id: UUID; // id interno (tabla archivos)
|
||||
openai_file_id: string; // id OpenAI
|
||||
nombre: string;
|
||||
mime_type: string | null;
|
||||
bytes: number | null;
|
||||
|
||||
// espejo Supabase para preview/descarga
|
||||
ruta_storage: string | null; // "bucket/path"
|
||||
signed_url?: string | null;
|
||||
|
||||
// auditoría/evidencia
|
||||
temporal: boolean;
|
||||
notas?: string | null;
|
||||
|
||||
subido_en: string;
|
||||
};
|
||||
|
||||
const EDGE = {
|
||||
upload: "openai_files_upload",
|
||||
remove: "openai_files_delete",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Sube archivo a OpenAI y (opcional) crea espejo en Storage
|
||||
* - El frontend NO toca Storage.
|
||||
*/
|
||||
export async function openai_files_upload(payload: {
|
||||
/**
|
||||
* Si tu Edge soporta multipart: manda File/Blob directo.
|
||||
* Si no, manda base64/bytes (según tu implementación).
|
||||
*/
|
||||
file: File;
|
||||
|
||||
/** “temporal” = evidencia usada para generar plan/materia */
|
||||
temporal?: boolean;
|
||||
|
||||
/** contexto para auditoría */
|
||||
contexto?: {
|
||||
planId?: UUID;
|
||||
asignaturaId?: UUID;
|
||||
motivo?: "WIZARD_PLAN" | "WIZARD_MATERIA" | "ADHOC";
|
||||
};
|
||||
|
||||
/** si quieres forzar espejo para preview siempre */
|
||||
mirrorToSupabase?: boolean;
|
||||
}): Promise<AppFile> {
|
||||
return invokeEdge<AppFile>(EDGE.upload, payload);
|
||||
}
|
||||
|
||||
export async function openai_files_delete(payload: {
|
||||
openaiFileId: string;
|
||||
/** si quieres borrar también espejo y registro */
|
||||
hardDelete?: boolean;
|
||||
}): Promise<{ ok: true }> {
|
||||
return invokeEdge<{ ok: true }>(EDGE.remove, payload);
|
||||
}
|
||||
260
src/data/api/plans.api.ts
Normal file
260
src/data/api/plans.api.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
import { supabaseBrowser } from "../supabase/client";
|
||||
import { invokeEdge } from "../supabase/invokeEdge";
|
||||
import { buildRange, throwIfError, requireData } from "./_helpers";
|
||||
import type {
|
||||
Asignatura,
|
||||
CambioPlan,
|
||||
LineaPlan,
|
||||
NivelPlanEstudio,
|
||||
Paged,
|
||||
PlanDatosSep,
|
||||
PlanEstudio,
|
||||
TipoCiclo,
|
||||
UUID,
|
||||
} from "../types/domain";
|
||||
|
||||
const EDGE = {
|
||||
plans_create_manual: "plans_create_manual",
|
||||
ai_generate_plan: "ai_generate_plan",
|
||||
plans_persist_from_ai: "plans_persist_from_ai",
|
||||
plans_clone_from_existing: "plans_clone_from_existing",
|
||||
plans_import_from_files: "plans_import_from_files",
|
||||
|
||||
plans_update_fields: "plans_update_fields",
|
||||
plans_update_map: "plans_update_map",
|
||||
plans_transition_state: "plans_transition_state",
|
||||
|
||||
plans_generate_document: "plans_generate_document",
|
||||
plans_get_document: "plans_get_document",
|
||||
} as const;
|
||||
|
||||
export type PlanListFilters = {
|
||||
search?: string;
|
||||
carreraId?: UUID;
|
||||
facultadId?: UUID; // filtra por carreras.facultad_id
|
||||
estadoId?: UUID;
|
||||
activo?: boolean;
|
||||
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
export async function plans_list(filters: PlanListFilters = {}): Promise<Paged<PlanEstudio>> {
|
||||
const supabase = supabaseBrowser();
|
||||
|
||||
let q = supabase
|
||||
.from("planes_estudio")
|
||||
.select(
|
||||
`
|
||||
id,carrera_id,estructura_id,nombre,nivel,tipo_ciclo,numero_ciclos,datos,estado_actual_id,activo,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,
|
||||
carreras(id,facultad_id,nombre,nombre_corto,clave_sep,activa, facultades(id,nombre,nombre_corto,color,icono)),
|
||||
estructuras_plan(id,nombre,tipo,version,definicion),
|
||||
estados_plan(id,clave,etiqueta,orden,es_final)
|
||||
`,
|
||||
{ count: "exact" }
|
||||
)
|
||||
.order("actualizado_en", { ascending: false });
|
||||
|
||||
if (filters.search?.trim()) q = q.ilike("nombre", `%${filters.search.trim()}%`);
|
||||
if (filters.carreraId) q = q.eq("carrera_id", filters.carreraId);
|
||||
if (filters.estadoId) q = q.eq("estado_actual_id", filters.estadoId);
|
||||
if (typeof filters.activo === "boolean") q = q.eq("activo", filters.activo);
|
||||
|
||||
// filtro por FK “hacia arriba” (PostgREST soporta filtros en recursos embebidos)
|
||||
if (filters.facultadId) q = q.eq("carreras.facultad_id", filters.facultadId);
|
||||
|
||||
const { from, to } = buildRange(filters.limit, filters.offset);
|
||||
if (typeof from === "number" && typeof to === "number") q = q.range(from, to);
|
||||
|
||||
const { data, error, count } = await q;
|
||||
throwIfError(error);
|
||||
|
||||
return { data: data ?? [], count: count ?? null };
|
||||
}
|
||||
|
||||
export async function plans_get(planId: UUID): Promise<PlanEstudio> {
|
||||
const supabase = supabaseBrowser();
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("planes_estudio")
|
||||
.select(
|
||||
`
|
||||
id,carrera_id,estructura_id,nombre,nivel,tipo_ciclo,numero_ciclos,datos,estado_actual_id,activo,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,
|
||||
carreras(id,facultad_id,nombre,nombre_corto,clave_sep,activa, facultades(id,nombre,nombre_corto,color,icono)),
|
||||
estructuras_plan(id,nombre,tipo,version,definicion),
|
||||
estados_plan(id,clave,etiqueta,orden,es_final)
|
||||
`
|
||||
)
|
||||
.eq("id", planId)
|
||||
.single();
|
||||
|
||||
throwIfError(error);
|
||||
return requireData(data, "Plan no encontrado.");
|
||||
}
|
||||
|
||||
export async function plan_lineas_list(planId: UUID): Promise<LineaPlan[]> {
|
||||
const supabase = supabaseBrowser();
|
||||
const { data, error } = await supabase
|
||||
.from("lineas_plan")
|
||||
.select("id,plan_estudio_id,nombre,orden,area,creado_en,actualizado_en")
|
||||
.eq("plan_estudio_id", planId)
|
||||
.order("orden", { ascending: true });
|
||||
|
||||
throwIfError(error);
|
||||
return data ?? [];
|
||||
}
|
||||
|
||||
export async function plan_asignaturas_list(planId: UUID): Promise<Asignatura[]> {
|
||||
const supabase = supabaseBrowser();
|
||||
const { data, error } = await supabase
|
||||
.from("asignaturas")
|
||||
.select(
|
||||
"id,plan_estudio_id,estructura_id,facultad_propietaria_id,codigo,nombre,tipo,creditos,horas_semana,numero_ciclo,linea_plan_id,orden_celda,datos,contenido_tematico,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en"
|
||||
)
|
||||
.eq("plan_estudio_id", planId)
|
||||
.order("numero_ciclo", { ascending: true, nullsFirst: false })
|
||||
.order("orden_celda", { ascending: true, nullsFirst: false })
|
||||
.order("nombre", { ascending: true });
|
||||
|
||||
throwIfError(error);
|
||||
return data ?? [];
|
||||
}
|
||||
|
||||
export async function plans_history(planId: UUID): Promise<CambioPlan[]> {
|
||||
const supabase = supabaseBrowser();
|
||||
const { data, error } = await supabase
|
||||
.from("cambios_plan")
|
||||
.select("id,plan_estudio_id,cambiado_por,cambiado_en,tipo,campo,valor_anterior,valor_nuevo,interaccion_ia_id")
|
||||
.eq("plan_estudio_id", planId)
|
||||
.order("cambiado_en", { ascending: false });
|
||||
|
||||
throwIfError(error);
|
||||
return data ?? [];
|
||||
}
|
||||
|
||||
/** Wizard: crear plan manual (Edge Function) */
|
||||
export type PlansCreateManualInput = {
|
||||
carreraId: UUID;
|
||||
estructuraId: UUID;
|
||||
nombre: string;
|
||||
nivel: NivelPlanEstudio;
|
||||
tipoCiclo: TipoCiclo;
|
||||
numCiclos: number;
|
||||
datos?: Partial<PlanDatosSep> & Record<string, any>;
|
||||
};
|
||||
|
||||
export async function plans_create_manual(input: PlansCreateManualInput): Promise<PlanEstudio> {
|
||||
return invokeEdge<PlanEstudio>(EDGE.plans_create_manual, input);
|
||||
}
|
||||
|
||||
/** Wizard: IA genera preview JSON (Edge Function) */
|
||||
export type AIGeneratePlanInput = {
|
||||
datosBasicos: {
|
||||
nombrePlan: string;
|
||||
carreraId: UUID;
|
||||
facultadId?: UUID;
|
||||
nivel: string;
|
||||
tipoCiclo: TipoCiclo;
|
||||
numCiclos: number;
|
||||
};
|
||||
iaConfig: {
|
||||
descripcionEnfoque: string;
|
||||
poblacionObjetivo?: string;
|
||||
notasAdicionales?: string;
|
||||
archivosReferencia?: UUID[];
|
||||
repositoriosIds?: UUID[];
|
||||
usarMCP?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export async function ai_generate_plan(input: AIGeneratePlanInput): Promise<any> {
|
||||
return invokeEdge<any>(EDGE.ai_generate_plan, input);
|
||||
}
|
||||
|
||||
export async function plans_persist_from_ai(payload: { jsonPlan: any }): Promise<PlanEstudio> {
|
||||
return invokeEdge<PlanEstudio>(EDGE.plans_persist_from_ai, payload);
|
||||
}
|
||||
|
||||
export async function plans_clone_from_existing(payload: {
|
||||
planOrigenId: UUID;
|
||||
overrides: Partial<Pick<PlanEstudio, "nombre" | "nivel" | "tipo_ciclo" | "numero_ciclos">> & {
|
||||
carrera_id?: UUID;
|
||||
estructura_id?: UUID;
|
||||
datos?: Partial<PlanDatosSep> & Record<string, any>;
|
||||
};
|
||||
}): Promise<PlanEstudio> {
|
||||
return invokeEdge<PlanEstudio>(EDGE.plans_clone_from_existing, payload);
|
||||
}
|
||||
|
||||
export async function plans_import_from_files(payload: {
|
||||
datosBasicos: {
|
||||
nombrePlan: string;
|
||||
carreraId: UUID;
|
||||
estructuraId: UUID;
|
||||
nivel: string;
|
||||
tipoCiclo: TipoCiclo;
|
||||
numCiclos: number;
|
||||
};
|
||||
archivoWordPlanId: UUID;
|
||||
archivoMapaExcelId?: UUID | null;
|
||||
archivoMateriasExcelId?: UUID | null;
|
||||
}): Promise<PlanEstudio> {
|
||||
return invokeEdge<PlanEstudio>(EDGE.plans_import_from_files, payload);
|
||||
}
|
||||
|
||||
/** Update de tarjetas/fields del plan (Edge Function: merge server-side) */
|
||||
export type PlansUpdateFieldsPatch = {
|
||||
nombre?: string;
|
||||
nivel?: NivelPlanEstudio;
|
||||
tipo_ciclo?: TipoCiclo;
|
||||
numero_ciclos?: number;
|
||||
datos?: Partial<PlanDatosSep> & Record<string, any>;
|
||||
};
|
||||
|
||||
export async function plans_update_fields(planId: UUID, patch: PlansUpdateFieldsPatch): Promise<PlanEstudio> {
|
||||
return invokeEdge<PlanEstudio>(EDGE.plans_update_fields, { planId, patch });
|
||||
}
|
||||
|
||||
/** Operaciones del mapa curricular (mover/reordenar) */
|
||||
export type PlanMapOperation =
|
||||
| {
|
||||
op: "MOVE_ASIGNATURA";
|
||||
asignaturaId: UUID;
|
||||
numero_ciclo: number | null;
|
||||
linea_plan_id: UUID | null;
|
||||
orden_celda?: number | null;
|
||||
}
|
||||
| {
|
||||
op: "REORDER_CELDA";
|
||||
linea_plan_id: UUID;
|
||||
numero_ciclo: number;
|
||||
asignaturaIdsOrdenados: UUID[];
|
||||
};
|
||||
|
||||
export async function plans_update_map(planId: UUID, ops: PlanMapOperation[]): Promise<{ ok: true }> {
|
||||
return invokeEdge<{ ok: true }>(EDGE.plans_update_map, { planId, ops });
|
||||
}
|
||||
|
||||
export async function plans_transition_state(payload: {
|
||||
planId: UUID;
|
||||
haciaEstadoId: UUID;
|
||||
comentario?: string;
|
||||
}): Promise<{ ok: true }> {
|
||||
return invokeEdge<{ ok: true }>(EDGE.plans_transition_state, payload);
|
||||
}
|
||||
|
||||
/** Documento (Edge Function: genera y devuelve URL firmada o metadata) */
|
||||
export type DocumentoResult = {
|
||||
archivoId: UUID;
|
||||
signedUrl: string;
|
||||
mimeType?: string;
|
||||
nombre?: string;
|
||||
};
|
||||
|
||||
export async function plans_generate_document(planId: UUID): Promise<DocumentoResult> {
|
||||
return invokeEdge<DocumentoResult>(EDGE.plans_generate_document, { planId });
|
||||
}
|
||||
|
||||
export async function plans_get_document(planId: UUID): Promise<DocumentoResult | null> {
|
||||
return invokeEdge<DocumentoResult | null>(EDGE.plans_get_document, { planId });
|
||||
}
|
||||
44
src/data/api/repositories.api.ts
Normal file
44
src/data/api/repositories.api.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { invokeEdge } from "../supabase/invokeEdge";
|
||||
|
||||
export type Repository = {
|
||||
id: string; // id del vector store (OpenAI) o tu id interno
|
||||
nombre: string;
|
||||
creado_en?: string;
|
||||
meta?: Record<string, any>;
|
||||
};
|
||||
|
||||
const EDGE = {
|
||||
create: "repos_create",
|
||||
remove: "repos_delete",
|
||||
add: "repos_add_files",
|
||||
detach: "repos_remove_files",
|
||||
} as const;
|
||||
|
||||
export async function repos_create(payload: {
|
||||
nombre: string;
|
||||
descripcion?: string;
|
||||
/** si tu implementación crea también registro DB */
|
||||
persist?: boolean;
|
||||
}): Promise<Repository> {
|
||||
return invokeEdge<Repository>(EDGE.create, payload);
|
||||
}
|
||||
|
||||
export async function repos_delete(payload: { repoId: string }): Promise<{ ok: true }> {
|
||||
return invokeEdge<{ ok: true }>(EDGE.remove, payload);
|
||||
}
|
||||
|
||||
/** Agrega archivos (OpenAI file ids) a un repositorio */
|
||||
export async function repos_add_files(payload: {
|
||||
repoId: string;
|
||||
openaiFileIds: string[];
|
||||
}): Promise<{ ok: true }> {
|
||||
return invokeEdge<{ ok: true }>(EDGE.add, payload);
|
||||
}
|
||||
|
||||
/** Quita archivos (OpenAI file ids) del repositorio */
|
||||
export async function repos_remove_files(payload: {
|
||||
repoId: string;
|
||||
openaiFileIds: string[];
|
||||
}): Promise<{ ok: true }> {
|
||||
return invokeEdge<{ ok: true }>(EDGE.detach, payload);
|
||||
}
|
||||
192
src/data/api/subjects.api.ts
Normal file
192
src/data/api/subjects.api.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { supabaseBrowser } from "../supabase/client";
|
||||
import { invokeEdge } from "../supabase/invokeEdge";
|
||||
import { throwIfError, requireData } from "./_helpers";
|
||||
import type {
|
||||
Asignatura,
|
||||
BibliografiaAsignatura,
|
||||
CambioAsignatura,
|
||||
TipoAsignatura,
|
||||
UUID,
|
||||
} from "../types/domain";
|
||||
import type { DocumentoResult } from "./plans.api";
|
||||
|
||||
const EDGE = {
|
||||
subjects_create_manual: "subjects_create_manual",
|
||||
ai_generate_subject: "ai_generate_subject",
|
||||
subjects_persist_from_ai: "subjects_persist_from_ai",
|
||||
subjects_clone_from_existing: "subjects_clone_from_existing",
|
||||
subjects_import_from_file: "subjects_import_from_file",
|
||||
|
||||
subjects_update_fields: "subjects_update_fields",
|
||||
subjects_update_contenido: "subjects_update_contenido",
|
||||
subjects_update_bibliografia: "subjects_update_bibliografia",
|
||||
|
||||
subjects_generate_document: "subjects_generate_document",
|
||||
subjects_get_document: "subjects_get_document",
|
||||
} as const;
|
||||
|
||||
export async function subjects_get(subjectId: UUID): Promise<Asignatura> {
|
||||
const supabase = supabaseBrowser();
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("asignaturas")
|
||||
.select(
|
||||
`
|
||||
id,plan_estudio_id,estructura_id,facultad_propietaria_id,codigo,nombre,tipo,creditos,horas_semana,numero_ciclo,linea_plan_id,orden_celda,datos,contenido_tematico,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,
|
||||
planes_estudio(
|
||||
id,carrera_id,estructura_id,nombre,nivel,tipo_ciclo,numero_ciclos,datos,estado_actual_id,activo,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,
|
||||
carreras(id,facultad_id,nombre,nombre_corto,clave_sep,activa, facultades(id,nombre,nombre_corto,color,icono))
|
||||
),
|
||||
estructuras_asignatura(id,nombre,version,definicion)
|
||||
`
|
||||
)
|
||||
.eq("id", subjectId)
|
||||
.single();
|
||||
|
||||
throwIfError(error);
|
||||
return requireData(data, "Materia no encontrada.");
|
||||
}
|
||||
|
||||
export async function subjects_history(subjectId: UUID): Promise<CambioAsignatura[]> {
|
||||
const supabase = supabaseBrowser();
|
||||
const { data, error } = await supabase
|
||||
.from("cambios_asignatura")
|
||||
.select(
|
||||
"id,asignatura_id,cambiado_por,cambiado_en,tipo,campo,valor_anterior,valor_nuevo,fuente,interaccion_ia_id"
|
||||
)
|
||||
.eq("asignatura_id", subjectId)
|
||||
.order("cambiado_en", { ascending: false });
|
||||
|
||||
throwIfError(error);
|
||||
return data ?? [];
|
||||
}
|
||||
|
||||
export async function subjects_bibliografia_list(subjectId: UUID): Promise<BibliografiaAsignatura[]> {
|
||||
const supabase = supabaseBrowser();
|
||||
const { data, error } = await supabase
|
||||
.from("bibliografia_asignatura")
|
||||
.select("id,asignatura_id,tipo,cita,tipo_fuente,biblioteca_item_id,creado_por,creado_en,actualizado_en")
|
||||
.eq("asignatura_id", subjectId)
|
||||
.order("tipo", { ascending: true })
|
||||
.order("creado_en", { ascending: true });
|
||||
|
||||
throwIfError(error);
|
||||
return data ?? [];
|
||||
}
|
||||
|
||||
/** Wizard: crear materia manual (Edge Function) */
|
||||
export type SubjectsCreateManualInput = {
|
||||
planId: UUID;
|
||||
datosBasicos: {
|
||||
nombre: string;
|
||||
clave?: string;
|
||||
tipo: TipoAsignatura;
|
||||
creditos: number;
|
||||
horasSemana?: number;
|
||||
estructuraId: UUID;
|
||||
};
|
||||
};
|
||||
|
||||
export async function subjects_create_manual(payload: SubjectsCreateManualInput): Promise<Asignatura> {
|
||||
return invokeEdge<Asignatura>(EDGE.subjects_create_manual, payload);
|
||||
}
|
||||
|
||||
export async function ai_generate_subject(payload: {
|
||||
planId: UUID;
|
||||
datosBasicos: {
|
||||
nombre: string;
|
||||
clave?: string;
|
||||
tipo: TipoAsignatura;
|
||||
creditos: number;
|
||||
horasSemana?: number;
|
||||
estructuraId: UUID;
|
||||
};
|
||||
iaConfig: {
|
||||
descripcionEnfoque: string;
|
||||
notasAdicionales?: string;
|
||||
archivosExistentesIds?: UUID[];
|
||||
repositoriosIds?: UUID[];
|
||||
archivosAdhocIds?: UUID[];
|
||||
usarMCP?: boolean;
|
||||
};
|
||||
}): Promise<any> {
|
||||
return invokeEdge<any>(EDGE.ai_generate_subject, payload);
|
||||
}
|
||||
|
||||
export async function subjects_persist_from_ai(payload: { planId: UUID; jsonMateria: any }): Promise<Asignatura> {
|
||||
return invokeEdge<Asignatura>(EDGE.subjects_persist_from_ai, payload);
|
||||
}
|
||||
|
||||
export async function subjects_clone_from_existing(payload: {
|
||||
materiaOrigenId: UUID;
|
||||
planDestinoId: UUID;
|
||||
overrides?: Partial<{
|
||||
nombre: string;
|
||||
codigo: string;
|
||||
tipo: TipoAsignatura;
|
||||
creditos: number;
|
||||
horas_semana: number;
|
||||
}>;
|
||||
}): Promise<Asignatura> {
|
||||
return invokeEdge<Asignatura>(EDGE.subjects_clone_from_existing, payload);
|
||||
}
|
||||
|
||||
export async function subjects_import_from_file(payload: {
|
||||
planId: UUID;
|
||||
archivoWordMateriaId: UUID;
|
||||
archivosAdicionalesIds?: UUID[];
|
||||
}): Promise<Asignatura> {
|
||||
return invokeEdge<Asignatura>(EDGE.subjects_import_from_file, payload);
|
||||
}
|
||||
|
||||
/** Guardado de tarjetas/fields (Edge: merge server-side en asignaturas.datos y columnas) */
|
||||
export type SubjectsUpdateFieldsPatch = Partial<{
|
||||
codigo: string | null;
|
||||
nombre: string;
|
||||
tipo: TipoAsignatura;
|
||||
creditos: number;
|
||||
horas_semana: number | null;
|
||||
numero_ciclo: number | null;
|
||||
linea_plan_id: UUID | null;
|
||||
|
||||
datos: Record<string, any>;
|
||||
}>;
|
||||
|
||||
export async function subjects_update_fields(subjectId: UUID, patch: SubjectsUpdateFieldsPatch): Promise<Asignatura> {
|
||||
return invokeEdge<Asignatura>(EDGE.subjects_update_fields, { subjectId, patch });
|
||||
}
|
||||
|
||||
export async function subjects_update_contenido(subjectId: UUID, unidades: any[]): Promise<Asignatura> {
|
||||
return invokeEdge<Asignatura>(EDGE.subjects_update_contenido, { subjectId, unidades });
|
||||
}
|
||||
|
||||
export type BibliografiaUpsertInput = Array<{
|
||||
id?: UUID;
|
||||
tipo: "BASICA" | "COMPLEMENTARIA";
|
||||
cita: string;
|
||||
tipo_fuente?: "MANUAL" | "BIBLIOTECA";
|
||||
biblioteca_item_id?: string | null;
|
||||
}>;
|
||||
|
||||
export async function subjects_update_bibliografia(
|
||||
subjectId: UUID,
|
||||
entries: BibliografiaUpsertInput
|
||||
): Promise<{ ok: true }> {
|
||||
return invokeEdge<{ ok: true }>(EDGE.subjects_update_bibliografia, { subjectId, entries });
|
||||
}
|
||||
|
||||
/** Documento SEP materia */
|
||||
/* export type DocumentoResult = {
|
||||
archivoId: UUID;
|
||||
signedUrl: string;
|
||||
mimeType?: string;
|
||||
nombre?: string;
|
||||
}; */
|
||||
|
||||
export async function subjects_generate_document(subjectId: UUID): Promise<DocumentoResult> {
|
||||
return invokeEdge<DocumentoResult>(EDGE.subjects_generate_document, { subjectId });
|
||||
}
|
||||
|
||||
export async function subjects_get_document(subjectId: UUID): Promise<DocumentoResult | null> {
|
||||
return invokeEdge<DocumentoResult | null>(EDGE.subjects_get_document, { subjectId });
|
||||
}
|
||||
31
src/data/api/tasks.api.ts
Normal file
31
src/data/api/tasks.api.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { supabaseBrowser } from "../supabase/client";
|
||||
import { throwIfError, getUserIdOrThrow, requireData } from "./_helpers";
|
||||
import type { TareaRevision, UUID } from "../types/domain";
|
||||
|
||||
export async function tareas_mias_list(): Promise<TareaRevision[]> {
|
||||
const supabase = supabaseBrowser();
|
||||
const userId = await getUserIdOrThrow(supabase);
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("tareas_revision")
|
||||
.select("id,plan_estudio_id,asignado_a,rol_id,estado_id,estatus,fecha_limite,creado_en,completado_en")
|
||||
.eq("asignado_a", userId as UUID)
|
||||
.order("creado_en", { ascending: false });
|
||||
|
||||
throwIfError(error);
|
||||
return data ?? [];
|
||||
}
|
||||
|
||||
export async function tareas_marcar_completada(tareaId: UUID): Promise<TareaRevision> {
|
||||
const supabase = supabaseBrowser();
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("tareas_revision")
|
||||
.update({ estatus: "COMPLETADA", completado_en: new Date().toISOString() })
|
||||
.eq("id", tareaId)
|
||||
.select("id,plan_estudio_id,asignado_a,rol_id,estado_id,estatus,fecha_limite,creado_en,completado_en")
|
||||
.single();
|
||||
|
||||
throwIfError(error);
|
||||
return requireData(data, "No se pudo marcar tarea.");
|
||||
}
|
||||
28
src/data/hooks/useAI.ts
Normal file
28
src/data/hooks/useAI.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import {
|
||||
ai_plan_chat,
|
||||
ai_plan_improve,
|
||||
ai_subject_chat,
|
||||
ai_subject_improve,
|
||||
library_search,
|
||||
} from "../api/ai.api";
|
||||
|
||||
export function useAIPlanImprove() {
|
||||
return useMutation({ mutationFn: ai_plan_improve });
|
||||
}
|
||||
|
||||
export function useAIPlanChat() {
|
||||
return useMutation({ mutationFn: ai_plan_chat });
|
||||
}
|
||||
|
||||
export function useAISubjectImprove() {
|
||||
return useMutation({ mutationFn: ai_subject_improve });
|
||||
}
|
||||
|
||||
export function useAISubjectChat() {
|
||||
return useMutation({ mutationFn: ai_subject_chat });
|
||||
}
|
||||
|
||||
export function useLibrarySearch() {
|
||||
return useMutation({ mutationFn: library_search });
|
||||
}
|
||||
59
src/data/hooks/useAuth.ts
Normal file
59
src/data/hooks/useAuth.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useEffect } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { supabaseBrowser } from "../supabase/client";
|
||||
import { qk } from "../query/keys";
|
||||
import { throwIfError } from "../api/_helpers";
|
||||
|
||||
export function useSession() {
|
||||
const supabase = supabaseBrowser();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: qk.session(),
|
||||
queryFn: async () => {
|
||||
const { data, error } = await supabase.auth.getSession();
|
||||
throwIfError(error);
|
||||
return data.session ?? null;
|
||||
},
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const { data } = supabase.auth.onAuthStateChange(() => {
|
||||
qc.invalidateQueries({ queryKey: qk.session() });
|
||||
qc.invalidateQueries({ queryKey: qk.meProfile() });
|
||||
qc.invalidateQueries({ queryKey: qk.auth });
|
||||
});
|
||||
|
||||
return () => data.subscription.unsubscribe();
|
||||
}, [supabase, qc]);
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
export function useMeProfile() {
|
||||
const supabase = supabaseBrowser();
|
||||
|
||||
return useQuery({
|
||||
queryKey: qk.meProfile(),
|
||||
queryFn: async () => {
|
||||
const { data: u, error: uErr } = await supabase.auth.getUser();
|
||||
throwIfError(uErr);
|
||||
const userId = u.user?.id;
|
||||
if (!userId) return null;
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("usuarios_app")
|
||||
.select("id,nombre_completo,email,externo,creado_en,actualizado_en")
|
||||
.eq("id", userId)
|
||||
.single();
|
||||
|
||||
// si aún no existe perfil en usuarios_app, permite null (tu seed/trigger puede crearlo)
|
||||
if (error && (error as any).code === "PGRST116") return null;
|
||||
|
||||
throwIfError(error);
|
||||
return data ?? null;
|
||||
},
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
43
src/data/hooks/useFiles.ts
Normal file
43
src/data/hooks/useFiles.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { files_get_signed_url, files_list } from "../api/files.api";
|
||||
import { openai_files_delete, openai_files_upload } from "../api/openaiFiles.api";
|
||||
|
||||
const qkFiles = {
|
||||
list: (filters: any) => ["files", "list", filters] as const,
|
||||
};
|
||||
|
||||
export function useFilesList(filters?: { temporal?: boolean; search?: string; limit?: number }) {
|
||||
return useQuery({
|
||||
queryKey: qkFiles.list(filters ?? {}),
|
||||
queryFn: () => files_list(filters),
|
||||
staleTime: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUploadOpenAIFile() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: openai_files_upload,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["files"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteOpenAIFile() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: openai_files_delete,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["files"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useFileSignedUrl() {
|
||||
return useMutation({
|
||||
mutationFn: files_get_signed_url,
|
||||
});
|
||||
}
|
||||
49
src/data/hooks/useMeta.ts
Normal file
49
src/data/hooks/useMeta.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { qk } from "../query/keys";
|
||||
import {
|
||||
carreras_list,
|
||||
estados_plan_list,
|
||||
estructuras_asignatura_list,
|
||||
estructuras_plan_list,
|
||||
facultades_list,
|
||||
} from "../api/meta.api";
|
||||
|
||||
export function useFacultades() {
|
||||
return useQuery({
|
||||
queryKey: qk.facultades(),
|
||||
queryFn: facultades_list,
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCarreras(params?: { facultadId?: string | null }) {
|
||||
return useQuery({
|
||||
queryKey: qk.carreras(params?.facultadId ?? null),
|
||||
queryFn: () => carreras_list(params),
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useEstructurasPlan(params?: { nivel?: string | null }) {
|
||||
return useQuery({
|
||||
queryKey: qk.estructurasPlan(params?.nivel ?? null),
|
||||
queryFn: () => estructuras_plan_list(params),
|
||||
staleTime: 10 * 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useEstructurasAsignatura() {
|
||||
return useQuery({
|
||||
queryKey: qk.estructurasAsignatura(),
|
||||
queryFn: estructuras_asignatura_list,
|
||||
staleTime: 10 * 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useEstadosPlan() {
|
||||
return useQuery({
|
||||
queryKey: qk.estadosPlan(),
|
||||
queryFn: estados_plan_list,
|
||||
staleTime: 10 * 60_000,
|
||||
});
|
||||
}
|
||||
49
src/data/hooks/useNotifications.ts
Normal file
49
src/data/hooks/useNotifications.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { useEffect } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../query/keys";
|
||||
import { notificaciones_marcar_leida, notificaciones_mias_list } from "../api/notifications.api";
|
||||
import { supabaseBrowser } from "../supabase/client";
|
||||
|
||||
export function useMisNotificaciones() {
|
||||
return useQuery({
|
||||
queryKey: qk.notificaciones(),
|
||||
queryFn: notificaciones_mias_list,
|
||||
staleTime: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** 🔥 Opcional: realtime (si tienes Realtime habilitado) */
|
||||
export function useRealtimeNotificaciones(enable = true) {
|
||||
const supabase = supabaseBrowser();
|
||||
const qc = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
if (!enable) return;
|
||||
|
||||
const channel = supabase
|
||||
.channel("rt-notificaciones")
|
||||
.on(
|
||||
"postgres_changes",
|
||||
{ event: "*", schema: "public", table: "notificaciones" },
|
||||
() => {
|
||||
qc.invalidateQueries({ queryKey: qk.notificaciones() });
|
||||
}
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
return () => {
|
||||
supabase.removeChannel(channel);
|
||||
};
|
||||
}, [enable, supabase, qc]);
|
||||
}
|
||||
|
||||
export function useMarcarNotificacionLeida() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: notificaciones_marcar_leida,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: qk.notificaciones() });
|
||||
},
|
||||
});
|
||||
}
|
||||
210
src/data/hooks/usePlans.ts
Normal file
210
src/data/hooks/usePlans.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../query/keys";
|
||||
import type { PlanEstudio, UUID } from "../types/domain";
|
||||
import type { PlanListFilters, PlanMapOperation, PlansCreateManualInput, PlansUpdateFieldsPatch } from "../api/plans.api";
|
||||
import {
|
||||
ai_generate_plan,
|
||||
plan_asignaturas_list,
|
||||
plan_lineas_list,
|
||||
plans_clone_from_existing,
|
||||
plans_create_manual,
|
||||
plans_generate_document,
|
||||
plans_get,
|
||||
plans_get_document,
|
||||
plans_history,
|
||||
plans_import_from_files,
|
||||
plans_list,
|
||||
plans_persist_from_ai,
|
||||
plans_transition_state,
|
||||
plans_update_fields,
|
||||
plans_update_map,
|
||||
} from "../api/plans.api";
|
||||
|
||||
export function usePlanes(filters: PlanListFilters) {
|
||||
// 🧠 Tip: memoiza "filters" (useMemo) para que queryKey sea estable.
|
||||
return useQuery({
|
||||
queryKey: qk.planesList(filters),
|
||||
queryFn: () => plans_list(filters),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
}
|
||||
|
||||
export function usePlan(planId: UUID | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: planId ? qk.plan(planId) : ["planes", "detail", null],
|
||||
queryFn: () => plans_get(planId as UUID),
|
||||
enabled: Boolean(planId),
|
||||
});
|
||||
}
|
||||
|
||||
export function usePlanLineas(planId: UUID | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: planId ? qk.planLineas(planId) : ["planes", "lineas", null],
|
||||
queryFn: () => plan_lineas_list(planId as UUID),
|
||||
enabled: Boolean(planId),
|
||||
});
|
||||
}
|
||||
|
||||
export function usePlanAsignaturas(planId: UUID | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: planId ? qk.planAsignaturas(planId) : ["planes", "asignaturas", null],
|
||||
queryFn: () => plan_asignaturas_list(planId as UUID),
|
||||
enabled: Boolean(planId),
|
||||
});
|
||||
}
|
||||
|
||||
export function usePlanHistorial(planId: UUID | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: planId ? qk.planHistorial(planId) : ["planes", "historial", null],
|
||||
queryFn: () => plans_history(planId as UUID),
|
||||
enabled: Boolean(planId),
|
||||
});
|
||||
}
|
||||
|
||||
export function usePlanDocumento(planId: UUID | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: planId ? qk.planDocumento(planId) : ["planes", "documento", null],
|
||||
queryFn: () => plans_get_document(planId as UUID),
|
||||
enabled: Boolean(planId),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
/* ------------------ Mutations ------------------ */
|
||||
|
||||
export function useCreatePlanManual() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (input: PlansCreateManualInput) => plans_create_manual(input),
|
||||
onSuccess: (plan) => {
|
||||
qc.invalidateQueries({ queryKey: ["planes", "list"] });
|
||||
qc.setQueryData(qk.plan(plan.id), plan);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useGeneratePlanAI() {
|
||||
return useMutation({
|
||||
mutationFn: ai_generate_plan,
|
||||
});
|
||||
}
|
||||
|
||||
export function usePersistPlanFromAI() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (payload: { jsonPlan: any }) => plans_persist_from_ai(payload),
|
||||
onSuccess: (plan) => {
|
||||
qc.invalidateQueries({ queryKey: ["planes", "list"] });
|
||||
qc.setQueryData(qk.plan(plan.id), plan);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useClonePlan() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: plans_clone_from_existing,
|
||||
onSuccess: (plan) => {
|
||||
qc.invalidateQueries({ queryKey: ["planes", "list"] });
|
||||
qc.setQueryData(qk.plan(plan.id), plan);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useImportPlanFromFiles() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: plans_import_from_files,
|
||||
onSuccess: (plan) => {
|
||||
qc.invalidateQueries({ queryKey: ["planes", "list"] });
|
||||
qc.setQueryData(qk.plan(plan.id), plan);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdatePlanFields() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (vars: { planId: UUID; patch: PlansUpdateFieldsPatch }) =>
|
||||
plans_update_fields(vars.planId, vars.patch),
|
||||
onSuccess: (updated) => {
|
||||
qc.setQueryData(qk.plan(updated.id), updated);
|
||||
qc.invalidateQueries({ queryKey: ["planes", "list"] });
|
||||
qc.invalidateQueries({ queryKey: qk.planHistorial(updated.id) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdatePlanMapa() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (vars: { planId: UUID; ops: PlanMapOperation[] }) => plans_update_map(vars.planId, vars.ops),
|
||||
|
||||
// ✅ Optimista (rápida) para el caso MOVE_ASIGNATURA
|
||||
onMutate: async (vars) => {
|
||||
await qc.cancelQueries({ queryKey: qk.planAsignaturas(vars.planId) });
|
||||
const prev = qc.getQueryData<any>(qk.planAsignaturas(vars.planId));
|
||||
|
||||
// solo optimizamos MOVEs simples
|
||||
const moves = vars.ops.filter((x) => x.op === "MOVE_ASIGNATURA") as Array<
|
||||
Extract<PlanMapOperation, { op: "MOVE_ASIGNATURA" }>
|
||||
>;
|
||||
|
||||
if (prev && Array.isArray(prev) && moves.length) {
|
||||
const next = prev.map((a: any) => {
|
||||
const m = moves.find((x) => x.asignaturaId === a.id);
|
||||
if (!m) return a;
|
||||
return {
|
||||
...a,
|
||||
numero_ciclo: m.numero_ciclo,
|
||||
linea_plan_id: m.linea_plan_id,
|
||||
orden_celda: m.orden_celda ?? a.orden_celda,
|
||||
};
|
||||
});
|
||||
qc.setQueryData(qk.planAsignaturas(vars.planId), next);
|
||||
}
|
||||
|
||||
return { prev };
|
||||
},
|
||||
|
||||
onError: (_err, vars, ctx) => {
|
||||
if (ctx?.prev) qc.setQueryData(qk.planAsignaturas(vars.planId), ctx.prev);
|
||||
},
|
||||
|
||||
onSuccess: (_ok, vars) => {
|
||||
qc.invalidateQueries({ queryKey: qk.planAsignaturas(vars.planId) });
|
||||
qc.invalidateQueries({ queryKey: qk.planHistorial(vars.planId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useTransitionPlanEstado() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: plans_transition_state,
|
||||
onSuccess: (_ok, vars) => {
|
||||
qc.invalidateQueries({ queryKey: qk.plan(vars.planId) });
|
||||
qc.invalidateQueries({ queryKey: qk.planHistorial(vars.planId) });
|
||||
qc.invalidateQueries({ queryKey: ["planes", "list"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useGeneratePlanDocumento() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (planId: UUID) => plans_generate_document(planId),
|
||||
onSuccess: (_doc, planId) => {
|
||||
qc.invalidateQueries({ queryKey: qk.planDocumento(planId) });
|
||||
qc.invalidateQueries({ queryKey: qk.planHistorial(planId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
46
src/data/hooks/useRepositories.ts
Normal file
46
src/data/hooks/useRepositories.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { repos_add_files, repos_create, repos_delete, repos_remove_files } from "../api/repositories.api";
|
||||
|
||||
export function useCreateRepository() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: repos_create,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["repos"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteRepository() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: repos_delete,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["repos"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRepoAddFiles() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: repos_add_files,
|
||||
onSuccess: (_ok, vars) => {
|
||||
qc.invalidateQueries({ queryKey: ["repos", vars.repoId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRepoRemoveFiles() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: repos_remove_files,
|
||||
onSuccess: (_ok, vars) => {
|
||||
qc.invalidateQueries({ queryKey: ["repos", vars.repoId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
166
src/data/hooks/useSubjects.ts
Normal file
166
src/data/hooks/useSubjects.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../query/keys";
|
||||
import type { UUID } from "../types/domain";
|
||||
import type {
|
||||
BibliografiaUpsertInput,
|
||||
SubjectsCreateManualInput,
|
||||
SubjectsUpdateFieldsPatch,
|
||||
} from "../api/subjects.api";
|
||||
import {
|
||||
ai_generate_subject,
|
||||
subjects_bibliografia_list,
|
||||
subjects_clone_from_existing,
|
||||
subjects_create_manual,
|
||||
subjects_generate_document,
|
||||
subjects_get,
|
||||
subjects_get_document,
|
||||
subjects_history,
|
||||
subjects_import_from_file,
|
||||
subjects_persist_from_ai,
|
||||
subjects_update_bibliografia,
|
||||
subjects_update_contenido,
|
||||
subjects_update_fields,
|
||||
} from "../api/subjects.api";
|
||||
|
||||
export function useSubject(subjectId: UUID | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: subjectId ? qk.asignatura(subjectId) : ["asignaturas", "detail", null],
|
||||
queryFn: () => subjects_get(subjectId as UUID),
|
||||
enabled: Boolean(subjectId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSubjectBibliografia(subjectId: UUID | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: subjectId ? qk.asignaturaBibliografia(subjectId) : ["asignaturas", "bibliografia", null],
|
||||
queryFn: () => subjects_bibliografia_list(subjectId as UUID),
|
||||
enabled: Boolean(subjectId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSubjectHistorial(subjectId: UUID | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: subjectId ? qk.asignaturaHistorial(subjectId) : ["asignaturas", "historial", null],
|
||||
queryFn: () => subjects_history(subjectId as UUID),
|
||||
enabled: Boolean(subjectId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSubjectDocumento(subjectId: UUID | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: subjectId ? qk.asignaturaDocumento(subjectId) : ["asignaturas", "documento", null],
|
||||
queryFn: () => subjects_get_document(subjectId as UUID),
|
||||
enabled: Boolean(subjectId),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
/* ------------------ Mutations ------------------ */
|
||||
|
||||
export function useCreateSubjectManual() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (payload: SubjectsCreateManualInput) => subjects_create_manual(payload),
|
||||
onSuccess: (subject) => {
|
||||
qc.setQueryData(qk.asignatura(subject.id), subject);
|
||||
qc.invalidateQueries({ queryKey: qk.planAsignaturas(subject.plan_estudio_id) });
|
||||
qc.invalidateQueries({ queryKey: qk.planHistorial(subject.plan_estudio_id) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useGenerateSubjectAI() {
|
||||
return useMutation({ mutationFn: ai_generate_subject });
|
||||
}
|
||||
|
||||
export function usePersistSubjectFromAI() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (payload: { planId: UUID; jsonMateria: any }) => subjects_persist_from_ai(payload),
|
||||
onSuccess: (subject) => {
|
||||
qc.setQueryData(qk.asignatura(subject.id), subject);
|
||||
qc.invalidateQueries({ queryKey: qk.planAsignaturas(subject.plan_estudio_id) });
|
||||
qc.invalidateQueries({ queryKey: qk.planHistorial(subject.plan_estudio_id) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCloneSubject() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: subjects_clone_from_existing,
|
||||
onSuccess: (subject) => {
|
||||
qc.setQueryData(qk.asignatura(subject.id), subject);
|
||||
qc.invalidateQueries({ queryKey: qk.planAsignaturas(subject.plan_estudio_id) });
|
||||
qc.invalidateQueries({ queryKey: qk.planHistorial(subject.plan_estudio_id) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useImportSubjectFromFile() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: subjects_import_from_file,
|
||||
onSuccess: (subject) => {
|
||||
qc.setQueryData(qk.asignatura(subject.id), subject);
|
||||
qc.invalidateQueries({ queryKey: qk.planAsignaturas(subject.plan_estudio_id) });
|
||||
qc.invalidateQueries({ queryKey: qk.planHistorial(subject.plan_estudio_id) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateSubjectFields() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (vars: { subjectId: UUID; patch: SubjectsUpdateFieldsPatch }) =>
|
||||
subjects_update_fields(vars.subjectId, vars.patch),
|
||||
onSuccess: (updated) => {
|
||||
qc.setQueryData(qk.asignatura(updated.id), updated);
|
||||
qc.invalidateQueries({ queryKey: qk.planAsignaturas(updated.plan_estudio_id) });
|
||||
qc.invalidateQueries({ queryKey: qk.asignaturaHistorial(updated.id) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateSubjectContenido() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (vars: { subjectId: UUID; unidades: any[] }) =>
|
||||
subjects_update_contenido(vars.subjectId, vars.unidades),
|
||||
onSuccess: (updated) => {
|
||||
qc.setQueryData(qk.asignatura(updated.id), updated);
|
||||
qc.invalidateQueries({ queryKey: qk.asignaturaHistorial(updated.id) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateSubjectBibliografia() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (vars: { subjectId: UUID; entries: BibliografiaUpsertInput }) =>
|
||||
subjects_update_bibliografia(vars.subjectId, vars.entries),
|
||||
onSuccess: (_ok, vars) => {
|
||||
qc.invalidateQueries({ queryKey: qk.asignaturaBibliografia(vars.subjectId) });
|
||||
qc.invalidateQueries({ queryKey: qk.asignaturaHistorial(vars.subjectId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useGenerateSubjectDocumento() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (subjectId: UUID) => subjects_generate_document(subjectId),
|
||||
onSuccess: (_doc, subjectId) => {
|
||||
qc.invalidateQueries({ queryKey: qk.asignaturaDocumento(subjectId) });
|
||||
qc.invalidateQueries({ queryKey: qk.asignaturaHistorial(subjectId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
22
src/data/hooks/useTasks.ts
Normal file
22
src/data/hooks/useTasks.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../query/keys";
|
||||
import { tareas_marcar_completada, tareas_mias_list } from "../api/tasks.api";
|
||||
|
||||
export function useMisTareas() {
|
||||
return useQuery({
|
||||
queryKey: qk.tareas(),
|
||||
queryFn: tareas_mias_list,
|
||||
staleTime: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useMarcarTareaCompletada() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: tareas_marcar_completada,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: qk.tareas() });
|
||||
},
|
||||
});
|
||||
}
|
||||
23
src/data/index.ts
Normal file
23
src/data/index.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export * from "./supabase/client";
|
||||
export * from "./supabase/invokeEdge";
|
||||
|
||||
export * from "./query/queryClient";
|
||||
export * from "./query/keys";
|
||||
|
||||
export * from "./types/domain";
|
||||
|
||||
export * from "./api/meta.api";
|
||||
export * from "./api/plans.api";
|
||||
export * from "./api/subjects.api";
|
||||
export * from "./api/files.api";
|
||||
export * from "./api/ai.api";
|
||||
export * from "./api/tasks.api";
|
||||
export * from "./api/notifications.api";
|
||||
|
||||
export * from "./hooks/useAuth";
|
||||
export * from "./hooks/useMeta";
|
||||
export * from "./hooks/usePlans";
|
||||
export * from "./hooks/useSubjects";
|
||||
export * from "./hooks/useAI";
|
||||
export * from "./hooks/useTasks";
|
||||
export * from "./hooks/useNotifications";
|
||||
302
src/data/mockMateriaData.ts
Normal file
302
src/data/mockMateriaData.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
import type {
|
||||
Materia,
|
||||
MateriaStructure,
|
||||
UnidadTematica,
|
||||
BibliografiaEntry,
|
||||
CambioMateria,
|
||||
DocumentoMateria,
|
||||
LibraryResource
|
||||
} from '@/types/materia';
|
||||
|
||||
export const mockMateria: Materia = {
|
||||
id: '1',
|
||||
nombre: 'Inteligencia Artificial Aplicada',
|
||||
clave: 'IAA-401',
|
||||
creditos: 8,
|
||||
lineaCurricular: 'Sistemas Inteligentes',
|
||||
ciclo: '7° Semestre',
|
||||
planId: 'plan-1',
|
||||
planNombre: 'Licenciatura en Ingeniería en Sistemas Computacionales 2024',
|
||||
carrera: 'Ingeniería en Sistemas Computacionales',
|
||||
facultad: 'Facultad de Ingeniería',
|
||||
estructuraId: 'estructura-1',
|
||||
};
|
||||
|
||||
export const mockEstructura: MateriaStructure = {
|
||||
id: 'estructura-1',
|
||||
nombre: 'Plantilla SEP Licenciatura',
|
||||
campos: [
|
||||
{
|
||||
id: 'objetivo_general',
|
||||
nombre: 'Objetivo General',
|
||||
tipo: 'texto_largo',
|
||||
obligatorio: true,
|
||||
descripcion: 'Describe el propósito principal de la materia',
|
||||
placeholder: 'Al finalizar el curso, el estudiante será capaz de...',
|
||||
},
|
||||
{
|
||||
id: 'competencias',
|
||||
nombre: 'Competencias a Desarrollar',
|
||||
tipo: 'texto_largo',
|
||||
obligatorio: true,
|
||||
descripcion: 'Competencias profesionales que se desarrollarán',
|
||||
},
|
||||
{
|
||||
id: 'justificacion',
|
||||
nombre: 'Justificación',
|
||||
tipo: 'texto_largo',
|
||||
obligatorio: true,
|
||||
descripcion: 'Relevancia de la materia en el plan de estudios',
|
||||
},
|
||||
{
|
||||
id: 'requisitos',
|
||||
nombre: 'Requisitos / Seriación',
|
||||
tipo: 'texto',
|
||||
obligatorio: false,
|
||||
descripcion: 'Materias previas requeridas',
|
||||
},
|
||||
{
|
||||
id: 'estrategias_didacticas',
|
||||
nombre: 'Estrategias Didácticas',
|
||||
tipo: 'texto_largo',
|
||||
obligatorio: true,
|
||||
descripcion: 'Métodos de enseñanza-aprendizaje',
|
||||
},
|
||||
{
|
||||
id: 'evaluacion',
|
||||
nombre: 'Sistema de Evaluación',
|
||||
tipo: 'texto_largo',
|
||||
obligatorio: true,
|
||||
descripcion: 'Criterios y porcentajes de evaluación',
|
||||
},
|
||||
{
|
||||
id: 'perfil_docente',
|
||||
nombre: 'Perfil del Docente',
|
||||
tipo: 'texto_largo',
|
||||
obligatorio: false,
|
||||
descripcion: 'Características requeridas del profesor',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const mockDatosGenerales: Record<string, any> = {
|
||||
objetivo_general: 'Formar profesionales capaces de diseñar, implementar y evaluar sistemas de inteligencia artificial que resuelvan problemas complejos del mundo real, aplicando principios éticos y metodologías actuales en el campo.',
|
||||
competencias: '• Diseñar algoritmos de machine learning para clasificación y predicción\n• Implementar redes neuronales profundas para procesamiento de imágenes y texto\n• Evaluar y optimizar modelos de IA considerando métricas de rendimiento\n• Aplicar principios éticos en el desarrollo de sistemas inteligentes',
|
||||
justificacion: 'La inteligencia artificial es una de las tecnologías más disruptivas del siglo XXI. Su integración en diversos sectores demanda profesionales con sólidas bases teóricas y prácticas. Esta materia proporciona las competencias necesarias para que el egresado pueda innovar y contribuir al desarrollo tecnológico del país.',
|
||||
requisitos: 'Programación Avanzada (PAV-301), Matemáticas Discretas (MAT-201)',
|
||||
estrategias_didacticas: '• Aprendizaje basado en proyectos\n• Talleres prácticos con datasets reales\n• Exposiciones y discusiones grupales\n• Análisis de casos de estudio\n• Desarrollo de prototipo integrador',
|
||||
evaluacion: '• Exámenes parciales: 30%\n• Proyecto integrador: 35%\n• Prácticas de laboratorio: 20%\n• Participación y tareas: 15%',
|
||||
perfil_docente: 'Profesional con maestría o doctorado en áreas afines a la inteligencia artificial, con experiencia mínima de 3 años en docencia y desarrollo de proyectos de IA.',
|
||||
};
|
||||
|
||||
export const mockContenidoTematico: UnidadTematica[] = [
|
||||
{
|
||||
id: 'unidad-1',
|
||||
nombre: 'Fundamentos de Inteligencia Artificial',
|
||||
numero: 1,
|
||||
temas: [
|
||||
{ id: 'tema-1-1', nombre: 'Historia y evolución de la IA', descripcion: 'Desde los orígenes hasta la actualidad', horasEstimadas: 2 },
|
||||
{ id: 'tema-1-2', nombre: 'Tipos de IA y aplicaciones', descripcion: 'IA débil, fuerte y superinteligencia', horasEstimadas: 3 },
|
||||
{ id: 'tema-1-3', nombre: 'Ética en IA', descripcion: 'Consideraciones éticas y responsabilidad', horasEstimadas: 2 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'unidad-2',
|
||||
nombre: 'Machine Learning',
|
||||
numero: 2,
|
||||
temas: [
|
||||
{ id: 'tema-2-1', nombre: 'Aprendizaje supervisado', descripcion: 'Regresión y clasificación', horasEstimadas: 6 },
|
||||
{ id: 'tema-2-2', nombre: 'Aprendizaje no supervisado', descripcion: 'Clustering y reducción de dimensionalidad', horasEstimadas: 5 },
|
||||
{ id: 'tema-2-3', nombre: 'Evaluación de modelos', descripcion: 'Métricas y validación cruzada', horasEstimadas: 4 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'unidad-3',
|
||||
nombre: 'Deep Learning',
|
||||
numero: 3,
|
||||
temas: [
|
||||
{ id: 'tema-3-1', nombre: 'Redes neuronales artificiales', descripcion: 'Perceptrón y backpropagation', horasEstimadas: 5 },
|
||||
{ id: 'tema-3-2', nombre: 'Redes convolucionales (CNN)', descripcion: 'Procesamiento de imágenes', horasEstimadas: 6 },
|
||||
{ id: 'tema-3-3', nombre: 'Redes recurrentes (RNN)', descripcion: 'Procesamiento de secuencias', horasEstimadas: 5 },
|
||||
{ id: 'tema-3-4', nombre: 'Transformers y atención', descripcion: 'Arquitecturas modernas', horasEstimadas: 6 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'unidad-4',
|
||||
nombre: 'Aplicaciones Prácticas',
|
||||
numero: 4,
|
||||
temas: [
|
||||
{ id: 'tema-4-1', nombre: 'Procesamiento de lenguaje natural', descripcion: 'NLP y chatbots', horasEstimadas: 6 },
|
||||
{ id: 'tema-4-2', nombre: 'Visión por computadora', descripcion: 'Detección y reconocimiento', horasEstimadas: 5 },
|
||||
{ id: 'tema-4-3', nombre: 'Sistemas de recomendación', descripcion: 'Filtrado colaborativo y contenido', horasEstimadas: 4 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const mockBibliografia: BibliografiaEntry[] = [
|
||||
{
|
||||
id: 'bib-1',
|
||||
tipo: 'BASICA',
|
||||
cita: 'Russell, S., & Norvig, P. (2021). Artificial Intelligence: A Modern Approach (4th ed.). Pearson.',
|
||||
fuenteBibliotecaId: 'lib-1',
|
||||
fuenteBiblioteca: {
|
||||
id: 'lib-1',
|
||||
titulo: 'Artificial Intelligence: A Modern Approach',
|
||||
autor: 'Stuart Russell, Peter Norvig',
|
||||
editorial: 'Pearson',
|
||||
anio: 2021,
|
||||
isbn: '978-0134610993',
|
||||
tipo: 'libro',
|
||||
disponible: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'bib-2',
|
||||
tipo: 'BASICA',
|
||||
cita: 'Géron, A. (2022). Hands-On Machine Learning with Scikit-Learn, Keras, and TensorFlow (3rd ed.). O\'Reilly Media.',
|
||||
fuenteBibliotecaId: 'lib-2',
|
||||
fuenteBiblioteca: {
|
||||
id: 'lib-2',
|
||||
titulo: 'Hands-On Machine Learning with Scikit-Learn, Keras, and TensorFlow',
|
||||
autor: 'Aurélien Géron',
|
||||
editorial: 'O\'Reilly Media',
|
||||
anio: 2022,
|
||||
isbn: '978-1098125974',
|
||||
tipo: 'libro',
|
||||
disponible: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'bib-3',
|
||||
tipo: 'COMPLEMENTARIA',
|
||||
cita: 'Goodfellow, I., Bengio, Y., & Courville, A. (2016). Deep Learning. MIT Press.',
|
||||
},
|
||||
{
|
||||
id: 'bib-4',
|
||||
tipo: 'COMPLEMENTARIA',
|
||||
cita: 'Chollet, F. (2021). Deep Learning with Python (2nd ed.). Manning Publications.',
|
||||
fuenteBibliotecaId: 'lib-4',
|
||||
fuenteBiblioteca: {
|
||||
id: 'lib-4',
|
||||
titulo: 'Deep Learning with Python',
|
||||
autor: 'François Chollet',
|
||||
editorial: 'Manning Publications',
|
||||
anio: 2021,
|
||||
isbn: '978-1617296864',
|
||||
tipo: 'libro',
|
||||
disponible: false,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const mockHistorial: CambioMateria[] = [
|
||||
{
|
||||
id: 'cambio-1',
|
||||
tipo: 'datos',
|
||||
descripcion: 'Actualización del objetivo general',
|
||||
usuario: 'Dr. Carlos Méndez',
|
||||
fecha: new Date('2024-12-10T14:30:00'),
|
||||
detalles: { campo: 'objetivo_general' },
|
||||
},
|
||||
{
|
||||
id: 'cambio-2',
|
||||
tipo: 'contenido',
|
||||
descripcion: 'Agregada Unidad 4: Aplicaciones Prácticas',
|
||||
usuario: 'Dr. Carlos Méndez',
|
||||
fecha: new Date('2024-12-09T10:15:00'),
|
||||
detalles: { unidad: 'Unidad 4' },
|
||||
},
|
||||
{
|
||||
id: 'cambio-3',
|
||||
tipo: 'ia',
|
||||
descripcion: 'IA mejoró las competencias a desarrollar',
|
||||
usuario: 'Dra. María López',
|
||||
fecha: new Date('2024-12-08T16:45:00'),
|
||||
detalles: { campo: 'competencias', accion: 'mejora' },
|
||||
},
|
||||
{
|
||||
id: 'cambio-4',
|
||||
tipo: 'bibliografia',
|
||||
descripcion: 'Añadida referencia: Deep Learning with Python',
|
||||
usuario: 'Biblioteca Central',
|
||||
fecha: new Date('2024-12-07T09:00:00'),
|
||||
},
|
||||
{
|
||||
id: 'cambio-5',
|
||||
tipo: 'documento',
|
||||
descripcion: 'Documento SEP regenerado (versión 3)',
|
||||
usuario: 'Sistema',
|
||||
fecha: new Date('2024-12-06T11:30:00'),
|
||||
},
|
||||
];
|
||||
|
||||
export const mockDocumentoSep: DocumentoMateria = {
|
||||
id: 'doc-1',
|
||||
materiaId: '1',
|
||||
version: 3,
|
||||
fechaGeneracion: new Date('2024-12-06T11:30:00'),
|
||||
estado: 'listo',
|
||||
};
|
||||
|
||||
export const mockLibraryResources: LibraryResource[] = [
|
||||
{
|
||||
id: 'lib-1',
|
||||
titulo: 'Artificial Intelligence: A Modern Approach',
|
||||
autor: 'Stuart Russell, Peter Norvig',
|
||||
editorial: 'Pearson',
|
||||
anio: 2021,
|
||||
isbn: '978-0134610993',
|
||||
tipo: 'libro',
|
||||
disponible: true,
|
||||
},
|
||||
{
|
||||
id: 'lib-2',
|
||||
titulo: 'Hands-On Machine Learning with Scikit-Learn, Keras, and TensorFlow',
|
||||
autor: 'Aurélien Géron',
|
||||
editorial: 'O\'Reilly Media',
|
||||
anio: 2022,
|
||||
isbn: '978-1098125974',
|
||||
tipo: 'libro',
|
||||
disponible: true,
|
||||
},
|
||||
{
|
||||
id: 'lib-3',
|
||||
titulo: 'Pattern Recognition and Machine Learning',
|
||||
autor: 'Christopher Bishop',
|
||||
editorial: 'Springer',
|
||||
anio: 2006,
|
||||
isbn: '978-0387310732',
|
||||
tipo: 'libro',
|
||||
disponible: true,
|
||||
},
|
||||
{
|
||||
id: 'lib-4',
|
||||
titulo: 'Deep Learning with Python',
|
||||
autor: 'François Chollet',
|
||||
editorial: 'Manning Publications',
|
||||
anio: 2021,
|
||||
isbn: '978-1617296864',
|
||||
tipo: 'libro',
|
||||
disponible: false,
|
||||
},
|
||||
{
|
||||
id: 'lib-5',
|
||||
titulo: 'Neural Networks and Deep Learning: A Textbook',
|
||||
autor: 'Charu C. Aggarwal',
|
||||
editorial: 'Springer',
|
||||
anio: 2023,
|
||||
isbn: '978-3031296413',
|
||||
tipo: 'libro',
|
||||
disponible: true,
|
||||
},
|
||||
{
|
||||
id: 'lib-6',
|
||||
titulo: 'Machine Learning: A Probabilistic Perspective',
|
||||
autor: 'Kevin Murphy',
|
||||
editorial: 'MIT Press',
|
||||
anio: 2012,
|
||||
isbn: '978-0262018029',
|
||||
tipo: 'libro',
|
||||
disponible: true,
|
||||
},
|
||||
];
|
||||
31
src/data/query/keys.ts
Normal file
31
src/data/query/keys.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
export const qk = {
|
||||
auth: ["auth"] as const,
|
||||
session: () => ["auth", "session"] as const,
|
||||
meProfile: () => ["auth", "meProfile"] as const,
|
||||
|
||||
facultades: () => ["meta", "facultades"] as const,
|
||||
carreras: (facultadId?: string | null) =>
|
||||
["meta", "carreras", { facultadId: facultadId ?? null }] as const,
|
||||
estructurasPlan: (nivel?: string | null) =>
|
||||
["meta", "estructurasPlan", { nivel: nivel ?? null }] as const,
|
||||
estructurasAsignatura: () => ["meta", "estructurasAsignatura"] as const,
|
||||
estadosPlan: () => ["meta", "estadosPlan"] as const,
|
||||
|
||||
planesList: (filters: unknown) => ["planes", "list", filters] as const,
|
||||
plan: (planId: string) => ["planes", "detail", planId] as const,
|
||||
planLineas: (planId: string) => ["planes", planId, "lineas"] as const,
|
||||
planAsignaturas: (planId: string) => ["planes", planId, "asignaturas"] as const,
|
||||
planHistorial: (planId: string) => ["planes", planId, "historial"] as const,
|
||||
planDocumento: (planId: string) => ["planes", planId, "documento"] as const,
|
||||
|
||||
asignatura: (asignaturaId: string) => ["asignaturas", "detail", asignaturaId] as const,
|
||||
asignaturaBibliografia: (asignaturaId: string) =>
|
||||
["asignaturas", asignaturaId, "bibliografia"] as const,
|
||||
asignaturaHistorial: (asignaturaId: string) =>
|
||||
["asignaturas", asignaturaId, "historial"] as const,
|
||||
asignaturaDocumento: (asignaturaId: string) =>
|
||||
["asignaturas", asignaturaId, "documento"] as const,
|
||||
|
||||
tareas: () => ["tareas", "mias"] as const,
|
||||
notificaciones: () => ["notificaciones", "mias"] as const,
|
||||
};
|
||||
14
src/data/query/queryClient.ts
Normal file
14
src/data/query/queryClient.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30_000,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: (failureCount) => failureCount < 2,
|
||||
},
|
||||
mutations: {
|
||||
retry: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
31
src/data/supabase/client.ts
Normal file
31
src/data/supabase/client.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { createClient, type SupabaseClient } from "@supabase/supabase-js";
|
||||
import type { Database } from "../types/database";
|
||||
import { getEnv } from "./env";
|
||||
|
||||
let _client: SupabaseClient<Database> | null = null;
|
||||
|
||||
export function supabaseBrowser(): SupabaseClient<Database> {
|
||||
if (_client) return _client;
|
||||
|
||||
const url = getEnv(
|
||||
"VITE_SUPABASE_URL",
|
||||
"NEXT_PUBLIC_SUPABASE_URL",
|
||||
"SUPABASE_URL"
|
||||
);
|
||||
|
||||
const anonKey = getEnv(
|
||||
"VITE_SUPABASE_ANON_KEY",
|
||||
"NEXT_PUBLIC_SUPABASE_ANON_KEY",
|
||||
"SUPABASE_ANON_KEY"
|
||||
);
|
||||
|
||||
_client = createClient<Database>(url, anonKey, {
|
||||
auth: {
|
||||
persistSession: true,
|
||||
autoRefreshToken: true,
|
||||
detectSessionInUrl: true,
|
||||
},
|
||||
});
|
||||
|
||||
return _client;
|
||||
}
|
||||
17
src/data/supabase/env.ts
Normal file
17
src/data/supabase/env.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export function getEnv(...keys: string[]): string {
|
||||
for (const key of keys) {
|
||||
const fromProcess =
|
||||
typeof process !== "undefined" ? (process as any).env?.[key] : undefined;
|
||||
|
||||
// Vite / bundlers
|
||||
const fromImportMeta =
|
||||
typeof import.meta !== "undefined" ? (import.meta as any).env?.[key] : undefined;
|
||||
|
||||
const value = fromProcess ?? fromImportMeta;
|
||||
if (typeof value === "string" && value.trim().length > 0) return value.trim();
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Falta variable de entorno. Probé: ${keys.join(", ")}`
|
||||
);
|
||||
}
|
||||
47
src/data/supabase/invokeEdge.ts
Normal file
47
src/data/supabase/invokeEdge.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import type { Database } from "../types/database";
|
||||
import { supabaseBrowser } from "./client";
|
||||
|
||||
export type EdgeInvokeOptions = {
|
||||
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
|
||||
export class EdgeFunctionError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly functionName: string,
|
||||
public readonly status?: number,
|
||||
public readonly details?: unknown
|
||||
) {
|
||||
super(message);
|
||||
this.name = "EdgeFunctionError";
|
||||
}
|
||||
}
|
||||
|
||||
export async function invokeEdge<TOut>(
|
||||
functionName: string,
|
||||
body?: unknown,
|
||||
opts: EdgeInvokeOptions = {},
|
||||
client?: SupabaseClient<Database>
|
||||
): Promise<TOut> {
|
||||
const supabase = client ?? supabaseBrowser();
|
||||
|
||||
const { data, error } = await supabase.functions.invoke(functionName, {
|
||||
body,
|
||||
method: opts.method ?? "POST",
|
||||
headers: opts.headers,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
const anyErr = error as any;
|
||||
throw new EdgeFunctionError(
|
||||
anyErr.message ?? "Error en Edge Function",
|
||||
functionName,
|
||||
anyErr.status,
|
||||
anyErr
|
||||
);
|
||||
}
|
||||
|
||||
return data as TOut;
|
||||
}
|
||||
9
src/data/types/database.ts
Normal file
9
src/data/types/database.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export type Json =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| { [key: string]: Json }
|
||||
| Json[];
|
||||
|
||||
export type Database = any; // ✅ Reemplaza por tipos generados (supabase gen types typescript)
|
||||
275
src/data/types/domain.ts
Normal file
275
src/data/types/domain.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
import type { Json } from "./database";
|
||||
|
||||
export type UUID = string;
|
||||
|
||||
export type TipoEstructuraPlan = "CURRICULAR" | "NO_CURRICULAR";
|
||||
export type NivelPlanEstudio =
|
||||
| "LICENCIATURA"
|
||||
| "MAESTRIA"
|
||||
| "DOCTORADO"
|
||||
| "ESPECIALIDAD"
|
||||
| "DIPLOMADO"
|
||||
| "OTRO";
|
||||
|
||||
export type TipoCiclo = "SEMESTRE" | "CUATRIMESTRE" | "TRIMESTRE" | "OTRO";
|
||||
|
||||
export type TipoOrigen = "MANUAL" | "IA" | "CLONADO_INTERNO" | "TRADICIONAL" | "OTRO";
|
||||
|
||||
export type TipoAsignatura = "OBLIGATORIA" | "OPTATIVA" | "TRONCAL" | "OTRA";
|
||||
|
||||
export type TipoBibliografia = "BASICA" | "COMPLEMENTARIA";
|
||||
export type TipoFuenteBibliografia = "MANUAL" | "BIBLIOTECA";
|
||||
|
||||
export type EstadoTareaRevision = "PENDIENTE" | "COMPLETADA" | "OMITIDA";
|
||||
export type TipoNotificacion = "PLAN_ASIGNADO" | "ESTADO_CAMBIADO" | "TAREA_ASIGNADA" | "COMENTARIO" | "OTRA";
|
||||
|
||||
export type TipoInteraccionIA = "GENERAR" | "MEJORAR_SECCION" | "CHAT" | "OTRA";
|
||||
|
||||
export type ModalidadEducativa = "Escolar" | "No escolarizada" | "Mixta";
|
||||
export type DisenoCurricular = "Rígido" | "Flexible";
|
||||
|
||||
/** Basado en tu schema JSON (va típicamente dentro de planes_estudio.datos) */
|
||||
export type PlanDatosSep = {
|
||||
nivel?: string;
|
||||
nombre?: string;
|
||||
modalidad_educativa?: ModalidadEducativa;
|
||||
|
||||
antecedente_academico?: string;
|
||||
area_de_estudio?: string;
|
||||
clave_del_plan_de_estudios?: string;
|
||||
|
||||
diseno_curricular?: DisenoCurricular;
|
||||
|
||||
total_de_ciclos_del_plan_de_estudios?: string;
|
||||
duracion_del_ciclo_escolar?: string;
|
||||
carga_horaria_a_la_semana?: number;
|
||||
|
||||
fines_de_aprendizaje_o_formacion?: string;
|
||||
perfil_de_egreso?: string;
|
||||
|
||||
programa_de_investigacion?: string | null;
|
||||
curso_propedeutico?: string | null;
|
||||
|
||||
perfil_de_ingreso?: string;
|
||||
|
||||
administracion_y_operatividad_del_plan_de_estudios?: string | null;
|
||||
sustento_teorico_del_modelo_curricular?: string | null;
|
||||
justificacion_de_la_propuesta_curricular?: string | null;
|
||||
propuesta_de_evaluacion_periodica_del_plan_de_estudios?: string | null;
|
||||
};
|
||||
|
||||
export type Paged<T> = { data: T[]; count: number | null };
|
||||
|
||||
export type Facultad = {
|
||||
id: UUID;
|
||||
nombre: string;
|
||||
nombre_corto: string | null;
|
||||
color: string | null;
|
||||
icono: string | null;
|
||||
creado_en: string;
|
||||
actualizado_en: string;
|
||||
};
|
||||
|
||||
export type Carrera = {
|
||||
id: UUID;
|
||||
facultad_id: UUID;
|
||||
nombre: string;
|
||||
nombre_corto: string | null;
|
||||
clave_sep: string | null;
|
||||
activa: boolean;
|
||||
creado_en: string;
|
||||
actualizado_en: string;
|
||||
|
||||
facultades?: Facultad | null;
|
||||
};
|
||||
|
||||
export type EstructuraPlan = {
|
||||
id: UUID;
|
||||
nombre: string;
|
||||
tipo: TipoEstructuraPlan;
|
||||
version: string | null;
|
||||
definicion: Json;
|
||||
};
|
||||
|
||||
export type EstructuraAsignatura = {
|
||||
id: UUID;
|
||||
nombre: string;
|
||||
version: string | null;
|
||||
definicion: Json;
|
||||
};
|
||||
|
||||
export type EstadoPlan = {
|
||||
id: UUID;
|
||||
clave: string;
|
||||
etiqueta: string;
|
||||
orden: number;
|
||||
es_final: boolean;
|
||||
};
|
||||
|
||||
export type PlanEstudio = {
|
||||
id: UUID;
|
||||
carrera_id: UUID;
|
||||
estructura_id: UUID;
|
||||
|
||||
nombre: string;
|
||||
nivel: NivelPlanEstudio;
|
||||
tipo_ciclo: TipoCiclo;
|
||||
numero_ciclos: number;
|
||||
|
||||
datos: Json;
|
||||
|
||||
estado_actual_id: UUID | null;
|
||||
activo: boolean;
|
||||
|
||||
tipo_origen: TipoOrigen | null;
|
||||
meta_origen: Json;
|
||||
|
||||
creado_por: UUID | null;
|
||||
actualizado_por: UUID | null;
|
||||
|
||||
creado_en: string;
|
||||
actualizado_en: string;
|
||||
|
||||
carreras?: Carrera | null;
|
||||
estructuras_plan?: EstructuraPlan | null;
|
||||
estados_plan?: EstadoPlan | null;
|
||||
};
|
||||
|
||||
export type LineaPlan = {
|
||||
id: UUID;
|
||||
plan_estudio_id: UUID;
|
||||
nombre: string;
|
||||
orden: number;
|
||||
area: string | null;
|
||||
creado_en: string;
|
||||
actualizado_en: string;
|
||||
};
|
||||
|
||||
export type Asignatura = {
|
||||
id: UUID;
|
||||
plan_estudio_id: UUID;
|
||||
estructura_id: UUID | null;
|
||||
|
||||
facultad_propietaria_id: UUID | null;
|
||||
|
||||
codigo: string | null;
|
||||
nombre: string;
|
||||
|
||||
tipo: TipoAsignatura;
|
||||
creditos: number;
|
||||
horas_semana: number | null;
|
||||
|
||||
numero_ciclo: number | null;
|
||||
linea_plan_id: UUID | null;
|
||||
orden_celda: number | null;
|
||||
|
||||
datos: Json;
|
||||
contenido_tematico: Json;
|
||||
|
||||
tipo_origen: TipoOrigen | null;
|
||||
meta_origen: Json;
|
||||
|
||||
creado_por: UUID | null;
|
||||
actualizado_por: UUID | null;
|
||||
|
||||
creado_en: string;
|
||||
actualizado_en: string;
|
||||
|
||||
planes_estudio?: PlanEstudio | null;
|
||||
estructuras_asignatura?: EstructuraAsignatura | null;
|
||||
};
|
||||
|
||||
export type BibliografiaAsignatura = {
|
||||
id: UUID;
|
||||
asignatura_id: UUID;
|
||||
tipo: TipoBibliografia;
|
||||
cita: string;
|
||||
tipo_fuente: TipoFuenteBibliografia;
|
||||
biblioteca_item_id: string | null;
|
||||
|
||||
creado_por: UUID | null;
|
||||
creado_en: string;
|
||||
actualizado_en: string;
|
||||
};
|
||||
|
||||
export type CambioPlan = {
|
||||
id: UUID;
|
||||
plan_estudio_id: UUID;
|
||||
cambiado_por: UUID | null;
|
||||
cambiado_en: string;
|
||||
tipo: "ACTUALIZACION_CAMPO" | "ACTUALIZACION_MAPA" | "OTRO";
|
||||
campo: string | null;
|
||||
valor_anterior: Json | null;
|
||||
valor_nuevo: Json | null;
|
||||
interaccion_ia_id: UUID | null;
|
||||
};
|
||||
|
||||
export type CambioAsignatura = {
|
||||
id: UUID;
|
||||
asignatura_id: UUID;
|
||||
cambiado_por: UUID | null;
|
||||
cambiado_en: string;
|
||||
tipo: "ACTUALIZACION_CAMPO" | "ACTUALIZACION_MAPA" | "OTRO";
|
||||
campo: string | null;
|
||||
valor_anterior: Json | null;
|
||||
valor_nuevo: Json | null;
|
||||
fuente: "HUMANO" | "IA" | null;
|
||||
interaccion_ia_id: UUID | null;
|
||||
};
|
||||
|
||||
export type InteraccionIA = {
|
||||
id: UUID;
|
||||
usuario_id: UUID | null;
|
||||
plan_estudio_id: UUID | null;
|
||||
asignatura_id: UUID | null;
|
||||
|
||||
tipo: TipoInteraccionIA;
|
||||
modelo: string | null;
|
||||
temperatura: number | null;
|
||||
|
||||
prompt: Json;
|
||||
respuesta: Json;
|
||||
|
||||
aceptada: boolean;
|
||||
|
||||
conversacion_id: string | null;
|
||||
ids_archivos: Json;
|
||||
ids_vector_store: Json;
|
||||
|
||||
creado_en: string;
|
||||
};
|
||||
|
||||
export type TareaRevision = {
|
||||
id: UUID;
|
||||
plan_estudio_id: UUID;
|
||||
asignado_a: UUID;
|
||||
rol_id: UUID | null;
|
||||
estado_id: UUID | null;
|
||||
estatus: EstadoTareaRevision;
|
||||
fecha_limite: string | null;
|
||||
creado_en: string;
|
||||
completado_en: string | null;
|
||||
};
|
||||
|
||||
export type Notificacion = {
|
||||
id: UUID;
|
||||
usuario_id: UUID;
|
||||
tipo: TipoNotificacion;
|
||||
payload: Json;
|
||||
leida: boolean;
|
||||
creado_en: string;
|
||||
leida_en: string | null;
|
||||
};
|
||||
|
||||
export type Archivo = {
|
||||
id: UUID;
|
||||
ruta_storage: string;
|
||||
nombre: string;
|
||||
mime_type: string | null;
|
||||
bytes: number | null;
|
||||
subido_por: UUID | null;
|
||||
subido_en: string;
|
||||
temporal: boolean;
|
||||
openai_file_id: string | null;
|
||||
notas: string | null;
|
||||
};
|
||||
@@ -1,56 +0,0 @@
|
||||
import type { TipoCiclo } from "./types";
|
||||
|
||||
export const FACULTADES = [
|
||||
{ id: "ing", nombre: "Facultad de Ingeniería" },
|
||||
{
|
||||
id: "med",
|
||||
nombre: "Facultad de Medicina en medicina en medicina en medicina",
|
||||
},
|
||||
{ id: "neg", nombre: "Facultad de Negocios" },
|
||||
];
|
||||
|
||||
export const CARRERAS = [
|
||||
{ id: "sis", nombre: "Ing. en Sistemas", facultadId: "ing" },
|
||||
{ id: "ind", nombre: "Ing. Industrial", facultadId: "ing" },
|
||||
{ id: "medico", nombre: "Médico Cirujano", facultadId: "med" },
|
||||
{ id: "act", nombre: "Actuaría", facultadId: "neg" },
|
||||
];
|
||||
|
||||
export const NIVELES = [
|
||||
"Licenciatura",
|
||||
"Especialidad",
|
||||
"Maestría",
|
||||
"Doctorado",
|
||||
];
|
||||
export const TIPOS_CICLO: Array<{ value: TipoCiclo; label: string }> = [
|
||||
{ value: "SEMESTRE", label: "Semestre" },
|
||||
{ value: "CUATRIMESTRE", label: "Cuatrimestre" },
|
||||
{ value: "TRIMESTRE", label: "Trimestre" },
|
||||
];
|
||||
|
||||
export const PLANES_EXISTENTES = [
|
||||
{
|
||||
id: "plan-2021-sis",
|
||||
nombre: "ISC 2021",
|
||||
estado: "Aprobado",
|
||||
anio: 2021,
|
||||
facultadId: "ing",
|
||||
carreraId: "sis",
|
||||
},
|
||||
{
|
||||
id: "plan-2020-ind",
|
||||
nombre: "I. Industrial 2020",
|
||||
estado: "Aprobado",
|
||||
anio: 2020,
|
||||
facultadId: "ing",
|
||||
carreraId: "ind",
|
||||
},
|
||||
{
|
||||
id: "plan-2019-med",
|
||||
nombre: "Medicina 2019",
|
||||
estado: "Vigente",
|
||||
anio: 2019,
|
||||
facultadId: "med",
|
||||
carreraId: "medico",
|
||||
},
|
||||
];
|
||||
@@ -3,8 +3,8 @@ import * as Icons from 'lucide-react'
|
||||
|
||||
import { useNuevoPlanWizard } from './hooks/useNuevoPlanWizard'
|
||||
|
||||
import { PasoBasicosForm } from '@/components/planes/wizard/PasoBasicosForm'
|
||||
import { PasoDetallesPanel } from '@/components/planes/wizard/PasoDetallesPanel'
|
||||
import { PasoBasicosForm } from '@/components/planes/wizard/PasoBasicosForm/PasoBasicosForm'
|
||||
import { PasoDetallesPanel } from '@/components/planes/wizard/PasoDetallesPanel/PasoDetallesPanel'
|
||||
import { PasoModoCardGroup } from '@/components/planes/wizard/PasoModoCardGroup'
|
||||
import { PasoResumenCard } from '@/components/planes/wizard/PasoResumenCard'
|
||||
import { WizardControls } from '@/components/planes/wizard/WizardControls'
|
||||
152
src/features/planes/nuevo/catalogs.ts
Normal file
152
src/features/planes/nuevo/catalogs.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import type { TipoCiclo } from "./types";
|
||||
|
||||
export const FACULTADES = [
|
||||
{ id: "ing", nombre: "Facultad de Ingeniería" },
|
||||
{
|
||||
id: "med",
|
||||
nombre: "Facultad de Medicina en medicina en medicina en medicina",
|
||||
},
|
||||
{ id: "neg", nombre: "Facultad de Negocios" },
|
||||
];
|
||||
|
||||
export const CARRERAS = [
|
||||
{ id: "sis", nombre: "Ing. en Sistemas", facultadId: "ing" },
|
||||
{ id: "ind", nombre: "Ing. Industrial", facultadId: "ing" },
|
||||
{ id: "medico", nombre: "Médico Cirujano", facultadId: "med" },
|
||||
{ id: "act", nombre: "Actuaría", facultadId: "neg" },
|
||||
];
|
||||
|
||||
export const NIVELES = [
|
||||
"Licenciatura",
|
||||
"Especialidad",
|
||||
"Maestría",
|
||||
"Doctorado",
|
||||
];
|
||||
export const TIPOS_CICLO: Array<{ value: TipoCiclo; label: string }> = [
|
||||
{ value: "SEMESTRE", label: "Semestre" },
|
||||
{ value: "CUATRIMESTRE", label: "Cuatrimestre" },
|
||||
{ value: "TRIMESTRE", label: "Trimestre" },
|
||||
];
|
||||
|
||||
export const PLANES_EXISTENTES = [
|
||||
{
|
||||
id: "plan-2021-sis",
|
||||
nombre: "ISC 2021",
|
||||
estado: "Aprobado",
|
||||
anio: 2021,
|
||||
facultadId: "ing",
|
||||
carreraId: "sis",
|
||||
},
|
||||
{
|
||||
id: "plan-2020-ind",
|
||||
nombre: "I. Industrial 2020",
|
||||
estado: "Aprobado",
|
||||
anio: 2020,
|
||||
facultadId: "ing",
|
||||
carreraId: "ind",
|
||||
},
|
||||
{
|
||||
id: "plan-2019-med",
|
||||
nombre: "Medicina 2019",
|
||||
estado: "Vigente",
|
||||
anio: 2019,
|
||||
facultadId: "med",
|
||||
carreraId: "medico",
|
||||
},
|
||||
];
|
||||
|
||||
export const ARCHIVOS = [
|
||||
{
|
||||
id: "file-1",
|
||||
nombre: "Sílabo POO 2023.docx",
|
||||
tipo: "docx",
|
||||
tamaño: "245 KB",
|
||||
},
|
||||
{
|
||||
id: "file-2",
|
||||
nombre: "Guía de prácticas BD.pdf",
|
||||
tipo: "pdf",
|
||||
tamaño: "1.2 MB",
|
||||
},
|
||||
{
|
||||
id: "file-3",
|
||||
nombre: "Rúbrica evaluación proyectos.xlsx",
|
||||
tipo: "xlsx",
|
||||
tamaño: "89 KB",
|
||||
},
|
||||
{
|
||||
id: "file-4",
|
||||
nombre: "Banco de reactivos IA.docx",
|
||||
tipo: "docx",
|
||||
tamaño: "567 KB",
|
||||
},
|
||||
{
|
||||
id: "file-5",
|
||||
nombre: "Material didáctico Web.pdf",
|
||||
tipo: "pdf",
|
||||
tamaño: "3.4 MB",
|
||||
},
|
||||
];
|
||||
|
||||
export const REPOSITORIOS = [
|
||||
{
|
||||
id: "repo-1",
|
||||
nombre: "Materiales ISC 2024",
|
||||
descripcion: "Documentos de referencia para Ingeniería en Sistemas",
|
||||
cantidadArchivos: 45,
|
||||
},
|
||||
{
|
||||
id: "repo-2",
|
||||
nombre: "Lineamientos SEP",
|
||||
descripcion: "Documentos oficiales y normativas SEP actualizadas",
|
||||
cantidadArchivos: 12,
|
||||
},
|
||||
{
|
||||
id: "repo-3",
|
||||
nombre: "Bibliografía Digital",
|
||||
descripcion: "Recursos bibliográficos digitalizados",
|
||||
cantidadArchivos: 128,
|
||||
},
|
||||
{
|
||||
id: "repo-4",
|
||||
nombre: "Plantillas Institucionales",
|
||||
descripcion: "Formatos y plantillas oficiales ULSA",
|
||||
cantidadArchivos: 23,
|
||||
},
|
||||
];
|
||||
|
||||
export const PLANTILLAS_ANEXO_1 = [
|
||||
{
|
||||
id: "sep-2025",
|
||||
name: "Licenciatura RVOE SEP.docx",
|
||||
versions: ["v2025.2 (Vigente)", "v2025.1", "v2024.Final"],
|
||||
},
|
||||
{
|
||||
id: "interno-mix",
|
||||
name: "Estándar Institucional Mixto.docx",
|
||||
versions: ["v2.0", "v1.5", "v1.0-beta"],
|
||||
},
|
||||
{
|
||||
id: "conacyt",
|
||||
name: "Formato Posgrado CONAHCYT.docx",
|
||||
versions: ["v3.0 (2025)", "v2.8"],
|
||||
},
|
||||
];
|
||||
|
||||
export const PLANTILLAS_ANEXO_2 = [
|
||||
{
|
||||
id: "sep-2017-xlsx",
|
||||
name: "Licenciatura RVOE 2017.xlsx",
|
||||
versions: ["v2017.0", "v2018.1", "v2019.2", "v2020.Final"],
|
||||
},
|
||||
{
|
||||
id: "interno-mix-xlsx",
|
||||
name: "Estándar Institucional Mixto.xlsx",
|
||||
versions: ["v1.0", "v1.5"],
|
||||
},
|
||||
{
|
||||
id: "conacyt-xlsx",
|
||||
name: "Formato Posgrado CONAHCYT.xlsx",
|
||||
versions: ["v1.0", "v2.0"],
|
||||
},
|
||||
];
|
||||
@@ -2,19 +2,35 @@ import { useMemo, useState } from "react";
|
||||
|
||||
import { CARRERAS } from "../catalogs";
|
||||
|
||||
import type { NewPlanWizardState, PlanPreview } from "../types";
|
||||
import type { NewPlanWizardState, PlanPreview, TipoCiclo } from "../types";
|
||||
|
||||
export function useNuevoPlanWizard() {
|
||||
const [wizard, setWizard] = useState<NewPlanWizardState>({
|
||||
step: 1,
|
||||
modoCreacion: null,
|
||||
// datosBasicos: {
|
||||
// nombrePlan: "",
|
||||
// carreraId: "",
|
||||
// facultadId: "",
|
||||
// nivel: "",
|
||||
// tipoCiclo: "",
|
||||
// numCiclos: undefined,
|
||||
// plantillaPlanId: "",
|
||||
// plantillaPlanVersion: "",
|
||||
// plantillaMapaId: "",
|
||||
// plantillaMapaVersion: "",
|
||||
// },
|
||||
datosBasicos: {
|
||||
nombrePlan: "",
|
||||
carreraId: "",
|
||||
facultadId: "",
|
||||
nivel: "",
|
||||
nombrePlan: "Medicina",
|
||||
carreraId: "medico",
|
||||
facultadId: "med",
|
||||
nivel: "Licenciatura",
|
||||
tipoCiclo: "SEMESTRE",
|
||||
numCiclos: 8,
|
||||
plantillaPlanId: "sep-2025",
|
||||
plantillaPlanVersion: "v2025.2 (Vigente)",
|
||||
plantillaMapaId: "sep-2017-xlsx",
|
||||
plantillaMapaVersion: "v2017.0",
|
||||
},
|
||||
clonInterno: { planOrigenId: null },
|
||||
clonTradicional: {
|
||||
@@ -27,6 +43,8 @@ export function useNuevoPlanWizard() {
|
||||
poblacionObjetivo: "",
|
||||
notasAdicionales: "",
|
||||
archivosReferencia: [],
|
||||
repositoriosReferencia: [],
|
||||
archivosAdjuntos: [],
|
||||
},
|
||||
resumen: {},
|
||||
isLoading: false,
|
||||
@@ -46,12 +64,20 @@ export function useNuevoPlanWizard() {
|
||||
!!wizard.datosBasicos.carreraId &&
|
||||
!!wizard.datosBasicos.facultadId &&
|
||||
!!wizard.datosBasicos.nivel &&
|
||||
wizard.datosBasicos.numCiclos > 0;
|
||||
(wizard.datosBasicos.numCiclos !== undefined &&
|
||||
wizard.datosBasicos.numCiclos > 0) &&
|
||||
// Requerir ambas plantillas (plan y mapa) con versión
|
||||
!!wizard.datosBasicos.plantillaPlanId &&
|
||||
!!wizard.datosBasicos.plantillaPlanVersion &&
|
||||
!!wizard.datosBasicos.plantillaMapaId &&
|
||||
!!wizard.datosBasicos.plantillaMapaVersion;
|
||||
|
||||
const canContinueDesdeDetalles = (() => {
|
||||
if (wizard.modoCreacion === "MANUAL") return true;
|
||||
if (wizard.modoCreacion === "IA") {
|
||||
return !!wizard.iaConfig?.descripcionEnfoque;
|
||||
// Requerimos descripción del enfoque y notas adicionales
|
||||
return !!wizard.iaConfig?.descripcionEnfoque &&
|
||||
!!wizard.iaConfig?.notasAdicionales;
|
||||
}
|
||||
if (wizard.modoCreacion === "CLONADO") {
|
||||
if (wizard.subModoClonado === "INTERNO") {
|
||||
@@ -72,12 +98,24 @@ export function useNuevoPlanWizard() {
|
||||
const generarPreviewIA = async () => {
|
||||
setWizard((w) => ({ ...w, isLoading: true, errorMessage: null }));
|
||||
await new Promise((r) => setTimeout(r, 800));
|
||||
// Ensure preview has the stricter types required by `PlanPreview`.
|
||||
let tipoCicloSafe: TipoCiclo;
|
||||
if (wizard.datosBasicos.tipoCiclo === "") {
|
||||
tipoCicloSafe = "SEMESTRE";
|
||||
} else {
|
||||
tipoCicloSafe = wizard.datosBasicos.tipoCiclo;
|
||||
}
|
||||
const numCiclosSafe: number =
|
||||
typeof wizard.datosBasicos.numCiclos === "number"
|
||||
? wizard.datosBasicos.numCiclos
|
||||
: 1;
|
||||
|
||||
const preview: PlanPreview = {
|
||||
nombrePlan: wizard.datosBasicos.nombrePlan || "Plan sin nombre",
|
||||
nivel: wizard.datosBasicos.nivel || "Licenciatura",
|
||||
tipoCiclo: wizard.datosBasicos.tipoCiclo,
|
||||
numCiclos: wizard.datosBasicos.numCiclos,
|
||||
numAsignaturasAprox: wizard.datosBasicos.numCiclos * 6,
|
||||
tipoCiclo: tipoCicloSafe,
|
||||
numCiclos: numCiclosSafe,
|
||||
numAsignaturasAprox: numCiclosSafe * 6,
|
||||
secciones: [
|
||||
{ id: "obj", titulo: "Objetivos", resumen: "Borrador de objetivos…" },
|
||||
{ id: "perfil", titulo: "Perfil de egreso", resumen: "Borrador…" },
|
||||
@@ -20,20 +20,46 @@ export type NewPlanWizardState = {
|
||||
carreraId: string;
|
||||
facultadId: string;
|
||||
nivel: string;
|
||||
tipoCiclo: TipoCiclo;
|
||||
numCiclos: number;
|
||||
tipoCiclo: TipoCiclo | "";
|
||||
numCiclos: number | undefined;
|
||||
// Selección de plantillas (obligatorias)
|
||||
plantillaPlanId?: string;
|
||||
plantillaPlanVersion?: string;
|
||||
plantillaMapaId?: string;
|
||||
plantillaMapaVersion?: string;
|
||||
};
|
||||
clonInterno?: { planOrigenId: string | null };
|
||||
clonTradicional?: {
|
||||
archivoWordPlanId: string | null;
|
||||
archivoMapaExcelId: string | null;
|
||||
archivoAsignaturasExcelId: string | null;
|
||||
archivoWordPlanId:
|
||||
| {
|
||||
id: string;
|
||||
name: string;
|
||||
size: string;
|
||||
type: string;
|
||||
}
|
||||
| null;
|
||||
archivoMapaExcelId: {
|
||||
id: string;
|
||||
name: string;
|
||||
size: string;
|
||||
type: string;
|
||||
} | null;
|
||||
archivoAsignaturasExcelId: {
|
||||
id: string;
|
||||
name: string;
|
||||
size: string;
|
||||
type: string;
|
||||
} | null;
|
||||
};
|
||||
iaConfig?: {
|
||||
descripcionEnfoque: string;
|
||||
poblacionObjetivo: string;
|
||||
notasAdicionales: string;
|
||||
archivosReferencia: Array<string>;
|
||||
repositoriosReferencia?: Array<string>;
|
||||
archivosAdjuntos?: Array<
|
||||
{ id: string; name: string; size: string; type: string }
|
||||
>;
|
||||
};
|
||||
resumen: { previewPlan?: PlanPreview };
|
||||
isLoading: boolean;
|
||||
@@ -12,11 +12,9 @@ import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as LoginRouteImport } from './routes/login'
|
||||
import { Route as DashboardRouteImport } from './routes/dashboard'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as MateriasIndexRouteImport } from './routes/materias/index'
|
||||
import { Route as DemoTanstackQueryRouteImport } from './routes/demo/tanstack-query'
|
||||
import { Route as PlanesListaRouteRouteImport } from './routes/planes/_lista/route'
|
||||
import { Route as PlanesListaNuevoRouteImport } from './routes/planes/_lista/nuevo'
|
||||
import { Route as MateriasMateriaIdMateriaIdRouteImport } from './routes/materias/$materiaId/$materiaId'
|
||||
import { Route as PlanesPlanIdAsignaturasRouteRouteImport } from './routes/planes/$planId/asignaturas/route'
|
||||
import { Route as PlanesPlanIdDetalleRouteRouteImport } from './routes/planes/$planId/_detalle/route'
|
||||
import { Route as PlanesPlanIdAsignaturasIndexRouteImport } from './routes/planes/$planId/asignaturas/index'
|
||||
@@ -46,11 +44,6 @@ const IndexRoute = IndexRouteImport.update({
|
||||
path: '/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const MateriasIndexRoute = MateriasIndexRouteImport.update({
|
||||
id: '/materias/',
|
||||
path: '/materias/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const DemoTanstackQueryRoute = DemoTanstackQueryRouteImport.update({
|
||||
id: '/demo/tanstack-query',
|
||||
path: '/demo/tanstack-query',
|
||||
@@ -66,12 +59,6 @@ const PlanesListaNuevoRoute = PlanesListaNuevoRouteImport.update({
|
||||
path: '/nuevo',
|
||||
getParentRoute: () => PlanesListaRouteRoute,
|
||||
} as any)
|
||||
const MateriasMateriaIdMateriaIdRoute =
|
||||
MateriasMateriaIdMateriaIdRouteImport.update({
|
||||
id: '/materias/$materiaId/$materiaId',
|
||||
path: '/materias/$materiaId/$materiaId',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const PlanesPlanIdAsignaturasRouteRoute =
|
||||
PlanesPlanIdAsignaturasRouteRouteImport.update({
|
||||
id: '/planes/$planId/asignaturas',
|
||||
@@ -155,10 +142,8 @@ export interface FileRoutesByFullPath {
|
||||
'/login': typeof LoginRoute
|
||||
'/planes': typeof PlanesListaRouteRouteWithChildren
|
||||
'/demo/tanstack-query': typeof DemoTanstackQueryRoute
|
||||
'/materias': typeof MateriasIndexRoute
|
||||
'/planes/$planId': typeof PlanesPlanIdDetalleRouteRouteWithChildren
|
||||
'/planes/$planId/asignaturas': typeof PlanesPlanIdAsignaturasListaRouteRouteWithChildren
|
||||
'/materias/$materiaId/$materiaId': typeof MateriasMateriaIdMateriaIdRoute
|
||||
'/planes/nuevo': typeof PlanesListaNuevoRoute
|
||||
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute
|
||||
'/planes/$planId/datos': typeof PlanesPlanIdDetalleDatosRoute
|
||||
@@ -177,9 +162,7 @@ export interface FileRoutesByTo {
|
||||
'/login': typeof LoginRoute
|
||||
'/planes': typeof PlanesListaRouteRouteWithChildren
|
||||
'/demo/tanstack-query': typeof DemoTanstackQueryRoute
|
||||
'/materias': typeof MateriasIndexRoute
|
||||
'/planes/$planId': typeof PlanesPlanIdDetalleRouteRouteWithChildren
|
||||
'/materias/$materiaId/$materiaId': typeof MateriasMateriaIdMateriaIdRoute
|
||||
'/planes/nuevo': typeof PlanesListaNuevoRoute
|
||||
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute
|
||||
'/planes/$planId/asignaturas': typeof PlanesPlanIdAsignaturasIndexRoute
|
||||
@@ -199,10 +182,8 @@ export interface FileRoutesById {
|
||||
'/login': typeof LoginRoute
|
||||
'/planes/_lista': typeof PlanesListaRouteRouteWithChildren
|
||||
'/demo/tanstack-query': typeof DemoTanstackQueryRoute
|
||||
'/materias/': typeof MateriasIndexRoute
|
||||
'/planes/$planId/_detalle': typeof PlanesPlanIdDetalleRouteRouteWithChildren
|
||||
'/planes/$planId/asignaturas': typeof PlanesPlanIdAsignaturasRouteRouteWithChildren
|
||||
'/materias/$materiaId/$materiaId': typeof MateriasMateriaIdMateriaIdRoute
|
||||
'/planes/_lista/nuevo': typeof PlanesListaNuevoRoute
|
||||
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute
|
||||
'/planes/$planId/asignaturas/_lista': typeof PlanesPlanIdAsignaturasListaRouteRouteWithChildren
|
||||
@@ -224,10 +205,8 @@ export interface FileRouteTypes {
|
||||
| '/login'
|
||||
| '/planes'
|
||||
| '/demo/tanstack-query'
|
||||
| '/materias'
|
||||
| '/planes/$planId'
|
||||
| '/planes/$planId/asignaturas'
|
||||
| '/materias/$materiaId/$materiaId'
|
||||
| '/planes/nuevo'
|
||||
| '/planes/$planId/asignaturas/$asignaturaId'
|
||||
| '/planes/$planId/datos'
|
||||
@@ -246,9 +225,7 @@ export interface FileRouteTypes {
|
||||
| '/login'
|
||||
| '/planes'
|
||||
| '/demo/tanstack-query'
|
||||
| '/materias'
|
||||
| '/planes/$planId'
|
||||
| '/materias/$materiaId/$materiaId'
|
||||
| '/planes/nuevo'
|
||||
| '/planes/$planId/asignaturas/$asignaturaId'
|
||||
| '/planes/$planId/asignaturas'
|
||||
@@ -267,10 +244,8 @@ export interface FileRouteTypes {
|
||||
| '/login'
|
||||
| '/planes/_lista'
|
||||
| '/demo/tanstack-query'
|
||||
| '/materias/'
|
||||
| '/planes/$planId/_detalle'
|
||||
| '/planes/$planId/asignaturas'
|
||||
| '/materias/$materiaId/$materiaId'
|
||||
| '/planes/_lista/nuevo'
|
||||
| '/planes/$planId/asignaturas/$asignaturaId'
|
||||
| '/planes/$planId/asignaturas/_lista'
|
||||
@@ -291,10 +266,8 @@ export interface RootRouteChildren {
|
||||
LoginRoute: typeof LoginRoute
|
||||
PlanesListaRouteRoute: typeof PlanesListaRouteRouteWithChildren
|
||||
DemoTanstackQueryRoute: typeof DemoTanstackQueryRoute
|
||||
MateriasIndexRoute: typeof MateriasIndexRoute
|
||||
PlanesPlanIdDetalleRouteRoute: typeof PlanesPlanIdDetalleRouteRouteWithChildren
|
||||
PlanesPlanIdAsignaturasRouteRoute: typeof PlanesPlanIdAsignaturasRouteRouteWithChildren
|
||||
MateriasMateriaIdMateriaIdRoute: typeof MateriasMateriaIdMateriaIdRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
@@ -320,13 +293,6 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof IndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/materias/': {
|
||||
id: '/materias/'
|
||||
path: '/materias'
|
||||
fullPath: '/materias'
|
||||
preLoaderRoute: typeof MateriasIndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/demo/tanstack-query': {
|
||||
id: '/demo/tanstack-query'
|
||||
path: '/demo/tanstack-query'
|
||||
@@ -348,13 +314,6 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof PlanesListaNuevoRouteImport
|
||||
parentRoute: typeof PlanesListaRouteRoute
|
||||
}
|
||||
'/materias/$materiaId/$materiaId': {
|
||||
id: '/materias/$materiaId/$materiaId'
|
||||
path: '/materias/$materiaId/$materiaId'
|
||||
fullPath: '/materias/$materiaId/$materiaId'
|
||||
preLoaderRoute: typeof MateriasMateriaIdMateriaIdRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/planes/$planId/asignaturas': {
|
||||
id: '/planes/$planId/asignaturas'
|
||||
path: '/planes/$planId/asignaturas'
|
||||
@@ -527,11 +486,9 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
LoginRoute: LoginRoute,
|
||||
PlanesListaRouteRoute: PlanesListaRouteRouteWithChildren,
|
||||
DemoTanstackQueryRoute: DemoTanstackQueryRoute,
|
||||
MateriasIndexRoute: MateriasIndexRoute,
|
||||
PlanesPlanIdDetalleRouteRoute: PlanesPlanIdDetalleRouteRouteWithChildren,
|
||||
PlanesPlanIdAsignaturasRouteRoute:
|
||||
PlanesPlanIdAsignaturasRouteRouteWithChildren,
|
||||
MateriasMateriaIdMateriaIdRoute: MateriasMateriaIdMateriaIdRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
import { Pencil, X, Info } from 'lucide-react';
|
||||
import { Pencil, X } from 'lucide-react';
|
||||
export type Materia = {
|
||||
id: string;
|
||||
clave: string;
|
||||
|
||||
@@ -1,41 +1,155 @@
|
||||
import { usePlan } from '@/data';
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useState } from 'react'
|
||||
import type { DatosGeneralesField } from '@/types/plan'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Pencil,
|
||||
Check,
|
||||
X,
|
||||
Sparkles,
|
||||
AlertCircle
|
||||
} from 'lucide-react'
|
||||
//import { toast } from 'sonner' // Asegúrate de tener sonner instalado o quita la línea
|
||||
|
||||
export const Route = createFileRoute('/planes/$planId/_detalle/datos')({
|
||||
component: DatosGenerales,
|
||||
component: DatosGeneralesPage,
|
||||
})
|
||||
|
||||
function DatosGenerales() {
|
||||
function DatosGeneralesPage() {
|
||||
const {data, isFetching} = usePlan('0e0aea4d-b8b4-4e75-8279-6224c3ac769f');
|
||||
if(!isFetching && !data) {
|
||||
return <div>No se encontró el plan de estudios.</div>
|
||||
}
|
||||
console.log(data);
|
||||
|
||||
// 1. Definimos los DATOS iniciales (Lo que antes venía por props)
|
||||
const [campos, setCampos] = useState<DatosGeneralesField[]>([
|
||||
{ id: '1', label: 'Objetivo General', value: 'Formar profesionales...', requerido: true, tipo: 'texto' },
|
||||
{ id: '2', label: 'Perfil de Ingreso', value: 'Interés por la tecnología...', requerido: true, tipo: 'lista' },
|
||||
{ id: '3', label: 'Perfil de Egreso', value: '', requerido: true, tipo: 'texto' },
|
||||
])
|
||||
|
||||
// 2. Estados de edición
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [editValue, setEditValue] = useState('')
|
||||
|
||||
// 3. Manejadores de acciones (Ahora como funciones locales)
|
||||
const handleEdit = (campo: DatosGeneralesField) => {
|
||||
setEditingId(campo.id)
|
||||
setEditValue(campo.value)
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
setEditingId(null)
|
||||
setEditValue('')
|
||||
}
|
||||
|
||||
const handleSave = (id: string) => {
|
||||
// Actualizamos el estado local de la lista
|
||||
setCampos(prev => prev.map(c =>
|
||||
c.id === id ? { ...c, value: editValue } : c
|
||||
))
|
||||
setEditingId(null)
|
||||
setEditValue('')
|
||||
//toast.success('Cambios guardados localmente')
|
||||
}
|
||||
|
||||
const handleIARequest = (id: string) => {
|
||||
//toast.info('La IA está analizando el campo ' + id)
|
||||
// Aquí conectarías con tu endpoint de IA
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<Card title="Objetivo General">
|
||||
Formar profesionales altamente capacitados...
|
||||
</Card>
|
||||
<div className="container mx-auto px-6 py-6 animate-in fade-in duration-500">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
Datos Generales del Plan
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Información estructural y descriptiva del plan de estudios
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card title="Perfil de Ingreso">
|
||||
Egresados de educación media superior...
|
||||
</Card>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{campos.map((campo) => {
|
||||
const isEditing = editingId === campo.id
|
||||
|
||||
<Card title="Perfil de Egreso">
|
||||
Profesional capaz de diseñar...
|
||||
</Card>
|
||||
return (
|
||||
<div
|
||||
key={campo.id}
|
||||
className={`border rounded-xl transition-all ${
|
||||
isEditing ? 'border-teal-500 ring-2 ring-teal-50 shadow-lg' : 'bg-white hover:shadow-md'
|
||||
}`}
|
||||
>
|
||||
{/* Header de la Card */}
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b bg-slate-50/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium text-sm text-slate-700">{campo.label}</h3>
|
||||
{campo.requerido && <span className="text-red-500 text-xs">*</span>}
|
||||
</div>
|
||||
|
||||
<Card title="Competencias Genéricas">
|
||||
Pensamiento crítico, comunicación efectiva...
|
||||
</Card>
|
||||
{!isEditing && (
|
||||
<div className="flex gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-teal-600" onClick={() => handleIARequest(campo.id)}>
|
||||
<Sparkles size={14} />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => handleEdit(campo)}>
|
||||
<Pencil size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Contenido de la Card */}
|
||||
<div className="p-5">
|
||||
{isEditing ? (
|
||||
<div className="space-y-3">
|
||||
<Textarea
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
className="min-h-[120px]"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleCancel}>
|
||||
<X size={14} className="mr-1" /> Cancelar
|
||||
</Button>
|
||||
<Button size="sm" className="bg-teal-600 hover:bg-teal-700" onClick={() => handleSave(campo.id)}>
|
||||
<Check size={14} className="mr-1" /> Guardar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="min-h-[100px]">
|
||||
{campo.value ? (
|
||||
<div className="text-sm text-slate-600 leading-relaxed">
|
||||
{campo.tipo === 'lista' ? (
|
||||
<ul className="space-y-1">
|
||||
{campo.value.split('\n').map((item, i) => (
|
||||
<li key={i} className="flex gap-2">
|
||||
<span className="mt-1.5 h-1.5 w-1.5 rounded-full bg-teal-500 shrink-0" />
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="whitespace-pre-wrap">{campo.value}</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-slate-400 text-sm">
|
||||
<AlertCircle size={14} />
|
||||
<span>Sin contenido.</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface CustomCardProps {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function Card({ title, children }: CustomCardProps) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-white p-4">
|
||||
<h3 className="font-semibold mb-2">{title}</h3>
|
||||
<p className="text-sm text-gray-600">{children}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,123 +1,302 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { MateriaCard } from './MateriaCard';
|
||||
import type { Materia } from './MateriaCard'; // Agregamos 'type' aquí
|
||||
import { useState } from 'react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Plus,
|
||||
ChevronDown,
|
||||
AlertTriangle,
|
||||
GripVertical,
|
||||
Trash2
|
||||
} from 'lucide-react'
|
||||
import type { Materia, LineaCurricular } from '@/types/plan'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle
|
||||
} from "@/components/ui/dialog"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
|
||||
export const Route = createFileRoute('/planes/$planId/_detalle/mapa')({
|
||||
component: MapaCurricular,
|
||||
component: MapaCurricularPage,
|
||||
})
|
||||
|
||||
const CICLOS = ["Ciclo 1", "Ciclo 2", "Ciclo 3", "Ciclo 4", "Ciclo 5", "Ciclo 6", "Ciclo 7", "Ciclo 8", "Ciclo 9"];
|
||||
const LINEAS = ["Formación Básica", "Ciencias de la Computación", "Desarrollo de Software", "Redes y Seguridad", "Gestión y Profesionalización"];
|
||||
// --- Constantes de Estilo y Datos ---
|
||||
const INITIAL_LINEAS: LineaCurricular[] = [
|
||||
{ id: 'l1', nombre: 'Formación Básica', orden: 1 },
|
||||
{ id: 'l2', nombre: 'Ciencias de la Computación', orden: 2 },
|
||||
];
|
||||
|
||||
// Ejemplo de materia
|
||||
const MATERIAS: Materia[] = [
|
||||
{
|
||||
id: "1",
|
||||
clave: 'MAT101',
|
||||
nombre: 'Cálculo Diferencial',
|
||||
creditos: 8,
|
||||
hd: 4,
|
||||
hi: 4,
|
||||
ciclo: 1,
|
||||
linea: 'Formación Básica',
|
||||
tipo: 'Obligatoria',
|
||||
estado: 'Aprobada',
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
clave: 'FIS101',
|
||||
nombre: 'Física Mecánica',
|
||||
creditos: 6,
|
||||
hd: 3,
|
||||
hi: 3,
|
||||
ciclo: 1,
|
||||
linea: 'Formación Básica',
|
||||
tipo: 'Obligatoria',
|
||||
estado: 'Aprobada',
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
clave: 'PRO101',
|
||||
nombre: 'Fundamentos de Programación',
|
||||
creditos: 8,
|
||||
hd: 4,
|
||||
hi: 4,
|
||||
ciclo: 1,
|
||||
linea: 'Ciencias de la Computación',
|
||||
tipo: 'Obligatoria',
|
||||
estado: 'Revisada',
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
clave: 'EST101',
|
||||
nombre: 'Estructura de Datos',
|
||||
creditos: 6,
|
||||
hd: 3,
|
||||
hi: 3,
|
||||
ciclo: 2,
|
||||
linea: 'Ciencias de la Computación',
|
||||
tipo: 'Obligatoria',
|
||||
estado: 'Borrador',
|
||||
},
|
||||
]
|
||||
const INITIAL_MATERIAS: Materia[] = [
|
||||
{ id: "1", clave: 'MAT101', nombre: 'Cálculo Diferencial', creditos: 8, hd: 4, hi: 4, ciclo: 1, lineaCurricularId: 'l1', tipo: 'obligatoria', estado: 'aprobada' },
|
||||
{ id: "2", clave: 'FIS101', nombre: 'Física Mecánica', creditos: 6, hd: 3, hi: 3, ciclo: 1, lineaCurricularId: 'l1', tipo: 'obligatoria', estado: 'aprobada' },
|
||||
{ id: "3", clave: 'PRO101', nombre: 'Fundamentos de Programación', creditos: 8, hd: 4, hi: 4, ciclo: null, lineaCurricularId: null, tipo: 'obligatoria', estado: 'borrador' },
|
||||
];
|
||||
|
||||
function MapaCurricular() {
|
||||
const lineColors = [
|
||||
'bg-blue-50 border-blue-200 text-blue-700',
|
||||
'bg-purple-50 border-purple-200 text-purple-700',
|
||||
'bg-orange-50 border-orange-200 text-orange-700',
|
||||
'bg-emerald-50 border-emerald-200 text-emerald-700',
|
||||
];
|
||||
|
||||
const statusBadge: Record<string, string> = {
|
||||
borrador: 'bg-slate-100 text-slate-600',
|
||||
revisada: 'bg-amber-100 text-amber-700',
|
||||
aprobada: 'bg-emerald-100 text-emerald-700',
|
||||
};
|
||||
|
||||
// --- Subcomponentes ---
|
||||
function StatItem({ label, value, total }: { label: string, value: number, total?: number }) {
|
||||
return (
|
||||
<div className="p-4 overflow-x-auto">
|
||||
<h2 className="text-xl font-semibold mb-6">Mapa Curricular</h2>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">{label}:</span>
|
||||
<span className="text-sm font-bold text-slate-700">
|
||||
{value}{total ? <span className="text-slate-400 font-normal">/{total}</span> : ''}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
{/* Contenedor de la Grid */}
|
||||
<div
|
||||
className="grid min-w-[1200px] border-l border-t border-slate-200"
|
||||
style={{
|
||||
// 1 columna para nombres de líneas + 9 ciclos
|
||||
gridTemplateColumns: '200px repeat(9, 1fr)',
|
||||
}}
|
||||
>
|
||||
{/* Header: Espacio vacío + Ciclos */}
|
||||
<div className="bg-slate-50 p-2 border-r border-b border-slate-200 font-medium text-sm text-slate-500">
|
||||
Línea Curricular
|
||||
</div>
|
||||
{CICLOS.map((ciclo) => (
|
||||
<div key={ciclo} className="bg-slate-50 p-2 border-r border-b border-slate-200 text-center font-medium text-sm text-slate-500">
|
||||
{ciclo}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Filas por cada Línea Curricular */}
|
||||
{LINEAS.map((linea) => (
|
||||
<>
|
||||
{/* Nombre de la línea (Primera columna) */}
|
||||
<div className="bg-slate-50 p-3 border-r border-b border-slate-200 flex items-center text-xs font-bold uppercase text-slate-600">
|
||||
{linea}
|
||||
</div>
|
||||
|
||||
{/* Celdas para cada ciclo en esta línea */}
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8, 9].map((numCiclo) => (
|
||||
<div
|
||||
key={`${linea}-${numCiclo}`}
|
||||
className="p-2 border-r border-b border-slate-100 min-h-[120px] bg-white/50"
|
||||
>
|
||||
{/* Filtrar materias que pertenecen a esta posición */}
|
||||
{MATERIAS.filter(m => m.linea === linea && m.ciclo === numCiclo).map((materia) => (
|
||||
<MateriaCard key={materia.id} materia={materia} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
))}
|
||||
function MateriaCardItem({ materia, onDragStart, isDragging, onClick }: {
|
||||
materia: Materia,
|
||||
onDragStart: (e: React.DragEvent, id: string) => void,
|
||||
isDragging: boolean,
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
draggable
|
||||
onDragStart={(e) => onDragStart(e, materia.id)}
|
||||
onClick={onClick}
|
||||
className={`group p-3 rounded-lg border bg-white shadow-sm cursor-grab active:cursor-grabbing transition-all ${
|
||||
isDragging ? 'opacity-40 scale-95' : 'hover:border-teal-400 hover:shadow-md'
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-start mb-1">
|
||||
<span className="text-[10px] font-mono font-bold text-slate-400">{materia.clave}</span>
|
||||
<Badge variant="outline" className={`text-[9px] px-1 py-0 uppercase ${statusBadge[materia.estado] || ''}`}>
|
||||
{materia.estado}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Sección de materias sin asignar (como en tu imagen) */}
|
||||
<div className="mt-8">
|
||||
<h3 className="text-sm font-bold text-slate-500 mb-3 uppercase tracking-wider">Materias sin asignar</h3>
|
||||
<div className="flex gap-4">
|
||||
<div className="p-3 border rounded-lg bg-slate-50 border-dashed border-slate-300 w-48 text-[10px]">
|
||||
<div className="font-bold">Inglés Técnico</div>
|
||||
<div className="text-slate-500">4 cr • HD: 2 • HI: 2</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs font-bold text-slate-700 leading-tight mb-1">{materia.nombre}</p>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<span className="text-[10px] text-slate-500">{materia.creditos} CR • HD:{materia.hd} • HI:{materia.hi}</span>
|
||||
<GripVertical size={12} className="text-slate-300 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Componente Principal ---
|
||||
function MapaCurricularPage() {
|
||||
const [materias, setMaterias] = useState<Materia[]>(INITIAL_MATERIAS);
|
||||
const [lineas, setLineas] = useState<LineaCurricular[]>(INITIAL_LINEAS);
|
||||
const [draggedMateria, setDraggedMateria] = useState<string | null>(null);
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
const [selectedMateria, setSelectedMateria] = useState<Materia | null>(null);
|
||||
|
||||
const ciclosTotales = 9;
|
||||
const ciclosArray = Array.from({ length: ciclosTotales }, (_, i) => i + 1);
|
||||
|
||||
// --- Lógica de Gestión ---
|
||||
const agregarLinea = (nombre: string) => {
|
||||
const nueva = { id: crypto.randomUUID(), nombre, orden: lineas.length + 1 };
|
||||
setLineas([...lineas, nueva]);
|
||||
};
|
||||
|
||||
const borrarLinea = (id: string) => {
|
||||
setMaterias(prev => prev.map(m => m.lineaCurricularId === id ? { ...m, ciclo: null, lineaCurricularId: null } : m));
|
||||
setLineas(prev => prev.filter(l => l.id !== id));
|
||||
};
|
||||
|
||||
const getTotalesCiclo = (ciclo: number) => {
|
||||
return materias.filter(m => m.ciclo === ciclo).reduce((acc, m) => ({
|
||||
cr: acc.cr + (m.creditos || 0), hd: acc.hd + (m.hd || 0), hi: acc.hi + (m.hi || 0)
|
||||
}), { cr: 0, hd: 0, hi: 0 });
|
||||
};
|
||||
|
||||
const getSubtotalLinea = (lineaId: string) => {
|
||||
return materias.filter(m => m.lineaCurricularId === lineaId && m.ciclo !== null).reduce((acc, m) => ({
|
||||
cr: acc.cr + (m.creditos || 0), hd: acc.hd + (m.hd || 0), hi: acc.hi + (m.hi || 0)
|
||||
}), { cr: 0, hd: 0, hi: 0 });
|
||||
};
|
||||
|
||||
// --- Handlers Drag & Drop ---
|
||||
const handleDragStart = (e: React.DragEvent, id: string) => { setDraggedMateria(id); e.dataTransfer.effectAllowed = 'move'; };
|
||||
const handleDragOver = (e: React.DragEvent) => e.preventDefault();
|
||||
const handleDrop = (e: React.DragEvent, ciclo: number | null, lineaId: string | null) => {
|
||||
e.preventDefault();
|
||||
if (draggedMateria) {
|
||||
setMaterias(prev => prev.map(m => m.id === draggedMateria ? { ...m, ciclo, lineaCurricularId: lineaId } : m));
|
||||
setDraggedMateria(null);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Estadísticas Generales ---
|
||||
const stats = materias.reduce((acc, m) => {
|
||||
if (m.ciclo !== null) {
|
||||
acc.cr += m.creditos || 0; acc.hd += m.hd || 0; acc.hi += m.hi || 0;
|
||||
}
|
||||
return acc;
|
||||
}, { cr: 0, hd: 0, hi: 0 });
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-2 py-6">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold">Mapa Curricular</h2>
|
||||
<p className="text-sm text-slate-500">Organiza las materias por línea curricular y ciclo</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{materias.filter(m => !m.ciclo).length > 0 && (
|
||||
<Badge className="bg-amber-50 text-amber-600 border-amber-100 hover:bg-amber-50">
|
||||
<AlertTriangle size={14} className="mr-1" /> {materias.filter(m => !m.ciclo).length} materias sin asignar
|
||||
</Badge>
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button className="bg-teal-700 hover:bg-teal-800 text-white">
|
||||
<Plus size={16} className="mr-2" /> Agregar <ChevronDown size={14} className="ml-2" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => agregarLinea("Nueva Línea")}>Nueva Línea Curricular</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => agregarLinea("Área Común")}>Agregar Área Común</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Barra Totales */}
|
||||
<div className="bg-slate-50/80 border border-slate-200 rounded-xl p-4 mb-8 flex gap-10">
|
||||
<StatItem label="Total Créditos" value={stats.cr} total={320} />
|
||||
<StatItem label="Total HD" value={stats.hd} />
|
||||
<StatItem label="Total HI" value={stats.hi} />
|
||||
<StatItem label="Total Horas" value={stats.hd + stats.hi} />
|
||||
</div>
|
||||
|
||||
{/* Grid Principal */}
|
||||
<div className="overflow-x-auto pb-6">
|
||||
<div className="min-w-[1500px]">
|
||||
{/* Header Ciclos */}
|
||||
<div className="grid gap-3 mb-4" style={{ gridTemplateColumns: `220px repeat(${ciclosTotales}, 1fr) 120px` }}>
|
||||
<div className="text-xs font-bold text-slate-400 self-end px-2">LÍNEA CURRICULAR</div>
|
||||
{ciclosArray.map(n => <div key={n} className="bg-slate-100 rounded-lg p-2 text-center text-sm font-bold text-slate-600">Ciclo {n}</div>)}
|
||||
<div className="text-xs font-bold text-slate-400 self-end text-center">SUBTOTAL</div>
|
||||
</div>
|
||||
|
||||
{/* Filas por Línea */}
|
||||
{lineas.map((linea, idx) => {
|
||||
const sub = getSubtotalLinea(linea.id);
|
||||
return (
|
||||
<div key={linea.id} className="grid gap-3 mb-3" style={{ gridTemplateColumns: `220px repeat(${ciclosTotales}, 1fr) 120px` }}>
|
||||
<div className={`p-4 rounded-xl border-l-4 flex justify-between items-center ${lineColors[idx % lineColors.length]}`}>
|
||||
<span className="text-xs font-bold">{linea.nombre}</span>
|
||||
<Trash2 size={14} className="text-slate-400 hover:text-red-500 cursor-pointer" onClick={() => borrarLinea(linea.id)} />
|
||||
</div>
|
||||
|
||||
{ciclosArray.map(ciclo => (
|
||||
<div
|
||||
key={ciclo}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={(e) => handleDrop(e, ciclo, linea.id)}
|
||||
className="min-h-[140px] p-2 rounded-xl border-2 border-dashed border-slate-100 bg-slate-50/20 space-y-2"
|
||||
>
|
||||
{materias.filter(m => m.ciclo === ciclo && m.lineaCurricularId === linea.id).map(m => (
|
||||
<MateriaCardItem key={m.id} materia={m} isDragging={draggedMateria === m.id} onDragStart={handleDragStart} onClick={() => { setSelectedMateria(m); setIsEditModalOpen(true); }} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="p-4 bg-slate-50 rounded-xl flex flex-col justify-center text-[10px] text-slate-500 font-medium border border-slate-100">
|
||||
<div>Cr: {sub.cr}</div><div>HD: {sub.hd}</div><div>HI: {sub.hi}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Fila Totales Ciclo */}
|
||||
<div className="grid gap-3 mt-6 border-t pt-4" style={{ gridTemplateColumns: `220px repeat(${ciclosTotales}, 1fr) 120px` }}>
|
||||
<div className="p-2 font-bold text-slate-600">Totales por Ciclo</div>
|
||||
{ciclosArray.map(ciclo => {
|
||||
const t = getTotalesCiclo(ciclo);
|
||||
return (
|
||||
<div key={ciclo} className="text-[10px] text-center p-2 bg-slate-50 rounded-lg">
|
||||
<div className="font-bold text-slate-700">Cr: {t.cr}</div>
|
||||
<div>HD: {t.hd} • HI: {t.hi}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="bg-teal-50 rounded-lg p-2 text-center text-teal-800 font-bold text-xs flex flex-col justify-center">
|
||||
<div>{stats.cr} Cr</div><div>{stats.hd + stats.hi} Hrs</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal de Edición */}
|
||||
<Dialog open={isEditModalOpen} onOpenChange={setIsEditModalOpen}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader><DialogTitle>Editar Materia</DialogTitle></DialogHeader>
|
||||
{selectedMateria && (
|
||||
<div className="grid grid-cols-2 gap-4 py-4">
|
||||
<div className="space-y-2"><label className="text-xs font-bold uppercase">Clave</label><Input defaultValue={selectedMateria.clave} /></div>
|
||||
<div className="space-y-2"><label className="text-xs font-bold uppercase">Nombre</label><Input defaultValue={selectedMateria.nombre} /></div>
|
||||
<div className="space-y-2"><label className="text-xs font-bold uppercase">Créditos</label><Input type="number" defaultValue={selectedMateria.creditos} /></div>
|
||||
<div className="flex gap-2">
|
||||
<div className="space-y-2"><label className="text-xs font-bold uppercase">HD</label><Input type="number" defaultValue={selectedMateria.hd} /></div>
|
||||
<div className="space-y-2"><label className="text-xs font-bold uppercase">HI</label><Input type="number" defaultValue={selectedMateria.hi} /></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-3 mt-4">
|
||||
<Button variant="outline" onClick={() => setIsEditModalOpen(false)}>Cancelar</Button>
|
||||
<Button className="bg-teal-700 text-white">Guardar Cambios</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 4. Materias Pendientes (Sin Asignar) */}
|
||||
{materias.filter(m => m.ciclo === null).length > 0 && (
|
||||
<div className="mt-10 p-6 bg-slate-50 rounded-2xl border border-slate-200 shadow-sm animate-in slide-in-from-bottom-4 duration-500">
|
||||
<div className="flex items-center gap-2 mb-4 text-amber-600">
|
||||
<AlertTriangle size={20} />
|
||||
<h3 className="font-bold text-sm uppercase tracking-tight">
|
||||
Materias pendientes de asignar ({materias.filter(m => m.ciclo === null).length})
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`flex flex-wrap gap-4 min-h-[100px] p-4 rounded-xl border-2 border-dashed transition-all ${
|
||||
draggedMateria ? 'border-amber-200 bg-amber-50/50' : 'border-slate-200 bg-white/50'
|
||||
}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={(e) => handleDrop(e, null, null)} // null devuelve la materia al estado "sin asignar"
|
||||
>
|
||||
{materias
|
||||
.filter(m => m.ciclo === null)
|
||||
.map(m => (
|
||||
<div key={m.id} className="w-[200px]">
|
||||
<MateriaCardItem
|
||||
materia={m}
|
||||
isDragging={draggedMateria === m.id}
|
||||
onDragStart={handleDragStart}
|
||||
onClick={() => { setSelectedMateria(m); setIsEditModalOpen(true); }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-3 text-[11px] text-slate-400 italic text-center">
|
||||
Arrastra las materias desde aquí hacia cualquier ciclo y línea del mapa curricular.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
import { NuevaAsignaturaModalContainer } from '@/features/asignaturas/new/NuevaAsignaturaModalContainer'
|
||||
import { NuevaAsignaturaModalContainer } from '@/features/asignaturas/nueva/NuevaAsignaturaModalContainer'
|
||||
|
||||
export const Route = createFileRoute(
|
||||
'/planes/$planId/asignaturas/_lista/nueva',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
import NuevoPlanModalContainer from '@/features/planes/new/NuevoPlanModalContainer'
|
||||
import NuevoPlanModalContainer from '@/features/planes/nuevo/NuevoPlanModalContainer'
|
||||
|
||||
export const Route = createFileRoute('/planes/_lista/nuevo')({
|
||||
component: NuevoPlanModalContainer,
|
||||
|
||||
@@ -183,10 +183,19 @@ function RouteComponent() {
|
||||
|
||||
// Filtrado de planes
|
||||
const filteredPlans = useMemo(() => {
|
||||
const term = search.trim().toLowerCase()
|
||||
// Función helper para limpiar texto (quita acentos y hace minúsculas)
|
||||
const cleanText = (text: string) => {
|
||||
return text
|
||||
.normalize('NFD') // Descompone "á" en "a" + "´"
|
||||
.replace(/[\u0300-\u036f]/g, '') // Elimina los símbolos diacríticos
|
||||
.toLowerCase() // Convierte a minúsculas
|
||||
}
|
||||
// Limpiamos el término de búsqueda una sola vez antes de filtrar
|
||||
const term = cleanText(search.trim())
|
||||
return planes.filter((p) => {
|
||||
const matchName = term
|
||||
? p.nombrePrograma.toLowerCase().includes(term)
|
||||
? // Limpiamos también el nombre del programa antes de comparar
|
||||
cleanText(p.nombrePrograma).includes(term)
|
||||
: true
|
||||
const matchFac =
|
||||
facultadSel === 'todas' ? true : p.facultadId === facultadSel
|
||||
|
||||
119
src/types/materia.ts
Normal file
119
src/types/materia.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
export type MateriaTab =
|
||||
| 'datos-generales'
|
||||
| 'contenido-tematico'
|
||||
| 'bibliografia'
|
||||
| 'ia-materia'
|
||||
| 'documento-sep'
|
||||
| 'historial';
|
||||
|
||||
export interface Materia {
|
||||
id: string;
|
||||
nombre: string;
|
||||
clave: string;
|
||||
creditos?: number;
|
||||
lineaCurricular?: string;
|
||||
ciclo?: string;
|
||||
planId: string;
|
||||
planNombre: string;
|
||||
carrera: string;
|
||||
facultad: string;
|
||||
estructuraId: string;
|
||||
}
|
||||
|
||||
export interface CampoEstructura {
|
||||
id: string;
|
||||
nombre: string;
|
||||
tipo: 'texto' | 'texto_largo' | 'lista' | 'numero';
|
||||
obligatorio: boolean;
|
||||
descripcion?: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export interface MateriaStructure {
|
||||
id: string;
|
||||
nombre: string;
|
||||
campos: CampoEstructura[];
|
||||
}
|
||||
|
||||
export interface Tema {
|
||||
id: string;
|
||||
nombre: string;
|
||||
descripcion?: string;
|
||||
horasEstimadas?: number;
|
||||
}
|
||||
|
||||
export interface UnidadTematica {
|
||||
id: string;
|
||||
nombre: string;
|
||||
numero: number;
|
||||
temas: Tema[];
|
||||
}
|
||||
|
||||
export interface BibliografiaEntry {
|
||||
id: string;
|
||||
tipo: 'BASICA' | 'COMPLEMENTARIA';
|
||||
cita: string;
|
||||
fuenteBibliotecaId?: string;
|
||||
fuenteBiblioteca?: LibraryResource;
|
||||
}
|
||||
|
||||
export interface LibraryResource {
|
||||
id: string;
|
||||
titulo: string;
|
||||
autor: string;
|
||||
editorial?: string;
|
||||
anio?: number;
|
||||
isbn?: string;
|
||||
tipo: 'libro' | 'articulo' | 'revista' | 'recurso_digital';
|
||||
disponible: boolean;
|
||||
}
|
||||
|
||||
export interface IAMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
campoAfectado?: string;
|
||||
sugerencia?: IASugerencia;
|
||||
}
|
||||
|
||||
export interface IASugerencia {
|
||||
campoId: string;
|
||||
campoNombre: string;
|
||||
valorActual: string;
|
||||
valorSugerido: string;
|
||||
aceptada?: boolean;
|
||||
}
|
||||
|
||||
export interface CambioMateria {
|
||||
id: string;
|
||||
tipo: 'datos' | 'contenido' | 'bibliografia' | 'ia' | 'documento';
|
||||
descripcion: string;
|
||||
usuario: string;
|
||||
fecha: Date;
|
||||
detalles?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface DocumentoMateria {
|
||||
id: string;
|
||||
materiaId: string;
|
||||
version: number;
|
||||
fechaGeneracion: Date;
|
||||
url?: string;
|
||||
estado: 'generando' | 'listo' | 'error';
|
||||
}
|
||||
|
||||
export interface MateriaDetailState {
|
||||
materia: Materia | null;
|
||||
estructura: MateriaStructure | null;
|
||||
datosGenerales: Record<string, any>;
|
||||
contenidoTematico: UnidadTematica[];
|
||||
bibliografia: BibliografiaEntry[];
|
||||
iaMessages: IAMessage[];
|
||||
documentoSep: DocumentoMateria | null;
|
||||
historial: CambioMateria[];
|
||||
activeTab: MateriaTab;
|
||||
isSaving: boolean;
|
||||
isLoading: boolean;
|
||||
errorMessage: string | null;
|
||||
}
|
||||
102
src/types/plan.ts
Normal file
102
src/types/plan.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
export type PlanStatus =
|
||||
| 'borrador'
|
||||
| 'revision'
|
||||
| 'expertos'
|
||||
| 'consejo'
|
||||
| 'aprobado'
|
||||
| 'rechazado';
|
||||
|
||||
export type TipoPlan = 'Licenciatura' | 'Maestría' | 'Doctorado' | 'Especialidad';
|
||||
|
||||
export type TipoMateria = 'obligatoria' | 'optativa' | 'troncal';
|
||||
|
||||
export type MateriaStatus = 'borrador' | 'revisada' | 'aprobada';
|
||||
|
||||
export interface Facultad {
|
||||
id: string;
|
||||
nombre: string;
|
||||
color: string;
|
||||
icono: string;
|
||||
}
|
||||
|
||||
export interface Carrera {
|
||||
id: string;
|
||||
nombre: string;
|
||||
facultadId: string;
|
||||
}
|
||||
|
||||
export interface LineaCurricular {
|
||||
id: string;
|
||||
nombre: string;
|
||||
orden: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface Materia {
|
||||
id: string;
|
||||
clave: string;
|
||||
nombre: string;
|
||||
creditos: number;
|
||||
ciclo: number | null;
|
||||
lineaCurricularId: string | null;
|
||||
tipo: TipoMateria;
|
||||
estado: MateriaStatus;
|
||||
orden?: number;
|
||||
hd: number; // <--- Añadir
|
||||
hi: number; // <--- Añadir
|
||||
}
|
||||
|
||||
export interface Plan {
|
||||
id: string;
|
||||
nombre: string;
|
||||
carrera: Carrera;
|
||||
facultad: Facultad;
|
||||
tipoPlan: TipoPlan;
|
||||
nivel?: string;
|
||||
modalidad?: string;
|
||||
duracionCiclos: number;
|
||||
creditosTotales: number;
|
||||
fechaCreacion: string;
|
||||
estadoActual: PlanStatus;
|
||||
}
|
||||
|
||||
export interface DatosGeneralesField {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
tipo: 'texto' | 'lista' | 'parrafo';
|
||||
requerido: boolean;
|
||||
}
|
||||
|
||||
export interface CambioPlan {
|
||||
id: string;
|
||||
fecha: string;
|
||||
usuario: string;
|
||||
tab: string;
|
||||
descripcion: string;
|
||||
detalle?: string;
|
||||
}
|
||||
|
||||
export interface ComentarioFlujo {
|
||||
id: string;
|
||||
usuario: string;
|
||||
fecha: string;
|
||||
texto: string;
|
||||
fase: PlanStatus;
|
||||
}
|
||||
|
||||
export interface DocumentoPlan {
|
||||
id: string;
|
||||
fechaGeneracion: string;
|
||||
version: number;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export type PlanTab =
|
||||
| 'datos-generales'
|
||||
| 'mapa-curricular'
|
||||
| 'materias'
|
||||
| 'flujo'
|
||||
| 'ia'
|
||||
| 'documento'
|
||||
| 'historial';
|
||||
Reference in New Issue
Block a user