feat(ADM-018): completed feature
24
.gitignore
vendored
@@ -5,4 +5,28 @@ __pycache__/
|
||||
.pytest_cache/
|
||||
.codegraph/
|
||||
.atl/
|
||||
|
||||
# Build artifacts
|
||||
.next/
|
||||
dist/
|
||||
build/
|
||||
*.log
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
# END ORQUESTRA
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readFileSync, watchFile, unwatchFile } from "node:fs";
|
||||
import { join, relative, resolve, sep } from "node:path";
|
||||
import type { ExtensionAPI, ExtensionContext, ToolCallEvent } from "@earendil-works/pi-coding-agent";
|
||||
@@ -50,6 +51,19 @@ type RuntimeStatus = {
|
||||
|
||||
const ROOT_PRODUCT_EXTENSIONS = new Set([".py", ".js", ".ts", ".go", ".rs", ".java", ".php", ".rb"]);
|
||||
|
||||
// Carpetas permitidas para escritura (Orquestra + proyecto)
|
||||
const ALLOWED_WRITE_DIRS = new Set([
|
||||
"project", // código del proyecto
|
||||
"tests", // tests del proyecto
|
||||
"work", // artifacts, current.md, runtime-status.json
|
||||
"backlog", // features.json
|
||||
"spec", // specs globales
|
||||
"harness", // configuración Orquestra (solo leader)
|
||||
"scripts", // scripts Orquestra (solo leader)
|
||||
"platforms", // extensiones Pi (solo leader)
|
||||
"docs", // documentación (solo leader)
|
||||
]);
|
||||
|
||||
function safeReadJson(root: string, path: string): RuntimeStatus | null {
|
||||
try {
|
||||
return JSON.parse(readFileSync(join(root, path), "utf8")) as RuntimeStatus;
|
||||
@@ -165,12 +179,50 @@ function hasActiveBuildRuntime(status: RuntimeStatus | null): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function parseStageCommand(args: string, status: RuntimeStatus | null): { stage?: string; featureId?: string; error?: string } {
|
||||
const parts = args.trim().split(/\s+/).filter(Boolean);
|
||||
const stage = parts[0];
|
||||
const featureFlag = parts.indexOf("--feature-id");
|
||||
const featureId = featureFlag >= 0 ? parts[featureFlag + 1] : parts[1] || status?.feature_id || undefined;
|
||||
if (!stage) return { error: "Uso: /orquestra-stage <stage> [feature_id]" };
|
||||
if (!featureId) return { error: "Falta feature_id. Uso: /orquestra-stage <stage> <feature_id>" };
|
||||
return { stage, featureId };
|
||||
}
|
||||
|
||||
function lastUsefulLine(text: string): string {
|
||||
return text
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.slice(-1)[0] || "stage terminado";
|
||||
}
|
||||
|
||||
function guardWriteOrEdit(event: ToolCallEvent, ctx: ExtensionContext): { block?: boolean; reason?: string } | undefined {
|
||||
if (event.toolName !== "write" && event.toolName !== "edit") return undefined;
|
||||
|
||||
const { relPath, reason } = normalizeWritePath(ctx.cwd, (event.input as { path?: unknown }).path);
|
||||
if (!relPath) return { block: true, reason };
|
||||
|
||||
// Extraer el directorio raíz del path
|
||||
const slashIndex = relPath.indexOf("/");
|
||||
const rootDir = slashIndex === -1 ? relPath : relPath.slice(0, slashIndex);
|
||||
|
||||
// Bloquear escrituras fuera de carpetas permitidas
|
||||
if (!ALLOWED_WRITE_DIRS.has(rootDir)) {
|
||||
return {
|
||||
block: true,
|
||||
reason: `Orquestra bloqueó '${relPath}': solo se permite escribir en ${Array.from(ALLOWED_WRITE_DIRS).join(", ")}. No crees carpetas ni archivos en la raíz ni en ubicaciones no autorizadas.`,
|
||||
};
|
||||
}
|
||||
|
||||
// Bloquear escrituras directas a backlog/features.json (debe usar close_feature.py)
|
||||
if (relPath === "backlog/features.json") {
|
||||
return {
|
||||
block: true,
|
||||
reason: `Orquestra bloqueó '${relPath}': NUNCA edites backlog/features.json directamente. Usa scripts/close_feature.py para cerrar features. Este script valida gates y actualiza el backlog automáticamente.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (isRootProductFile(relPath)) {
|
||||
return {
|
||||
block: true,
|
||||
@@ -285,4 +337,34 @@ export default function orquestraStatusExtension(pi: ExtensionAPI) {
|
||||
ctx.ui.notify("Orquestra status refrescado", "info");
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerCommand("orquestra-stage", {
|
||||
description: "Ejecuta un stage Orquestra en un proceso Pi fresco: /orquestra-stage <stage> [feature_id]",
|
||||
handler: async (args, ctx) => {
|
||||
const parsed = parseStageCommand(args, safeReadJson(ctx.cwd, STATUS_FILE));
|
||||
if (parsed.error) {
|
||||
ctx.ui.notify(parsed.error, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.ui.notify(`Orquestra ejecutando ${parsed.stage} en Pi fresco…`, "info");
|
||||
try {
|
||||
const output = execFileSync("python3", ["scripts/run_stage.py", parsed.stage!, "--feature-id", parsed.featureId!], {
|
||||
cwd: ctx.cwd,
|
||||
encoding: "utf8",
|
||||
maxBuffer: 1024 * 1024,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
currentCtx = ctx;
|
||||
apply(ctx);
|
||||
ctx.ui.notify(`Orquestra stage terminado: ${lastUsefulLine(output)}`, "info");
|
||||
} catch (error) {
|
||||
const failed = error as { stdout?: string; stderr?: string; message?: string };
|
||||
const detail = lastUsefulLine(`${failed.stdout || ""}\n${failed.stderr || ""}`) || failed.message || "error desconocido";
|
||||
currentCtx = ctx;
|
||||
apply(ctx);
|
||||
ctx.ui.notify(`Orquestra stage falló: ${detail}`, "error");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
- `implementer` nunca marca `done`.
|
||||
- `done` requiere gates aprobados: `reviewer`, `security`, `qa`.
|
||||
- Si `verify.sh` falla, no se cierra la feature.
|
||||
- **Solo se puede escribir en carpetas permitidas**: `project/`, `tests/`, `work/`, `backlog/`, `spec/`, `harness/`, `scripts/`, `platforms/`, `docs/`. Cualquier otra escritura será bloqueada por la extensión `orquestra-status`.
|
||||
- **Al cerrar una feature, el leader ejecuta `scripts/close_feature.py <feature_id>`** que valida gates, actualiza el backlog y hace commit/push automáticamente.
|
||||
- **NUNCA editar `backlog/features.json` directamente**. Usar siempre `scripts/close_feature.py` para cerrar features.
|
||||
|
||||
## Reentrada
|
||||
- Releer `work/current.md`, `work/runtime-status.json` y artefactos de la feature activa.
|
||||
|
||||
276
docs/context-handoff.md
Normal file
@@ -0,0 +1,276 @@
|
||||
# Context Handoff in Orquestra
|
||||
|
||||
Orquestra keeps agents focused by making context explicit. Agents do not pass a whole chat history to the next agent. They pass durable artifacts on disk, and each new stage reads only the files declared for that stage.
|
||||
|
||||
## Quick path
|
||||
|
||||
1. The current agent writes its result to `work/artifacts/<feature_id>/`.
|
||||
2. From the active Pi session, `/orquestra-stage <stage> [feature_id]` calls `scripts/run_stage.py`.
|
||||
3. `run_stage.py` starts a fresh Pi process.
|
||||
4. The new agent receives only the `input` paths declared in `harness/workflow.stages.yml`.
|
||||
5. The new agent writes its own output artifact.
|
||||
6. `scripts/agent_status.py` and `scripts/verify.sh` enforce stage order and gates.
|
||||
7. When the `close` stage completes, `scripts/close_feature.py <feature_id>` runs automatically to validate gates, update the backlog, commit and push if a remote repo is configured.
|
||||
|
||||
## Mental model
|
||||
|
||||
```text
|
||||
Agent A
|
||||
reads its stage inputs
|
||||
writes one durable artifact
|
||||
│
|
||||
▼
|
||||
work/artifacts/F-001/agent-a-output
|
||||
│
|
||||
▼
|
||||
run_stage.py starts a fresh Pi process
|
||||
│
|
||||
▼
|
||||
Agent B
|
||||
reads only declared inputs, including Agent A's artifact if relevant
|
||||
writes its own artifact
|
||||
```
|
||||
|
||||
The agents “talk” through files, not through memory-heavy chat context.
|
||||
|
||||
## Why fresh processes matter
|
||||
|
||||
A single long Pi session naturally accumulates chat, tool output, false starts, and old assumptions. That causes two problems:
|
||||
|
||||
| Problem | Orquestra answer |
|
||||
|---|---|
|
||||
| The next agent gets distracted by old context | Start the next stage with `--no-session --no-context-files` |
|
||||
| Token usage grows until compaction | Each stage starts with a small prompt and declared files |
|
||||
| Claims become hard to verify | Only artifacts on disk count as handoff evidence |
|
||||
| Agents can skip gates in chat | `agent_status.py` validates stage owner and prerequisites |
|
||||
|
||||
## What a stage receives
|
||||
|
||||
Each stage receives three kinds of context:
|
||||
|
||||
1. **Harness rules** — always read first.
|
||||
- `AGENTS.md`
|
||||
- `harness/agents.matrix.yml`
|
||||
- `harness/workflow.stages.yml`
|
||||
- `harness/contracts/handoff.md`
|
||||
|
||||
2. **Declared stage inputs** — from `harness/workflow.stages.yml`.
|
||||
|
||||
3. **Selected Engram memory** — only when the agent performs a narrow search for a concrete need.
|
||||
|
||||
The full previous chat is not valid input.
|
||||
|
||||
## Example: design to build
|
||||
|
||||
`architect` receives product/spec context:
|
||||
|
||||
```yaml
|
||||
- name: design
|
||||
owner: architect
|
||||
input:
|
||||
- work/current.md
|
||||
- spec/product.md
|
||||
- spec/tech.md
|
||||
- spec/acceptance.md
|
||||
output:
|
||||
- work/artifacts/<feature_id>/architect.md
|
||||
```
|
||||
|
||||
It writes:
|
||||
|
||||
```text
|
||||
work/artifacts/F-001/architect.md
|
||||
```
|
||||
|
||||
Then `implementer` starts fresh from the active Pi session:
|
||||
|
||||
```text
|
||||
/orquestra-stage build F-001
|
||||
```
|
||||
|
||||
Equivalent shell form:
|
||||
|
||||
```bash
|
||||
python3 scripts/run_stage.py build --feature-id F-001
|
||||
```
|
||||
|
||||
The generated prompt gives it only:
|
||||
|
||||
```yaml
|
||||
- name: build
|
||||
owner: implementer
|
||||
input:
|
||||
- work/current.md
|
||||
- spec/product.md
|
||||
- spec/tech.md
|
||||
- spec/acceptance.md
|
||||
- work/artifacts/<feature_id>/architect.md
|
||||
output:
|
||||
- work/artifacts/<feature_id>/implementer.md
|
||||
```
|
||||
|
||||
So the implementer knows the design result, but not the architect's full chat.
|
||||
|
||||
## Example: build to review
|
||||
|
||||
The implementer writes evidence:
|
||||
|
||||
```text
|
||||
work/artifacts/F-001/implementer.md
|
||||
```
|
||||
|
||||
A minimal implementer artifact should answer:
|
||||
|
||||
```markdown
|
||||
# Implementer Evidence
|
||||
|
||||
## Changed files
|
||||
- project/app.py
|
||||
- tests/test_app.py
|
||||
|
||||
## What changed
|
||||
- Added ticket creation validation.
|
||||
|
||||
## Checks
|
||||
- `python3 -m unittest discover -s tests -v` passed.
|
||||
|
||||
## Notes for reviewer
|
||||
- Main behavior lives in `project/app.py:create_ticket`.
|
||||
```
|
||||
|
||||
Then reviewer starts fresh:
|
||||
|
||||
```text
|
||||
/orquestra-stage review_gate F-001
|
||||
```
|
||||
|
||||
Reviewer input is only:
|
||||
|
||||
```yaml
|
||||
input:
|
||||
- work/current.md
|
||||
- spec/acceptance.md
|
||||
- work/artifacts/<feature_id>/implementer.md
|
||||
```
|
||||
|
||||
Reviewer does not need the architect's whole reasoning unless the workflow explicitly declares it. If reviewer needs more, it blocks and asks for a specific artifact/path.
|
||||
|
||||
## Example: security and QA reuse only relevant context
|
||||
|
||||
Security does not need all acceptance prose plus every design note. It needs the implementation evidence and review result:
|
||||
|
||||
```yaml
|
||||
- name: security_gate
|
||||
owner: security
|
||||
input:
|
||||
- work/current.md
|
||||
- work/artifacts/<feature_id>/implementer.md
|
||||
- work/artifacts/<feature_id>/reviewer.json
|
||||
```
|
||||
|
||||
QA needs acceptance criteria and approved gate context:
|
||||
|
||||
```yaml
|
||||
- name: qa_gate
|
||||
owner: qa
|
||||
input:
|
||||
- work/current.md
|
||||
- spec/acceptance.md
|
||||
- work/artifacts/<feature_id>/implementer.md
|
||||
- work/artifacts/<feature_id>/reviewer.json
|
||||
- work/artifacts/<feature_id>/security.json
|
||||
```
|
||||
|
||||
That is the core rule: give each agent the smallest context that lets it do its job.
|
||||
|
||||
## Anti-cheating controls
|
||||
|
||||
```text
|
||||
run_stage.py
|
||||
├─ starts fresh Pi process
|
||||
├─ disables previous session/context files
|
||||
└─ injects only declared input/output paths
|
||||
|
||||
agent_status.py
|
||||
├─ validates stage owner
|
||||
├─ requires feature_id for real stages
|
||||
├─ rejects gated stages when prerequisite artifacts are missing
|
||||
└─ keeps visible runtime status
|
||||
|
||||
orquestra-status extension
|
||||
├─ blocks writes outside allowed directories: project/, tests/, work/, backlog/, spec/, harness/, scripts/, platforms/, docs/
|
||||
├─ blocks product/test writes outside implementer build stage
|
||||
└─ blocks product code in repository root
|
||||
|
||||
verify.sh
|
||||
├─ validates harness structure
|
||||
├─ validates backlog/runtime JSON
|
||||
├─ validates Engram availability
|
||||
└─ rejects done features without approved gate artifacts
|
||||
```
|
||||
|
||||
Chat-only claims do not pass any gate. The agent must write evidence to disk.
|
||||
|
||||
## What Engram is for
|
||||
|
||||
Engram is durable memory for reusable facts, not a replacement for stage artifacts.
|
||||
|
||||
Each stage must save to Engram only when it produced durable knowledge.
|
||||
|
||||
Use Engram for:
|
||||
|
||||
- stable project decisions,
|
||||
- bug fixes,
|
||||
- non-obvious discoveries,
|
||||
- reusable conventions or patterns,
|
||||
- configuration changes,
|
||||
- repeated gotchas,
|
||||
- environment facts,
|
||||
- lessons learned across features.
|
||||
|
||||
Do not use Engram for:
|
||||
|
||||
- replacing `implementer.md`,
|
||||
- hiding gate evidence,
|
||||
- routine progress,
|
||||
- command output dumps,
|
||||
- artifact summaries,
|
||||
- passing huge summaries to every stage,
|
||||
- bypassing declared workflow inputs.
|
||||
|
||||
A good Engram lookup is narrow:
|
||||
|
||||
```text
|
||||
Search: "orquestra project root product files rule"
|
||||
Use result only if it affects this stage.
|
||||
```
|
||||
|
||||
A bad lookup is broad:
|
||||
|
||||
```text
|
||||
Search: "everything about this project"
|
||||
```
|
||||
|
||||
## Updating an installed project safely
|
||||
|
||||
From the Orquestra source checkout:
|
||||
|
||||
```bash
|
||||
/path/to/orquestra/scripts/install.sh /path/to/project
|
||||
cd /path/to/project
|
||||
./scripts/verify.sh
|
||||
```
|
||||
|
||||
The installer updates harness-owned files and preserves project-owned progress files when they already exist:
|
||||
|
||||
```text
|
||||
backlog/features.json
|
||||
spec/*
|
||||
work/current.md
|
||||
work/history.md
|
||||
work/runtime-status.json
|
||||
project/*
|
||||
work/artifacts/*
|
||||
```
|
||||
|
||||
That means an existing project can adopt fresh-stage handoff without losing its current feature state.
|
||||
@@ -92,6 +92,8 @@ stages:
|
||||
output:
|
||||
- work/artifacts/<feature_id>/leader-close.json
|
||||
- work/history.md
|
||||
post_actions:
|
||||
- scripts/close_feature.py <feature_id>
|
||||
|
||||
close_requirements:
|
||||
- reviewer.json.verdict == "APPROVED"
|
||||
|
||||
@@ -22,13 +22,14 @@ No debe crear `.pi/subagents/` ni `.pi/subagents.json`.
|
||||
- `verify.sh` falla si `~/.pi/agent/npm/node_modules/gentle-engram/index.ts` no existe.
|
||||
- `pi_orquestra.sh` carga Engram con `-e` aunque Pi arranque con `--no-extensions`; así se evita cargar extensiones globales no declaradas sin perder memoria.
|
||||
- Cada rol trabaja desde los `input` declarados en `harness/workflow.stages.yml`; el chat completo no es un handoff válido.
|
||||
- Para aislamiento real, ejecutar cada stage con `python3 scripts/run_stage.py <stage> --feature-id <feature_id>`; usa `pi --no-session --no-context-files` y carga solo Engram + extensiones Orquestra.
|
||||
- Para aislamiento real desde la sesión Pi, ejecutar cada stage con `/orquestra-stage <stage> [feature_id]`; internamente usa `run_stage.py`, `pi --no-session --no-context-files` y carga solo Engram + extensiones Orquestra.
|
||||
- Detalle completo del handoff: `docs/context-handoff.md`.
|
||||
|
||||
## Flujo secuencial
|
||||
1. Ejecutar `./scripts/verify.sh`.
|
||||
2. Abrir Pi limpio desde la raíz con `./scripts/pi_orquestra.sh`.
|
||||
3. Confirmar el widget con `/orquestra-status`.
|
||||
4. Ejecutar cada stage como proceso fresco: `python3 scripts/run_stage.py <stage> --feature-id <feature_id>`.
|
||||
4. Ejecutar cada stage como proceso fresco desde Pi: `/orquestra-stage <stage> [feature_id]`.
|
||||
5. `run_stage.py` genera un prompt mínimo con las rutas `input`/`output` del stage y no hereda la sesión anterior.
|
||||
6. `agent_status.py` rechaza saltos de stage sin artefactos previos obligatorios.
|
||||
7. Durante `build`, escribir producto en `project/` y tests en `tests/`; requiere `feature_id`, `stage=build`, `agent=implementer` y `state=running` en `work/runtime-status.json`.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readFileSync, watchFile, unwatchFile } from "node:fs";
|
||||
import { join, relative, resolve, sep } from "node:path";
|
||||
import type { ExtensionAPI, ExtensionContext, ToolCallEvent } from "@earendil-works/pi-coding-agent";
|
||||
@@ -50,6 +51,19 @@ type RuntimeStatus = {
|
||||
|
||||
const ROOT_PRODUCT_EXTENSIONS = new Set([".py", ".js", ".ts", ".go", ".rs", ".java", ".php", ".rb"]);
|
||||
|
||||
// Carpetas permitidas para escritura (Orquestra + proyecto)
|
||||
const ALLOWED_WRITE_DIRS = new Set([
|
||||
"project", // código del proyecto
|
||||
"tests", // tests del proyecto
|
||||
"work", // artifacts, current.md, runtime-status.json
|
||||
"backlog", // features.json
|
||||
"spec", // specs globales
|
||||
"harness", // configuración Orquestra (solo leader)
|
||||
"scripts", // scripts Orquestra (solo leader)
|
||||
"platforms", // extensiones Pi (solo leader)
|
||||
"docs", // documentación (solo leader)
|
||||
]);
|
||||
|
||||
function safeReadJson(root: string, path: string): RuntimeStatus | null {
|
||||
try {
|
||||
return JSON.parse(readFileSync(join(root, path), "utf8")) as RuntimeStatus;
|
||||
@@ -165,12 +179,50 @@ function hasActiveBuildRuntime(status: RuntimeStatus | null): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function parseStageCommand(args: string, status: RuntimeStatus | null): { stage?: string; featureId?: string; error?: string } {
|
||||
const parts = args.trim().split(/\s+/).filter(Boolean);
|
||||
const stage = parts[0];
|
||||
const featureFlag = parts.indexOf("--feature-id");
|
||||
const featureId = featureFlag >= 0 ? parts[featureFlag + 1] : parts[1] || status?.feature_id || undefined;
|
||||
if (!stage) return { error: "Uso: /orquestra-stage <stage> [feature_id]" };
|
||||
if (!featureId) return { error: "Falta feature_id. Uso: /orquestra-stage <stage> <feature_id>" };
|
||||
return { stage, featureId };
|
||||
}
|
||||
|
||||
function lastUsefulLine(text: string): string {
|
||||
return text
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.slice(-1)[0] || "stage terminado";
|
||||
}
|
||||
|
||||
function guardWriteOrEdit(event: ToolCallEvent, ctx: ExtensionContext): { block?: boolean; reason?: string } | undefined {
|
||||
if (event.toolName !== "write" && event.toolName !== "edit") return undefined;
|
||||
|
||||
const { relPath, reason } = normalizeWritePath(ctx.cwd, (event.input as { path?: unknown }).path);
|
||||
if (!relPath) return { block: true, reason };
|
||||
|
||||
// Extraer el directorio raíz del path
|
||||
const slashIndex = relPath.indexOf("/");
|
||||
const rootDir = slashIndex === -1 ? relPath : relPath.slice(0, slashIndex);
|
||||
|
||||
// Bloquear escrituras fuera de carpetas permitidas
|
||||
if (!ALLOWED_WRITE_DIRS.has(rootDir)) {
|
||||
return {
|
||||
block: true,
|
||||
reason: `Orquestra bloqueó '${relPath}': solo se permite escribir en ${Array.from(ALLOWED_WRITE_DIRS).join(", ")}. No crees carpetas ni archivos en la raíz ni en ubicaciones no autorizadas.`,
|
||||
};
|
||||
}
|
||||
|
||||
// Bloquear escrituras directas a backlog/features.json (debe usar close_feature.py)
|
||||
if (relPath === "backlog/features.json") {
|
||||
return {
|
||||
block: true,
|
||||
reason: `Orquestra bloqueó '${relPath}': NUNCA edites backlog/features.json directamente. Usa scripts/close_feature.py para cerrar features. Este script valida gates y actualiza el backlog automáticamente.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (isRootProductFile(relPath)) {
|
||||
return {
|
||||
block: true,
|
||||
@@ -285,4 +337,34 @@ export default function orquestraStatusExtension(pi: ExtensionAPI) {
|
||||
ctx.ui.notify("Orquestra status refrescado", "info");
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerCommand("orquestra-stage", {
|
||||
description: "Ejecuta un stage Orquestra en un proceso Pi fresco: /orquestra-stage <stage> [feature_id]",
|
||||
handler: async (args, ctx) => {
|
||||
const parsed = parseStageCommand(args, safeReadJson(ctx.cwd, STATUS_FILE));
|
||||
if (parsed.error) {
|
||||
ctx.ui.notify(parsed.error, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.ui.notify(`Orquestra ejecutando ${parsed.stage} en Pi fresco…`, "info");
|
||||
try {
|
||||
const output = execFileSync("python3", ["scripts/run_stage.py", parsed.stage!, "--feature-id", parsed.featureId!], {
|
||||
cwd: ctx.cwd,
|
||||
encoding: "utf8",
|
||||
maxBuffer: 1024 * 1024,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
currentCtx = ctx;
|
||||
apply(ctx);
|
||||
ctx.ui.notify(`Orquestra stage terminado: ${lastUsefulLine(output)}`, "info");
|
||||
} catch (error) {
|
||||
const failed = error as { stdout?: string; stderr?: string; message?: string };
|
||||
const detail = lastUsefulLine(`${failed.stdout || ""}\n${failed.stderr || ""}`) || failed.message || "error desconocido";
|
||||
currentCtx = ctx;
|
||||
apply(ctx);
|
||||
ctx.ui.notify(`Orquestra stage falló: ${detail}`, "error");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,3 +4,4 @@ coverage/
|
||||
package-lock.json
|
||||
scripts/tests/fixtures/
|
||||
design_prompt.md
|
||||
storefront/
|
||||
|
||||
248
project/CAVEMAN.md
Normal file
@@ -0,0 +1,248 @@
|
||||
# MercadoDeVida vNext — Caveman Architecture
|
||||
|
||||
> SIMPLE CODE. CLEAR MODULES. SMALL CHANGES. NO MAGIC.
|
||||
|
||||
---
|
||||
|
||||
# 0. CORE PHILOSOPHY
|
||||
|
||||
Build boring software. Boring = good.
|
||||
|
||||
Prefer: simple code, explicit dependencies, small modules, clear APIs, strong typing, predictable behavior, easy testing, easy replacement, easy debugging.
|
||||
|
||||
Avoid: clever abstractions, unnecessary microservices, circular dependencies, global state, giant service classes, framework magic.
|
||||
|
||||
The system must be easy for humans AND AI agents to understand.
|
||||
|
||||
---
|
||||
|
||||
# 1. ARCHITECTURE
|
||||
|
||||
**Modular Monolith** — each business domain is an isolated module.
|
||||
|
||||
```
|
||||
src/
|
||||
├── modules/
|
||||
│ ├── auth/ # Identity module (sessions, login, register)
|
||||
│ ├── users/ # User profiles
|
||||
│ ├── catalog/ # Products, variants, attributes
|
||||
│ ├── categories/ # Category taxonomy
|
||||
│ ├── brands/ # Brand management
|
||||
│ ├── pricing/ # Price calculation + VAT
|
||||
│ ├── promotions/ # Discounts, promo codes
|
||||
│ ├── inventory/ # Stock management
|
||||
│ ├── cart/ # Shopping cart
|
||||
│ ├── checkout/ # Checkout orchestrator
|
||||
│ ├── orders/ # Order management
|
||||
│ ├── payments/ # Payment provider interface
|
||||
│ ├── shipping/ # Shipping zones and methods
|
||||
│ ├── seo/ # SEO metadata
|
||||
│ ├── cms/ # Content management
|
||||
│ ├── reviews/ # Product reviews
|
||||
│ ├── notifications/ # Email/push notifications
|
||||
│ ├── cache/ # Caching layer
|
||||
│ ├── security/ # Rate limiting, audit log, MFA
|
||||
│ ├── observability/ # Traces, metrics
|
||||
│ └── flags/ # Feature flags
|
||||
├── shared/
|
||||
├── infrastructure/
|
||||
└── app/
|
||||
```
|
||||
|
||||
Modules communicate through: (1) explicit public interfaces, (2) domain/application events, (3) typed contracts. Never access another module's internal implementation.
|
||||
|
||||
---
|
||||
|
||||
# 2. MODULE RULE
|
||||
|
||||
Every module owns its logic. Structure:
|
||||
|
||||
```
|
||||
modules/<name>/
|
||||
├── domain/ # Pure business rules. No DB, no HTTP, no framework.
|
||||
├── application/ # Use cases: CreateProduct, ReserveStock, CreateOrder
|
||||
├── infrastructure/ # PostgreSQL, Redis, Stripe, Email adapters
|
||||
├── api/ # Thin HTTP controllers
|
||||
├── tests/ # Unit + boundary tests
|
||||
└── index.ts # Public API only
|
||||
```
|
||||
|
||||
Controllers must be thin: request → validate → use case → response. NO business logic in controllers.
|
||||
|
||||
---
|
||||
|
||||
# 3. SDD IS MANDATORY
|
||||
|
||||
**NO FEATURE STARTS WITH CODE.** Every change starts with a specification.
|
||||
|
||||
Directory: `specs/<feature>/`
|
||||
|
||||
Each feature gets:
|
||||
|
||||
- `SPEC.md` — Problem, Goal, User story, Functional requirements, Acceptance criteria
|
||||
- `DESIGN.md` — Affected modules, new interfaces, API changes, DB changes, events
|
||||
- `TASKS.md` — Small atomic tasks
|
||||
- `TESKS.md` — Required tests
|
||||
- Optional: `ADR.md`, `MIGRATION.md`, `ROLLBACK.md`
|
||||
|
||||
---
|
||||
|
||||
# 4. TECH STACK
|
||||
|
||||
## Frontend
|
||||
|
||||
- **Next.js + React + TypeScript + Tailwind CSS**
|
||||
- Server Components where useful
|
||||
- SSR/SSG for SEO-sensitive pages
|
||||
- Client-side only where interaction requires it
|
||||
|
||||
## Backend
|
||||
|
||||
- TypeScript + Node.js + Fastify
|
||||
- Strict module boundaries
|
||||
- PostgreSQL (primary database)
|
||||
- Redis (cache, sessions, rate limiting — NOT source of truth)
|
||||
|
||||
## Observability
|
||||
|
||||
- Structured logs, metrics, traces
|
||||
- OpenTelemetry-compatible interfaces
|
||||
- Per-module metrics
|
||||
|
||||
---
|
||||
|
||||
# 5. DATABASE RULES
|
||||
|
||||
Database belongs to modules. Logical ownership must remain clear.
|
||||
|
||||
Naming: `<module>_<entity>` — e.g., `catalog_products`, `orders_orders`, `inventory_stock`.
|
||||
|
||||
Never let random modules query arbitrary tables. Access data through module interfaces only.
|
||||
|
||||
---
|
||||
|
||||
# 6. CORE BUSINESS RULES
|
||||
|
||||
- **Catalog ≠ Inventory**: Catalog answers "what is this product?"; Inventory answers "can I sell it?"
|
||||
- **Cart ≠ Checkout**: Cart stores items; Checkout validates, calculates, orchestrates
|
||||
- **Checkout is orchestrator**: coordinates cart, pricing, inventory, shipping, orders, payments
|
||||
- **Orders are historical**: snapshot product name, SKU, EAN, prices, taxes at creation time
|
||||
- **Payment provider behind interface**: StripePaymentProvider, RedsysPaymentProvider — domain never imports Stripe SDK directly
|
||||
- **Backend calculates everything**: never trust price, stock, discount, total from frontend
|
||||
- **Idempotency everywhere**: checkout, webhooks, payment operations
|
||||
|
||||
---
|
||||
|
||||
# 7. SEO IS CORE BUSINESS LOGIC
|
||||
|
||||
Support: canonical URLs, structured data (Product, Breadcrumb, Organization schema), sitemap.xml, robots.txt, OpenGraph, metadata per page.
|
||||
|
||||
URLs: `/productos/<slug>`, `/categoria/<slug>`, `/marca/<slug>`.
|
||||
|
||||
---
|
||||
|
||||
# 8. CAVEMAN RULES
|
||||
|
||||
```
|
||||
ONE MODULE = ONE JOB
|
||||
ONE USE CASE = ONE PURPOSE
|
||||
DATABASE = SOURCE OF TRUTH
|
||||
REDIS = CACHE, NOT TRUTH
|
||||
CONTROLLER = THIN
|
||||
BUSINESS LOGIC = DOMAIN
|
||||
EXTERNAL API = ADAPTER
|
||||
NO CROSS-MODULE TABLE QUERIES
|
||||
NO GLOBAL STATE
|
||||
NO HIDDEN MAGIC
|
||||
NO COPY-PASTE BUSINESS LOGIC
|
||||
NO FEATURE WITHOUT SPEC
|
||||
NO DATABASE CHANGE WITHOUT MIGRATION
|
||||
NO CRITICAL LOGIC WITHOUT TEST
|
||||
NO EXTERNAL EVENT WITHOUT IDEMPOTENCY
|
||||
NO PAYMENT TRUST FROM FRONTEND
|
||||
NO PRICE TRUST FROM FRONTEND
|
||||
NO BIG REWRITE
|
||||
SMALL CHANGE
|
||||
TEST CHANGE
|
||||
SHIP CHANGE
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 9. FRONTEND STRUCTURE (to be built)
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── src/
|
||||
│ ├── app/ # Next.js App Router pages
|
||||
│ │ ├── (shop)/ # Shop routes (product, category, brand, search)
|
||||
│ │ ├── (checkout)/ # Cart + checkout flow
|
||||
│ │ ├── (account)/ # User account, orders
|
||||
│ │ ├── (admin)/ # Admin panel (protected)
|
||||
│ │ └── api/ # API routes
|
||||
│ ├── components/ # Shared UI components
|
||||
│ │ ├── ui/ # Base components (Button, Input, Card...)
|
||||
│ │ ├── product/ # Product-specific components
|
||||
│ │ ├── cart/ # Cart components
|
||||
│ │ └── layout/ # Header, Footer, Nav
|
||||
│ ├── modules/ # Module-specific frontend code (mirrors backend modules)
|
||||
│ ├── lib/ # Utilities, API client, types
|
||||
│ └── styles/
|
||||
├── public/
|
||||
└── tests/
|
||||
├── unit/
|
||||
├── integration/
|
||||
└── e2e/
|
||||
```
|
||||
|
||||
**Pages to build (Frontend v1):**
|
||||
|
||||
1. Homepage — Hero, featured products, categories, brand highlights
|
||||
2. Category page — Product listing with filters, pagination, SEO metadata
|
||||
3. Product detail page — Images, description, nutrition, reviews, add to cart
|
||||
4. Brand page — Brand info + brand products
|
||||
5. Search results page — Search with filters
|
||||
6. Cart page — Cart items, totals, promo code
|
||||
7. Checkout — Address, shipping, payment, order summary
|
||||
8. Order confirmation — Order details, next steps
|
||||
9. User account — Profile, orders history, addresses
|
||||
10. Admin panel — Product CRUD, order management, CMS
|
||||
|
||||
---
|
||||
|
||||
# 10. FRONTEND-BACKEND COMMUNICATION
|
||||
|
||||
Frontend communicates with backend via:
|
||||
|
||||
1. **Server Components** (SSR): Direct DB queries through Prisma/Postgres (same DB, no HTTP overhead)
|
||||
2. **Server Actions**: Form submissions, mutations (type-safe, no REST overhead)
|
||||
3. **API Routes** (minimal): External integrations, webhooks, special cases
|
||||
|
||||
Never call backend REST API from client components. Use Server Components and Server Actions.
|
||||
|
||||
---
|
||||
|
||||
# 11. TESTING PYRAMID
|
||||
|
||||
- **Unit tests**: Business rules in domain/application layers — many
|
||||
- **Integration tests**: Repositories against real DB, API endpoints — some
|
||||
- **E2E tests**: Critical flows — few
|
||||
|
||||
Critical E2E flows: register → login → search → view product → add to cart → checkout → payment → order confirmation
|
||||
|
||||
---
|
||||
|
||||
# 12. DEFINITION OF DONE
|
||||
|
||||
A feature is DONE when:
|
||||
|
||||
- SPEC.md complete with testable acceptance criteria
|
||||
- DESIGN.md reviewed and approved
|
||||
- Implementation complete (smallest possible change)
|
||||
- Unit + integration tests passing
|
||||
- Security reviewed
|
||||
- Observability added (logs/metrics)
|
||||
- Documentation updated
|
||||
- Migration tested
|
||||
- Rollback possible
|
||||
- Acceptance criteria verified against spec
|
||||
@@ -19,6 +19,17 @@ npm run lint # eslint + prettier check
|
||||
npm run lint:boundaries # module boundary check
|
||||
```
|
||||
|
||||
Frontend shell commands live in `storefront/`:
|
||||
|
||||
```bash
|
||||
cd storefront
|
||||
npm install
|
||||
npm run dev # Next.js development server
|
||||
npm run lint # storefront formatting check
|
||||
npm run typecheck # frontend TypeScript check
|
||||
npm run build # production Next.js build
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Startup is fail-fast: `src/infrastructure/config` parses env once and refuses to boot
|
||||
@@ -38,6 +49,11 @@ is separate from deployment (no redeploy). Copy `.env.example` to `.env` to star
|
||||
- Errors always use one envelope:
|
||||
`{ "error": { "statusCode", "code", "message", "details?" }, "requestId" }`
|
||||
Codes: `NOT_FOUND`, `VALIDATION_ERROR`, `BAD_REQUEST`/Fastify 4xx codes, `INTERNAL_ERROR`.
|
||||
Category-specific codes: `CATEGORY_SLUG_EXISTS` (409), `CATEGORY_PARENT_NOT_FOUND` (422),
|
||||
`CATEGORY_TREE_CYCLE` (422), `CATEGORY_HAS_CHILDREN` (409). Product-specific codes:
|
||||
`PRODUCT_SLUG_EXISTS` (409), `PRODUCT_CATEGORY_NOT_FOUND` (422),
|
||||
`PRODUCT_BRAND_NOT_FOUND` (422), `PRODUCT_VARIANT_CODE_EXISTS` (409).
|
||||
Brand-specific codes: `BRAND_SLUG_EXISTS` (409).
|
||||
5xx messages are always generic; stack traces stay in server logs only.
|
||||
- Input validation is explicit per route: `parseJson(schema, body)` (zod) in the handler.
|
||||
- Auth codes: `UNAUTHORIZED` (401, missing/invalid/revoked session),
|
||||
@@ -96,6 +112,111 @@ operation in this slice (no admin API yet).
|
||||
- The users module never imports identity: session resolution arrives as an
|
||||
injected `Authenticate` function from the composition root.
|
||||
|
||||
## Categories (categories module)
|
||||
|
||||
Categories provide the public taxonomy for SEO-friendly catalog URLs. The module
|
||||
owns `categories_categories`; products are not assigned to categories until the
|
||||
catalog slice.
|
||||
|
||||
| Route | Access | Result |
|
||||
| ---------------------- | ---------- | ------------------------------------------- |
|
||||
| GET /categories/tree | public | `200` + `{ items: [categoryTreeNode] }` |
|
||||
| GET /categoria/:slug | public | `200` category by slug, `404` if missing |
|
||||
| POST /categories | admin only | `201` category, `409` on duplicate slug |
|
||||
| PATCH /categories/:id | admin only | `200` category, `422` on invalid tree move |
|
||||
| DELETE /categories/:id | admin only | `204`, `409` when the category has children |
|
||||
|
||||
- Public URLs are slug-based: `/categoria/<slug>`, never internal ids.
|
||||
- Slugs are globally unique for this slice.
|
||||
- Category hierarchy uses `parentId`; self-parenting and descendant-as-parent moves
|
||||
are rejected with `CATEGORY_TREE_CYCLE`.
|
||||
- Category rows include `seoTitle` and `seoDescription`.
|
||||
|
||||
## Catalog core (catalog module)
|
||||
|
||||
Catalog owns product identity, variants, rich product data, product images and public product discovery.
|
||||
Stock, prices and external sync jobs are intentionally outside this slice.
|
||||
|
||||
| Route | Access | Result |
|
||||
| --------------------------------------- | ---------- | ------------------------------------------------------- |
|
||||
| GET /productos/:slug | public | `200` active product by slug, `404` otherwise |
|
||||
| GET /products/search | public | `200` + `{ items: [activeProduct] }` via PostgreSQL FTS |
|
||||
| POST /products | admin only | `201` product, `409` on duplicate slug |
|
||||
| PATCH /products/:id | admin only | `200` product, `422` on unknown category |
|
||||
| GET /products/:id/variants | public | `200` + `{ items: [variant] }` |
|
||||
| POST /products/:id/variants | admin only | `201` variant, `409` on duplicate SKU/EAN |
|
||||
| PATCH /products/:id/variants/:variantId | admin only | `200` variant, `404` if missing |
|
||||
| PATCH /products/:id/rich-data | admin only | `200` rich data with nutrition provenance |
|
||||
| GET /products/:id/images | public | `200` + `{ items: [image] }` |
|
||||
| POST /products/:id/images | admin only | `201` image, `404` on missing product |
|
||||
| DELETE /products/:id/images/:imageId | admin only | `204`, `404` if missing |
|
||||
| PATCH /products/:id/images/reorder | admin only | `200` + reordered `{ items: [image] }` |
|
||||
|
||||
- Public URLs are slug-based: `/productos/<slug>`, never internal ids.
|
||||
- Product states are `draft`, `active` and `archived`; public reads/search return
|
||||
only `active` products.
|
||||
- Product rows include `seoTitle` and `seoDescription`.
|
||||
- Category assignment is stored in `catalog_product_categories` and validates
|
||||
category ids against `categories_categories` without importing categories internals.
|
||||
- Product search is behind a `ProductSearchRepository` port so future engines can replace PostgreSQL
|
||||
without changing the HTTP API. `GET /products/search?q=<term>` uses PostgreSQL full-text search over
|
||||
product, brand and category text with stable pagination/relevance ordering.
|
||||
- Product reads can be filtered by brand with `GET /products/search?brandSlug=<slug>` and by category
|
||||
with `GET /products/search?categorySlug=<slug>`.
|
||||
- Product brand assignment is stored as `catalog_products.brand_id` and validates
|
||||
brand ids against `brands_brands` without importing brands internals.
|
||||
- Variants live in `catalog_product_variants`; non-null SKU and optional EAN are globally unique.
|
||||
- Images live in `catalog_product_images`; product pages expose ordered image metadata with `url`,
|
||||
`altText`, `position` and `role` (`main` or `gallery`). Images can be product-level or variant-level.
|
||||
Storage is behind a catalog infrastructure adapter; this slice stores URLs only, with no binary upload,
|
||||
CDN or processing pipeline.
|
||||
- Rich data lives in `catalog_product_rich_data`; nutrition payloads require `nutritionSource`
|
||||
(`manual`, `manufacturer`, `openfoodfacts`).
|
||||
- Manual nutrition is trusted: external-source updates cannot overwrite existing manual nutrition.
|
||||
- Search logs structured `catalog_search` telemetry with duration, result count and sanitized bounded
|
||||
query metadata for later popular-search/cache work.
|
||||
|
||||
## Brands (brands module)
|
||||
|
||||
Brands provide public SEO-friendly brand identity and product filtering support.
|
||||
The module owns `brands_brands`; product assignment remains catalog-owned.
|
||||
|
||||
| Route | Access | Result |
|
||||
| ----------------- | ---------- | ------------------------------------- |
|
||||
| GET /brands | public | `200` brand list for sitemap/catalog |
|
||||
| GET /marca/:slug | public | `200` brand by slug, `404` if missing |
|
||||
| POST /brands | admin only | `201` brand, `409` on duplicate slug |
|
||||
| PATCH /brands/:id | admin only | `200` brand, `404` if missing |
|
||||
|
||||
- Public URLs are slug-based: `/marca/<slug>`, never internal ids.
|
||||
- Slugs are globally unique for this slice.
|
||||
- Brand rows include `seoTitle` and `seoDescription`.
|
||||
|
||||
## Storefront shell (Next.js)
|
||||
|
||||
The customer-facing shell lives in `storefront/` as a separate frontend package. It uses
|
||||
Next.js App Router, React, TypeScript and Tailwind CSS.
|
||||
|
||||
- Home renders as a server component with global layout, navigation and footer.
|
||||
- Product detail pages live at `/productos/<slug>`; category pages at `/categoria/<slug>`; brand pages at
|
||||
`/marca/<slug>`; search results at `/products/search`.
|
||||
- Product, category and brand pages use ISR (`revalidate = 300`) and generate metadata with canonical URLs
|
||||
and OpenGraph fields. Search results use `revalidate = 120`.
|
||||
- Product pages embed Product JSON-LD; product/category/brand pages embed BreadcrumbList JSON-LD; the root
|
||||
layout embeds Organization JSON-LD.
|
||||
- `sitemap.xml` and `robots.txt` are generated by Next.js metadata routes. Sitemap includes static public URLs,
|
||||
active products, categories and brands, and degrades to static URLs if the API is unavailable.
|
||||
- Permanent SEO redirects are configured with `REDIRECTS_JSON`, an array of local same-origin
|
||||
`{ "from": "/old", "to": "/new" }` path entries handled by `src/proxy.ts` with HTTP 301.
|
||||
- On-demand catalog revalidation is available at `POST /api/revalidate` with `REVALIDATE_SECRET` and
|
||||
`x-revalidate-secret`; accepted paths are catalog public paths only.
|
||||
- The frontend consumes backend public endpoints only through `src/lib/api.ts` typed DTOs.
|
||||
- `src/lib/api.ts` imports `server-only`, so API calls are not accidentally bundled into client components.
|
||||
- Backend internals under `src/` are never imported by the storefront.
|
||||
- Configure API origin with `API_BASE_URL` for server-side rendering or `NEXT_PUBLIC_API_BASE_URL` when needed;
|
||||
the local default is `http://localhost:3000`.
|
||||
- Cart and checkout UI are intentionally outside this slice.
|
||||
|
||||
## Database (local dev)
|
||||
|
||||
```bash
|
||||
@@ -129,7 +250,10 @@ src/
|
||||
│ ├── health/ # exemplar module: public API only via index.ts
|
||||
│ ├── flags/ # feature flags (unknown default OFF, runtime flip)
|
||||
│ ├── identity/ # register/login/logout, argon2, sessions, rate limit
|
||||
│ └── users/ # profile + address CRUD, owner-or-admin RBAC
|
||||
│ ├── users/ # profile + address CRUD, owner-or-admin RBAC
|
||||
│ ├── categories/ # category tree, slugs, SEO metadata
|
||||
│ ├── catalog/ # products, states, slugs, category/brand assignment
|
||||
│ └── brands/ # brands, slugs, SEO metadata
|
||||
└── shared/ # cross-cutting helpers (error envelope, input parsing)
|
||||
```
|
||||
|
||||
|
||||
9
project/apps/admin/AGENTS.md
Normal file
@@ -0,0 +1,9 @@
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
|
||||
# This is NOT the Next.js you know
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
|
||||
|
||||
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
|
||||
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
1
project/apps/admin/CLAUDE.md
Normal file
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
18
project/apps/admin/eslint.config.mjs
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
7
project/apps/admin/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
import "./.next/dev/types/root-params.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
12
project/apps/admin/next.config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
outputFileTracingRoot: __dirname,
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{ protocol: 'https', hostname: '**' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
6781
project/apps/admin/package-lock.json
generated
Normal file
27
project/apps/admin/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "@mercadodevida/admin",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --port 3004",
|
||||
"build": "next build",
|
||||
"start": "next start --port 3004",
|
||||
"lint": "next lint",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "16.3.1",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.3.1",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
7
project/apps/admin/postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
BIN
project/apps/admin/public/images/logo-main.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
178
project/apps/admin/src/app/(auth)/login/page.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
'use client';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Redirect if already logged in
|
||||
useEffect(() => {
|
||||
fetch('/api/auth/me')
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (data.id) {
|
||||
router.push('/');
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [router]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
credentials: 'include',
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setError(
|
||||
data?.message ||
|
||||
(data?.code === 'TOO_MANY_ATTEMPTS'
|
||||
? 'Demasiados intentos. Espera un momento.'
|
||||
: 'Email o contraseña incorrectos'),
|
||||
);
|
||||
} else {
|
||||
router.push('/');
|
||||
}
|
||||
} catch {
|
||||
setError('Error de conexión. Intenta de nuevo.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ minHeight: '100vh' }}
|
||||
className="flex items-center justify-center bg-gray-50 px-4"
|
||||
>
|
||||
<div className="w-full max-w-sm">
|
||||
{/* Logo */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center gap-2 mb-2">
|
||||
<svg
|
||||
className="w-10 h-10 text-[#2D6A4F]"
|
||||
viewBox="0 0 32 32"
|
||||
fill="none"
|
||||
>
|
||||
<circle cx="16" cy="16" r="14" stroke="currentColor" strokeWidth="2" />
|
||||
<path
|
||||
d="M10 20c2-4 4-8 6-10s4 6 6 10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<circle cx="16" cy="10" r="2" fill="currentColor" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1
|
||||
className="text-2xl font-bold text-gray-900"
|
||||
style={{ fontFamily: 'var(--font-heading)' }}
|
||||
>
|
||||
MercadoDeVida
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Panel de administración</p>
|
||||
</div>
|
||||
|
||||
{/* Card */}
|
||||
<div className="bg-white border border-gray-200 rounded-2xl p-8 shadow-sm">
|
||||
<h2 className="text-lg font-bold text-gray-900 mb-6 text-center">
|
||||
Iniciar sesión
|
||||
</h2>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 text-sm rounded-lg px-4 py-3">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="admin@mercadodevida.es"
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-[#2D6A4F] focus:border-transparent outline-none transition-all"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Contraseña
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-[#2D6A4F] focus:border-transparent outline-none transition-all"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-3 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-60 text-white font-semibold rounded-xl transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<svg
|
||||
className="animate-spin h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
Entrando...
|
||||
</>
|
||||
) : (
|
||||
'Iniciar sesión'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-sm text-gray-400 mt-6">
|
||||
© {new Date().getFullYear()} MercadoDeVida
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
129
project/apps/admin/src/app/(dashboard)/audit/page.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
'use client';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { auditApi, type AuditEntry } from '@/lib/api-client';
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
const ACTION_COLORS: Record<string, string> = {
|
||||
'admin.mfa.enroll': 'bg-purple-100 text-purple-700',
|
||||
'admin.mfa.status': 'bg-purple-100 text-purple-700',
|
||||
'admin.mfa.challenge': 'bg-purple-100 text-purple-700',
|
||||
'auth.login': 'bg-blue-100 text-blue-700',
|
||||
'auth.logout': 'bg-gray-100 text-gray-600',
|
||||
'product.created': 'bg-green-100 text-green-700',
|
||||
'product.updated': 'bg-green-100 text-green-700',
|
||||
'product.deleted': 'bg-red-100 text-red-700',
|
||||
'order.placed': 'bg-indigo-100 text-indigo-700',
|
||||
'order.state_changed': 'bg-indigo-100 text-indigo-700',
|
||||
};
|
||||
|
||||
function colorForAction(action: string): string {
|
||||
return ACTION_COLORS[action] ?? 'bg-gray-100 text-gray-600';
|
||||
}
|
||||
|
||||
export default function AuditLogPage() {
|
||||
const [items, setItems] = useState<AuditEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [filter, setFilter] = useState('');
|
||||
const [debounced, setDebounced] = useState('');
|
||||
const [page, setPage] = useState(0);
|
||||
const [total, setTotal] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebounced(filter), 400);
|
||||
return () => clearTimeout(t);
|
||||
}, [filter]);
|
||||
|
||||
useEffect(() => { setPage(0); }, [debounced]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true); setError('');
|
||||
try {
|
||||
const data = await auditApi.list({ action: debounced || undefined, limit: PAGE_SIZE, offset: page * PAGE_SIZE });
|
||||
setItems(data.items ?? []);
|
||||
setTotal(data.total ?? 0);
|
||||
} catch (e) { setError(e instanceof Error ? e.message : 'Error'); }
|
||||
finally { setLoading(false); }
|
||||
}, [page, debounced]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
return (
|
||||
<div className="p-8 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Log de auditoría</h1>
|
||||
<p className="text-sm text-gray-500 mt-0.5">
|
||||
{total > 0 ? `${total} entrada${total !== 1 ? 's' : ''}` : 'Sin entradas'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="max-w-xs">
|
||||
<div className="relative">
|
||||
<input type="text" placeholder="Filtrar por acción..." value={filter}
|
||||
onChange={e => setFilter(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2.5 border border-gray-200 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">🔍</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl border border-gray-200 overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-16 text-gray-400 text-sm">Cargando...</div>
|
||||
) : error ? (
|
||||
<div className="flex items-center justify-center py-16 text-red-500 text-sm">{error}</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-gray-400 text-sm gap-2">
|
||||
<span className="text-3xl">📋</span><span>Sin entradas de auditoría</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Fecha</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Acción</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Objetivo</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Actor</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Detalles</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{items.map(entry => (
|
||||
<tr key={entry.id} className="hover:bg-gray-50 transition-colors">
|
||||
<td className="px-6 py-4 text-sm text-gray-500 whitespace-nowrap">
|
||||
{new Date(entry.createdAt).toLocaleString('es-ES', { dateStyle: 'short', timeStyle: 'short' })}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${colorForAction(entry.action)}`}>
|
||||
{entry.action}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600 max-w-xs truncate">{entry.target}</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-400 font-mono">{entry.actorId?.slice(0, 8) ?? '—'}</td>
|
||||
<td className="px-6 py-4 text-xs text-gray-400 font-mono max-w-xs truncate">
|
||||
{Object.keys(entry.metadata ?? {}).length > 0 ? JSON.stringify(entry.metadata) : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{total > PAGE_SIZE && (
|
||||
<div className="flex items-center justify-between px-6 py-4 border-t border-gray-200">
|
||||
<span className="text-sm text-gray-500">
|
||||
{page * PAGE_SIZE + 1}–{Math.min((page + 1) * PAGE_SIZE, total)} de {total}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button disabled={page === 0} onClick={() => setPage(p => p - 1)}
|
||||
className="px-4 py-2 text-sm border border-gray-300 rounded-xl disabled:opacity-40 hover:bg-gray-50 transition-colors">Anterior</button>
|
||||
<button disabled={(page + 1) * PAGE_SIZE >= total} onClick={() => setPage(p => p + 1)}
|
||||
className="px-4 py-2 text-sm border border-gray-300 rounded-xl disabled:opacity-40 hover:bg-gray-50 transition-colors">Siguiente</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
244
project/apps/admin/src/app/(dashboard)/brands/page.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
'use client';
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import type { Brand } from '@/types';
|
||||
import { brandsApi } from '@/lib/api-client';
|
||||
|
||||
function slugify(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
function autoSeoTitle(name: string): string {
|
||||
return name;
|
||||
}
|
||||
|
||||
function autoSeoDescription(name: string): string {
|
||||
return `${name} — Compra online en MercadoDeVida. Productos naturales y ecológicos con envío a toda España.`;
|
||||
}
|
||||
|
||||
export default function BrandsPage() {
|
||||
const [brands, setBrands] = useState<Brand[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editing, setEditing] = useState<Brand | null>(null);
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [slug, setSlug] = useState('');
|
||||
const [slugManual, setSlugManual] = useState(false);
|
||||
const [seoTitle, setSeoTitle] = useState('');
|
||||
const [seoTitleManual, setSeoTitleManual] = useState(false);
|
||||
const [seoDescription, setSeoDescription] = useState('');
|
||||
const [seoDescriptionManual, setSeoDescriptionManual] = useState(false);
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await brandsApi.list();
|
||||
setBrands((data as { items?: Brand[] }).items ?? []);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
setName(''); setSlug(''); setSlugManual(false);
|
||||
setSeoTitle(''); setSeoTitleManual(false);
|
||||
setSeoDescription(''); setSeoDescriptionManual(false);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const openEdit = (b: Brand) => {
|
||||
setEditing(b);
|
||||
setName(b.name);
|
||||
setSlug(b.slug); setSlugManual(true);
|
||||
setSeoTitle(b.seoTitle ?? ''); setSeoTitleManual(true);
|
||||
setSeoDescription(b.seoDescription ?? ''); setSeoDescriptionManual(true);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleNameChange = (value: string) => {
|
||||
setName(value);
|
||||
if (!slugManual) setSlug(slugify(value));
|
||||
if (!seoTitleManual) setSeoTitle(autoSeoTitle(value));
|
||||
if (!seoDescriptionManual) setSeoDescription(autoSeoDescription(value));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true); setMsg('');
|
||||
const payload = {
|
||||
name,
|
||||
slug,
|
||||
seoTitle: seoTitle || undefined,
|
||||
seoDescription: seoDescription || undefined,
|
||||
};
|
||||
try {
|
||||
if (editing) {
|
||||
await brandsApi.update(editing.id, payload);
|
||||
setMsg('Marca actualizada');
|
||||
} else {
|
||||
await brandsApi.create(payload);
|
||||
setMsg('Marca creada');
|
||||
}
|
||||
setShowForm(false);
|
||||
load();
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : 'Error al guardar');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('¿Eliminar esta marca?')) return;
|
||||
try {
|
||||
await brandsApi.delete!(id);
|
||||
load();
|
||||
} catch (e) {
|
||||
alert(e instanceof Error ? e.message : 'Error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-8 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Marcas</h1>
|
||||
<button onClick={openCreate} className="px-4 py-2 bg-[#2D6A4F] hover:bg-[#1B4332] text-white text-sm font-semibold rounded-xl">+ Nueva marca</button>
|
||||
</div>
|
||||
|
||||
{msg && (
|
||||
<div className={`p-4 rounded-xl text-sm ${msg.startsWith('Error') ? 'bg-red-50 text-red-700' : 'bg-green-50 text-green-700'}`}>{msg}</div>
|
||||
)}
|
||||
|
||||
{showForm && (
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-6 space-y-4">
|
||||
<h2 className="font-semibold text-gray-900">{editing ? 'Editar marca' : 'Nueva marca'}</h2>
|
||||
|
||||
{/* Nombre */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Nombre *</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
placeholder="Ej: NaturGreen"
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Slug */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="text-sm font-medium text-gray-700">Slug *</label>
|
||||
<span className="text-xs text-gray-400">{slugManual ? 'editado' : 'auto'}</span>
|
||||
</div>
|
||||
<input
|
||||
value={slug}
|
||||
onChange={(e) => { setSlugManual(true); setSlug(e.target.value); }}
|
||||
placeholder="auto-generado"
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm font-mono focus:ring-2 focus:ring-[#2D6A4F] outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* SEO Title */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="text-sm font-medium text-gray-700">SEO Title</label>
|
||||
<span className="text-xs text-gray-400">{seoTitleManual ? 'editado' : 'auto'}</span>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<input
|
||||
value={seoTitle}
|
||||
onChange={(e) => { setSeoTitleManual(true); setSeoTitle(e.target.value); }}
|
||||
placeholder="auto-generado desde nombre"
|
||||
className="w-full px-4 py-2.5 pr-14 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none"
|
||||
/>
|
||||
<span className={`absolute right-3 top-1/2 -translate-y-1/2 text-xs ${seoTitle.length > 60 ? 'text-red-500 font-medium' : 'text-gray-400'}`}>
|
||||
{seoTitle.length}/60
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SEO Description */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="text-sm font-medium text-gray-700">SEO Description</label>
|
||||
<span className="text-xs text-gray-400">{seoDescriptionManual ? 'editado' : 'auto'}</span>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<textarea
|
||||
value={seoDescription}
|
||||
onChange={(e) => { setSeoDescriptionManual(true); setSeoDescription(e.target.value); }}
|
||||
rows={2}
|
||||
placeholder="auto-generado desde nombre"
|
||||
className="w-full px-4 py-2.5 pr-14 border border-gray-300 rounded-xl text-sm resize-none focus:ring-2 focus:ring-[#2D6A4F] outline-none"
|
||||
/>
|
||||
<span className={`absolute right-3 bottom-2 text-xs ${seoDescription.length > 160 ? 'text-red-500 font-medium' : 'text-gray-400'}`}>
|
||||
{seoDescription.length}/160
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || !name || !slug}
|
||||
className="px-5 py-2.5 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-xl"
|
||||
>
|
||||
{saving ? 'Guardando...' : 'Guardar'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowForm(false)}
|
||||
className="px-5 py-2.5 border border-gray-300 text-gray-600 text-sm rounded-xl hover:bg-gray-50"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
|
||||
{loading ? <div className="p-12 text-center text-gray-400">Cargando...</div> :
|
||||
error ? <div className="p-8 text-center text-red-600">{error}</div> :
|
||||
brands.length === 0 ? <div className="p-12 text-center text-gray-400">No hay marcas</div> :
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 border-b border-gray-200">
|
||||
{['Nombre', 'Slug', 'SEO Title'].map(h => (
|
||||
<th key={h} className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3">{h}</th>
|
||||
))}
|
||||
<th className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-50">
|
||||
{brands.map(b => (
|
||||
<tr key={b.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3.5 text-sm font-medium text-gray-900">{b.name}</td>
|
||||
<td className="px-4 py-3.5 text-sm text-gray-500 font-mono">/{b.slug}</td>
|
||||
<td className="px-4 py-3.5 text-sm text-gray-500">{b.seoTitle ?? '—'}</td>
|
||||
<td className="px-4 py-3.5">
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => openEdit(b)} className="text-xs text-[#2D6A4F] hover:underline">Editar</button>
|
||||
<button onClick={() => handleDelete(b.id)} className="text-xs text-red-600 hover:underline">Eliminar</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
272
project/apps/admin/src/app/(dashboard)/categories/page.tsx
Normal file
@@ -0,0 +1,272 @@
|
||||
'use client';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import type { Category } from '@/types';
|
||||
import { categoriesApi } from '@/lib/api-client';
|
||||
|
||||
function slugify(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
function CategoryRow({ cat, onEdit, onDelete }: { cat: Category; onEdit: (c: Category) => void; onDelete: (id: string) => void }) {
|
||||
return (
|
||||
<tr className="hover:bg-gray-50 transition-colors">
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{cat.children && cat.children.length > 0 && <span className="text-gray-300">📁</span>}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">{cat.name}</p>
|
||||
<p className="text-xs text-gray-400">/{cat.slug}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500">{cat.parentId ? 'Sí' : 'Raíz'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => onEdit(cat)} className="p-1.5 text-gray-400 hover:text-[#2D6A4F] hover:bg-green-50 rounded-lg transition-colors" title="Editar">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button onClick={() => onDelete(cat.id)} className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors" title="Eliminar">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CategoriesPage() {
|
||||
const [tree, setTree] = useState<Category[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editing, setEditing] = useState<Category | null>(null);
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [slug, setSlug] = useState('');
|
||||
const [slugManual, setSlugManual] = useState(false);
|
||||
const [description, setDescription] = useState('');
|
||||
const [seoTitle, setSeoTitle] = useState('');
|
||||
const [seoTitleManual, setSeoTitleManual] = useState(false);
|
||||
const [seoDescription, setSeoDescription] = useState('');
|
||||
const [seoDescManual, setSeoDescManual] = useState(false);
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await categoriesApi.list() as { items?: Category[] };
|
||||
setTree(data?.items ?? []);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
setName(''); setSlug(''); setSlugManual(false);
|
||||
setDescription('');
|
||||
setSeoTitle(''); setSeoTitleManual(false);
|
||||
setSeoDescription(''); setSeoDescManual(false);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const openEdit = (cat: Category) => {
|
||||
setEditing(cat);
|
||||
setName(cat.name); setSlug(cat.slug); setSlugManual(true);
|
||||
setDescription(cat.description ?? '');
|
||||
setSeoTitle((cat as any).seoTitle ?? ''); setSeoTitleManual(true);
|
||||
setSeoDescription((cat as any).seoDescription ?? ''); setSeoDescManual(true);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleNameChange = (value: string) => {
|
||||
setName(value);
|
||||
if (!slugManual) setSlug(slugify(value));
|
||||
if (!seoTitleManual) setSeoTitle(value);
|
||||
if (!seoDescManual) setSeoDescription(`${value} — Compra online en MercadoDeVida. Productos naturales y ecológicos.`);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true); setMsg('');
|
||||
try {
|
||||
if (editing) {
|
||||
await categoriesApi.update(editing.id, { name, slug, description: description || undefined, seoTitle: seoTitle || undefined, seoDescription: seoDescription || undefined });
|
||||
setMsg('Categoría actualizada');
|
||||
} else {
|
||||
await categoriesApi.create({ name, slug, description: description || undefined, seoTitle: seoTitle || undefined, seoDescription: seoDescription || undefined });
|
||||
setMsg('Categoría creada');
|
||||
}
|
||||
setShowForm(false);
|
||||
load();
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : 'Error al guardar');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('¿Eliminar esta categoría?')) return;
|
||||
try {
|
||||
await categoriesApi.delete(id);
|
||||
load();
|
||||
} catch (e) {
|
||||
alert(e instanceof Error ? e.message : 'Error al eliminar');
|
||||
}
|
||||
};
|
||||
|
||||
const flat = (cats: Category[]): Category[] =>
|
||||
cats.flatMap((c) => [c, ...flat(c.children ?? [])]);
|
||||
|
||||
return (
|
||||
<div className="p-8 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Categorías</h1>
|
||||
<button onClick={openCreate} className="px-4 py-2 bg-[#2D6A4F] hover:bg-[#1B4332] text-white text-sm font-semibold rounded-xl">
|
||||
+ Nueva categoría
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{msg && (
|
||||
<div className={`p-4 rounded-xl text-sm ${msg.startsWith('Error') ? 'bg-red-50 text-red-700' : 'bg-green-50 text-green-700'}`}>{msg}</div>
|
||||
)}
|
||||
|
||||
{showForm && (
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-6 space-y-4">
|
||||
<h2 className="font-semibold text-gray-900">{editing ? 'Editar categoría' : 'Nueva categoría'}</h2>
|
||||
|
||||
{/* Nombre */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Nombre *</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
placeholder="Ej: Alimentación"
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Slug */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="text-sm font-medium text-gray-700">Slug *</label>
|
||||
<span className="text-xs text-gray-400">{slugManual ? 'editado' : 'auto'}</span>
|
||||
</div>
|
||||
<input
|
||||
value={slug}
|
||||
onChange={(e) => { setSlugManual(true); setSlug(e.target.value); }}
|
||||
placeholder="auto-generado"
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm font-mono focus:ring-2 focus:ring-[#2D6A4F] outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Descripción */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Descripción</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="Descripción opcional de la categoría"
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm resize-none focus:ring-2 focus:ring-[#2D6A4F] outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* SEO Title */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="text-sm font-medium text-gray-700">Título SEO (Google)</label>
|
||||
<span className={`text-xs ${seoTitleManual ? 'text-gray-400' : 'text-[#2D6A4F] font-medium'}`}>
|
||||
{seoTitleManual ? 'editado' : 'copiado del nombre'}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={seoTitle}
|
||||
maxLength={60}
|
||||
onChange={(e) => { setSeoTitleManual(true); setSeoTitle(e.target.value); }}
|
||||
placeholder="Título para Google (copiado del nombre)"
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none"
|
||||
/>
|
||||
<div className="mt-1 text-xs text-gray-400">{seoTitle.length}/60</div>
|
||||
</div>
|
||||
|
||||
{/* SEO Description */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="text-sm font-medium text-gray-700">Descripción SEO (Google)</label>
|
||||
<span className={`text-xs ${seoDescManual ? 'text-gray-400' : 'text-[#2D6A4F] font-medium'}`}>
|
||||
{seoDescManual ? 'editada' : 'auto-generada'}
|
||||
</span>
|
||||
</div>
|
||||
<textarea
|
||||
value={seoDescription}
|
||||
maxLength={160}
|
||||
onChange={(e) => { setSeoDescManual(true); setSeoDescription(e.target.value); }}
|
||||
rows={2}
|
||||
placeholder="Descripción para Google (max 160 caracteres)"
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm resize-none focus:ring-2 focus:ring-[#2D6A4F] outline-none"
|
||||
/>
|
||||
<div className="mt-1 text-xs text-gray-400">{seoDescription.length}/160</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || !name || !slug}
|
||||
className="px-5 py-2.5 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-xl"
|
||||
>
|
||||
{saving ? 'Guardando...' : 'Guardar'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowForm(false)}
|
||||
className="px-5 py-2.5 border border-gray-300 text-gray-600 text-sm rounded-xl hover:bg-gray-50"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="p-12 text-center text-gray-400">Cargando...</div>
|
||||
) : error ? (
|
||||
<div className="p-8 text-center text-red-600">{error}</div>
|
||||
) : flat(tree).length === 0 ? (
|
||||
<div className="p-12 text-center text-gray-400">No hay categorías</div>
|
||||
) : (
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 border-b border-gray-200">
|
||||
<th className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3">Nombre</th>
|
||||
<th className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3">Subcategoría</th>
|
||||
<th className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-50">
|
||||
{flat(tree).map((c) => (
|
||||
<CategoryRow key={c.id} cat={c} onEdit={openEdit} onDelete={handleDelete} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
97
project/apps/admin/src/app/(dashboard)/cms/page.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
'use client';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { cmsApi } from '@/lib/api-client';
|
||||
|
||||
interface Page { id: string; slug: string; title: string; body: string; status: string; createdAt: string; updatedAt: string; }
|
||||
|
||||
export default function CmsPage() {
|
||||
const [items, setItems] = useState<Page[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
const [form, setForm] = useState({ slug: '', title: '', body: '' });
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try { const d = await cmsApi.list() as { items: Page[] }; setItems(d.items ?? []); }
|
||||
catch (e) { setError(e instanceof Error ? e.message : 'Error'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setMsg('');
|
||||
try {
|
||||
await cmsApi.create({ slug: form.slug, title: form.title, body: form.body });
|
||||
setMsg('Página creada');
|
||||
setShowForm(false);
|
||||
setForm({ slug: '', title: '', body: '' });
|
||||
load();
|
||||
} catch (e) { setMsg(e instanceof Error ? e.message : 'Error al crear'); }
|
||||
};
|
||||
|
||||
const togglePublish = async (id: string, currentStatus: string) => {
|
||||
try {
|
||||
if (currentStatus === 'published') await cmsApi.unpublish(id);
|
||||
else await cmsApi.publish(id);
|
||||
load();
|
||||
} catch (e) { alert(e instanceof Error ? e.message : 'Error'); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-8 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Páginas CMS</h1>
|
||||
<button onClick={() => setShowForm(true)} className="px-4 py-2 bg-[#2D6A4F] hover:bg-[#1B4332] text-white text-sm font-semibold rounded-xl">+ Nueva página</button>
|
||||
</div>
|
||||
|
||||
{msg && <div className={`p-4 rounded-xl text-sm ${msg.startsWith('Error') ? 'bg-red-50 text-red-700' : 'bg-green-50 text-green-700'}`}>{msg}</div>}
|
||||
|
||||
{showForm && (
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-6 space-y-4">
|
||||
<h2 className="font-semibold text-gray-900">Nueva página</h2>
|
||||
{[['slug','Slug *','text'],['title','Título *','text']].map(([k,label,t]) => (
|
||||
<div key={k}>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{label}</label>
|
||||
<input value={(form as Record<string,string>)[k]} onChange={e => setForm({...form,[k]:e.target.value})} className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
|
||||
</div>
|
||||
))}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Contenido *</label>
|
||||
<textarea value={form.body} onChange={e => setForm({...form,body:e.target.value})} rows={6} className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none resize-none font-mono" />
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={handleSave} disabled={!form.slug || !form.title || !form.body} className="px-5 py-2.5 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-xl">Crear</button>
|
||||
<button onClick={() => setShowForm(false)} className="px-5 py-2.5 border border-gray-300 text-gray-600 text-sm rounded-xl">Cancelar</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{loading ? <div className="p-12 text-center text-gray-400">Cargando...</div> :
|
||||
error ? <div className="p-8 text-center text-red-600">{error}</div> :
|
||||
items.length === 0 ? <div className="p-12 text-center text-gray-400">No hay páginas</div> :
|
||||
items.map(p => (
|
||||
<div key={p.id} className="bg-white border border-gray-200 rounded-xl p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900 text-sm">{p.title}</p>
|
||||
<p className="text-xs text-gray-400 font-mono">/{p.slug}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${p.status === 'published' ? 'bg-green-100 text-green-700' : 'bg-amber-100 text-amber-700'}`}>
|
||||
{p.status === 'published' ? 'Publicada' : 'Borrador'}
|
||||
</span>
|
||||
<button onClick={() => togglePublish(p.id, p.status)} className="text-xs text-[#2D6A4F] hover:underline">
|
||||
{p.status === 'published' ? 'Despublicar' : 'Publicar'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
'use client';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { customersApi } from '@/lib/api-client';
|
||||
import type { Customer } from '@/types';
|
||||
|
||||
export default function CustomerDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const router = useRouter();
|
||||
const [customer, setCustomer] = useState<Customer | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
customersApi.get(id).then(setCustomer).catch(() => setError('No se encontró el cliente')).finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!customer) return;
|
||||
setSaving(true);
|
||||
setMsg('');
|
||||
try {
|
||||
// PATCH /users/:id for profile fields (displayName, phone — role changes require separate process)
|
||||
await fetch(`/api/customers/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ role: customer.role }),
|
||||
});
|
||||
setMsg('Cliente actualizado');
|
||||
} catch {
|
||||
setMsg('Error al guardar');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div className="p-8 text-gray-400">Cargando...</div>;
|
||||
if (error || !customer) return <div className="p-8 text-red-600">{error || 'No encontrado'}</div>;
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-2xl space-y-6">
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<button onClick={() => router.push('/customers')} className="text-sm text-gray-500 hover:text-gray-700">← Clientes</button>
|
||||
<h1 className="text-2xl font-bold text-gray-900">{customer.email}</h1>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-6 space-y-5">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-1">Email</p>
|
||||
<p className="text-sm text-gray-900">{customer.email}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-1">Rol</p>
|
||||
<select
|
||||
value={customer.role}
|
||||
onChange={(e) => setCustomer({ ...customer, role: e.target.value as 'customer' | 'admin' })}
|
||||
className="px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none bg-white"
|
||||
>
|
||||
<option value="customer">Cliente</option>
|
||||
<option value="admin">Administrador</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-1">Registrado</p>
|
||||
<p className="text-sm text-gray-900">
|
||||
{customer.createdAt ? new Date(customer.createdAt).toLocaleString('es-ES') : '—'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{msg && (
|
||||
<div className={`p-4 rounded-xl text-sm ${msg.startsWith('Error') ? 'bg-red-50 text-red-700' : 'bg-green-50 text-green-700'}`}>
|
||||
{msg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="px-6 py-2.5 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-xl transition-colors"
|
||||
>
|
||||
{saving ? 'Guardando...' : 'Guardar cambios'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
298
project/apps/admin/src/app/(dashboard)/customers/page.tsx
Normal file
@@ -0,0 +1,298 @@
|
||||
'use client';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import type { Customer } from '@/types';
|
||||
import { customersApi } from '@/lib/api-client';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
// ── Modal genérico ────────────────────────────────────────────────────────────
|
||||
function Modal({ title, onClose, children }: { title: string; onClose: () => void; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
|
||||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-md mx-4" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100">
|
||||
<h2 className="text-lg font-semibold text-gray-900">{title}</h2>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 transition-colors">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-6">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Formulario crear cliente ─────────────────────────────────────────────────
|
||||
function CreateForm({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handle = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSaving(true); setError('');
|
||||
try {
|
||||
await customersApi.create({ email, password, displayName: displayName || undefined, phone: phone || undefined });
|
||||
onCreated();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al crear cliente');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handle} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Email *</label>
|
||||
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Contraseña *</label>
|
||||
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} required minLength={8}
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Nombre / Razón social</label>
|
||||
<input type="text" value={displayName} onChange={(e) => setDisplayName(e.target.value)} placeholder="Opcional"
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Teléfono</label>
|
||||
<input type="tel" value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="+34 600 000 000"
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-600 bg-red-50 rounded-xl px-4 py-2">{error}</p>}
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button type="submit" disabled={saving}
|
||||
className="flex-1 px-5 py-2.5 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-xl transition-colors">
|
||||
{saving ? 'Creando...' : 'Crear cliente'}
|
||||
</button>
|
||||
<button type="button" onClick={onClose}
|
||||
className="px-5 py-2.5 border border-gray-300 text-gray-600 text-sm rounded-xl hover:bg-gray-50 transition-colors">
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Formulario editar cliente ────────────────────────────────────────────────
|
||||
function EditForm({ customer, onClose, onSaved }: { customer: Customer; onClose: () => void; onSaved: () => void }) {
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
setDisplayName(customer.displayName || '');
|
||||
setPhone(customer.phone || '');
|
||||
}, [customer]);
|
||||
|
||||
const handle = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSaving(true); setError('');
|
||||
try {
|
||||
await customersApi.update(customer.id, {
|
||||
displayName: displayName || undefined,
|
||||
phone: phone || undefined,
|
||||
});
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al guardar');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handle} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
|
||||
<input type="email" value={customer.email} disabled
|
||||
className="w-full px-4 py-2.5 border border-gray-200 rounded-xl text-sm bg-gray-50 text-gray-400" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Nombre / Razón social</label>
|
||||
<input type="text" value={displayName} onChange={(e) => setDisplayName(e.target.value)}
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Teléfono</label>
|
||||
<input type="tel" value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="+34 600 000 000"
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-600 bg-red-50 rounded-xl px-4 py-2">{error}</p>}
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button type="submit" disabled={saving}
|
||||
className="flex-1 px-5 py-2.5 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-xl transition-colors">
|
||||
{saving ? 'Guardando...' : 'Guardar cambios'}
|
||||
</button>
|
||||
<button type="button" onClick={onClose}
|
||||
className="px-5 py-2.5 border border-gray-300 text-gray-600 text-sm rounded-xl hover:bg-gray-50 transition-colors">
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Página principal ───────────────────────────────────────────────────────────
|
||||
export default function CustomersPage() {
|
||||
const [customers, setCustomers] = useState<Customer[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [debounced, setDebounced] = useState('');
|
||||
const [page, setPage] = useState(0);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [editing, setEditing] = useState<Customer | null>(null);
|
||||
const [msg, setMsg] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebounced(search), 400);
|
||||
return () => clearTimeout(t);
|
||||
}, [search]);
|
||||
|
||||
useEffect(() => { setPage(0); }, [debounced]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true); setError('');
|
||||
try {
|
||||
const data = await customersApi.list({ limit: PAGE_SIZE, offset: page * PAGE_SIZE, q: debounced || undefined });
|
||||
setCustomers(data.items ?? []);
|
||||
setTotal(data.total ?? 0);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, debounced]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleCreated = () => { setMsg('Cliente creado correctamente'); setTimeout(() => setMsg(''), 3000); load(); };
|
||||
const handleSaved = () => { setMsg('Cliente actualizado'); setTimeout(() => setMsg(''), 3000); load(); };
|
||||
|
||||
return (
|
||||
<div className="p-8 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Clientes</h1>
|
||||
<p className="text-sm text-gray-500 mt-0.5">{total > 0 ? `${total} cliente${total !== 1 ? 's' : ''}` : ''}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="px-4 py-2 bg-[#2D6A4F] hover:bg-[#1B4332] text-white text-sm font-semibold rounded-xl transition-colors"
|
||||
>
|
||||
+ Nuevo cliente
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{msg && (
|
||||
<div className="p-4 rounded-xl text-sm bg-green-50 text-green-700">{msg}</div>
|
||||
)}
|
||||
|
||||
{/* Buscador */}
|
||||
<div className="relative max-w-sm">
|
||||
<input type="search" placeholder="Buscar por email..." value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] focus:border-transparent outline-none" />
|
||||
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<circle cx="11" cy="11" r="8" /><path d="M21 21l-4.35-4.35" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Tabla */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="p-12 flex items-center justify-center gap-3 text-gray-400">
|
||||
<div className="h-5 w-5 border-2 border-gray-300 border-t-[#2D6A4F] rounded-full animate-spin" />
|
||||
<span className="text-sm">Cargando...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-8 text-center">
|
||||
<p className="text-red-600 text-sm mb-3">{error}</p>
|
||||
<button onClick={load} className="text-sm text-[#2D6A4F] hover:underline">Reintentar</button>
|
||||
</div>
|
||||
) : customers.length === 0 ? (
|
||||
<div className="p-12 text-center">
|
||||
<p className="text-4xl mb-3">👥</p>
|
||||
<p className="text-gray-500 text-sm">No hay clientes</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 border-b border-gray-200">
|
||||
{['Email', 'Nombre', 'Teléfono', 'Rol', 'Alta', ''].map((h) => (
|
||||
<th key={h} className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-50">
|
||||
{customers.map((c) => (
|
||||
<tr key={c.id} className="hover:bg-gray-50 transition-colors">
|
||||
<td className="px-4 py-3.5 text-sm font-medium text-gray-900">{c.email}</td>
|
||||
<td className="px-4 py-3.5 text-sm text-gray-600">{c.displayName || '—'}</td>
|
||||
<td className="px-4 py-3.5 text-sm text-gray-600">{c.phone || '—'}</td>
|
||||
<td className="px-4 py-3.5">
|
||||
<span className={`inline-flex px-2 py-0.5 rounded-full text-xs font-medium capitalize ${
|
||||
c.role === 'admin' ? 'bg-purple-100 text-purple-700' : 'bg-blue-100 text-blue-700'
|
||||
}`}>{c.role}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3.5 text-sm text-gray-500">
|
||||
{c.createdAt ? new Date(c.createdAt).toLocaleDateString('es-ES') : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3.5">
|
||||
<button onClick={() => setEditing(c)}
|
||||
className="text-xs text-[#2D6A4F] hover:underline">Editar</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{/* Paginación */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-gray-200 bg-gray-50">
|
||||
<p className="text-sm text-gray-500">Página {page + 1}</p>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => setPage((p) => Math.max(0, p - 1))} disabled={page === 0}
|
||||
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg disabled:opacity-40 hover:bg-white">
|
||||
← Anterior
|
||||
</button>
|
||||
<button onClick={() => setPage((p) => p + 1)} disabled={customers.length < PAGE_SIZE}
|
||||
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg disabled:opacity-40 hover:bg-white">
|
||||
Siguiente →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal crear */}
|
||||
{showCreate && (
|
||||
<Modal title="Nuevo cliente" onClose={() => setShowCreate(false)}>
|
||||
<CreateForm onClose={() => setShowCreate(false)} onCreated={handleCreated} />
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* Modal editar */}
|
||||
{editing && (
|
||||
<Modal title={`Editar: ${editing.email}`} onClose={() => setEditing(null)}>
|
||||
<EditForm customer={editing} onClose={() => setEditing(null)} onSaved={handleSaved} />
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
348
project/apps/admin/src/app/(dashboard)/inventory/page.tsx
Normal file
@@ -0,0 +1,348 @@
|
||||
'use client';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { productsApi, inventoryApi } from '@/lib/api-client';
|
||||
import type { Product, ProductVariant, StockAvailability } from '@/types';
|
||||
|
||||
interface VariantRow {
|
||||
productId: string;
|
||||
productName: string;
|
||||
variant: ProductVariant;
|
||||
stock: StockAvailability | null;
|
||||
loading: boolean;
|
||||
editing: boolean;
|
||||
editValue: string;
|
||||
saving: boolean;
|
||||
msg: string;
|
||||
}
|
||||
|
||||
type StockFilter = 'all' | 'in_stock' | 'low_stock' | 'out_of_stock';
|
||||
|
||||
const STOCK_LABELS: Record<StockFilter, string> = {
|
||||
all: 'Todos',
|
||||
in_stock: 'En stock',
|
||||
low_stock: 'Stock bajo',
|
||||
out_of_stock: 'Sin stock',
|
||||
};
|
||||
|
||||
function StockBadge({ qty }: { qty: number }) {
|
||||
if (qty === 0) return <span className="px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-700">Sin stock</span>;
|
||||
if (qty < 5) return <span className="px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-700">Stock bajo ({qty})</span>;
|
||||
return <span className="px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700">En stock ({qty})</span>;
|
||||
}
|
||||
|
||||
export default function InventoryPage() {
|
||||
const [rows, setRows] = useState<VariantRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [filter, setFilter] = useState<StockFilter>('all');
|
||||
const [search, setSearch] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
|
||||
// Debounce search
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebouncedSearch(search), 400);
|
||||
return () => clearTimeout(t);
|
||||
}, [search]);
|
||||
|
||||
// Load products + variants + stock
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const { items: products } = await productsApi.list({
|
||||
limit: 100,
|
||||
q: debouncedSearch || undefined,
|
||||
});
|
||||
|
||||
const variantRows: VariantRow[] = [];
|
||||
|
||||
for (const product of products ?? []) {
|
||||
const { items: variants } = await productsApi.getVariants(product.id);
|
||||
for (const variant of variants ?? []) {
|
||||
variantRows.push({
|
||||
productId: product.id,
|
||||
productName: product.name,
|
||||
variant,
|
||||
stock: null,
|
||||
loading: true,
|
||||
editing: false,
|
||||
editValue: '',
|
||||
saving: false,
|
||||
msg: '',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setRows(variantRows);
|
||||
|
||||
// Load stock for each variant
|
||||
for (const vr of variantRows) {
|
||||
inventoryApi.getAvailability(vr.variant.id)
|
||||
.then((stock) => {
|
||||
setRows((prev) =>
|
||||
prev.map((r) =>
|
||||
r.variant.id === vr.variant.id
|
||||
? { ...r, stock, loading: false, editValue: String(stock.availableQuantity) }
|
||||
: r,
|
||||
),
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
setRows((prev) =>
|
||||
prev.map((r) =>
|
||||
r.variant.id === vr.variant.id ? { ...r, loading: false, editValue: '0' } : r,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Error al cargar inventario');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [debouncedSearch]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
// Filter rows
|
||||
const filtered = rows.filter((r) => {
|
||||
if (filter === 'in_stock') return (r.stock?.availableQuantity ?? 0) >= 5;
|
||||
if (filter === 'low_stock') return (r.stock?.availableQuantity ?? 0) > 0 && (r.stock?.availableQuantity ?? 0) < 5;
|
||||
if (filter === 'out_of_stock') return (r.stock?.availableQuantity ?? 0) === 0;
|
||||
return true;
|
||||
});
|
||||
|
||||
const inStockCount = rows.filter((r) => (r.stock?.availableQuantity ?? 0) >= 5).length;
|
||||
const lowStockCount = rows.filter((r) => {
|
||||
const q = r.stock?.availableQuantity ?? 0;
|
||||
return q > 0 && q < 5;
|
||||
}).length;
|
||||
const outOfStockCount = rows.filter((r) => (r.stock?.availableQuantity ?? 0) === 0).length;
|
||||
|
||||
return (
|
||||
<div className="p-8 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Inventario</h1>
|
||||
<p className="text-sm text-gray-500 mt-0.5">{rows.length} variantes</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{[
|
||||
{ label: 'En stock', count: inStockCount, cls: 'bg-green-50 border-green-100 text-green-700' },
|
||||
{ label: 'Stock bajo', count: lowStockCount, cls: 'bg-amber-50 border-amber-100 text-amber-700' },
|
||||
{ label: 'Sin stock', count: outOfStockCount, cls: 'bg-red-50 border-red-100 text-red-700' },
|
||||
].map(({ label, count, cls }) => (
|
||||
<div key={label} className={`p-4 rounded-xl border ${cls}`}>
|
||||
<p className="text-2xl font-bold">{count}</p>
|
||||
<p className="text-sm font-medium">{label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Search + filters */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Buscar por producto o SKU..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] focus:border-transparent outline-none"
|
||||
/>
|
||||
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<circle cx="11" cy="11" r="8" /><path d="M21 21l-4.35-4.35" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{(Object.keys(STOCK_LABELS) as StockFilter[]).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
|
||||
filter === f
|
||||
? 'bg-[#2D6A4F] text-white'
|
||||
: 'bg-white border border-gray-300 text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{STOCK_LABELS[f]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={load}
|
||||
className="text-sm text-[#2D6A4F] hover:underline"
|
||||
>
|
||||
Recargar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="p-12 flex items-center justify-center gap-3 text-gray-400">
|
||||
<div className="h-5 w-5 border-2 border-gray-300 border-t-[#2D6A4F] rounded-full animate-spin" />
|
||||
<span className="text-sm">Cargando inventario...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-8 text-center">
|
||||
<p className="text-red-600 text-sm mb-3">{error}</p>
|
||||
<button onClick={load} className="text-sm text-[#2D6A4F] hover:underline">Reintentar</button>
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="p-12 text-center">
|
||||
<p className="text-4xl mb-3">📦</p>
|
||||
<p className="text-gray-500 text-sm">No hay variantes para este filtro</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 border-b border-gray-200 text-left">
|
||||
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">Producto</th>
|
||||
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">SKU</th>
|
||||
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">EAN</th>
|
||||
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">Stock</th>
|
||||
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">Estado</th>
|
||||
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">Acción</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-50">
|
||||
{filtered.map((row) => (
|
||||
<tr key={row.variant.id} className="hover:bg-gray-50/50 transition-colors">
|
||||
<td className="px-4 py-3">
|
||||
<p className="text-sm font-medium text-gray-900">{row.productName}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-gray-600">{row.variant.sku}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-gray-400">{row.variant.ean ?? '—'}</td>
|
||||
<td className="px-4 py-3">
|
||||
{row.loading ? (
|
||||
<span className="text-gray-300">—</span>
|
||||
) : row.editing ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={row.editValue}
|
||||
onChange={(e) =>
|
||||
setRows((prev) =>
|
||||
prev.map((r) =>
|
||||
r.variant.id === row.variant.id
|
||||
? { ...r, editValue: e.target.value }
|
||||
: r,
|
||||
),
|
||||
)
|
||||
}
|
||||
className="w-20 px-2 py-1 border border-gray-300 rounded-lg text-sm focus:ring-1 focus:ring-[#2D6A4F] outline-none"
|
||||
/>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const qty = parseInt(row.editValue, 10);
|
||||
if (isNaN(qty) || qty < 0) return;
|
||||
setRows((prev) =>
|
||||
prev.map((r) =>
|
||||
r.variant.id === row.variant.id ? { ...r, saving: true } : r,
|
||||
),
|
||||
);
|
||||
try {
|
||||
const result = await inventoryApi.setStock(row.variant.id, qty);
|
||||
setRows((prev) =>
|
||||
prev.map((r) =>
|
||||
r.variant.id === row.variant.id
|
||||
? {
|
||||
...r,
|
||||
stock: { available: result.available > 0, availableQuantity: result.available },
|
||||
editing: false,
|
||||
saving: false,
|
||||
msg: '✓',
|
||||
}
|
||||
: r,
|
||||
),
|
||||
);
|
||||
setTimeout(() => {
|
||||
setRows((prev) =>
|
||||
prev.map((r) =>
|
||||
r.variant.id === row.variant.id ? { ...r, msg: '' } : r,
|
||||
),
|
||||
);
|
||||
}, 3000);
|
||||
} catch {
|
||||
setRows((prev) =>
|
||||
prev.map((r) =>
|
||||
r.variant.id === row.variant.id
|
||||
? { ...r, saving: false, msg: 'Error' }
|
||||
: r,
|
||||
),
|
||||
);
|
||||
}
|
||||
}}
|
||||
disabled={row.saving}
|
||||
className="px-2 py-1 bg-[#2D6A4F] text-white text-xs rounded-lg hover:bg-[#1B4332] disabled:opacity-50"
|
||||
>
|
||||
{row.saving ? '...' : 'OK'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
setRows((prev) =>
|
||||
prev.map((r) =>
|
||||
r.variant.id === row.variant.id
|
||||
? {
|
||||
...r,
|
||||
editing: false,
|
||||
editValue: String(r.stock?.availableQuantity ?? 0),
|
||||
}
|
||||
: r,
|
||||
),
|
||||
)
|
||||
}
|
||||
className="text-gray-400 hover:text-gray-600 text-xs"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-medium text-gray-900">
|
||||
{row.stock?.availableQuantity ?? '—'}
|
||||
</span>
|
||||
<button
|
||||
onClick={() =>
|
||||
setRows((prev) =>
|
||||
prev.map((r) =>
|
||||
r.variant.id === row.variant.id ? { ...r, editing: true } : r,
|
||||
),
|
||||
)
|
||||
}
|
||||
className="ml-1 text-gray-400 hover:text-[#2D6A4F] text-xs"
|
||||
title="Editar stock"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<StockBadge qty={row.stock?.availableQuantity ?? 0} />
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{row.msg && (
|
||||
<span className={`text-xs ${row.msg === '✓' ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{row.msg}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
122
project/apps/admin/src/app/(dashboard)/layout.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
'use client';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { AuthProvider, useAuth } from '@/features/auth/components/AuthProvider';
|
||||
import { visibleNavItems, type NavItem } from '@/lib/permissions';
|
||||
import type { Role } from '@/types';
|
||||
|
||||
function Sidebar({
|
||||
navItems,
|
||||
user,
|
||||
onLogout,
|
||||
}: {
|
||||
navItems: NavItem[];
|
||||
user: { email: string; role: Role };
|
||||
onLogout: () => void;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<div className="w-60 bg-white border-r border-gray-200 flex flex-col h-screen sticky top-0">
|
||||
{/* Logo */}
|
||||
<div className="px-4 py-5 border-b border-gray-100">
|
||||
<img
|
||||
src="/images/logo-main.png"
|
||||
alt="MercadoDeVida"
|
||||
className="h-9 w-auto object-contain mx-auto"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Nav */}
|
||||
<nav className="flex-1 px-3 py-4 space-y-0.5 overflow-y-auto">
|
||||
{navItems.map((item) => {
|
||||
const active =
|
||||
item.href === '/'
|
||||
? pathname === '/'
|
||||
: pathname.startsWith(item.href);
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`
|
||||
flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-all
|
||||
${
|
||||
active
|
||||
? 'bg-[#2D6A4F]/10 text-[#2D6A4F] border-l-[3px] border-[#2D6A4F]'
|
||||
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<span className="text-base">{item.icon}</span>
|
||||
<span className="truncate">{item.label}</span>
|
||||
{item.badge != null && item.badge > 0 && (
|
||||
<span className="ml-auto bg-[#E76F51] text-white text-xs font-bold rounded-full px-1.5 py-0.5 min-w-[18px] text-center">
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* User footer */}
|
||||
<div className="px-3 py-4 border-t border-gray-100">
|
||||
<div className="px-3 py-2 mb-2">
|
||||
<p className="text-xs text-gray-400 truncate">{user.email}</p>
|
||||
<p className="text-xs text-gray-500 capitalize">{user.role}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onLogout}
|
||||
className="w-full text-left px-3 py-2 text-sm text-gray-500 hover:text-gray-700 hover:bg-gray-50 rounded-lg transition-colors"
|
||||
>
|
||||
Cerrar sesión
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardShell({ children }: { children: React.ReactNode }) {
|
||||
const { user, loading, logout } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !user) {
|
||||
router.push('/login');
|
||||
}
|
||||
}, [user, loading, router]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="text-gray-500">Cargando...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const navItems = visibleNavItems(user.role);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-gray-50">
|
||||
<Sidebar navItems={navItems} user={user} onLogout={logout} />
|
||||
<main className="flex-1 min-w-0">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<DashboardShell>{children}</DashboardShell>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
273
project/apps/admin/src/app/(dashboard)/orders/[id]/page.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
'use client';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import type { Order, OrderState } from '@/types';
|
||||
import { ordersApi } from '@/lib/api-client';
|
||||
|
||||
const STATE_LABELS: Record<OrderState, string> = {
|
||||
PENDING: 'Pendiente',
|
||||
AWAITING_PAYMENT: 'Esperando pago',
|
||||
PAID: 'Pagado',
|
||||
PROCESSING: 'Procesando',
|
||||
SHIPPED: 'Enviado',
|
||||
DELIVERED: 'Entregado',
|
||||
CANCELLED: 'Cancelado',
|
||||
REFUNDED: 'Reembolsado',
|
||||
PARTIALLY_REFUNDED: 'Reembolso parcial',
|
||||
};
|
||||
|
||||
const STATE_COLORS: Record<OrderState, string> = {
|
||||
PENDING: 'bg-amber-100 text-amber-800',
|
||||
AWAITING_PAYMENT: 'bg-orange-100 text-orange-800',
|
||||
PAID: 'bg-blue-100 text-blue-800',
|
||||
PROCESSING: 'bg-indigo-100 text-indigo-800',
|
||||
SHIPPED: 'bg-purple-100 text-purple-800',
|
||||
DELIVERED: 'bg-green-100 text-green-800',
|
||||
CANCELLED: 'bg-red-100 text-red-800',
|
||||
REFUNDED: 'bg-purple-100 text-purple-800',
|
||||
PARTIALLY_REFUNDED: 'bg-pink-100 text-pink-800',
|
||||
};
|
||||
|
||||
const ALLOWED_TRANSITIONS: Record<OrderState, OrderState[]> = {
|
||||
PENDING: ['AWAITING_PAYMENT', 'CANCELLED'],
|
||||
AWAITING_PAYMENT: ['PAID', 'CANCELLED'],
|
||||
PAID: ['PROCESSING', 'CANCELLED', 'REFUNDED'],
|
||||
PROCESSING: ['SHIPPED', 'CANCELLED', 'REFUNDED'],
|
||||
SHIPPED: ['DELIVERED', 'PARTIALLY_REFUNDED'],
|
||||
DELIVERED: ['PARTIALLY_REFUNDED'],
|
||||
CANCELLED: [],
|
||||
REFUNDED: [],
|
||||
PARTIALLY_REFUNDED: [],
|
||||
};
|
||||
|
||||
const ACTION_LABELS: Record<OrderState, string> = {
|
||||
AWAITING_PAYMENT: 'Marcar como Pagado',
|
||||
PAID: 'Procesar pedido',
|
||||
PROCESSING: 'Marcar como Enviado',
|
||||
SHIPPED: 'Marcar como Entregado',
|
||||
DELIVERED: 'Reembolso parcial',
|
||||
CANCELLED: 'Cancelar pedido',
|
||||
PENDING: 'Marcar como Pagado',
|
||||
REFUNDED: 'Reembolsar',
|
||||
PARTIALLY_REFUNDED: 'Reembolso parcial',
|
||||
};
|
||||
|
||||
function formatPrice(cents: number) {
|
||||
return `€${(cents / 100).toFixed(2)}`;
|
||||
}
|
||||
|
||||
export default function OrderDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const router = useRouter();
|
||||
const [order, setOrder] = useState<Order | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [transitioning, setTransitioning] = useState(false);
|
||||
const [showConfirm, setShowConfirm] = useState<OrderState | null>(null);
|
||||
const [confirmReason, setConfirmReason] = useState('');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await ordersApi.get(id);
|
||||
setOrder(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al cargar');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleTransition = async (nextState: OrderState) => {
|
||||
setTransitioning(true);
|
||||
try {
|
||||
const updated = await ordersApi.transition(id, nextState);
|
||||
setOrder(updated);
|
||||
setShowConfirm(null);
|
||||
setConfirmReason('');
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Error al cambiar estado');
|
||||
} finally {
|
||||
setTransitioning(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-8 flex items-center justify-center min-h-64">
|
||||
<div className="text-gray-400">Cargando...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !order) {
|
||||
return (
|
||||
<div className="p-8">
|
||||
<p className="text-red-600">{error || 'Pedido no encontrado'}</p>
|
||||
<button onClick={load} className="text-sm text-[#2D6A4F] hover:underline mt-2">
|
||||
Reintentar
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const currentState = order.state as OrderState;
|
||||
const allowed = ALLOWED_TRANSITIONS[currentState] ?? [];
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
{/* Back */}
|
||||
<Link href="/orders" className="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 mb-6">
|
||||
← Volver a pedidos
|
||||
</Link>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 font-mono">#{order.id.slice(0, 8)}</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{new Date(order.createdAt).toLocaleString('es-ES', {
|
||||
dateStyle: 'long',
|
||||
timeStyle: 'short',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<span className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium ${STATE_COLORS[currentState]}`}>
|
||||
<span className="w-2 h-2 rounded-full bg-current" />
|
||||
{STATE_LABELS[currentState]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Main content */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Actions */}
|
||||
{allowed.length > 0 && (
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-6">
|
||||
<h2 className="font-bold text-gray-900 mb-4">Acciones</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{allowed.map((next) => (
|
||||
<button
|
||||
key={next}
|
||||
onClick={() => setShowConfirm(next)}
|
||||
disabled={transitioning}
|
||||
className="px-4 py-2 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-xl transition-colors"
|
||||
>
|
||||
{ACTION_LABELS[next] ?? next}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Order items */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-6">
|
||||
<h2 className="font-bold text-gray-900 mb-4">Productos</h2>
|
||||
<div className="space-y-3">
|
||||
{order.items.map((item) => (
|
||||
<div key={item.id} className="flex justify-between items-start py-2 border-b border-gray-50 last:border-0">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900">{item.name}</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
{item.quantity} × {formatPrice(item.unitPriceCents)}
|
||||
{item.discountCents > 0 && ` (-${formatPrice(item.discountCents)})`}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm font-bold text-gray-900 ml-4">
|
||||
{formatPrice((item.unitPriceCents - item.discountCents) * item.quantity)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* Totals */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-6">
|
||||
<h2 className="font-bold text-gray-900 mb-4">Resumen</h2>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Subtotal</span>
|
||||
<span className="font-medium">{formatPrice(order.subtotalCents)}</span>
|
||||
</div>
|
||||
{order.discountCents > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Descuento</span>
|
||||
<span className="font-medium text-green-600">-{formatPrice(order.discountCents)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">IVA</span>
|
||||
<span className="font-medium">{formatPrice(order.taxCents)}</span>
|
||||
</div>
|
||||
<div className="border-t border-gray-200 pt-2 mt-2 flex justify-between items-center">
|
||||
<span className="font-bold text-gray-900">Total</span>
|
||||
<span className="text-xl font-bold text-[#2D6A4F]">{formatPrice(order.totalCents)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-6">
|
||||
<h2 className="font-bold text-gray-900 mb-4">Historial</h2>
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-3">
|
||||
<div className="w-2 h-2 rounded-full bg-[#2D6A4F] mt-1.5 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">{STATE_LABELS[currentState]}</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
{new Date(order.createdAt).toLocaleString('es-ES')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confirmation Modal */}
|
||||
{showConfirm && (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-2xl p-6 max-w-md w-full shadow-xl">
|
||||
<h3 className="text-lg font-bold text-gray-900 mb-2">
|
||||
Confirmar cambio de estado
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
¿{ACTION_LABELS[showConfirm] ?? showConfirm}?
|
||||
</p>
|
||||
{(showConfirm === 'CANCELLED' || showConfirm === 'REFUNDED') && (
|
||||
<textarea
|
||||
value={confirmReason}
|
||||
onChange={(e) => setConfirmReason(e.target.value)}
|
||||
placeholder="Motivo (opcional)"
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none mb-4 resize-none"
|
||||
/>
|
||||
)}
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button
|
||||
onClick={() => { setShowConfirm(null); setConfirmReason(''); }}
|
||||
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleTransition(showConfirm)}
|
||||
disabled={transitioning}
|
||||
className="px-4 py-2 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-xl transition-colors"
|
||||
>
|
||||
{transitioning ? 'Guardando...' : 'Confirmar'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
203
project/apps/admin/src/app/(dashboard)/orders/page.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
'use client';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import Link from 'next/link';
|
||||
import type { Order, OrderState } from '@/types';
|
||||
import { ordersApi } from '@/lib/api-client';
|
||||
|
||||
const ORDER_STATES: OrderState[] = [
|
||||
'PENDING',
|
||||
'AWAITING_PAYMENT',
|
||||
'PAID',
|
||||
'PROCESSING',
|
||||
'SHIPPED',
|
||||
'DELIVERED',
|
||||
'CANCELLED',
|
||||
'REFUNDED',
|
||||
'PARTIALLY_REFUNDED',
|
||||
];
|
||||
|
||||
const STATE_LABELS: Record<OrderState, string> = {
|
||||
PENDING: 'Pendiente',
|
||||
AWAITING_PAYMENT: 'Esperando pago',
|
||||
PAID: 'Pagado',
|
||||
PROCESSING: 'Procesando',
|
||||
SHIPPED: 'Enviado',
|
||||
DELIVERED: 'Entregado',
|
||||
CANCELLED: 'Cancelado',
|
||||
REFUNDED: 'Reembolsado',
|
||||
PARTIALLY_REFUNDED: 'Reembolso parcial',
|
||||
};
|
||||
|
||||
const STATE_COLORS: Record<OrderState, string> = {
|
||||
PENDING: 'bg-amber-100 text-amber-800',
|
||||
AWAITING_PAYMENT: 'bg-orange-100 text-orange-800',
|
||||
PAID: 'bg-blue-100 text-blue-800',
|
||||
PROCESSING: 'bg-indigo-100 text-indigo-800',
|
||||
SHIPPED: 'bg-purple-100 text-purple-800',
|
||||
DELIVERED: 'bg-green-100 text-green-800',
|
||||
CANCELLED: 'bg-red-100 text-red-800',
|
||||
REFUNDED: 'bg-purple-100 text-purple-800',
|
||||
PARTIALLY_REFUNDED: 'bg-pink-100 text-pink-800',
|
||||
};
|
||||
|
||||
function formatPrice(cents: number) {
|
||||
return `€${(cents / 100).toFixed(2)}`;
|
||||
}
|
||||
|
||||
function timeAgo(dateStr: string) {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
if (diffDays === 0) return 'Hoy';
|
||||
if (diffDays === 1) return 'Ayer';
|
||||
if (diffDays < 30) return `Hace ${diffDays} días`;
|
||||
return date.toLocaleDateString('es-ES', { day: 'numeric', month: 'short' });
|
||||
}
|
||||
|
||||
export default function OrdersPage() {
|
||||
const [orders, setOrders] = useState<Order[] | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [filterState, setFilterState] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebouncedSearch(search), 400);
|
||||
return () => clearTimeout(t);
|
||||
}, [search]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await ordersApi.list({
|
||||
status: filterState || undefined,
|
||||
q: debouncedSearch || undefined,
|
||||
limit: 20,
|
||||
});
|
||||
setOrders(data.items);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al cargar');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filterState, debouncedSearch]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Pedidos</h1>
|
||||
<p className="text-sm text-gray-500 mt-0.5">{orders?.length ?? 0} pedidos</p>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex gap-3 mb-6 flex-wrap">
|
||||
<div className="relative flex-1 max-w-xs">
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Buscar por ID o email..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full pl-9 pr-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] focus:border-transparent outline-none"
|
||||
/>
|
||||
<svg
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="M21 21l-4.35-4.35" />
|
||||
</svg>
|
||||
</div>
|
||||
<select
|
||||
value={filterState}
|
||||
onChange={(e) => setFilterState(e.target.value)}
|
||||
className="px-3 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] focus:border-transparent outline-none bg-white"
|
||||
>
|
||||
<option value="">Todos los estados</option>
|
||||
{ORDER_STATES.map((s) => (
|
||||
<option key={s} value={s}>{STATE_LABELS[s]}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="p-8 text-center text-gray-400">
|
||||
<div className="inline-block animate-spin h-5 w-5 border-2 border-gray-300 border-t-[#2D6A4F] rounded-full" />
|
||||
<p className="mt-2 text-sm">Cargando...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-8 text-center">
|
||||
<p className="text-red-600 text-sm mb-3">{error}</p>
|
||||
<button onClick={load} className="text-sm text-[#2D6A4F] hover:underline">
|
||||
Reintentar
|
||||
</button>
|
||||
</div>
|
||||
) : !orders || orders.length === 0 ? (
|
||||
<div className="p-12 text-center">
|
||||
<p className="text-4xl mb-3">🧾</p>
|
||||
<p className="text-gray-500 text-sm">No hay pedidos</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 border-b border-gray-200">
|
||||
{['ID', 'Fecha', 'Total', 'Estado'].map((h) => (
|
||||
<th
|
||||
key={h}
|
||||
className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3"
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-50">
|
||||
{orders.map((o) => (
|
||||
<tr key={o.id} className="hover:bg-gray-50 transition-colors">
|
||||
<td className="px-4 py-3.5">
|
||||
<Link
|
||||
href={`/orders/${o.id}`}
|
||||
className="text-sm font-mono text-[#2D6A4F] hover:underline"
|
||||
>
|
||||
{o.id.slice(0, 8)}...
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<p className="text-sm text-gray-600">{timeAgo(o.createdAt)}</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
{new Date(o.createdAt).toLocaleTimeString('es-ES', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</p>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<p className="text-sm font-bold text-gray-900">{formatPrice(o.totalCents)}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium ${STATE_COLORS[o.state]}`}
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-current" />
|
||||
{STATE_LABELS[o.state]}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
248
project/apps/admin/src/app/(dashboard)/page.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
'use client';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { api } from '@/lib/api-client';
|
||||
|
||||
interface Stats {
|
||||
ordersToday: number;
|
||||
revenueTodayCents: number;
|
||||
revenueTodayFormatted: string;
|
||||
ordersByState: Record<string, number>;
|
||||
outOfStockVariants: number;
|
||||
totalActiveProducts: number;
|
||||
newCustomersThisMonth: number;
|
||||
generatedAt: string;
|
||||
}
|
||||
|
||||
function formatCents(cents: number): string {
|
||||
return `€${(cents / 100).toFixed(2)}`;
|
||||
}
|
||||
|
||||
const STATE_LABELS: Record<string, string> = {
|
||||
PENDING: 'Pendientes',
|
||||
AWAITING_PAYMENT: 'Esperando pago',
|
||||
PAID: 'Pagados',
|
||||
PROCESSING: 'Procesando',
|
||||
SHIPPED: 'Enviados',
|
||||
DELIVERED: 'Entregados',
|
||||
CANCELLED: 'Cancelados',
|
||||
REFUNDED: 'Reembolsados',
|
||||
PARTIALLY_REFUNDED: 'Reembolso parcial',
|
||||
};
|
||||
|
||||
const STATE_COLORS: Record<string, string> = {
|
||||
PENDING: 'bg-amber-100 text-amber-700',
|
||||
AWAITING_PAYMENT: 'bg-orange-100 text-orange-700',
|
||||
PAID: 'bg-green-100 text-green-700',
|
||||
PROCESSING: 'bg-blue-100 text-blue-700',
|
||||
SHIPPED: 'bg-indigo-100 text-indigo-700',
|
||||
DELIVERED: 'bg-emerald-100 text-emerald-700',
|
||||
CANCELLED: 'bg-gray-100 text-gray-600',
|
||||
REFUNDED: 'bg-red-100 text-red-700',
|
||||
PARTIALLY_REFUNDED: 'bg-pink-100 text-pink-700',
|
||||
};
|
||||
|
||||
function KPICard({
|
||||
label,
|
||||
value,
|
||||
sub,
|
||||
icon,
|
||||
trend,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
sub?: string;
|
||||
icon: string;
|
||||
trend?: 'up' | 'down' | 'neutral';
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-5">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-500">{label}</p>
|
||||
<p className="text-3xl font-bold text-gray-900 mt-1">{value}</p>
|
||||
{sub && <p className="text-xs text-gray-400 mt-1">{sub}</p>}
|
||||
</div>
|
||||
<div className="text-3xl">{icon}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OrderStateBar({ state, count, total }: { state: string; count: number; total: number }) {
|
||||
const pct = total > 0 ? (count / total) * 100 : 0;
|
||||
const label = STATE_LABELS[state] ?? state;
|
||||
const color = STATE_COLORS[state] ?? 'bg-gray-100 text-gray-700';
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-2">
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium min-w-[100px] ${color}`}>
|
||||
{label}
|
||||
</span>
|
||||
<div className="flex-1 h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-[#2D6A4F] rounded-full transition-all"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-gray-700 min-w-[32px] text-right">{count}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [stats, setStats] = useState<Stats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.get<Stats>('/admin/stats')
|
||||
.then(setStats)
|
||||
.catch(() => setError('No se pudieron cargar las estadísticas'))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-8 space-y-6">
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="bg-white border border-gray-200 rounded-xl p-5 animate-pulse">
|
||||
<div className="h-4 bg-gray-200 rounded w-1/2 mb-3" />
|
||||
<div className="h-8 bg-gray-200 rounded w-3/4" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-6 animate-pulse">
|
||||
<div className="h-5 bg-gray-200 rounded w-1/4 mb-4" />
|
||||
<div className="space-y-3">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="h-8 bg-gray-100 rounded" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !stats) {
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-4 text-sm text-red-700">
|
||||
{error ?? 'Error desconocido'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const totalOrders = Object.values(stats.ordersByState).reduce((a, b) => a + b, 0);
|
||||
const ordersByStateSorted = Object.entries(stats.ordersByState).sort(
|
||||
([, a], [, b]) => b - a,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-8 space-y-6">
|
||||
{/* KPI Cards */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<KPICard
|
||||
label="Pedidos hoy"
|
||||
value={String(stats.ordersToday)}
|
||||
sub="Órdenes del día"
|
||||
icon="📦"
|
||||
/>
|
||||
<KPICard
|
||||
label="Ingresos hoy"
|
||||
value={formatCents(stats.revenueTodayCents)}
|
||||
sub="Revenue del día"
|
||||
icon="💶"
|
||||
/>
|
||||
<KPICard
|
||||
label="Productos activos"
|
||||
value={String(stats.totalActiveProducts)}
|
||||
sub="En el catálogo"
|
||||
icon="🌿"
|
||||
/>
|
||||
<KPICard
|
||||
label="Sin stock"
|
||||
value={String(stats.outOfStockVariants)}
|
||||
sub="Variantes agotadas"
|
||||
icon="⚠️"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Secondary KPIs */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<KPICard
|
||||
label="Clientes nuevos"
|
||||
value={String(stats.newCustomersThisMonth)}
|
||||
sub="Este mes"
|
||||
icon="👥"
|
||||
/>
|
||||
<KPICard
|
||||
label="Total pedidos"
|
||||
value={String(totalOrders)}
|
||||
sub="En el sistema"
|
||||
icon="📋"
|
||||
/>
|
||||
<KPICard
|
||||
label="Alertas"
|
||||
value={
|
||||
stats.outOfStockVariants > 0
|
||||
? `${stats.outOfStockVariants} sin stock`
|
||||
: 'Sin alertas'
|
||||
}
|
||||
sub={stats.outOfStockVariants > 0 ? 'Revisar inventario' : 'Todo OK'}
|
||||
icon={stats.outOfStockVariants > 0 ? '🔴' : '✅'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Orders by state */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-base font-semibold text-gray-900">Pedidos por estado</h2>
|
||||
<span className="text-sm text-gray-500">{totalOrders} total</span>
|
||||
</div>
|
||||
|
||||
{totalOrders === 0 ? (
|
||||
<div className="py-8 text-center text-gray-400 text-sm">
|
||||
<p className="text-3xl mb-2">📋</p>
|
||||
<p>No hay pedidos en el sistema</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{ordersByStateSorted.map(([state, count]) => (
|
||||
<OrderStateBar
|
||||
key={state}
|
||||
state={state}
|
||||
count={count}
|
||||
total={totalOrders}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick actions */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-6">
|
||||
<h2 className="text-base font-semibold text-gray-900 mb-4">Acciones rápidas</h2>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
{[
|
||||
{ href: '/products/new', label: '+ Nuevo producto', icon: '🌿' },
|
||||
{ href: '/orders', label: 'Ver pedidos', icon: '📦' },
|
||||
{ href: '/inventory', label: 'Revisar stock', icon: '📊' },
|
||||
{ href: '/customers', label: 'Clientes', icon: '👥' },
|
||||
].map(({ href, label, icon }) => (
|
||||
<a
|
||||
key={href}
|
||||
href={href}
|
||||
className="flex items-center gap-2 px-4 py-3 border border-gray-200 rounded-xl hover:bg-gray-50 hover:border-[#2D6A4F] transition-colors text-sm font-medium text-gray-700"
|
||||
>
|
||||
<span>{icon}</span>
|
||||
<span>{label}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
130
project/apps/admin/src/app/(dashboard)/payments/page.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
'use client';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { paymentsApi, type PaymentTransaction } from '@/lib/api-client';
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
succeeded: 'bg-green-100 text-green-700',
|
||||
requires_payment: 'bg-yellow-100 text-yellow-700',
|
||||
failed: 'bg-red-100 text-red-700',
|
||||
refunded: 'bg-gray-100 text-gray-600',
|
||||
chargeback: 'bg-red-100 text-red-800',
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
export default function PaymentsPage() {
|
||||
const [items, setItems] = useState<PaymentTransaction[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [filter, setFilter] = useState('');
|
||||
const [debounced, setDebounced] = useState('');
|
||||
const [page, setPage] = useState(0);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [msg, setMsg] = useState('');
|
||||
|
||||
useEffect(() => { const t = setTimeout(() => setDebounced(filter), 400); return () => clearTimeout(t); }, [filter]);
|
||||
useEffect(() => { setPage(0); }, [debounced]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true); setError('');
|
||||
try {
|
||||
const data = await paymentsApi.list({ limit: PAGE_SIZE, offset: page * PAGE_SIZE, q: debounced || undefined });
|
||||
setItems(data.items ?? []); setTotal(data.total ?? 0);
|
||||
} catch (e) { setError(e instanceof Error ? e.message : 'Error'); }
|
||||
finally { setLoading(false); }
|
||||
}, [page, debounced]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleRefund = async (id: string) => {
|
||||
if (!confirm('¿Reembolsar este pago? Esta acción no se puede deshacer.')) return;
|
||||
try {
|
||||
await paymentsApi.refund(id);
|
||||
setMsg('Reembolso procesado'); setTimeout(() => setMsg(''), 3000); load();
|
||||
} catch (er) { alert(er instanceof Error ? er.message : 'Error al reembolsar'); }
|
||||
};
|
||||
|
||||
const fmt = (cents: number) => `€${(cents / 100).toFixed(2)}`;
|
||||
const fmtDate = (d: string) => new Date(d).toLocaleString('es-ES', { dateStyle: 'short', timeStyle: 'short' });
|
||||
|
||||
return (
|
||||
<div className="p-8 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Pagos</h1>
|
||||
<p className="text-sm text-gray-500 mt-0.5">{total > 0 ? `${total} transacción${total !== 1 ? 'es' : ''}` : ''}</p>
|
||||
</div>
|
||||
{msg && <span className="text-sm text-green-600 bg-green-50 px-3 py-1 rounded-full">{msg}</span>}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative flex-1 max-w-xs">
|
||||
<input type="text" placeholder="Buscar por ID de pago..." value={filter} onChange={e => setFilter(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2.5 border border-gray-200 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">🔍</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl border border-gray-200 overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-16 text-gray-400 text-sm">Cargando...</div>
|
||||
) : error ? (
|
||||
<div className="flex items-center justify-center py-16 text-red-500 text-sm">{error}</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-gray-400 text-sm gap-2">
|
||||
<span className="text-3xl">💳</span><span>Sin transacciones</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Fecha</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Importe</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Estado</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Provider</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">ID Pago</th>
|
||||
<th className="text-right px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{items.map(txn => (
|
||||
<tr key={txn.id} className="hover:bg-gray-50 transition-colors">
|
||||
<td className="px-6 py-4 text-sm text-gray-500 whitespace-nowrap">{fmtDate(txn.createdAt)}</td>
|
||||
<td className="px-6 py-4 text-sm font-semibold text-gray-800">{fmt(txn.amountCents)}</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${STATUS_COLORS[txn.status] ?? 'bg-gray-100 text-gray-600'}`}>
|
||||
{txn.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">{txn.provider}</td>
|
||||
<td className="px-6 py-4 text-xs font-mono text-gray-400 max-w-[120px] truncate">{txn.providerPaymentId ?? '—'}</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
{txn.status === 'succeeded' && (
|
||||
<button onClick={() => handleRefund(txn.id)}
|
||||
className="text-sm text-amber-600 hover:text-amber-700 font-medium px-3 py-1.5 rounded-lg hover:bg-amber-50 transition-colors">
|
||||
Reembolsar
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{total > PAGE_SIZE && (
|
||||
<div className="flex items-center justify-between px-6 py-4 border-t border-gray-200">
|
||||
<span className="text-sm text-gray-500">{page * PAGE_SIZE + 1}–{Math.min((page + 1) * PAGE_SIZE, total)} de {total}</span>
|
||||
<div className="flex gap-2">
|
||||
<button disabled={page === 0} onClick={() => setPage(p => p - 1)}
|
||||
className="px-4 py-2 text-sm border border-gray-300 rounded-xl disabled:opacity-40 hover:bg-gray-50 transition-colors">Anterior</button>
|
||||
<button disabled={(page + 1) * PAGE_SIZE >= total} onClick={() => setPage(p => p + 1)}
|
||||
className="px-4 py-2 text-sm border border-gray-300 rounded-xl disabled:opacity-40 hover:bg-gray-50 transition-colors">Siguiente</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { ProductEditor } from '@/features/products/components/ProductEditor';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function ProductEditPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
return <ProductEditor productId={id} />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ProductEditor } from '@/features/products/components/ProductEditor';
|
||||
|
||||
export default function NewProductPage() {
|
||||
return <ProductEditor />;
|
||||
}
|
||||
224
project/apps/admin/src/app/(dashboard)/products/page.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
'use client';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import type { Product } from '@/types';
|
||||
import { productsApi } from '@/lib/api-client';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
function formatPrice(cents?: number) {
|
||||
if (cents == null) return '—';
|
||||
return `€${(cents / 100).toFixed(2)}`;
|
||||
}
|
||||
|
||||
function StateBadge({ state }: { state: string }) {
|
||||
const map: Record<string, { label: string; cls: string }> = {
|
||||
active: { label: 'Activo', cls: 'bg-green-100 text-green-800' },
|
||||
archived: { label: 'Archivado', cls: 'bg-gray-100 text-gray-600' },
|
||||
draft: { label: 'Borrador', cls: 'bg-amber-100 text-amber-800' },
|
||||
};
|
||||
const { label, cls } = map[state] ?? { label: state, cls: 'bg-gray-100 text-gray-600' };
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${cls}`}>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProductsPage() {
|
||||
const router = useRouter();
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
const [page, setPage] = useState(0);
|
||||
const [total, setTotal] = useState(0);
|
||||
|
||||
// Debounce search
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebouncedSearch(search), 400);
|
||||
return () => clearTimeout(t);
|
||||
}, [search]);
|
||||
|
||||
// Reset page on search change
|
||||
useEffect(() => {
|
||||
setPage(0);
|
||||
}, [debouncedSearch]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await productsApi.list({
|
||||
limit: PAGE_SIZE,
|
||||
offset: page * PAGE_SIZE,
|
||||
q: debouncedSearch || undefined,
|
||||
});
|
||||
setProducts(data.items ?? []);
|
||||
setTotal(data.items?.length ?? 0);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al cargar');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, debouncedSearch]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const totalPages = Math.ceil(total / PAGE_SIZE) || 1;
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Productos</h1>
|
||||
<p className="text-sm text-gray-500 mt-0.5">{total} productos</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/products/new"
|
||||
className="px-4 py-2 bg-[#2D6A4F] hover:bg-[#1B4332] text-white text-sm font-semibold rounded-xl transition-colors"
|
||||
>
|
||||
+ Crear producto
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="mb-6">
|
||||
<div className="relative max-w-md">
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Buscar por nombre..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] focus:border-transparent outline-none"
|
||||
/>
|
||||
<svg
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="M21 21l-4.35-4.35" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="p-8 text-center text-gray-400">
|
||||
<div className="inline-block animate-spin h-5 w-5 border-2 border-gray-300 border-t-[#2D6A4F] rounded-full" />
|
||||
<p className="mt-2 text-sm">Cargando...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-8 text-center">
|
||||
<p className="text-red-600 text-sm mb-3">{error}</p>
|
||||
<button
|
||||
onClick={load}
|
||||
className="text-sm text-[#2D6A4F] hover:underline"
|
||||
>
|
||||
Reintentar
|
||||
</button>
|
||||
</div>
|
||||
) : products.length === 0 ? (
|
||||
<div className="p-12 text-center">
|
||||
<p className="text-4xl mb-3">📦</p>
|
||||
<p className="text-gray-500 text-sm">
|
||||
{debouncedSearch ? 'No hay productos para esta búsqueda' : 'No hay productos'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 border-b border-gray-200">
|
||||
<th className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3">
|
||||
Producto
|
||||
</th>
|
||||
<th className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3">
|
||||
Marca
|
||||
</th>
|
||||
<th className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3">
|
||||
Estado
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-50">
|
||||
{products.map((p) => (
|
||||
<tr
|
||||
key={p.id}
|
||||
className="hover:bg-gray-50 transition-colors cursor-pointer"
|
||||
onClick={() => router.push(`/products/${p.id}`)}
|
||||
>
|
||||
<td className="px-4 py-3.5">
|
||||
<div className="flex items-center gap-3">
|
||||
{p.imageUrl ? (
|
||||
<img
|
||||
src={p.imageUrl}
|
||||
alt={p.name}
|
||||
className="w-10 h-10 rounded-lg object-cover bg-gray-100 flex-shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-10 h-10 rounded-lg bg-gray-100 flex items-center justify-center text-lg flex-shrink-0">
|
||||
🌿
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 truncate max-w-xs">
|
||||
{p.name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 truncate max-w-xs">
|
||||
{p.description?.slice(0, 60) ?? p.slug}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<p className="text-sm text-gray-600">{p.brand?.name ?? '—'}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<StateBadge state={p.state} />
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-gray-300">→</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-gray-200 bg-gray-50">
|
||||
<p className="text-sm text-gray-500">
|
||||
Página {page + 1} de {totalPages}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
disabled={page === 0}
|
||||
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg disabled:opacity-40 hover:bg-white transition-colors"
|
||||
>
|
||||
← Anterior
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
|
||||
disabled={page >= totalPages - 1}
|
||||
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg disabled:opacity-40 hover:bg-white transition-colors"
|
||||
>
|
||||
Siguiente →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
137
project/apps/admin/src/app/(dashboard)/promotions/page.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
'use client';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { promotionsApi } from '@/lib/api-client';
|
||||
|
||||
interface Promo { code: string; type: string; value: number; startsAt: string; endsAt: string; active: boolean; usageLimit: number | null; usageCount: number; }
|
||||
|
||||
export default function PromotionsPage() {
|
||||
const [items, setItems] = useState<Promo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
|
||||
const [form, setForm] = useState({
|
||||
code: '', type: 'percent', value: '',
|
||||
startsAt: new Date().toISOString().split('T')[0],
|
||||
endsAt: new Date(Date.now() + 30 * 86400000).toISOString().split('T')[0],
|
||||
usageLimit: '',
|
||||
});
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const d = await promotionsApi.list() as { items: Promo[] };
|
||||
setItems(d.items ?? []);
|
||||
} catch (e) { setError(e instanceof Error ? e.message : 'Error'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setMsg('');
|
||||
try {
|
||||
await promotionsApi.create({
|
||||
code: form.code,
|
||||
type: form.type,
|
||||
value: parseInt(form.value, 10),
|
||||
startsAt: new Date(form.startsAt).toISOString(),
|
||||
endsAt: new Date(form.endsAt).toISOString(),
|
||||
usageLimit: form.usageLimit ? parseInt(form.usageLimit, 10) : null,
|
||||
});
|
||||
setMsg('Promoción creada');
|
||||
setShowForm(false);
|
||||
load();
|
||||
} catch (e) { setMsg(e instanceof Error ? e.message : 'Error'); }
|
||||
};
|
||||
|
||||
const toggleActive = async (code: string, currentActive: boolean) => {
|
||||
try {
|
||||
await promotionsApi.update(code, { active: !currentActive });
|
||||
load();
|
||||
} catch (e) { alert(e instanceof Error ? e.message : 'Error'); }
|
||||
};
|
||||
|
||||
const handleDelete = async (code: string) => {
|
||||
if (!confirm(`¿Eliminar "${code}"?`)) return;
|
||||
try { await promotionsApi.delete(code); load(); }
|
||||
catch (e) { alert(e instanceof Error ? e.message : 'Error'); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-8 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Promociones</h1>
|
||||
<button onClick={() => setShowForm(true)} className="px-4 py-2 bg-[#2D6A4F] hover:bg-[#1B4332] text-white text-sm font-semibold rounded-xl">+ Nueva promoción</button>
|
||||
</div>
|
||||
|
||||
{msg && <div className={`p-4 rounded-xl text-sm ${msg.startsWith('Error') ? 'bg-red-50 text-red-700' : 'bg-green-50 text-green-700'}`}>{msg}</div>}
|
||||
|
||||
{showForm && (
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-6 space-y-4">
|
||||
<h2 className="font-semibold text-gray-900">Nueva promoción</h2>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{[['code','Código *','text'],['value','Valor *','number']].map(([k, label, t]) => (
|
||||
<div key={k}>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{label}</label>
|
||||
<input type={t} value={(form as Record<string,string>)[k]} onChange={e => setForm({...form, [k]: e.target.value})} className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
|
||||
</div>
|
||||
))}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Tipo</label>
|
||||
<select value={form.type} onChange={e => setForm({...form, type: e.target.value})} className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm bg-white focus:ring-2 focus:ring-[#2D6A4F] outline-none">
|
||||
<option value="percent">Porcentaje</option><option value="fixed_amount">Cantidad fija</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Límite de uso</label>
|
||||
<input type="number" value={form.usageLimit} onChange={e => setForm({...form, usageLimit: e.target.value})} placeholder="Sin límite" className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Fecha inicio</label>
|
||||
<input type="date" value={form.startsAt} onChange={e => setForm({...form, startsAt: e.target.value})} className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Fecha fin</label>
|
||||
<input type="date" value={form.endsAt} onChange={e => setForm({...form, endsAt: e.target.value})} className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={handleSave} className="px-5 py-2.5 bg-[#2D6A4F] hover:bg-[#1B4332] text-white text-sm font-semibold rounded-xl">Crear</button>
|
||||
<button onClick={() => setShowForm(false)} className="px-5 py-2.5 border border-gray-300 text-gray-600 text-sm rounded-xl">Cancelar</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
|
||||
{loading ? <div className="p-12 text-center text-gray-400">Cargando...</div> :
|
||||
error ? <div className="p-8 text-center text-red-600">{error}</div> :
|
||||
items.length === 0 ? <div className="p-12 text-center text-gray-400">No hay promociones</div> :
|
||||
<table className="w-full">
|
||||
<thead><tr className="bg-gray-50 border-b border-gray-200">
|
||||
{['Código','Tipo','Valor','Activa','Límite','Usos','Fin'].map(h => <th key={h} className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3">{h}</th>)}
|
||||
<th className="px-4 py-3"></th>
|
||||
</tr></thead>
|
||||
<tbody className="divide-y divide-gray-50">
|
||||
{items.map(p => (
|
||||
<tr key={p.code} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3.5 font-mono text-sm font-medium text-gray-900">{p.code}</td>
|
||||
<td className="px-4 py-3.5 text-sm text-gray-600">{p.type === 'percent' ? '%' : 'Fijo'}</td>
|
||||
<td className="px-4 py-3.5 text-sm font-medium text-gray-900">{p.type === 'percent' ? `${p.value / 100}%` : `€${(p.value / 100).toFixed(2)}`}</td>
|
||||
<td className="px-4 py-3.5">
|
||||
<button onClick={() => toggleActive(p.code, p.active)} className={`px-2 py-0.5 rounded-full text-xs font-medium ${p.active ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'}`}>{p.active ? 'Sí' : 'No'}</button>
|
||||
</td>
|
||||
<td className="px-4 py-3.5 text-sm text-gray-500">{p.usageLimit ?? '∞'}</td>
|
||||
<td className="px-4 py-3.5 text-sm text-gray-500">{p.usageCount}</td>
|
||||
<td className="px-4 py-3.5 text-sm text-gray-500">{new Date(p.endsAt).toLocaleDateString('es-ES')}</td>
|
||||
<td className="px-4 py-3.5"><button onClick={() => handleDelete(p.code)} className="text-xs text-red-600 hover:underline">Eliminar</button></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
96
project/apps/admin/src/app/(dashboard)/reviews/page.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
'use client';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { reviewsApi } from '@/lib/api-client';
|
||||
|
||||
interface Review {
|
||||
id: string; productId: string; userId: string; orderId: string;
|
||||
rating: number; title: string; body: string; status: string; createdAt: string;
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = { pending: 'Pendiente', published: 'Publicada', rejected: 'Rechazada' };
|
||||
const STATUS_CLS: Record<string, string> = {
|
||||
pending: 'bg-amber-100 text-amber-700',
|
||||
published: 'bg-green-100 text-green-700',
|
||||
rejected: 'bg-red-100 text-red-700',
|
||||
};
|
||||
|
||||
function Stars({ n }: { n: number }) {
|
||||
return (
|
||||
<span className="text-amber-400 text-sm">
|
||||
{'★'.repeat(n)}{'☆'.repeat(5 - n)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ReviewsPage() {
|
||||
const [items, setItems] = useState<Review[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [filter, setFilter] = useState<string>('');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const d = await reviewsApi.listAdmin({ status: filter || undefined, limit: 50 }) as { items: Review[]; total: number };
|
||||
setItems(d.items ?? []);
|
||||
setTotal(d.total ?? 0);
|
||||
} catch (e) { setError(e instanceof Error ? e.message : 'Error'); }
|
||||
finally { setLoading(false); }
|
||||
}, [filter]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const moderate = async (id: string, status: 'published' | 'rejected') => {
|
||||
try {
|
||||
await reviewsApi.moderate(id, status);
|
||||
load();
|
||||
} catch (e) { alert(e instanceof Error ? e.message : 'Error'); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-8 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div><h1 className="text-2xl font-bold text-gray-900">Reseñas</h1><p className="text-sm text-gray-500 mt-0.5">{total} reseñas pendientes de moderación</p></div>
|
||||
<div className="flex gap-2">
|
||||
{['', 'pending', 'published', 'rejected'].map(s => (
|
||||
<button key={s} onClick={() => setFilter(s)} className={`px-3 py-1.5 rounded-lg text-xs font-medium ${filter === s ? 'bg-[#2D6A4F] text-white' : 'bg-white border border-gray-300 text-gray-600 hover:bg-gray-50'}`}>
|
||||
{s ? STATUS_LABELS[s] : 'Todas'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{loading ? <div className="p-12 text-center text-gray-400">Cargando...</div> :
|
||||
error ? <div className="p-8 text-center text-red-600">{error}</div> :
|
||||
items.length === 0 ? <div className="p-12 text-center text-gray-400">No hay reseñas</div> :
|
||||
items.map(r => (
|
||||
<div key={r.id} className="bg-white border border-gray-200 rounded-xl p-5">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<Stars n={r.rating} />
|
||||
<p className="font-semibold text-gray-900 text-sm mt-1">{r.title}</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">{new Date(r.createdAt).toLocaleString('es-ES')}</p>
|
||||
</div>
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${STATUS_CLS[r.status] ?? 'bg-gray-100 text-gray-600'}`}>{STATUS_LABELS[r.status] ?? r.status}</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 leading-relaxed mb-4">{r.body}</p>
|
||||
{r.status === 'pending' && (
|
||||
<div className="flex gap-3">
|
||||
<button onClick={() => moderate(r.id, 'published')} className="px-4 py-2 bg-[#2D6A4F] hover:bg-[#1B4332] text-white text-xs font-semibold rounded-lg">✓ Publicar</button>
|
||||
<button onClick={() => moderate(r.id, 'rejected')} className="px-4 py-2 border border-red-200 text-red-600 hover:bg-red-50 text-xs font-semibold rounded-lg">✕ Rechazar</button>
|
||||
</div>
|
||||
)}
|
||||
{r.status !== 'pending' && (
|
||||
<button onClick={() => moderate(r.id, r.status === 'published' ? 'rejected' : 'published')} className="text-xs text-gray-400 hover:text-gray-600">
|
||||
{r.status === 'published' ? 'Despublicar' : 'Aprobar'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
109
project/apps/admin/src/app/(dashboard)/settings/page.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
'use client';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { settingsApi, type StoreSettings } from '@/lib/api-client';
|
||||
|
||||
type FormData = StoreSettings;
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [data, setData] = useState<FormData | null>(null);
|
||||
const [form, setForm] = useState<FormData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
const [err, setErr] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
settingsApi.get().then(d => {
|
||||
setData(d); setForm(d);
|
||||
}).catch(() => setErr('Error al cargar ajustes')).finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!form) return;
|
||||
setSaving(true); setErr(''); setMsg('');
|
||||
try {
|
||||
const updated = await settingsApi.update(form);
|
||||
setData(updated); setForm(updated);
|
||||
setMsg('Cambios guardados correctamente');
|
||||
setTimeout(() => setMsg(''), 4000);
|
||||
} catch (er) {
|
||||
setErr(er instanceof Error ? er.message : 'Error al guardar');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const field = (key: keyof FormData, label: string, opts?: { type?: string; placeholder?: string; rows?: number }) => (
|
||||
<div key={key}>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{label}</label>
|
||||
{opts?.rows ? (
|
||||
<textarea value={form?.[key] ?? ''} onChange={e => setForm(f => f ? { ...f, [key]: e.target.value } : f)}
|
||||
rows={opts.rows} placeholder={opts.placeholder}
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none resize-none" />
|
||||
) : (
|
||||
<input type={opts?.type ?? 'text'} value={form?.[key] ?? ''}
|
||||
onChange={e => setForm(f => f ? { ...f, [key]: e.target.value } : f)}
|
||||
placeholder={opts?.placeholder} maxLength={key === 'contactAddress' ? 400 : 200}
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-8 space-y-6 max-w-3xl">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Ajustes de tienda</h1>
|
||||
<p className="text-sm text-gray-500 mt-0.5">Configuración general de la tienda visible para los clientes.</p>
|
||||
</div>
|
||||
|
||||
{msg && <div className="bg-green-50 text-green-700 text-sm px-4 py-3 rounded-xl border border-green-200">{msg}</div>}
|
||||
{err && <div className="bg-red-50 text-red-700 text-sm px-4 py-3 rounded-xl border border-red-200">{err}</div>}
|
||||
|
||||
{loading ? (
|
||||
<div className="bg-white rounded-2xl border border-gray-200 p-12 flex items-center justify-center text-gray-400 text-sm">Cargando...</div>
|
||||
) : (
|
||||
<form onSubmit={handleSave} className="bg-white rounded-2xl border border-gray-200 overflow-hidden">
|
||||
<div className="px-6 py-4 bg-gray-50 border-b border-gray-200">
|
||||
<h2 className="text-base font-semibold text-gray-800">Información general</h2>
|
||||
</div>
|
||||
<div className="p-6 space-y-5">
|
||||
{field('storeName', 'Nombre de la tienda', { placeholder: 'Mercado de Vida' })}
|
||||
{field('storeTagline', 'Eslogan', { placeholder: 'Productos naturales y ecológicos' })}
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 bg-gray-50 border-t border-b border-gray-200">
|
||||
<h2 className="text-base font-semibold text-gray-800">Contacto</h2>
|
||||
</div>
|
||||
<div className="p-6 space-y-5">
|
||||
{field('contactEmail', 'Email de contacto', { type: 'email', placeholder: 'info@mercadodevida.es' })}
|
||||
{field('contactPhone', 'Teléfono', { placeholder: '+34 600 000 000' })}
|
||||
{field('contactAddress', 'Dirección', { placeholder: 'Calle ejemplo, ciudad', rows: 3 })}
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 bg-gray-50 border-t border-b border-gray-200">
|
||||
<h2 className="text-base font-semibold text-gray-800">Redes sociales</h2>
|
||||
</div>
|
||||
<div className="p-6 space-y-5">
|
||||
{field('facebookUrl', 'Facebook', { placeholder: 'https://facebook.com/...' })}
|
||||
{field('instagramUrl', 'Instagram', { placeholder: 'https://instagram.com/...' })}
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 bg-gray-50 border-t border-b border-gray-200">
|
||||
<h2 className="text-base font-semibold text-gray-800">Footer</h2>
|
||||
</div>
|
||||
<div className="p-6 space-y-5">
|
||||
{field('footerText', 'Texto del pie de página', { placeholder: '© 2026 Mercado de Vida...', rows: 2 })}
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-5 bg-gray-50 border-t border-gray-200 flex justify-end">
|
||||
<button type="submit" disabled={saving || !form}
|
||||
className="px-6 py-2.5 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-xl transition-colors">
|
||||
{saving ? 'Guardando...' : 'Guardar cambios'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
289
project/apps/admin/src/app/(dashboard)/shipping/page.tsx
Normal file
@@ -0,0 +1,289 @@
|
||||
'use client';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { shippingApi, type ShippingZone, type ShippingMethod } from '@/lib/api-client';
|
||||
|
||||
type Tab = 'zones' | 'methods';
|
||||
|
||||
// ── Zone helpers ──────────────────────────────────────────────────────────────────
|
||||
function ZoneRow({ zone, onEdit, onDelete }: { zone: ShippingZone; onEdit: () => void; onDelete: () => void }) {
|
||||
return (
|
||||
<tr className="hover:bg-gray-50 transition-colors">
|
||||
<td className="px-6 py-4 text-sm font-medium text-gray-900">{zone.name}</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">{zone.country}</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">{zone.postalCodePrefix ?? <span className="italic">Todos</span>}</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${zone.active ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'}`}>
|
||||
{zone.active ? 'Activo' : 'Inactivo'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<button onClick={onEdit} className="text-sm text-[#2D6A4F] hover:text-[#1B4332] font-medium px-3 py-1.5 rounded-lg hover:bg-green-50 mr-1">Editar</button>
|
||||
<button onClick={onDelete} className="text-sm text-red-600 hover:text-red-700 font-medium px-3 py-1.5 rounded-lg hover:bg-red-50">Eliminar</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function ZoneForm({ zone, onSave, onCancel }: { zone?: ShippingZone; onSave: () => void; onCancel: () => void }) {
|
||||
const [name, setName] = useState(zone?.name ?? '');
|
||||
const [country, setCountry] = useState(zone?.country ?? '');
|
||||
const [prefix, setPrefix] = useState(zone?.postalCodePrefix ?? '');
|
||||
const [active, setActive] = useState(zone?.active ?? true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [err, setErr] = useState('');
|
||||
|
||||
const handle = async (e: React.FormEvent) => {
|
||||
e.preventDefault(); setSaving(true); setErr('');
|
||||
try {
|
||||
if (zone) {
|
||||
await shippingApi.updateZone(zone.id, { name, country, postalCodePrefix: prefix || null, active });
|
||||
} else {
|
||||
await shippingApi.createZone({ name, country, postalCodePrefix: prefix || null, active });
|
||||
}
|
||||
onSave(); onCancel();
|
||||
} catch (er) { setErr(er instanceof Error ? er.message : 'Error'); } finally { setSaving(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<tr className="bg-green-50/50 border-b border-green-100">
|
||||
<td className="px-4 py-3"><input value={name} onChange={e => setName(e.target.value)} required placeholder="Nombre zona"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" /></td>
|
||||
<td className="px-4 py-3"><input value={country} onChange={e => setCountry(e.target.value)} required placeholder="ES, FR..."
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" /></td>
|
||||
<td className="px-4 py-3"><input value={prefix} onChange={e => setPrefix(e.target.value)} placeholder="Ej: 28"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" /></td>
|
||||
<td className="px-4 py-3">
|
||||
<select value={String(active)} onChange={e => setActive(e.target.value === 'true')}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none">
|
||||
<option value="true">Activo</option><option value="false">Inactivo</option></select></td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex gap-1">
|
||||
<button disabled={saving} onClick={handle}
|
||||
className="px-3 py-1.5 bg-[#2D6A4F] text-white text-xs font-medium rounded-lg hover:bg-[#1B4332] disabled:opacity-50">
|
||||
{saving ? '...' : 'Guardar'}
|
||||
</button>
|
||||
<button onClick={onCancel}
|
||||
className="px-3 py-1.5 border border-gray-300 text-gray-600 text-xs rounded-lg hover:bg-white">Cancelar</button>
|
||||
</div>
|
||||
{err && <p className="text-xs text-red-600 mt-1">{err}</p>}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Method helpers ───────────────────────────────────────────────────────────────
|
||||
function MethodRow({ method, onEdit, onDelete }: { method: ShippingMethod; onEdit: () => void; onDelete: () => void }) {
|
||||
const fmt = (cents: number) => `€${(cents / 100).toFixed(2)}`;
|
||||
return (
|
||||
<tr className="hover:bg-gray-50 transition-colors">
|
||||
<td className="px-6 py-4 text-sm font-medium text-gray-900">{method.name}</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">{method.zoneName}</td>
|
||||
<td className="px-6 py-4 text-sm font-semibold text-gray-800">{fmt(method.baseCostCents)}</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">{method.freeShippingThresholdCents ? `Gratis desde ${fmt(method.freeShippingThresholdCents)}` : '—'}</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${method.active ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'}`}>
|
||||
{method.active ? 'Activo' : 'Inactivo'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<button onClick={onEdit} className="text-sm text-[#2D6A4F] hover:text-[#1B4332] font-medium px-3 py-1.5 rounded-lg hover:bg-green-50 mr-1">Editar</button>
|
||||
<button onClick={onDelete} className="text-sm text-red-600 hover:text-red-700 font-medium px-3 py-1.5 rounded-lg hover:bg-red-50">Eliminar</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function MethodForm({ zones, method, onSave, onCancel }: { zones: ShippingZone[]; method?: ShippingMethod; onSave: () => void; onCancel: () => void }) {
|
||||
const [name, setName] = useState(method?.name ?? '');
|
||||
const [zoneId, setZoneId] = useState(method?.zoneId ?? zones[0]?.id ?? '');
|
||||
const [cost, setCost] = useState(method ? String(method.baseCostCents / 100) : '');
|
||||
const [threshold, setThreshold] = useState(method?.freeShippingThresholdCents ? String(method.freeShippingThresholdCents / 100) : '');
|
||||
const [active, setActive] = useState(method?.active ?? true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [err, setErr] = useState('');
|
||||
|
||||
const handle = async (e: React.FormEvent) => {
|
||||
e.preventDefault(); setSaving(true); setErr('');
|
||||
try {
|
||||
const baseCostCents = Math.round(parseFloat(cost) * 100);
|
||||
const freeThreshold = threshold ? Math.round(parseFloat(threshold) * 100) : null;
|
||||
if (method) {
|
||||
await shippingApi.updateMethod(method.id, { name, baseCostCents, freeShippingThresholdCents: freeThreshold, active });
|
||||
} else {
|
||||
await shippingApi.createMethod({ zoneId, name, baseCostCents, freeShippingThresholdCents: freeThreshold, active });
|
||||
}
|
||||
onSave(); onCancel();
|
||||
} catch (er) { setErr(er instanceof Error ? er.message : 'Error'); } finally { setSaving(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<tr className="bg-green-50/50 border-b border-green-100">
|
||||
<td className="px-4 py-3"><input value={name} onChange={e => setName(e.target.value)} required placeholder="Nombre método"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" /></td>
|
||||
<td className="px-4 py-3">
|
||||
<select value={zoneId} onChange={e => setZoneId(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none">
|
||||
{zones.map(z => <option key={z.id} value={z.id}>{z.name}</option>)}
|
||||
</select></td>
|
||||
<td className="px-4 py-3"><input type="number" step="0.01" value={cost} onChange={e => setCost(e.target.value)} required placeholder="0.00"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" /></td>
|
||||
<td className="px-4 py-3"><input type="number" step="0.01" value={threshold} onChange={e => setThreshold(e.target.value)} placeholder="Sin gratis"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" /></td>
|
||||
<td className="px-4 py-3">
|
||||
<select value={String(active)} onChange={e => setActive(e.target.value === 'true')}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none">
|
||||
<option value="true">Activo</option><option value="false">Inactivo</option></select></td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex gap-1">
|
||||
<button disabled={saving} onClick={handle}
|
||||
className="px-3 py-1.5 bg-[#2D6A4F] text-white text-xs font-medium rounded-lg hover:bg-[#1B4332] disabled:opacity-50">
|
||||
{saving ? '...' : 'Guardar'}
|
||||
</button>
|
||||
<button onClick={onCancel}
|
||||
className="px-3 py-1.5 border border-gray-300 text-gray-600 text-xs rounded-lg hover:bg-white">Cancelar</button>
|
||||
</div>
|
||||
{err && <p className="text-xs text-red-600 mt-1">{err}</p>}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main page ─────────────────────────────────────────────────────────────────────
|
||||
export default function ShippingPage() {
|
||||
const [tab, setTab] = useState<Tab>('zones');
|
||||
const [zones, setZones] = useState<ShippingZone[]>([]);
|
||||
const [methods, setMethods] = useState<ShippingMethod[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editingZone, setEditingZone] = useState<ShippingZone | null>(null);
|
||||
const [editingMethod, setEditingMethod] = useState<ShippingMethod | null>(null);
|
||||
const [showZoneForm, setShowZoneForm] = useState(false);
|
||||
const [showMethodForm, setShowMethodForm] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
|
||||
const loadZones = useCallback(async () => {
|
||||
try { const d = await shippingApi.listZones(); setZones(d.items ?? []); }
|
||||
catch { /* silent */ }
|
||||
}, []);
|
||||
|
||||
const loadMethods = useCallback(async () => {
|
||||
try { const d = await shippingApi.listMethods(); setMethods(d.items ?? []); }
|
||||
catch { /* silent */ }
|
||||
}, []);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
await Promise.all([loadZones(), loadMethods()]);
|
||||
setLoading(false);
|
||||
}, [loadZones, loadMethods]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleDeleteZone = async (id: string) => {
|
||||
if (!confirm('¿Eliminar esta zona y todos sus métodos?')) return;
|
||||
try { await shippingApi.deleteZone(id); setMsg('Zona eliminada'); setTimeout(() => setMsg(''), 3000); loadZones(); }
|
||||
catch (er) { alert(er instanceof Error ? er.message : 'Error'); }
|
||||
};
|
||||
|
||||
const handleDeleteMethod = async (id: string) => {
|
||||
if (!confirm('¿Eliminar este método de envío?')) return;
|
||||
try { await shippingApi.deleteMethod(id); setMsg('Método eliminado'); setTimeout(() => setMsg(''), 3000); loadMethods(); }
|
||||
catch (er) { alert(er instanceof Error ? er.message : 'Error'); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-8 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Envíos</h1>
|
||||
{msg && <span className="text-sm text-green-600 bg-green-50 px-3 py-1 rounded-full">{msg}</span>}
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 border-b border-gray-200">
|
||||
{([['zones', 'Zonas de envío'], ['methods', 'Métodos de envío']] as [Tab, string][]).map(([t, label]) => (
|
||||
<button key={t} onClick={() => setTab(t)}
|
||||
className={`px-5 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors ${tab === t ? 'border-[#2D6A4F] text-[#2D6A4F]' : 'border-transparent text-gray-500 hover:text-gray-700'}`}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-16 text-gray-400 text-sm">Cargando...</div>
|
||||
) : tab === 'zones' ? (
|
||||
<div className="bg-white rounded-2xl border border-gray-200 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold text-gray-800">Zonas ({zones.length})</h2>
|
||||
<button onClick={() => { setShowZoneForm(true); setEditingZone(null); }}
|
||||
className="px-4 py-2 bg-[#2D6A4F] hover:bg-[#1B4332] text-white text-sm font-semibold rounded-xl transition-colors">
|
||||
+ Nueva zona
|
||||
</button>
|
||||
</div>
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase">Nombre</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase">País</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase">CP prefijo</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase">Estado</th>
|
||||
<th className="text-right px-6 py-3 text-xs font-semibold text-gray-500 uppercase">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{showZoneForm && !editingZone && (
|
||||
<ZoneForm onSave={loadZones} onCancel={() => setShowZoneForm(false)} />
|
||||
)}
|
||||
{editingZone && (
|
||||
<ZoneForm zone={editingZone} onSave={() => { setEditingZone(null); loadZones(); }} onCancel={() => setEditingZone(null)} />
|
||||
)}
|
||||
{zones.length === 0 && !showZoneForm ? (
|
||||
<tr><td colSpan={5} className="px-6 py-12 text-center text-gray-400 text-sm">Sin zonas de envío</td></tr>
|
||||
) : zones.map(z => (
|
||||
<ZoneRow key={z.id} zone={z} onEdit={() => { setEditingZone(z); setShowZoneForm(false); }}
|
||||
onDelete={() => handleDeleteZone(z.id)} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-2xl border border-gray-200 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold text-gray-800">Métodos ({methods.length})</h2>
|
||||
<button onClick={() => { setShowMethodForm(true); setEditingMethod(null); }}
|
||||
className="px-4 py-2 bg-[#2D6A4F] hover:bg-[#1B4332] text-white text-sm font-semibold rounded-xl transition-colors"
|
||||
disabled={zones.length === 0}>
|
||||
+ Nuevo método
|
||||
</button>
|
||||
</div>
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase">Nombre</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase">Zona</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase">Coste</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase">Envío gratis</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase">Estado</th>
|
||||
<th className="text-right px-6 py-3 text-xs font-semibold text-gray-500 uppercase">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{showMethodForm && !editingMethod && (
|
||||
<MethodForm zones={zones} onSave={loadMethods} onCancel={() => setShowMethodForm(false)} />
|
||||
)}
|
||||
{editingMethod && (
|
||||
<MethodForm zones={zones} method={editingMethod} onSave={() => { setEditingMethod(null); loadMethods(); }} onCancel={() => setEditingMethod(null)} />
|
||||
)}
|
||||
{methods.length === 0 && !showMethodForm ? (
|
||||
<tr><td colSpan={6} className="px-6 py-12 text-center text-gray-400 text-sm">
|
||||
{zones.length === 0 ? 'Crea primero una zona de envío' : 'Sin métodos de envío'}
|
||||
</td></tr>
|
||||
) : methods.map(m => (
|
||||
<MethodRow key={m.id} method={m} onEdit={() => { setEditingMethod(m); setShowMethodForm(false); }}
|
||||
onDelete={() => handleDeleteMethod(m.id)} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
128
project/apps/admin/src/app/(dashboard)/tax-rates/page.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
'use client';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { taxApi, type TaxRate } from '@/lib/api-client';
|
||||
|
||||
export default function TaxRatesPage() {
|
||||
const [rates, setRates] = useState<TaxRate[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editing, setEditing] = useState<string | null>(null);
|
||||
const [editName, setEditName] = useState('');
|
||||
const [editRate, setEditRate] = useState('');
|
||||
const [editActive, setEditActive] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try { const d = await taxApi.list(); setRates(d.items ?? []); }
|
||||
catch { /* silent */ }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const startEdit = (r: TaxRate) => {
|
||||
setEditing(r.id); setEditName(r.name); setEditRate(String(r.ratePercent)); setEditActive(r.active);
|
||||
};
|
||||
|
||||
const handleSave = async (id: string) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await taxApi.update(id, { name: editName, ratePercent: parseFloat(editRate), active: editActive });
|
||||
setEditing(null); setMsg('Tipo impositivo actualizado'); setTimeout(() => setMsg(''), 3000); load();
|
||||
} catch (er) { alert(er instanceof Error ? er.message : 'Error'); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const fmt = (r: TaxRate) => `${r.ratePercent}%`;
|
||||
|
||||
return (
|
||||
<div className="p-8 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Tipos impositivos (IVA)</h1>
|
||||
<p className="text-sm text-gray-500 mt-0.5">Configura los tipos de IVA aplicables a los productos.</p>
|
||||
</div>
|
||||
|
||||
{msg && <div className="bg-green-50 text-green-700 text-sm px-4 py-2.5 rounded-xl border border-green-200">{msg}</div>}
|
||||
|
||||
<div className="bg-white rounded-2xl border border-gray-200 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b bg-gray-50">
|
||||
<h2 className="text-base font-semibold text-gray-800">IVA en España (ES)</h2>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-16 text-gray-400 text-sm">Cargando...</div>
|
||||
) : (
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Nombre</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Tipo</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Tasa</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Estado</th>
|
||||
<th className="text-right px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{rates.map(r => (
|
||||
<tr key={r.id} className="hover:bg-gray-50 transition-colors">
|
||||
{editing === r.id ? (
|
||||
<>
|
||||
<td className="px-4 py-3">
|
||||
<input value={editName} onChange={e => setEditName(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500">{r.appliesTo}</td>
|
||||
<td className="px-4 py-3">
|
||||
<input type="number" step="0.01" value={editRate} onChange={e => setEditRate(e.target.value)}
|
||||
className="w-24 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<select value={String(editActive)} onChange={e => setEditActive(e.target.value === 'true')}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none">
|
||||
<option value="true">Activo</option><option value="false">Inactivo</option></select>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex gap-1 justify-end">
|
||||
<button disabled={saving} onClick={() => handleSave(r.id)}
|
||||
className="px-3 py-1.5 bg-[#2D6A4F] text-white text-xs font-medium rounded-lg hover:bg-[#1B4332] disabled:opacity-50">
|
||||
{saving ? '...' : 'Guardar'}
|
||||
</button>
|
||||
<button onClick={() => setEditing(null)}
|
||||
className="px-3 py-1.5 border border-gray-300 text-gray-600 text-xs rounded-lg hover:bg-gray-50">Cancelar</button>
|
||||
</div>
|
||||
</td>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<td className="px-6 py-4 text-sm font-medium text-gray-900">{r.name}</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500 capitalize">{r.appliesTo}</td>
|
||||
<td className="px-6 py-4 text-sm font-bold text-gray-800">{fmt(r)}</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${r.active ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'}`}>
|
||||
{r.active ? 'Activo' : 'Inactivo'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<button onClick={() => startEdit(r)}
|
||||
className="text-sm text-[#2D6A4F] hover:text-[#1B4332] font-medium px-3 py-1.5 rounded-lg hover:bg-green-50 transition-colors">
|
||||
Editar
|
||||
</button>
|
||||
</td>
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-amber-50 rounded-xl border border-amber-200 px-5 py-4">
|
||||
<p className="text-sm text-amber-800">
|
||||
<strong>España:</strong> IVA General 21%, IVA Reducido 10%, IVA Superreducido 4%. Los tipos se aplican a los precios sin IVA (netos) del producto.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
236
project/apps/admin/src/app/(dashboard)/users/page.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
'use client';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { adminUsersApi } from '@/lib/api-client';
|
||||
|
||||
interface AdminUser { id: string; email: string; role: string; createdAt: string; }
|
||||
|
||||
function Modal({ title, onClose, children }: { title: string; onClose: () => void; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
|
||||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-md mx-4" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b">
|
||||
<h2 className="text-lg font-semibold text-gray-900">{title}</h2>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl leading-none">×</button>
|
||||
</div>
|
||||
<div className="p-6">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateForm({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [role, setRole] = useState('editor');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [err, setErr] = useState('');
|
||||
const handle = async (e: React.FormEvent) => {
|
||||
e.preventDefault(); setSaving(true); setErr('');
|
||||
try { await adminUsersApi.create({ email, password, role }); onCreated(); onClose(); }
|
||||
catch (er) { setErr(er instanceof Error ? er.message : 'Error'); } finally { setSaving(false); }
|
||||
};
|
||||
return (
|
||||
<form onSubmit={handle} className="space-y-4">
|
||||
<div><label className="block text-sm font-medium text-gray-700 mb-1">Email *</label>
|
||||
<input type="email" value={email} onChange={e => setEmail(e.target.value)} required
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" /></div>
|
||||
<div><label className="block text-sm font-medium text-gray-700 mb-1">Contraseña *</label>
|
||||
<input type="password" value={password} onChange={e => setPassword(e.target.value)} required minLength={8}
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" /></div>
|
||||
<div><label className="block text-sm font-medium text-gray-700 mb-1">Rol *</label>
|
||||
<select value={role} onChange={e => setRole(e.target.value)}
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none">
|
||||
<option value="editor">Editor</option><option value="admin">Admin</option></select></div>
|
||||
{err && <p className="text-sm text-red-600 bg-red-50 rounded-xl px-4 py-2">{err}</p>}
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button type="submit" disabled={saving}
|
||||
className="flex-1 px-5 py-2.5 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-xl transition-colors">
|
||||
{saving ? 'Creando...' : 'Crear usuario'}</button>
|
||||
<button type="button" onClick={onClose}
|
||||
className="px-5 py-2.5 border border-gray-300 text-gray-600 text-sm rounded-xl hover:bg-gray-50">Cancelar</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function EditForm({ user, onClose, onSaved }: { user: AdminUser; onClose: () => void; onSaved: () => void }) {
|
||||
const [role, setRole] = useState(user.role);
|
||||
const [password, setPassword] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [err, setErr] = useState('');
|
||||
const handle = async (e: React.FormEvent) => {
|
||||
e.preventDefault(); setSaving(true); setErr('');
|
||||
try {
|
||||
const data: { role?: string; password?: string } = { role };
|
||||
if (password) data.password = password;
|
||||
await adminUsersApi.update(user.id, data); onSaved(); onClose();
|
||||
} catch (er) { setErr(er instanceof Error ? er.message : 'Error'); } finally { setSaving(false); }
|
||||
};
|
||||
return (
|
||||
<form onSubmit={handle} className="space-y-4">
|
||||
<div><label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
|
||||
<input type="email" value={user.email} disabled
|
||||
className="w-full px-4 py-2.5 border border-gray-200 rounded-xl text-sm bg-gray-50 text-gray-400" /></div>
|
||||
<div><label className="block text-sm font-medium text-gray-700 mb-1">Rol *</label>
|
||||
<select value={role} onChange={e => setRole(e.target.value)}
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none">
|
||||
<option value="admin">Admin</option><option value="editor">Editor</option></select></div>
|
||||
<div><label className="block text-sm font-medium text-gray-700 mb-1">Nueva contraseña</label>
|
||||
<input type="password" value={password} onChange={e => setPassword(e.target.value)} minLength={8} placeholder="Dejar vacío para no cambiar"
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" /></div>
|
||||
{err && <p className="text-sm text-red-600 bg-red-50 rounded-xl px-4 py-2">{err}</p>}
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button type="submit" disabled={saving}
|
||||
className="flex-1 px-5 py-2.5 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-xl transition-colors">
|
||||
{saving ? 'Guardando...' : 'Guardar'}</button>
|
||||
<button type="button" onClick={onClose}
|
||||
className="px-5 py-2.5 border border-gray-300 text-gray-600 text-sm rounded-xl hover:bg-gray-50">Cancelar</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
const ROLE_COLORS: Record<string, string> = { admin: 'bg-purple-100 text-purple-700', editor: 'bg-amber-100 text-amber-700', customer: 'bg-blue-100 text-blue-700' };
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [filterRole, setFilterRole] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [debounced, setDebounced] = useState('');
|
||||
const [page, setPage] = useState(0);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [editing, setEditing] = useState<AdminUser | null>(null);
|
||||
const [msg, setMsg] = useState('');
|
||||
|
||||
useEffect(() => { const t = setTimeout(() => setDebounced(search), 400); return () => clearTimeout(t); }, [search]);
|
||||
useEffect(() => { setPage(0); }, [debounced, filterRole]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true); setError('');
|
||||
try {
|
||||
const data = await adminUsersApi.list({ limit: PAGE_SIZE, offset: page * PAGE_SIZE, role: filterRole || undefined, q: debounced || undefined });
|
||||
setUsers(data.items ?? []); setTotal(data.total ?? 0);
|
||||
} catch (e) { setError(e instanceof Error ? e.message : 'Error'); } finally { setLoading(false); }
|
||||
}, [page, debounced, filterRole]);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('¿Eliminar este usuario? No se puede deshacer.')) return;
|
||||
try { await adminUsersApi.delete(id); setMsg('Usuario eliminado'); setTimeout(() => setMsg(''), 3000); load(); }
|
||||
catch (er) { alert(er instanceof Error ? er.message : 'Error al eliminar'); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-8 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div><h1 className="text-2xl font-bold text-gray-900">Usuarios backoffice</h1>
|
||||
<p className="text-sm text-gray-500 mt-0.5">{total > 0 ? `${total} usuario${total !== 1 ? 's' : ''}` : ''}</p></div>
|
||||
<button onClick={() => setShowCreate(true)}
|
||||
className="flex items-center gap-2 px-5 py-2.5 bg-[#2D6A4F] hover:bg-[#1B4332] text-white text-sm font-semibold rounded-xl transition-colors">
|
||||
+ Nuevo usuario</button>
|
||||
</div>
|
||||
|
||||
{msg && <div className="bg-green-50 text-green-700 text-sm px-4 py-2.5 rounded-xl border border-green-200">{msg}</div>}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative flex-1 max-w-xs">
|
||||
<input type="text" placeholder="Buscar por email..." value={search} onChange={e => setSearch(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2.5 border border-gray-200 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">🔍</span>
|
||||
</div>
|
||||
<select value={filterRole} onChange={e => setFilterRole(e.target.value)}
|
||||
className="px-4 py-2.5 border border-gray-200 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none">
|
||||
<option value="">Todos los roles</option>
|
||||
<option value="admin">Admin</option><option value="editor">Editor</option><option value="customer">Customer</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl border border-gray-200 overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-16 text-gray-400 text-sm">Cargando...</div>
|
||||
) : error ? (
|
||||
<div className="flex items-center justify-center py-16 text-red-500 text-sm">{error}</div>
|
||||
) : users.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-gray-400 text-sm gap-2">
|
||||
<span className="text-3xl">🔐</span><span>No hay usuarios backoffice</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Email</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Rol</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Creado</th>
|
||||
<th className="text-right px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{users.map(u => (
|
||||
<tr key={u.id} className="hover:bg-gray-50 transition-colors">
|
||||
<td className="px-6 py-4 text-sm text-gray-900">{u.email}</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${ROLE_COLORS[u.role] ?? 'bg-gray-100 text-gray-600'}`}>
|
||||
{u.role}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">{new Date(u.createdAt).toLocaleDateString('es-ES')}</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button onClick={() => setEditing(u)}
|
||||
className="p-2 text-gray-400 hover:text-[#2D6A4F] hover:bg-green-50 rounded-lg transition-colors" title="Editar">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button onClick={() => handleDelete(u.id)}
|
||||
className="p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors" title="Eliminar">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{total > PAGE_SIZE && (
|
||||
<div className="flex items-center justify-between px-6 py-4 border-t border-gray-200">
|
||||
<span className="text-sm text-gray-500">
|
||||
Mostrando {page * PAGE_SIZE + 1}–{Math.min((page + 1) * PAGE_SIZE, total)} de {total}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button disabled={page === 0} onClick={() => setPage(p => p - 1)}
|
||||
className="px-4 py-2 text-sm border border-gray-300 rounded-xl disabled:opacity-40 hover:bg-gray-50 transition-colors">
|
||||
Anterior
|
||||
</button>
|
||||
<button disabled={(page + 1) * PAGE_SIZE >= total} onClick={() => setPage(p => p + 1)}
|
||||
className="px-4 py-2 text-sm border border-gray-300 rounded-xl disabled:opacity-40 hover:bg-gray-50 transition-colors">
|
||||
Siguiente
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<Modal title="Nuevo usuario backoffice" onClose={() => setShowCreate(false)}>
|
||||
<CreateForm onClose={() => setShowCreate(false)} onCreated={() => { setMsg('Usuario creado correctamente'); setTimeout(() => setMsg(''), 3000); load(); }} />
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{editing && (
|
||||
<Modal title="Editar usuario" onClose={() => setEditing(null)}>
|
||||
<EditForm user={editing} onClose={() => setEditing(null)} onSaved={() => { setMsg('Usuario actualizado'); setTimeout(() => setMsg(''), 3000); load(); }} />
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
99
project/apps/admin/src/app/api/[...path]/route.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://127.0.0.1:3000';
|
||||
|
||||
/**
|
||||
* Catch-all proxy: forwards ALL requests to the backend API.
|
||||
* This avoids CORS preflight issues since requests stay within the
|
||||
* same origin (localhost:3004 -> localhost:3004 proxy -> 127.0.0.1:3000 backend).
|
||||
*
|
||||
* More specific routes (e.g. /api/auth/login) take precedence in Next.js,
|
||||
* so they are NOT served by this handler.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const path = req.nextUrl.pathname.replace('/api/', '');
|
||||
const cookies = req.headers.get('cookie') ?? '';
|
||||
try {
|
||||
const backendRes = await fetch(`${API}/${path}`, {
|
||||
headers: { Cookie: cookies },
|
||||
});
|
||||
const data = await backendRes.json().catch(() => null);
|
||||
const resp = NextResponse.json(data ?? { error: 'Bad response' }, { status: backendRes.status });
|
||||
return resp;
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Proxy error' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const path = req.nextUrl.pathname.replace('/api/', '');
|
||||
const cookies = req.headers.get('cookie') ?? '';
|
||||
const body = await req.text();
|
||||
try {
|
||||
const backendRes = await fetch(`${API}/${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Cookie: cookies },
|
||||
body,
|
||||
});
|
||||
const setCookie = backendRes.headers.get('set-cookie');
|
||||
const data = await backendRes.json().catch(() => null);
|
||||
const resp = NextResponse.json(data ?? { error: 'Bad response' }, { status: backendRes.status });
|
||||
if (setCookie) {
|
||||
resp.headers.set(
|
||||
'Set-Cookie',
|
||||
setCookie.replace(/;\s*Secure/gi, '').replace(/;\s*SameSite=Lax/gi, '').trim(),
|
||||
);
|
||||
}
|
||||
return resp;
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Proxy error' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(req: NextRequest) {
|
||||
const path = req.nextUrl.pathname.replace('/api/', '');
|
||||
const cookies = req.headers.get('cookie') ?? '';
|
||||
const body = await req.text();
|
||||
try {
|
||||
const backendRes = await fetch(`${API}/${path}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', Cookie: cookies },
|
||||
body,
|
||||
});
|
||||
const data = await backendRes.json().catch(() => null);
|
||||
return NextResponse.json(data ?? { error: 'Bad response' }, { status: backendRes.status });
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Proxy error' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(req: NextRequest) {
|
||||
const path = req.nextUrl.pathname.replace('/api/', '');
|
||||
const cookies = req.headers.get('cookie') ?? '';
|
||||
const body = await req.text();
|
||||
try {
|
||||
const backendRes = await fetch(`${API}/${path}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', Cookie: cookies },
|
||||
body,
|
||||
});
|
||||
const data = await backendRes.json().catch(() => null);
|
||||
return NextResponse.json(data ?? { error: 'Bad response' }, { status: backendRes.status });
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Proxy error' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest) {
|
||||
const path = req.nextUrl.pathname.replace('/api/', '');
|
||||
const cookies = req.headers.get('cookie') ?? '';
|
||||
try {
|
||||
const backendRes = await fetch(`${API}/${path}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Cookie: cookies },
|
||||
});
|
||||
return NextResponse.json({ ok: backendRes.ok }, { status: backendRes.status });
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Proxy error' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
46
project/apps/admin/src/app/api/auth/login/route.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://127.0.0.1:3000';
|
||||
|
||||
/**
|
||||
* Strip the Secure flag from the backend's Set-Cookie so the browser
|
||||
* (which connects over HTTP) actually stores the session cookie.
|
||||
* Also drop SameSite=Lax to avoid browser restrictions.
|
||||
*/
|
||||
function makeLocalhostCompatible(cookie: string): string {
|
||||
return cookie
|
||||
.replace(/;\s*Secure/gi, '')
|
||||
.replace(/;\s*SameSite=Lax/gi, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { email, password } = body;
|
||||
|
||||
const backendRes = await fetch(`${API}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
const data = await backendRes.json();
|
||||
|
||||
if (!backendRes.ok) {
|
||||
return NextResponse.json(data, { status: backendRes.status });
|
||||
}
|
||||
|
||||
const setCookie = backendRes.headers.get('set-cookie');
|
||||
const response = NextResponse.json(data, { status: 200 });
|
||||
if (setCookie) {
|
||||
response.headers.set('Set-Cookie', makeLocalhostCompatible(setCookie));
|
||||
}
|
||||
return response;
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ statusCode: 500, code: 'SERVER_ERROR', message: 'Error del servidor' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
19
project/apps/admin/src/app/api/auth/logout/route.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://127.0.0.1:3000';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const cookies = req.headers.get('cookie') ?? '';
|
||||
await fetch(`${API}/auth/logout`, {
|
||||
method: 'POST',
|
||||
headers: { Cookie: cookies },
|
||||
});
|
||||
} catch {
|
||||
// Best-effort
|
||||
}
|
||||
|
||||
const response = NextResponse.json({ ok: true });
|
||||
response.cookies.delete('mdv_session');
|
||||
return response;
|
||||
}
|
||||
16
project/apps/admin/src/app/api/auth/me/route.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://127.0.0.1:3000';
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const cookies = req.headers.get('cookie') ?? '';
|
||||
try {
|
||||
const backendRes = await fetch(`${API}/auth/me`, {
|
||||
headers: { Cookie: cookies },
|
||||
});
|
||||
if (!backendRes.ok) return NextResponse.json({ user: null });
|
||||
return NextResponse.json(await backendRes.json());
|
||||
} catch {
|
||||
return NextResponse.json({ user: null });
|
||||
}
|
||||
}
|
||||
44
project/apps/admin/src/app/api/upload/route.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { writeFile, mkdir } from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/avif', 'image/gif'];
|
||||
const MAX_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('file') as File | null;
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Tipo no permitido. Usa: ${ALLOWED_TYPES.join(', ')}` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (file.size > MAX_SIZE) {
|
||||
return NextResponse.json({ error: 'El archivo excede 10MB' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Unique filename
|
||||
const ext = file.name.split('.').pop() ?? 'jpg';
|
||||
const filename = `${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`;
|
||||
const uploadDir = path.join(process.cwd(), 'public', 'uploads');
|
||||
const filePath = path.join(uploadDir, filename);
|
||||
|
||||
await mkdir(uploadDir, { recursive: true });
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
await writeFile(filePath, buffer);
|
||||
|
||||
const url = `/uploads/${filename}`;
|
||||
return NextResponse.json({ url, filename, size: file.size });
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
return NextResponse.json({ error: 'Error al subir el archivo' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
51
project/apps/admin/src/app/globals.css
Normal file
@@ -0,0 +1,51 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-primary: #2D6A4F;
|
||||
--color-primary-dark: #1B4332;
|
||||
--color-primary-light: #40916C;
|
||||
--color-secondary: #F5F0E8;
|
||||
--color-accent: #E76F51;
|
||||
--color-text: #111827;
|
||||
--color-muted: #6B7280;
|
||||
--color-border: #E5E7EB;
|
||||
--color-bg: #F9FAFB;
|
||||
--color-surface: #FFFFFF;
|
||||
--color-danger: #DC2626;
|
||||
--color-warning: #D97706;
|
||||
--color-success: #059669;
|
||||
--font-sans: "Inter", system-ui, sans-serif;
|
||||
--font-heading: "Playfair Display", Georgia, serif;
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: #F9FAFB;
|
||||
--foreground: #111827;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #D1D5DB;
|
||||
border-radius: 3px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #9CA3AF;
|
||||
}
|
||||
18
project/apps/admin/src/app/layout.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { Metadata } from 'next';
|
||||
import './globals.css';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: 'MercadoDeVida Admin',
|
||||
template: '%s | MercadoDeVida Admin',
|
||||
},
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="es" suppressHydrationWarning>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
'use client';
|
||||
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
import type { AuthUser, Role } from '@/types';
|
||||
import { authApi } from '@/lib/api-client';
|
||||
|
||||
interface AuthContextValue {
|
||||
user: AuthUser | null;
|
||||
loading: boolean;
|
||||
login: (email: string, password: string) => Promise<{ ok: boolean; error?: string }>;
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Load session on mount
|
||||
useEffect(() => {
|
||||
authApi
|
||||
.me()
|
||||
.then((data) => {
|
||||
if ('id' in data) {
|
||||
setUser({ id: data.id, email: data.email, role: data.role as Role });
|
||||
}
|
||||
})
|
||||
.catch(() => setUser(null))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (email: string, password: string) => {
|
||||
try {
|
||||
const data = await authApi.login(email, password);
|
||||
// The backend sets the session cookie via Set-Cookie header.
|
||||
// We also set it client-side for immediate access.
|
||||
setUser({ id: data.id, email: data.email, role: data.role as Role });
|
||||
return { ok: true };
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
err instanceof Error
|
||||
? (err as { message?: string }).message ?? 'Error de login'
|
||||
: 'Error de login';
|
||||
return { ok: false, error: msg };
|
||||
}
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
await authApi.logout();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
setUser(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, login, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
'use client';
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import type { Product, Brand, Category } from '@/types';
|
||||
import { productsApi, brandsApi, categoriesApi } from '@/lib/api-client';
|
||||
import { ImagesSection } from './sections/ImagesSection';
|
||||
import { InventorySection } from './sections/InventorySection';
|
||||
import { PricingSection } from './sections/PricingSection';
|
||||
|
||||
interface ProductEditorProps {
|
||||
productId?: string;
|
||||
}
|
||||
|
||||
const ATTRIBUTE_LABELS: Record<string, string> = {
|
||||
bio: '🌿 Bio',
|
||||
'comercio-justo': '⚖️ Comercio Justo',
|
||||
congelado: '❄️ Congelado',
|
||||
'cruelty-free': '🐰 Cruelty Free',
|
||||
'de-temporada': '🍂 De Temporada',
|
||||
demeter: '🌱 Demeter',
|
||||
'fruta-verdura': '🥕 Fruta y Verdura',
|
||||
keto: '🥑 Keto',
|
||||
kosher: '✡️ Kosher',
|
||||
'low-carb': '🍖 Low Carb',
|
||||
'raw-food': '🥗 Raw Food',
|
||||
'sin-azucar': '🚫 Sin Azúcar',
|
||||
'sin-gluten': '🌾 Sin Gluten',
|
||||
'sin-lactosa': '🥛 Sin Lactosa',
|
||||
vegano: '🌱 Vegano',
|
||||
'zero-waste': '♻️ Zero Waste',
|
||||
};
|
||||
|
||||
const CHANNEL_OPTIONS = [
|
||||
{ value: 'all', label: 'Todos los canales' },
|
||||
{ value: 'online', label: 'Solo online' },
|
||||
{ value: 'offline', label: 'Solo offline' },
|
||||
] as const;
|
||||
|
||||
function slugify(text: string): string {
|
||||
return text.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
export function ProductEditor({ productId }: ProductEditorProps) {
|
||||
const router = useRouter();
|
||||
const isCreate = !productId;
|
||||
const [tab, setTab] = useState<'general' | 'pricing' | 'inventory' | 'images' | 'seo' | 'publish'>('general');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [loading, setLoading] = useState(!isCreate);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [slug, setSlug] = useState('');
|
||||
const [slugManual, setSlugManual] = useState(false);
|
||||
const [desc, setDesc] = useState('');
|
||||
const [brandId, setBrandId] = useState('');
|
||||
const [categoryIds, setCategoryIds] = useState<string[]>([]);
|
||||
const [channels, setChannels] = useState<'online' | 'offline' | 'all'>('all');
|
||||
const [featured, setFeatured] = useState(false);
|
||||
const [attributes, setAttributes] = useState<string[]>([]);
|
||||
const [state, setState] = useState('active');
|
||||
const [seoTitle, setSeoTitle] = useState('');
|
||||
const [seoTitleManual, setSeoTitleManual] = useState(false);
|
||||
const [seoDesc, setSeoDesc] = useState('');
|
||||
const [seoDescManual, setSeoDescManual] = useState(false);
|
||||
const [brands, setBrands] = useState<Brand[]>([]);
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
brandsApi.list().then(({ items }) => setBrands(items ?? [])).catch(() => {});
|
||||
categoriesApi.list().then((data) => {
|
||||
const tree = (data as { items?: Category[] }).items ?? [];
|
||||
const flat = (cats: Category[]): Category[] => cats.flatMap(c => [c, ...flat(c.children ?? [])]);
|
||||
setCategories(flat(tree));
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const snapRef = useRef('');
|
||||
const dirtyRef = useRef(false);
|
||||
|
||||
const getSnap = useCallback(() => JSON.stringify({
|
||||
name, slug, desc, brandId, categoryIds, channels, featured, attributes, state, seoTitle, seoDesc,
|
||||
}), [name, slug, desc, brandId, categoryIds, channels, featured, attributes, state, seoTitle, seoDesc]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!productId) { setLoading(false); return; }
|
||||
productsApi.get(productId).then((p: Product) => {
|
||||
setName(p.name); setSlug(p.slug); setDesc(p.description ?? '');
|
||||
setBrandId(p.brandId ?? ''); setCategoryIds(p.categoryIds ?? []);
|
||||
setChannels((p as any).channels ?? 'all');
|
||||
setFeatured((p as any).featured ?? false);
|
||||
setAttributes((p as any).attributes ?? []);
|
||||
setState(p.state);
|
||||
setSeoTitle((p as any).seoTitle ?? ''); setSeoTitleManual(true);
|
||||
setSeoDesc((p as any).seoDescription ?? ''); setSeoDescManual(true);
|
||||
snapRef.current = getSnap();
|
||||
setLoading(false);
|
||||
}).catch(() => { setError('No se pudo cargar el producto'); setLoading(false); });
|
||||
}, [productId, getSnap]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
dirtyRef.current = getSnap() !== snapRef.current;
|
||||
}, [name, slug, desc, brandId, categoryIds, channels, featured, attributes, state, seoTitle, seoDesc, loading, getSnap]);
|
||||
|
||||
useEffect(() => {
|
||||
const h = (e: BeforeUnloadEvent) => { if (dirtyRef.current) { e.preventDefault(); e.returnValue = ''; } };
|
||||
window.addEventListener('beforeunload', h);
|
||||
return () => window.removeEventListener('beforeunload', h);
|
||||
}, []);
|
||||
|
||||
const handleNameChange = (v: string) => {
|
||||
setName(v);
|
||||
if (!slugManual) setSlug(slugify(v));
|
||||
if (!seoTitleManual) setSeoTitle(v);
|
||||
if (!seoDescManual) setSeoDesc(`${v} — Compra online en MercadoDeVida. Productos naturales y ecológicos.`);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true); setError(''); setSuccess('');
|
||||
try {
|
||||
const payload = {
|
||||
name, slug,
|
||||
description: desc || undefined,
|
||||
brandId: brandId || undefined,
|
||||
categoryIds,
|
||||
channels,
|
||||
featured,
|
||||
attributes,
|
||||
state,
|
||||
seoTitle: seoTitle || undefined,
|
||||
seoDescription: seoDesc || undefined,
|
||||
};
|
||||
let saved: Product;
|
||||
if (isCreate) saved = await productsApi.create(payload);
|
||||
else saved = await productsApi.update(productId, payload);
|
||||
snapRef.current = getSnap();
|
||||
dirtyRef.current = false;
|
||||
setSuccess(isCreate ? '¡Producto creado!' : 'Cambios guardados');
|
||||
if (isCreate) router.push(`/products/${saved.id}`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al guardar');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleAttr = (key: string) => {
|
||||
setAttributes(prev => prev.includes(key) ? prev.filter(a => a !== key) : [...prev, key]);
|
||||
};
|
||||
|
||||
const saveState = async (s: string) => {
|
||||
setState(s);
|
||||
if (productId) {
|
||||
try {
|
||||
await productsApi.setState(productId, s as 'active' | 'archived');
|
||||
setSuccess(`Estado actualizado a: ${s}`);
|
||||
} catch {
|
||||
setError('Error al cambiar estado');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return (
|
||||
<div className="p-8 flex justify-center">
|
||||
<div className="h-6 w-6 border-2 border-gray-300 border-t-[#2D6A4F] rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-4xl">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<button onClick={() => router.push('/products')} className="text-sm text-gray-500 hover:text-gray-700 mb-1 flex items-center gap-1">← Productos</button>
|
||||
<h1 className="text-2xl font-bold text-gray-900">{isCreate ? 'Nuevo producto' : `Editar: ${name}`}</h1>
|
||||
</div>
|
||||
<button onClick={handleSave} disabled={saving}
|
||||
className="px-5 py-2.5 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-xl transition-colors">
|
||||
{saving ? 'Guardando...' : isCreate ? 'Crear producto' : 'Guardar cambios'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-xl text-sm text-red-700">{error}</div>}
|
||||
{success && <div className="mb-4 p-4 bg-green-50 border border-green-200 rounded-xl text-sm text-green-700">{success}</div>}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="hidden md:flex border-b border-gray-200 mb-8">
|
||||
{(['general', 'pricing', 'inventory', 'images', 'seo', 'publish'] as const).map(t => (
|
||||
<button key={t} onClick={() => setTab(t)}
|
||||
className={`px-5 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors ${
|
||||
tab === t ? 'border-[#2D6A4F] text-[#2D6A4F]' : 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}>
|
||||
{t === 'general' ? 'General' : t === 'pricing' ? 'Precios' : t === 'inventory' ? 'Inventario' : t === 'images' ? 'Imágenes' : t === 'seo' ? 'SEO' : 'Publicar'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── GENERAL ── */}
|
||||
{tab === 'general' && (
|
||||
<section className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-900 mb-1.5">Nombre del producto *</label>
|
||||
<input type="text" value={name} onChange={e => handleNameChange(e.target.value)} required
|
||||
placeholder="Ej: Almendras Crudas Ecológicas"
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<label className="text-sm font-semibold text-gray-900">Slug (URL)</label>
|
||||
<span className={`text-xs ${slugManual ? 'text-gray-400' : 'text-[#2D6A4F] font-medium'}`}>
|
||||
{slugManual ? 'editado manualmente' : 'auto-generado'}
|
||||
</span>
|
||||
</div>
|
||||
<input type="text" value={slug}
|
||||
onChange={e => { setSlugManual(true); setSlug(e.target.value); }}
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm font-mono focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-900 mb-1.5">Descripción</label>
|
||||
<textarea value={desc} onChange={e => setDesc(e.target.value)} rows={4}
|
||||
placeholder="Descripción detallada del producto..."
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none resize-none" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-900 mb-1.5">Marca</label>
|
||||
<select value={brandId} onChange={e => setBrandId(e.target.value)}
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none bg-white">
|
||||
<option value="">Sin marca</option>
|
||||
{brands.map(b => <option key={b.id} value={b.id}>{b.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-900 mb-1.5">Canal de venta</label>
|
||||
<select value={channels} onChange={e => setChannels(e.target.value as typeof channels)}
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none bg-white">
|
||||
{CHANNEL_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="flex items-center gap-2 mb-3">
|
||||
<input type="checkbox" checked={featured} onChange={e => setFeatured(e.target.checked)}
|
||||
className="rounded text-[#2D6A4F] focus:ring-[#2D6A4F]" />
|
||||
<span className="text-sm font-semibold text-gray-900">⭐ Producto destacado</span>
|
||||
<span className="text-xs text-gray-400">(aparece en la home)</span>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<label className="text-sm font-semibold text-gray-900">Categorías</label>
|
||||
</div>
|
||||
<div className="border border-gray-200 rounded-xl p-3 space-y-2 max-h-52 overflow-y-auto">
|
||||
{categories.map(cat => (
|
||||
<label key={cat.id} className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" checked={categoryIds.includes(cat.id)}
|
||||
onChange={e => {
|
||||
if (e.target.checked) setCategoryIds(prev => [...prev, cat.id]);
|
||||
else setCategoryIds(prev => prev.filter(id => id !== cat.id));
|
||||
}}
|
||||
className="rounded text-[#2D6A4F] focus:ring-[#2D6A4F]" />
|
||||
<span className={`text-sm ${cat.parentId ? 'text-gray-500' : 'font-medium text-gray-700'}`}>
|
||||
{cat.parentId ? `↳ ${cat.name}` : cat.name}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<label className="text-sm font-semibold text-gray-900">Atributos</label>
|
||||
<span className="text-xs text-gray-400">{attributes.length} / 16</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{Object.entries(ATTRIBUTE_LABELS).map(([key, label]) => (
|
||||
<label key={key}
|
||||
className={`flex items-center gap-2 px-3 py-2 border rounded-xl cursor-pointer transition-colors text-sm ${
|
||||
attributes.includes(key)
|
||||
? 'border-[#2D6A4F] bg-[#2D6A4F]/5 text-[#2D6A4F]'
|
||||
: 'border-gray-200 hover:border-gray-300 text-gray-600'
|
||||
}`}>
|
||||
<input type="checkbox" checked={attributes.includes(key)}
|
||||
onChange={() => toggleAttr(key)} className="hidden" />
|
||||
{label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── PRICING ── */}
|
||||
{tab === 'pricing' && (
|
||||
<section>
|
||||
{!productId ? (
|
||||
<div className="p-6 bg-amber-50 border border-amber-200 rounded-xl text-sm text-amber-800">
|
||||
⚠️ Guarda primero el producto para configurar precios.
|
||||
</div>
|
||||
) : (
|
||||
<PricingSection productId={productId} />
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── INVENTORY ── */}
|
||||
{tab === 'inventory' && (
|
||||
<section>
|
||||
{!productId ? (
|
||||
<div className="p-6 bg-amber-50 border border-amber-200 rounded-xl text-sm text-amber-800">
|
||||
⚠️ Guarda primero el producto para gestionar inventario.
|
||||
</div>
|
||||
) : (
|
||||
<InventorySection productId={productId} />
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── IMAGES ── */}
|
||||
{tab === 'images' && (
|
||||
<section>
|
||||
{!productId ? (
|
||||
<div className="p-6 bg-amber-50 border border-amber-200 rounded-xl text-sm text-amber-800">
|
||||
⚠️ Guarda primero el producto para subir imágenes.
|
||||
</div>
|
||||
) : (
|
||||
<ImagesSection productId={productId} />
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── SEO ── */}
|
||||
{tab === 'seo' && (
|
||||
<section className="space-y-6">
|
||||
<div className="p-4 bg-blue-50 border border-blue-100 rounded-xl text-xs text-blue-700 space-y-1">
|
||||
<p>El título y descripción SEO se usan en Google. Si están vacíos, se usan automáticamente.</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<label className="text-sm font-semibold text-gray-900">Título SEO (Google)</label>
|
||||
<span className={`text-xs ${seoTitleManual ? 'text-gray-400' : 'text-[#2D6A4F] font-medium'}`}>
|
||||
{seoTitleManual ? 'editado manualmente' : 'copiado del nombre'}
|
||||
</span>
|
||||
</div>
|
||||
<input type="text" value={seoTitle}
|
||||
onChange={e => { setSeoTitleManual(true); setSeoTitle(e.target.value); }}
|
||||
maxLength={60}
|
||||
placeholder="Título para Google (max 60 caracteres)"
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
|
||||
<div className="mt-1 text-xs text-gray-400">{seoTitle.length}/60</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<label className="text-sm font-semibold text-gray-900">Descripción SEO (Google)</label>
|
||||
<span className={`text-xs ${seoDescManual ? 'text-gray-400' : 'text-[#2D6A4F] font-medium'}`}>
|
||||
{seoDescManual ? 'editada manualmente' : 'auto-generada'}
|
||||
</span>
|
||||
</div>
|
||||
<textarea value={seoDesc}
|
||||
onChange={e => { setSeoDescManual(true); setSeoDesc(e.target.value); }}
|
||||
rows={3} maxLength={160}
|
||||
placeholder="Descripción para Google (max 160 caracteres)"
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none resize-none" />
|
||||
<div className="mt-1 text-xs text-gray-400">{seoDesc.length}/160</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── PUBLISH ── */}
|
||||
{tab === 'publish' && (
|
||||
<section className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-900 mb-3">Estado del producto</label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{[['draft', 'Borrador', 'gray'], ['active', 'Activo', 'green'], ['archived', 'Archivado', 'amber']].map(([s, label, color]) => (
|
||||
<button key={s} onClick={() => saveState(s as string)}
|
||||
className={`px-4 py-3 rounded-xl border-2 text-sm font-medium transition-all ${
|
||||
state === s
|
||||
? color === 'green' ? 'border-[#2D6A4F] bg-[#2D6A4F]/5 text-[#2D6A4F]' : color === 'amber' ? 'border-amber-400 bg-amber-50 text-amber-700' : 'border-gray-400 bg-gray-100 text-gray-700'
|
||||
: 'border-gray-200 text-gray-500 hover:border-gray-300'
|
||||
}`}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-5 bg-gray-50 border border-gray-200 rounded-xl text-sm text-gray-600 space-y-2">
|
||||
<div className="flex justify-between"><span>Borrador</span><span>No visible en la tienda</span></div>
|
||||
<div className="flex justify-between"><span>Activo</span><span>Visible y comprable online</span></div>
|
||||
<div className="flex justify-between"><span>Archivado</span><span>Oculto pero conservado</span></div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
'use client';
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { productsApi } from '@/lib/api-client';
|
||||
import type { ProductImage } from '@/types';
|
||||
|
||||
interface ImagesSectionProps {
|
||||
productId: string;
|
||||
}
|
||||
|
||||
export function ImagesSection({ productId }: ImagesSectionProps) {
|
||||
const [images, setImages] = useState<ProductImage[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [urlInput, setUrlInput] = useState('');
|
||||
const [savingUrl, setSavingUrl] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const p = await productsApi.get(productId);
|
||||
setImages(p.images ?? []);
|
||||
} catch {
|
||||
setError('Error al cargar imágenes');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [productId]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const addImageByUrl = async (url: string) => {
|
||||
if (!url.trim()) return;
|
||||
setSavingUrl(true);
|
||||
try {
|
||||
const p = await productsApi.update(productId, {});
|
||||
// Attach via images array — for now use the attach endpoint
|
||||
await fetch(`/api/products/${productId}/images`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', credentials: 'include' },
|
||||
body: JSON.stringify({ url: url.trim(), altText: '', role: 'gallery' }),
|
||||
});
|
||||
setUrlInput('');
|
||||
load();
|
||||
} catch {
|
||||
setError('Error al añadir imagen');
|
||||
} finally {
|
||||
setSavingUrl(false);
|
||||
}
|
||||
};
|
||||
|
||||
const uploadFile = async (file: File) => {
|
||||
setUploading(true);
|
||||
setError('');
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
const res = await fetch('/api/upload', { method: 'POST', body: fd, credentials: 'include' });
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error ?? 'Error al subir');
|
||||
}
|
||||
const { url } = await res.json() as { url: string };
|
||||
await fetch(`/api/products/${productId}/images`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', credentials: 'include' },
|
||||
body: JSON.stringify({ url, altText: '', role: 'gallery' }),
|
||||
});
|
||||
load();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al subir');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) uploadFile(file);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file && file.type.startsWith('image/')) {
|
||||
uploadFile(file);
|
||||
}
|
||||
};
|
||||
|
||||
const setMain = async (imageId: string) => {
|
||||
// Reorder: put this image first
|
||||
await fetch(`/api/products/${productId}/images/reorder`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', credentials: 'include' },
|
||||
body: JSON.stringify({
|
||||
items: [
|
||||
{ imageId, position: 0 },
|
||||
...images.filter(i => i.id !== imageId).map((img, idx) => ({ imageId: img.id, position: idx + 1 })),
|
||||
],
|
||||
}),
|
||||
});
|
||||
load();
|
||||
};
|
||||
|
||||
const deleteImage = async (imageId: string) => {
|
||||
await fetch(`/api/products/${productId}/images/${imageId}`, { method: 'DELETE', credentials: 'include' });
|
||||
load();
|
||||
};
|
||||
|
||||
if (!productId) {
|
||||
return <div className="p-4 bg-amber-50 border border-amber-200 rounded-xl text-sm text-amber-800">
|
||||
⚠️ Guarda primero el producto para gestionar imágenes.
|
||||
</div>;
|
||||
}
|
||||
|
||||
if (loading) return <div className="p-8 text-gray-400 text-sm">Cargando imágenes...</div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Upload / URL / Drag&drop */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<input
|
||||
type="text"
|
||||
value={urlInput}
|
||||
onChange={e => setUrlInput(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && addImageByUrl(urlInput)}
|
||||
placeholder="Pega una URL de imagen..."
|
||||
className="flex-1 px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none"
|
||||
/>
|
||||
<button
|
||||
onClick={() => addImageByUrl(urlInput)}
|
||||
disabled={savingUrl || !urlInput.trim()}
|
||||
className="px-5 py-2.5 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-xl transition-colors"
|
||||
>
|
||||
{savingUrl ? 'Añadiendo...' : 'Añadir URL'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="px-5 py-2.5 border border-gray-300 hover:border-[#2D6A4F] text-gray-700 text-sm font-medium rounded-xl transition-colors"
|
||||
>
|
||||
{uploading ? 'Subiendo...' : '📤 Subir imagen'}
|
||||
</button>
|
||||
<input ref={fileRef} type="file" accept="image/*" className="hidden" onChange={handleFileInput} />
|
||||
</div>
|
||||
|
||||
{/* Drop zone */}
|
||||
<div
|
||||
onDragOver={e => { e.preventDefault(); setDragOver(true); }}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={handleDrop}
|
||||
className={`border-2 border-dashed rounded-xl p-8 text-center transition-colors ${
|
||||
dragOver ? 'border-[#2D6A4F] bg-[#2D6A4F]/5' : 'border-gray-200'
|
||||
}`}
|
||||
>
|
||||
<p className="text-gray-400 text-sm">
|
||||
🖼️ Arrastra imágenes aquí para añadirlas al producto
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-xl text-sm text-red-700">{error}</div>
|
||||
)}
|
||||
|
||||
{/* Gallery */}
|
||||
{images.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-400 text-sm border border-dashed border-gray-300 rounded-xl">
|
||||
No hay imágenes para este producto
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{images.map((img, idx) => (
|
||||
<div key={img.id} className="relative group">
|
||||
<img
|
||||
src={img.url}
|
||||
alt={img.altText ?? img.url}
|
||||
className="w-full aspect-square object-cover rounded-xl bg-gray-100"
|
||||
/>
|
||||
{/* Main badge */}
|
||||
{idx === 0 && (
|
||||
<span className="absolute top-2 left-2 px-2 py-0.5 bg-[#2D6A4F] text-white text-xs font-medium rounded-full">
|
||||
Principal
|
||||
</span>
|
||||
)}
|
||||
{/* Actions */}
|
||||
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity rounded-xl flex items-center justify-center gap-2">
|
||||
{idx !== 0 && (
|
||||
<button
|
||||
onClick={() => setMain(img.id)}
|
||||
className="px-2 py-1 bg-white text-gray-800 text-xs rounded-lg hover:bg-gray-100"
|
||||
>
|
||||
Principal
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => deleteImage(img.id)}
|
||||
className="p-2 bg-white text-red-600 rounded-lg hover:bg-red-50"
|
||||
title="Eliminar"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
'use client';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { productsApi, inventoryApi, pricingApi } from '@/lib/api-client';
|
||||
import type { ProductVariant, VariantPrice, StockAvailability } from '@/types';
|
||||
|
||||
interface VariantRow {
|
||||
variant: ProductVariant;
|
||||
price: VariantPrice | null;
|
||||
stock: StockAvailability | null;
|
||||
loadingStock: boolean;
|
||||
loadingPrice: boolean;
|
||||
editingStock: boolean;
|
||||
editingPrice: boolean;
|
||||
stockValue: string;
|
||||
priceValue: string;
|
||||
vatRate: 'general' | 'reduced';
|
||||
}
|
||||
|
||||
function formatCents(cents: number): string {
|
||||
return `€${(cents / 100).toFixed(2)}`;
|
||||
}
|
||||
|
||||
function StockStatusBadge({ available, quantity }: { available: boolean; quantity: number }) {
|
||||
if (!available || quantity === 0) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-700">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-red-400" />
|
||||
Sin stock
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (quantity < 5) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-700">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-amber-400" />
|
||||
Bajo stock ({quantity})
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-green-400" />
|
||||
En stock ({quantity})
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface InventorySectionProps {
|
||||
productId: string;
|
||||
}
|
||||
|
||||
export function InventorySection({ productId }: InventorySectionProps) {
|
||||
const [variants, setVariants] = useState<ProductVariant[]>([]);
|
||||
const [loadingVariants, setLoadingVariants] = useState(true);
|
||||
const [variantsError, setVariantsError] = useState('');
|
||||
|
||||
const [rows, setRows] = useState<Record<string, VariantRow>>({});
|
||||
const [savingVariant, setSavingVariant] = useState<string | null>(null);
|
||||
const [saveMsg, setSaveMsg] = useState<Record<string, string>>({});
|
||||
|
||||
// Load variants
|
||||
useEffect(() => {
|
||||
if (!productId) return;
|
||||
setLoadingVariants(true);
|
||||
productsApi.getVariants(productId)
|
||||
.then(({ items }) => {
|
||||
setVariants(items ?? []);
|
||||
const initial: Record<string, VariantRow> = {};
|
||||
for (const variant of items ?? []) {
|
||||
initial[variant.id] = {
|
||||
variant,
|
||||
price: null,
|
||||
stock: null,
|
||||
loadingStock: true,
|
||||
loadingPrice: true,
|
||||
editingStock: false,
|
||||
editingPrice: false,
|
||||
stockValue: '',
|
||||
priceValue: '',
|
||||
vatRate: 'general',
|
||||
};
|
||||
}
|
||||
setRows(initial);
|
||||
setLoadingVariants(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setVariantsError('No se pudieron cargar las variantes');
|
||||
setLoadingVariants(false);
|
||||
});
|
||||
}, [productId]);
|
||||
|
||||
// Load stock and price for each variant
|
||||
useEffect(() => {
|
||||
for (const variant of variants) {
|
||||
// Stock
|
||||
inventoryApi.getAvailability(variant.id)
|
||||
.then((stock) => {
|
||||
setRows((prev) => {
|
||||
const current = prev[variant.id];
|
||||
if (!current) return prev;
|
||||
return {
|
||||
...prev,
|
||||
[variant.id]: {
|
||||
...current,
|
||||
stock,
|
||||
loadingStock: false,
|
||||
stockValue: String(stock.availableQuantity),
|
||||
},
|
||||
};
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setRows((prev) => {
|
||||
const current = prev[variant.id];
|
||||
if (!current) return prev;
|
||||
return { ...prev, [variant.id]: { ...current, loadingStock: false } };
|
||||
});
|
||||
});
|
||||
|
||||
// Price
|
||||
pricingApi.getVariantPrice(variant.id)
|
||||
.then((price) => {
|
||||
setRows((prev) => {
|
||||
const current = prev[variant.id];
|
||||
if (!current) return prev;
|
||||
return {
|
||||
...prev,
|
||||
[variant.id]: {
|
||||
...current,
|
||||
price,
|
||||
loadingPrice: false,
|
||||
priceValue: String(price.netUnitAmountCents),
|
||||
vatRate: price.vatRate,
|
||||
},
|
||||
};
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setRows((prev) => {
|
||||
const current = prev[variant.id];
|
||||
if (!current) return prev;
|
||||
return { ...prev, [variant.id]: { ...current, loadingPrice: false } };
|
||||
});
|
||||
});
|
||||
}
|
||||
}, [variants]);
|
||||
|
||||
const startEditStock = (variantId: string) => {
|
||||
setRows((prev) => ({
|
||||
...prev,
|
||||
[variantId]: { ...prev[variantId], editingStock: true },
|
||||
}));
|
||||
};
|
||||
|
||||
const startEditPrice = (variantId: string) => {
|
||||
setRows((prev) => ({
|
||||
...prev,
|
||||
[variantId]: { ...prev[variantId], editingPrice: true },
|
||||
}));
|
||||
};
|
||||
|
||||
const cancelEditStock = (variantId: string) => {
|
||||
const r = rows[variantId];
|
||||
setRows((prev) => ({
|
||||
...prev,
|
||||
[variantId]: { ...r, editingStock: false, stockValue: String(r.stock?.availableQuantity ?? 0) },
|
||||
}));
|
||||
};
|
||||
|
||||
const cancelEditPrice = (variantId: string) => {
|
||||
const r = rows[variantId];
|
||||
setRows((prev) => ({
|
||||
...prev,
|
||||
[variantId]: {
|
||||
...r,
|
||||
editingPrice: false,
|
||||
priceValue: String(r.price?.netUnitAmountCents ?? 0),
|
||||
vatRate: r.price?.vatRate ?? 'general',
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const saveStock = async (variantId: string) => {
|
||||
const r = rows[variantId];
|
||||
const qty = parseInt(r.stockValue, 10);
|
||||
if (isNaN(qty) || qty < 0) return;
|
||||
setSavingVariant(variantId);
|
||||
setSaveMsg((prev) => ({ ...prev, [variantId]: '' }));
|
||||
try {
|
||||
const result = await inventoryApi.setStock(variantId, qty);
|
||||
setRows((prev) => ({
|
||||
...prev,
|
||||
[variantId]: {
|
||||
...prev[variantId],
|
||||
stock: {
|
||||
available: result.available > 0,
|
||||
availableQuantity: result.available,
|
||||
},
|
||||
editingStock: false,
|
||||
},
|
||||
}));
|
||||
setSaveMsg((prev) => ({ ...prev, [variantId]: '✓ Guardado' }));
|
||||
setTimeout(() => setSaveMsg((prev) => ({ ...prev, [variantId]: '' })), 3000);
|
||||
} catch {
|
||||
setSaveMsg((prev) => ({ ...prev, [variantId]: 'Error' }));
|
||||
} finally {
|
||||
setSavingVariant(null);
|
||||
}
|
||||
};
|
||||
|
||||
const savePrice = async (variantId: string) => {
|
||||
const r = rows[variantId];
|
||||
const cents = parseInt(r.priceValue, 10);
|
||||
if (isNaN(cents) || cents < 0) return;
|
||||
setSavingVariant(variantId);
|
||||
setSaveMsg((prev) => ({ ...prev, [variantId]: '' }));
|
||||
try {
|
||||
const result = await pricingApi.setVariantPrice(variantId, cents, r.vatRate);
|
||||
setRows((prev) => ({
|
||||
...prev,
|
||||
[variantId]: {
|
||||
...prev[variantId],
|
||||
price: result,
|
||||
editingPrice: false,
|
||||
},
|
||||
}));
|
||||
setSaveMsg((prev) => ({ ...prev, [variantId]: '✓ Guardado' }));
|
||||
setTimeout(() => setSaveMsg((prev) => ({ ...prev, [variantId]: '' })), 3000);
|
||||
} catch {
|
||||
setSaveMsg((prev) => ({ ...prev, [variantId]: 'Error' }));
|
||||
} finally {
|
||||
setSavingVariant(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (loadingVariants) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 p-8 text-gray-400 text-sm">
|
||||
<div className="h-4 w-4 border-2 border-gray-300 border-t-[#2D6A4F] rounded-full animate-spin" />
|
||||
Cargando inventario...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (variantsError) {
|
||||
return (
|
||||
<div className="p-4 bg-red-50 border border-red-200 rounded-xl text-sm text-red-700">
|
||||
{variantsError}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (variants.length === 0) {
|
||||
return (
|
||||
<div className="p-8 text-center">
|
||||
<p className="text-4xl mb-3">📦</p>
|
||||
<p className="text-gray-500 text-sm">Este producto no tiene variantes</p>
|
||||
<p className="text-gray-400 text-xs mt-1">
|
||||
Las variantes se crean desde la pestaña Publicar
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="overflow-x-auto rounded-xl border border-gray-200">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 border-b border-gray-200 text-left">
|
||||
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">SKU</th>
|
||||
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">EAN</th>
|
||||
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">Precio neto</th>
|
||||
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">IVA</th>
|
||||
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">Stock</th>
|
||||
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">Estado</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{variants.map((variant) => {
|
||||
const r = rows[variant.id];
|
||||
if (!r) return null;
|
||||
|
||||
const grossPrice = r.price
|
||||
? (r.price.netUnitAmountCents * (r.price.vatRate === 'general' ? 1.21 : 1.1)) / 100
|
||||
: null;
|
||||
|
||||
return (
|
||||
<tr key={variant.id} className="hover:bg-gray-50/50 transition-colors">
|
||||
{/* SKU */}
|
||||
<td className="px-4 py-3 font-mono text-xs text-gray-600">{variant.sku}</td>
|
||||
|
||||
{/* EAN */}
|
||||
<td className="px-4 py-3 font-mono text-xs text-gray-500">{variant.ean ?? '—'}</td>
|
||||
|
||||
{/* Precio */}
|
||||
<td className="px-4 py-3">
|
||||
{r.loadingPrice ? (
|
||||
<span className="text-gray-300">—</span>
|
||||
) : r.editingPrice ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-gray-400">€</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={r.priceValue}
|
||||
onChange={(e) =>
|
||||
setRows((prev) => ({
|
||||
...prev,
|
||||
[variant.id]: { ...prev[variant.id], priceValue: e.target.value },
|
||||
}))
|
||||
}
|
||||
className="w-20 px-2 py-1 border border-gray-300 rounded-lg text-sm focus:ring-1 focus:ring-[#2D6A4F] outline-none"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-medium text-gray-900">
|
||||
{r.price ? formatCents(r.price.netUnitAmountCents) : '—'}
|
||||
</span>
|
||||
{r.price && (
|
||||
<button
|
||||
onClick={() => startEditPrice(variant.id)}
|
||||
className="ml-1 text-gray-400 hover:text-[#2D6A4F] text-xs"
|
||||
title="Editar precio"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* IVA */}
|
||||
<td className="px-4 py-3">
|
||||
{r.editingPrice ? (
|
||||
<select
|
||||
value={r.vatRate}
|
||||
onChange={(e) =>
|
||||
setRows((prev) => ({
|
||||
...prev,
|
||||
[variant.id]: {
|
||||
...prev[variant.id],
|
||||
vatRate: e.target.value as 'general' | 'reduced',
|
||||
},
|
||||
}))
|
||||
}
|
||||
className="px-2 py-1 border border-gray-300 rounded-lg text-xs focus:ring-1 focus:ring-[#2D6A4F] outline-none"
|
||||
>
|
||||
<option value="general">21% (general)</option>
|
||||
<option value="reduced">10% (reducido)</option>
|
||||
</select>
|
||||
) : (
|
||||
<span className="text-xs text-gray-500">
|
||||
{r.price?.vatRate === 'reduced' ? '10%' : '21%'}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Stock */}
|
||||
<td className="px-4 py-3">
|
||||
{r.loadingStock ? (
|
||||
<span className="text-gray-300">—</span>
|
||||
) : r.editingStock ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={r.stockValue}
|
||||
onChange={(e) =>
|
||||
setRows((prev) => ({
|
||||
...prev,
|
||||
[variant.id]: { ...prev[variant.id], stockValue: e.target.value },
|
||||
}))
|
||||
}
|
||||
className="w-16 px-2 py-1 border border-gray-300 rounded-lg text-sm focus:ring-1 focus:ring-[#2D6A4F] outline-none"
|
||||
/>
|
||||
<button
|
||||
onClick={() => saveStock(variant.id)}
|
||||
disabled={savingVariant === variant.id}
|
||||
className="px-2 py-1 bg-[#2D6A4F] text-white text-xs rounded-lg hover:bg-[#1B4332] disabled:opacity-50"
|
||||
>
|
||||
{savingVariant === variant.id ? '...' : 'OK'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => cancelEditStock(variant.id)}
|
||||
className="text-gray-400 hover:text-gray-600 text-xs"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-medium text-gray-900">
|
||||
{r.stock?.availableQuantity ?? '—'}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => startEditStock(variant.id)}
|
||||
className="ml-1 text-gray-400 hover:text-[#2D6A4F] text-xs"
|
||||
title="Editar stock"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Estado + acciones */}
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<StockStatusBadge
|
||||
available={r.stock?.available ?? false}
|
||||
quantity={r.stock?.availableQuantity ?? 0}
|
||||
/>
|
||||
{r.editingPrice && (
|
||||
<button
|
||||
onClick={() => savePrice(variant.id)}
|
||||
disabled={savingVariant === variant.id}
|
||||
className="px-2 py-1 bg-[#2D6A4F] text-white text-xs rounded-lg hover:bg-[#1B4332] disabled:opacity-50"
|
||||
>
|
||||
{savingVariant === variant.id ? '...' : 'OK'}
|
||||
</button>
|
||||
)}
|
||||
{r.editingPrice && (
|
||||
<button
|
||||
onClick={() => cancelEditPrice(variant.id)}
|
||||
className="text-gray-400 hover:text-gray-600 text-xs"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
{saveMsg[variant.id] && !r.editingStock && !r.editingPrice && (
|
||||
<span className={`text-xs ${saveMsg[variant.id].startsWith('✓') ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{saveMsg[variant.id]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-400">
|
||||
* Precio con IVA:{' '}
|
||||
{variants[0] && rows[variants[0].id]?.price
|
||||
? formatCents(
|
||||
Math.round(
|
||||
rows[variants[0].id].price!.netUnitAmountCents *
|
||||
(rows[variants[0].id].vatRate === 'general' ? 1.21 : 1.1),
|
||||
),
|
||||
)
|
||||
: '—'}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
'use client';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { productsApi, pricingApi } from '@/lib/api-client';
|
||||
import type { ProductVariant, VariantPrice } from '@/types';
|
||||
|
||||
const VAT_GENERAL = 1.21;
|
||||
const VAT_REDUCED = 1.10;
|
||||
|
||||
function fmt(cents: number): string {
|
||||
return `€${(cents / 100).toFixed(2)}`;
|
||||
}
|
||||
|
||||
function calcGross(netCents: number, vatRate: 'general' | 'reduced'): number {
|
||||
return Math.round(netCents * (vatRate === 'general' ? VAT_GENERAL : VAT_REDUCED));
|
||||
}
|
||||
|
||||
function calcMarginBruto(grossCents: number, costCents: number): number {
|
||||
if (grossCents === 0) return 0;
|
||||
return Math.round(((grossCents - costCents) / grossCents) * 100);
|
||||
}
|
||||
|
||||
interface PricingSectionProps {
|
||||
productId: string;
|
||||
}
|
||||
|
||||
export function PricingSection({ productId }: PricingSectionProps) {
|
||||
const [variants, setVariants] = useState<ProductVariant[]>([]);
|
||||
const [loadingVariants, setLoadingVariants] = useState(true);
|
||||
const [loadingPrices, setLoadingPrices] = useState(true);
|
||||
const [prices, setPrices] = useState<Record<string, VariantPrice>>({});
|
||||
const [saving, setSaving] = useState<string | null>(null);
|
||||
const [msg, setMsg] = useState<Record<string, string>>({});
|
||||
|
||||
// Edit state per variant
|
||||
const [net, setNet] = useState<Record<string, string>>({});
|
||||
const [offer, setOffer] = useState<Record<string, string>>({});
|
||||
const [cost, setCost] = useState<Record<string, string>>({});
|
||||
const [vatRate, setVatRate] = useState<Record<string, 'general' | 'reduced'>>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!productId) { setLoadingVariants(false); return; }
|
||||
productsApi.getVariants(productId)
|
||||
.then(({ items }) => {
|
||||
setVariants(items ?? []);
|
||||
setLoadingVariants(false);
|
||||
})
|
||||
.catch(() => setLoadingVariants(false));
|
||||
}, [productId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (variants.length === 0) { setLoadingPrices(false); return; }
|
||||
let done = 0;
|
||||
for (const v of variants) {
|
||||
pricingApi.getVariantPrice(v.id)
|
||||
.then((p) => {
|
||||
setPrices(prev => ({ ...prev, [v.id]: p }));
|
||||
setNet(prev => ({ ...prev, [v.id]: String(p.netUnitAmountCents) }));
|
||||
setOffer(prev => ({ ...prev, [v.id]: p.offerCents !== null ? String(p.offerCents) : '' }));
|
||||
setCost(prev => ({ ...prev, [v.id]: p.costCents !== null ? String(p.costCents) : '' }));
|
||||
setVatRate(prev => ({ ...prev, [v.id]: p.vatRate }));
|
||||
})
|
||||
.catch(() => {
|
||||
setNet(prev => ({ ...prev, [v.id]: '0' }));
|
||||
setOffer(prev => ({ ...prev, [v.id]: '' }));
|
||||
setCost(prev => ({ ...prev, [v.id]: '' }));
|
||||
setVatRate(prev => ({ ...prev, [v.id]: 'general' }));
|
||||
})
|
||||
.finally(() => {
|
||||
done++;
|
||||
if (done >= variants.length) setLoadingPrices(false);
|
||||
});
|
||||
}
|
||||
}, [variants]);
|
||||
|
||||
const savePrice = async (variantId: string) => {
|
||||
const netCents = parseInt(net[variantId] ?? '0', 10);
|
||||
const offerCentsVal = offer[variantId] ? parseInt(offer[variantId], 10) : null;
|
||||
const costCentsVal = cost[variantId] ? parseInt(cost[variantId], 10) : null;
|
||||
if (isNaN(netCents) || netCents < 0) return;
|
||||
if (offerCentsVal !== null && (isNaN(offerCentsVal) || offerCentsVal < 0)) return;
|
||||
if (costCentsVal !== null && (isNaN(costCentsVal) || costCentsVal < 0)) return;
|
||||
setSaving(variantId);
|
||||
setMsg(prev => ({ ...prev, [variantId]: '' }));
|
||||
try {
|
||||
const updated = await pricingApi.setVariantPrice(variantId, netCents, vatRate[variantId]);
|
||||
if (offerCentsVal !== null) {
|
||||
// set offer via separate update
|
||||
const offerUpdated = await pricingApi.setVariantPrice(variantId, netCents, vatRate[variantId], offerCentsVal, costCentsVal);
|
||||
setPrices(prev => ({ ...prev, [variantId]: offerUpdated }));
|
||||
} else {
|
||||
setPrices(prev => ({ ...prev, [variantId]: updated }));
|
||||
}
|
||||
setMsg(prev => ({ ...prev, [variantId]: '✓' }));
|
||||
setTimeout(() => setMsg(prev => ({ ...prev, [variantId]: '' })), 3000);
|
||||
} catch {
|
||||
setMsg(prev => ({ ...prev, [variantId]: 'Error' }));
|
||||
} finally {
|
||||
setSaving(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (loadingVariants) return <div className="p-8 text-gray-400 text-sm">Cargando precios...</div>;
|
||||
|
||||
if (variants.length === 0) {
|
||||
return (
|
||||
<div className="p-6 bg-amber-50 border border-amber-200 rounded-xl text-sm text-amber-800">
|
||||
⚠️ Este producto no tiene variantes. Las variantes se crean desde la pestaña Publicar.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="overflow-x-auto rounded-xl border border-gray-200">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 border-b border-gray-200 text-left">
|
||||
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">SKU</th>
|
||||
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">Coste (sin IVA)</th>
|
||||
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">PVP (IVA incl.)</th>
|
||||
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">Oferta (IVA incl.)</th>
|
||||
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">IVA</th>
|
||||
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">Margen bruto %</th>
|
||||
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">Neto (sin IVA)</th>
|
||||
<th className="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{variants.map(v => {
|
||||
const p = prices[v.id];
|
||||
const netCents = parseInt(net[v.id] ?? '0', 10);
|
||||
const costCents = cost[v.id] ? parseInt(cost[v.id], 10) : 0;
|
||||
const vr = vatRate[v.id] ?? 'general';
|
||||
const grossCents = calcGross(netCents, vr);
|
||||
const marginBruto = calcMarginBruto(grossCents, costCents);
|
||||
const editing = saving === v.id;
|
||||
|
||||
return (
|
||||
<tr key={v.id} className="hover:bg-gray-50/50">
|
||||
<td className="px-4 py-3 font-mono text-xs text-gray-600">{v.sku}</td>
|
||||
|
||||
{/* Coste */}
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-gray-400 text-xs">€</span>
|
||||
<input
|
||||
type="number" min={0} step={1}
|
||||
value={cost[v.id] ?? ''}
|
||||
disabled={editing}
|
||||
onChange={e => setCost(prev => ({ ...prev, [v.id]: e.target.value }))}
|
||||
placeholder="0.00"
|
||||
className="w-20 px-2 py-1 border border-gray-300 rounded-lg text-xs focus:ring-1 focus:ring-[#2D6A4F] outline-none disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* PVP (gross) */}
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-gray-400 text-xs">€</span>
|
||||
<input
|
||||
type="number" min={0} step={1}
|
||||
value={grossCents}
|
||||
disabled={editing}
|
||||
onChange={e => {
|
||||
const gross = parseInt(e.target.value, 10) || 0;
|
||||
const newNet = Math.round(gross / (vr === 'general' ? VAT_GENERAL : VAT_REDUCED));
|
||||
setNet(prev => ({ ...prev, [v.id]: String(newNet) }));
|
||||
}}
|
||||
className="w-20 px-2 py-1 border border-gray-300 rounded-lg text-xs focus:ring-1 focus:ring-[#2D6A4F] outline-none disabled:opacity-50 font-semibold text-[#2D6A4F]"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Oferta */}
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-gray-400 text-xs">€</span>
|
||||
<input
|
||||
type="number" min={0} step={1}
|
||||
value={offer[v.id] ?? ''}
|
||||
disabled={editing}
|
||||
onChange={e => setOffer(prev => ({ ...prev, [v.id]: e.target.value }))}
|
||||
placeholder="—"
|
||||
className="w-20 px-2 py-1 border border-gray-300 rounded-lg text-xs focus:ring-1 focus:ring-[#2D6A4F] outline-none disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* IVA */}
|
||||
<td className="px-4 py-3">
|
||||
<select
|
||||
value={vatRate[v.id] ?? 'general'}
|
||||
disabled={editing}
|
||||
onChange={e => setVatRate(prev => ({ ...prev, [v.id]: e.target.value as 'general' | 'reduced' }))}
|
||||
className="px-2 py-1 border border-gray-300 rounded-lg text-xs focus:ring-1 focus:ring-[#2D6A4F] outline-none disabled:opacity-50"
|
||||
>
|
||||
<option value="general">21% gen.</option>
|
||||
<option value="reduced">10% red.</option>
|
||||
</select>
|
||||
</td>
|
||||
|
||||
{/* Margen bruto */}
|
||||
<td className="px-4 py-3">
|
||||
{costCents > 0 ? (
|
||||
<span className={`text-xs font-bold ${marginBruto > 30 ? 'text-green-600' : marginBruto > 10 ? 'text-amber-600' : 'text-red-600'}`}>
|
||||
{marginBruto}%
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-gray-300">—</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Neto */}
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-gray-400 text-xs">€</span>
|
||||
<input
|
||||
type="number" min={0} step={1}
|
||||
value={netCents}
|
||||
disabled={editing}
|
||||
onChange={e => setNet(prev => ({ ...prev, [v.id]: e.target.value }))}
|
||||
className="w-20 px-2 py-1 border border-gray-300 rounded-lg text-xs focus:ring-1 focus:ring-[#2D6A4F] outline-none disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Guardar */}
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => savePrice(v.id)}
|
||||
disabled={editing}
|
||||
className="px-3 py-1 bg-[#2D6A4F] text-white text-xs rounded-lg hover:bg-[#1B4332] disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{editing ? '...' : 'Guardar'}
|
||||
</button>
|
||||
{msg[v.id] && (
|
||||
<span className={`text-xs ${msg[v.id] === '✓' ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{msg[v.id]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-blue-50 border border-blue-100 rounded-xl text-xs text-blue-700 space-y-1">
|
||||
<p><strong>Coste:</strong> precio de compra sin IVA (uso interno, no se muestra al cliente).</p>
|
||||
<p><strong>PVP:</strong> precio de venta al público con IVA incluido.</p>
|
||||
<p><strong>Oferta:</strong> precio promocional opcional. Dejar vacío si no hay oferta.</p>
|
||||
<p><strong>Margen bruto:</strong> (PVP − Coste) ÷ PVP × 100. Verde >30%, ámbar 10-30%, rojo <10%.</p>
|
||||
<p><strong>IVA:</strong> 21% general (alimentación procesada) · 10% reducido (alimentos básicos, frutas, verduras).</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
314
project/apps/admin/src/lib/api-client.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
import { ApiError } from '@/types';
|
||||
|
||||
/**
|
||||
* All requests go to /api/* (relative paths) — the Next.js catch-all
|
||||
* route handler proxies them to the backend. This keeps all traffic
|
||||
* within the same origin, avoiding CORS preflights entirely.
|
||||
*/
|
||||
|
||||
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: body != null ? JSON.stringify(body) : undefined,
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (res.status === 401) {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
throw new ApiError(401, 'UNAUTHORIZED', 'Authentication required');
|
||||
}
|
||||
|
||||
if (res.status === 403) {
|
||||
throw new ApiError(403, 'FORBIDDEN', 'Insufficient permissions');
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({ message: 'Request failed' }));
|
||||
throw new ApiError(
|
||||
res.status,
|
||||
(body as { code?: string }).code ?? 'REQUEST_FAILED',
|
||||
(body as { message?: string }).message ?? 'Request failed',
|
||||
);
|
||||
}
|
||||
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string) => request<T>('GET', path),
|
||||
post: <T>(path: string, body?: unknown) => request<T>('POST', path, body),
|
||||
patch: <T>(path: string, body?: unknown) => request<T>('PATCH', path, body),
|
||||
put: <T>(path: string, body?: unknown) => request<T>('PUT', path, body),
|
||||
delete: <T>(path: string) => request<T>('DELETE', path),
|
||||
};
|
||||
|
||||
// ── Auth ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const authApi = {
|
||||
login: (email: string, password: string) =>
|
||||
api.post<{ id: string; email: string; role: string }>('/api/auth/login', { email, password }),
|
||||
logout: () => api.post('/api/auth/logout'),
|
||||
me: () =>
|
||||
api.get<{ id: string; email: string; role: string } | { user: null }>('/api/auth/me'),
|
||||
};
|
||||
|
||||
// ── Products ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export const productsApi = {
|
||||
list: (params?: { limit?: number; offset?: number; q?: string }) => {
|
||||
const sp = new URLSearchParams();
|
||||
if (params?.limit) sp.set('limit', String(params.limit));
|
||||
if (params?.offset) sp.set('offset', String(params.offset));
|
||||
if (params?.q) sp.set('q', params.q);
|
||||
const qs = sp.toString();
|
||||
return api.get<{ items: import('@/types').Product[]; total: number }>(
|
||||
`/api/catalog/products${qs ? `?${qs}` : ''}`,
|
||||
);
|
||||
},
|
||||
get: (id: string) => api.get<import('@/types').Product>(`/api/catalog/products/${id}`),
|
||||
getVariants: (id: string) =>
|
||||
api.get<{ items: import('@/types').ProductVariant[] }>(`/api/catalog/products/${id}/variants`),
|
||||
create: (data: unknown) => api.post<import('@/types').Product>('/api/catalog/products', data),
|
||||
update: (id: string, data: unknown) =>
|
||||
api.patch<import('@/types').Product>(`/api/catalog/products/${id}`, data),
|
||||
setState: (id: string, state: 'active' | 'archived') =>
|
||||
api.patch(`/api/catalog/products/${id}/state`, { state }),
|
||||
delete: (id: string) => api.delete(`/api/catalog/products/${id}`),
|
||||
};
|
||||
|
||||
// ── Orders ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const ordersApi = {
|
||||
list: (params?: { limit?: number; offset?: number; status?: string; q?: string }) => {
|
||||
const sp = new URLSearchParams();
|
||||
if (params?.limit) sp.set('limit', String(params.limit));
|
||||
if (params?.offset) sp.set('offset', String(params.offset));
|
||||
if (params?.status) sp.set('status', params.status);
|
||||
if (params?.q) sp.set('q', params.q);
|
||||
const qs = sp.toString();
|
||||
return api.get<{ items: import('@/types').Order[]; total: number }>(
|
||||
`/api/orders${qs ? `?${qs}` : ''}`,
|
||||
);
|
||||
},
|
||||
get: (id: string) => api.get<import('@/types').Order>(`/api/orders/${id}`),
|
||||
transition: (id: string, state: string) =>
|
||||
api.post<import('@/types').Order>(`/api/orders/${id}/transitions`, { state }),
|
||||
};
|
||||
|
||||
// ── Customers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export const customersApi = {
|
||||
list: (params?: { offset?: number; limit?: number; q?: string }) => {
|
||||
const sp = new URLSearchParams();
|
||||
if (params?.offset !== undefined) sp.set('offset', String(params.offset));
|
||||
if (params?.limit !== undefined) sp.set('limit', String(params.limit));
|
||||
if (params?.q) sp.set('q', params.q);
|
||||
const qs = sp.toString();
|
||||
return api.get<{ items: import('@/types').Customer[]; total: number }>(`/api/users${qs ? `?${qs}` : ''}`);
|
||||
},
|
||||
get: (id: string) => api.get<import('@/types').Customer>(`/api/users/${id}`),
|
||||
update: (id: string, data: { displayName?: string; phone?: string }) =>
|
||||
api.patch<import('@/types').Customer>(`/api/users/${id}`, data),
|
||||
create: (data: { email: string; password: string; displayName?: string; phone?: string }) =>
|
||||
api.post<import('@/types').Customer>('/api/auth/register', data),
|
||||
};
|
||||
|
||||
// ── Brands ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const brandsApi = {
|
||||
list: () => api.get<{ items: import('@/types').Brand[] }>('/api/brands'),
|
||||
create: (data: unknown) => api.post<import('@/types').Brand>('/api/brands', data),
|
||||
update: (id: string, data: unknown) =>
|
||||
api.patch<import('@/types').Brand>(`/api/brands/${id}`, data),
|
||||
delete: (id: string) => api.delete<void>(`/api/brands/${id}`),
|
||||
};
|
||||
|
||||
// ── Categories ────────────────────────────────────────────────────────────────
|
||||
|
||||
export const categoriesApi = {
|
||||
list: () => api.get<{ items: import('@/types').Category[] }>('/api/categories/tree'),
|
||||
create: (data: unknown) => api.post<import('@/types').Category>('/api/categories', data),
|
||||
update: (id: string, data: unknown) =>
|
||||
api.patch<import('@/types').Category>(`/api/categories/${id}`, data),
|
||||
delete: (id: string) => api.delete<void>(`/api/categories/${id}`),
|
||||
};
|
||||
|
||||
// ── Inventory ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export const inventoryApi = {
|
||||
getAvailability: (variantId: string) =>
|
||||
api.get<import('@/types').StockAvailability>(`/api/inventory/${variantId}/availability`),
|
||||
setStock: (id: string, quantity: number) =>
|
||||
api.put<import('@/types').StockItem>(`/api/inventory/${id}/stock`, { quantity }),
|
||||
};
|
||||
|
||||
// ── Pricing ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export const pricingApi = {
|
||||
getVariantPrice: (id: string) => api.get<import('@/types').VariantPrice>(`/api/pricing/variants/${id}`),
|
||||
setVariantPrice: (
|
||||
id: string,
|
||||
netUnitAmountCents: number,
|
||||
vatRate: 'general' | 'reduced',
|
||||
offerCents?: number | null,
|
||||
costCents?: number | null,
|
||||
) =>
|
||||
api.put<import('@/types').VariantPrice>(`/api/pricing/variants/${id}`, {
|
||||
netUnitAmountCents,
|
||||
vatRate,
|
||||
offerCents: offerCents ?? null,
|
||||
costCents: costCents ?? null,
|
||||
}),
|
||||
};
|
||||
|
||||
// ── Promotions ────────────────────────────────────────────────────────────────
|
||||
|
||||
export const promotionsApi = {
|
||||
list: () => api.get<{ items: unknown[] }>('/api/promotions'),
|
||||
create: (data: unknown) => api.post('/api/promotions', data),
|
||||
update: (code: string, data: unknown) => api.patch(`/api/promotions/${code}`, data),
|
||||
delete: (code: string) => api.delete(`/api/promotions/${code}`),
|
||||
};
|
||||
|
||||
// ── Reviews ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export const reviewsApi = {
|
||||
listAdmin: (params?: { status?: string; limit?: number; offset?: number }) => {
|
||||
const sp = new URLSearchParams();
|
||||
if (params?.status) sp.set('status', params.status);
|
||||
if (params?.limit) sp.set('limit', String(params.limit));
|
||||
if (params?.offset) sp.set('offset', String(params.offset));
|
||||
const qs = sp.toString();
|
||||
return api.get<{ items: unknown[]; total: number }>(`/api/reviews/admin${qs ? `?${qs}` : ''}`);
|
||||
},
|
||||
moderate: (id: string, status: 'published' | 'rejected') =>
|
||||
api.patch(`/api/reviews/${id}/moderate`, { status }),
|
||||
};
|
||||
|
||||
// ── CMS ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const cmsApi = {
|
||||
list: () => api.get<{ items: unknown[] }>('/api/cms/pages'),
|
||||
get: (slug: string) => api.get(`/api/cms/pages/${slug}`),
|
||||
create: (data: unknown) => api.post('/api/cms/pages', data),
|
||||
update: (id: string, data: unknown) => api.patch(`/api/cms/pages/${id}`, data),
|
||||
publish: (id: string) => api.post(`/api/cms/pages/${id}/publish`, {}),
|
||||
unpublish: (id: string) => api.post(`/api/cms/pages/${id}/unpublish`, {}),
|
||||
};
|
||||
|
||||
// ── Admin Users ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export const adminUsersApi = {
|
||||
list: (params?: { limit?: number; offset?: number; role?: string; q?: string }) => {
|
||||
const sp = new URLSearchParams();
|
||||
if (params?.limit) sp.set('limit', String(params.limit));
|
||||
if (params?.offset) sp.set('offset', String(params.offset));
|
||||
if (params?.role) sp.set('role', params.role);
|
||||
if (params?.q) sp.set('q', params.q);
|
||||
const qs = sp.toString();
|
||||
return api.get<{ items: { id: string; email: string; role: string; createdAt: string }[]; total: number }>(
|
||||
`/api/admin/users${qs ? `?${qs}` : ''}`,
|
||||
);
|
||||
},
|
||||
create: (data: { email: string; password: string; role: string }) =>
|
||||
api.post<{ id: string; email: string; role: string; createdAt: string }>('/api/admin/users', data),
|
||||
update: (id: string, data: { role?: string; password?: string }) =>
|
||||
api.patch<{ id: string; email: string; role: string; createdAt: string }>(`/api/admin/users/${id}`, data),
|
||||
delete: (id: string) => api.delete<void>(`/api/admin/users/${id}`),
|
||||
};
|
||||
|
||||
// ── Tax Rates ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface TaxRate {
|
||||
id: string; name: string; ratePercent: number; country: string; appliesTo: string; active: boolean;
|
||||
}
|
||||
export const taxApi = {
|
||||
list: () => api.get<{ items: TaxRate[] }>('/api/admin/tax-rates'),
|
||||
update: (id: string, data: Partial<{ name: string; ratePercent: number; active: boolean }>) =>
|
||||
api.patch('/api/admin/tax-rates/' + id, data),
|
||||
};
|
||||
|
||||
// ── Payments ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface PaymentTransaction {
|
||||
id: string; provider: string; providerPaymentId: string | null;
|
||||
orderId: string | null; amountCents: number; currency: string;
|
||||
status: string; raw: unknown; createdAt: string;
|
||||
}
|
||||
export const paymentsApi = {
|
||||
list: (params?: { limit?: number; offset?: number; status?: string; q?: string }) => {
|
||||
const sp = new URLSearchParams();
|
||||
if (params?.limit) sp.set('limit', String(params.limit));
|
||||
if (params?.offset) sp.set('offset', String(params.offset));
|
||||
if (params?.status) sp.set('status', params.status);
|
||||
if (params?.q) sp.set('q', params.q);
|
||||
const qs = sp.toString();
|
||||
return api.get<{ items: PaymentTransaction[]; total: number }>(`/api/admin/payments${qs ? `?${qs}` : ''}`);
|
||||
},
|
||||
refund: (id: string) => api.post<{ ok: boolean }>(`/api/admin/payments/${id}/refund`, {}),
|
||||
};
|
||||
|
||||
// ── Shipping ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ShippingZone {
|
||||
id: string; name: string; country: string; postalCodePrefix: string | null; active: boolean;
|
||||
}
|
||||
export interface ShippingMethod {
|
||||
id: string; zoneId: string; zoneName: string; name: string;
|
||||
baseCostCents: number; freeShippingThresholdCents: number | null; active: boolean;
|
||||
}
|
||||
export const shippingApi = {
|
||||
listZones: () => api.get<{ items: ShippingZone[] }>('/api/admin/shipping/zones'),
|
||||
createZone: (data: { name: string; country: string; postalCodePrefix?: string | null; active?: boolean }) =>
|
||||
api.post<{ id: string }>('/api/admin/shipping/zones', data),
|
||||
updateZone: (id: string, data: Partial<{ name: string; country: string; postalCodePrefix?: string | null; active: boolean }>) =>
|
||||
api.patch('/api/admin/shipping/zones/' + id, data),
|
||||
deleteZone: (id: string) => api.delete<void>('/api/admin/shipping/zones/' + id),
|
||||
listMethods: () => api.get<{ items: ShippingMethod[] }>('/api/admin/shipping/methods'),
|
||||
createMethod: (data: { zoneId: string; name: string; baseCostCents: number; freeShippingThresholdCents?: number | null; active?: boolean }) =>
|
||||
api.post<{ id: string }>('/api/admin/shipping/methods', data),
|
||||
updateMethod: (id: string, data: Partial<{ name: string; baseCostCents: number; freeShippingThresholdCents?: number | null; active: boolean }>) =>
|
||||
api.patch('/api/admin/shipping/methods/' + id, data),
|
||||
deleteMethod: (id: string) => api.delete<void>('/api/admin/shipping/methods/' + id),
|
||||
};
|
||||
|
||||
// ── Store Settings ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export const auditApi = {
|
||||
list: (params?: { actorId?: string; action?: string; limit?: number; offset?: number }) => {
|
||||
const sp = new URLSearchParams();
|
||||
if (params?.action) sp.set('action', params.action);
|
||||
if (params?.limit) sp.set('limit', String(params.limit));
|
||||
if (params?.offset) sp.set('offset', String(params.offset));
|
||||
const qs = sp.toString();
|
||||
return api.get<{ items: AuditEntry[]; total: number }>(`/api/admin/audit${qs ? `?${qs}` : ''}`);
|
||||
},
|
||||
};
|
||||
|
||||
export interface AuditEntry {
|
||||
id: string;
|
||||
actorId: string | null;
|
||||
action: string;
|
||||
target: string;
|
||||
metadata: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface StoreSettings {
|
||||
storeName: string;
|
||||
storeTagline: string;
|
||||
contactEmail: string;
|
||||
contactPhone: string;
|
||||
contactAddress: string;
|
||||
footerText: string;
|
||||
facebookUrl: string;
|
||||
instagramUrl: string;
|
||||
}
|
||||
|
||||
export const settingsApi = {
|
||||
get: () => api.get<StoreSettings>('/api/admin/settings'),
|
||||
update: (data: Partial<StoreSettings>) => api.patch<StoreSettings>('/api/admin/settings', data),
|
||||
};
|
||||
63
project/apps/admin/src/lib/permissions.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { Role } from '@/types';
|
||||
|
||||
export type Permission =
|
||||
| 'dashboard'
|
||||
| 'products.read'
|
||||
| 'products.write'
|
||||
| 'orders.read'
|
||||
| 'orders.write'
|
||||
| 'inventory.read'
|
||||
| 'inventory.write'
|
||||
| 'customers.read'
|
||||
| 'customers.write'
|
||||
| 'categories.read'
|
||||
| 'categories.write'
|
||||
| 'categories.delete'
|
||||
| 'brands.read'
|
||||
| 'brands.write'
|
||||
| 'promotions.read'
|
||||
| 'promotions.write'
|
||||
| 'reviews.read'
|
||||
| 'reviews.moderate'
|
||||
| 'cms.read'
|
||||
| 'cms.write'
|
||||
| 'admin-users.read'
|
||||
| 'admin-users.write'
|
||||
| 'audit.read';
|
||||
|
||||
export function can(role: Role, permission: Permission): boolean {
|
||||
if (role === 'admin') return true;
|
||||
// Future: granular permission checks when backend supports them
|
||||
return false;
|
||||
}
|
||||
|
||||
export interface NavItem {
|
||||
href: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
permission: Permission;
|
||||
badge?: number;
|
||||
}
|
||||
|
||||
export const NAV_ITEMS: NavItem[] = [
|
||||
{ href: '/', label: 'Dashboard', icon: '📊', permission: 'dashboard' },
|
||||
{ href: '/products', label: 'Productos', icon: '📦', permission: 'products.read' },
|
||||
{ href: '/orders', label: 'Pedidos', icon: '🧾', permission: 'orders.read' },
|
||||
{ href: '/payments', label: 'Pagos', icon: '💳', permission: 'orders.read' },
|
||||
{ href: '/inventory', label: 'Inventario', icon: '📊', permission: 'inventory.read' },
|
||||
{ href: '/customers', label: 'Clientes', icon: '👥', permission: 'customers.read' },
|
||||
{ href: '/categories', label: 'Categorías', icon: '🏷️', permission: 'categories.read' },
|
||||
{ href: '/brands', label: 'Marcas', icon: '🏷️', permission: 'brands.read' },
|
||||
{ href: '/promotions', label: 'Promociones', icon: '🏷️', permission: 'promotions.read' },
|
||||
{ href: '/shipping', label: 'Envíos', icon: '📦', permission: 'orders.read' },
|
||||
{ href: '/reviews', label: 'Reseñas', icon: '⭐', permission: 'reviews.read' },
|
||||
{ href: '/cms', label: 'CMS', icon: '📄', permission: 'cms.read' },
|
||||
{ href: '/users', label: 'Usuarios', icon: '🔐', permission: 'admin-users.read' },
|
||||
{ href: '/tax-rates', label: 'IVA', icon: '📊', permission: 'orders.read' },
|
||||
{ href: '/audit', label: 'Auditoría', icon: '📋', permission: 'audit.read' },
|
||||
{ href: '/settings', label: 'Ajustes', icon: '⚙️', permission: 'dashboard' },
|
||||
];
|
||||
|
||||
export function visibleNavItems(role: Role): NavItem[] {
|
||||
return NAV_ITEMS.filter((item) => can(role, item.permission));
|
||||
}
|
||||
178
project/apps/admin/src/types/index.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
// ── User / Auth ──────────────────────────────────────────────────────────────
|
||||
|
||||
export type Role = 'customer' | 'admin';
|
||||
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
email: string;
|
||||
role: Role;
|
||||
}
|
||||
|
||||
// ── Products ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ProductImage {
|
||||
id: string;
|
||||
url: string;
|
||||
altText?: string;
|
||||
position?: number;
|
||||
role?: 'main' | 'gallery';
|
||||
}
|
||||
|
||||
export interface Product {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
description?: string;
|
||||
state: string;
|
||||
channels: 'online' | 'offline' | 'all';
|
||||
featured: boolean;
|
||||
attributes: string[];
|
||||
seoTitle?: string;
|
||||
seoDescription?: string;
|
||||
images: ProductImage[];
|
||||
brandId?: string;
|
||||
categoryIds?: string[];
|
||||
brand?: { id: string; name: string; slug: string };
|
||||
imageUrl?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface ProductVariant {
|
||||
id: string;
|
||||
productId: string;
|
||||
sku: string;
|
||||
ean: string | null;
|
||||
attributes: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface VariantPrice {
|
||||
variantId: string;
|
||||
netUnitAmountCents: number;
|
||||
offerCents: number | null;
|
||||
costCents: number | null;
|
||||
vatRate: 'general' | 'reduced';
|
||||
currency: string;
|
||||
}
|
||||
|
||||
export interface StockAvailability {
|
||||
available: boolean;
|
||||
availableQuantity: number;
|
||||
}
|
||||
|
||||
export interface StockItem {
|
||||
id: string;
|
||||
variantId: string;
|
||||
available: number;
|
||||
reserved: number;
|
||||
sold: number;
|
||||
incoming: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// ── Orders ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type OrderState =
|
||||
| 'PENDING'
|
||||
| 'AWAITING_PAYMENT'
|
||||
| 'PAID'
|
||||
| 'PROCESSING'
|
||||
| 'SHIPPED'
|
||||
| 'DELIVERED'
|
||||
| 'CANCELLED'
|
||||
| 'REFUNDED'
|
||||
| 'PARTIALLY_REFUNDED';
|
||||
|
||||
export interface OrderItem {
|
||||
id: string;
|
||||
productId: string;
|
||||
variantId: string;
|
||||
sku: string;
|
||||
ean: string | null;
|
||||
name: string;
|
||||
unitPriceCents: number;
|
||||
discountCents: number;
|
||||
taxCents: number;
|
||||
quantity: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Order {
|
||||
id: string;
|
||||
userId: string;
|
||||
state: OrderState;
|
||||
currency: 'EUR';
|
||||
subtotalCents: number;
|
||||
discountCents: number;
|
||||
taxCents: number;
|
||||
totalCents: number;
|
||||
idempotencyKey: string | null;
|
||||
items: OrderItem[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface OrderSummary {
|
||||
id: string;
|
||||
userId: string;
|
||||
state: OrderState;
|
||||
totalCents: number;
|
||||
currency: 'EUR';
|
||||
itemCount: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// ── Customers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface Customer {
|
||||
id: string;
|
||||
email: string;
|
||||
role: Role;
|
||||
displayName?: string;
|
||||
phone?: string;
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
// ── Categories & Brands ────────────────────────────────────────────────────────
|
||||
|
||||
export interface Category {
|
||||
id: string;
|
||||
parentId: string | null;
|
||||
name: string;
|
||||
slug: string;
|
||||
seoTitle?: string;
|
||||
seoDescription?: string;
|
||||
imageUrl?: string;
|
||||
description?: string;
|
||||
children?: Category[];
|
||||
}
|
||||
|
||||
export interface Brand {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
logoUrl?: string;
|
||||
seoTitle?: string;
|
||||
seoDescription?: string;
|
||||
}
|
||||
|
||||
// ── API Errors ────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ApiErrorBody {
|
||||
statusCode: number;
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public readonly statusCode: number,
|
||||
public readonly code: string,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
}
|
||||
42
project/apps/admin/tsconfig.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
1
project/apps/admin/tsconfig.tsbuildinfo
Normal file
@@ -5,7 +5,14 @@ import prettier from 'eslint-config-prettier';
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: ['dist/**', 'node_modules/**', 'coverage/**', 'scripts/tests/fixtures/**'],
|
||||
ignores: [
|
||||
'dist/**',
|
||||
'node_modules/**',
|
||||
'**/node_modules/**',
|
||||
'coverage/**',
|
||||
'scripts/tests/fixtures/**',
|
||||
'storefront/**',
|
||||
],
|
||||
},
|
||||
eslint.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
|
||||
41
project/frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
9
project/frontend/AGENTS.md
Normal file
@@ -0,0 +1,9 @@
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
|
||||
# This is NOT the Next.js you know
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
|
||||
|
||||
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
|
||||
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
1
project/frontend/CLAUDE.md
Normal file
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
36
project/frontend/README.md
Normal file
@@ -0,0 +1,36 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
18
project/frontend/eslint.config.mjs
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
11
project/frontend/next.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{ protocol: 'https', hostname: '**' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
26
project/frontend/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "16.3.1",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.3.1",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
7
project/frontend/postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
1
project/frontend/public/file.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
1
project/frontend/public/globe.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
BIN
project/frontend/public/images/favicon.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
project/frontend/public/images/logo-main.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
project/frontend/public/images/logo-small.png
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
1
project/frontend/public/next.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
project/frontend/public/vercel.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
1
project/frontend/public/window.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |