first commit

This commit is contained in:
2026-07-24 11:34:00 -03:00
commit 7ad059e613
282 changed files with 49054 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
node_modules
.next
.git
.gitignore
.env
.env.local
.env.*.local
data/
*.sqlite
*.sqlite-journal
*.db
.DS_Store
*.log
README.md
docker-compose.yml
Dockerfile
.dockerignore
.vscode
.idea
coverage/
src/lib/db/migrations/
.atl/
+28
View File
@@ -0,0 +1,28 @@
# ─── Database ───────────────────────────────────────────────
# SQLite database file path.
# Development: relative path (./data/db.sqlite)
# Docker: absolute path (/app/data/db.sqlite)
DATABASE_URL=./data/db.sqlite
# ─── Authentication ─────────────────────────────────────────
# Secret key for JWT signing and verification.
# MUST be changed to a random, secure value in production.
# Generate with: openssl rand -base64 32
JWT_SECRET=change-me-to-a-256-bit-secret
# ─── Storage ────────────────────────────────────────────────
# Directory for uploaded images (product photos, logos, etc.).
# Development: relative path (./public/uploads)
# Docker: absolute path (/app/public/uploads)
UPLOAD_DIR=./public/uploads
# Storage backend adapter. Currently only 'local' (filesystem) is supported.
STORAGE_ADAPTER=local
# ─── Environment ────────────────────────────────────────────
# Node.js environment: 'development' | 'production'
NODE_ENV=production
# Disable Next.js anonymous telemetry data collection.
# Set to 1 to disable. See: https://nextjs.org/telemetry
NEXT_TELEMETRY_DISABLED=1
+63
View File
@@ -0,0 +1,63 @@
# 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
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# SQLite
*.sqlite
*.sqlite-journal
*.db
/data/
# Uploads
/public/uploads/*
!/public/uploads/.gitkeep
# Docker
docker-compose.override.yml
*.tar
*.tar.gz
# IDE
.vscode/*
!.vscode/settings.json
!.vscode/extensions.json
.idea
# OS
Thumbs.db
# ATL
.atl/
+7
View File
@@ -0,0 +1,7 @@
{
"semi": true,
"trailingComma": "all",
"singleQuote": true,
"printWidth": 100,
"tabWidth": 2
}
+59
View File
@@ -0,0 +1,59 @@
# Stage 1: Dependencies
FROM node:22-alpine AS deps
RUN apk add --no-cache python3 make g++
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --omit=dev
# Stage 2: Build
FROM node:22-alpine AS builder
RUN apk add --no-cache python3 make g++
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
COPY . .
RUN mkdir -p /app/data && npx drizzle-kit push
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production
RUN npm run build
# Stage 3: Runner
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Copy standalone output
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
# Copy seed DB with schema already applied
COPY --from=builder /app/data/db.sqlite /app/seed/db.sqlite
COPY docker-entrypoint.sh /app/docker-entrypoint.sh
RUN chmod +x /app/docker-entrypoint.sh
# Create data and uploads directories
RUN mkdir -p /app/data /app/public/uploads
RUN chown -R nextjs:nodejs /app/data /app/public/uploads /app/seed
USER nextjs
EXPOSE 3017
ENV PORT=3017
ENV HOSTNAME="0.0.0.0"
ENTRYPOINT ["/app/docker-entrypoint.sh"]
CMD ["node", "server.js"]
+559
View File
@@ -0,0 +1,559 @@
# ByRachel — Clothing Business Management
A full-featured web application for managing a small clothing business. Built with Next.js, optimized for deployment on a single VPS with Docker.
## Features
- **Product Catalog** — Products with variants (size/color), images, traffic light inventory system
- **Sales Management** — Full sales workflow: create, confirm, deliver, cancel, returns
- **Purchase Orders** — Supplier purchases with receive/confirm/cancel workflow
- **Inventory** — Movement-based stock tracking, restock suggestions, adjustments
- **Customers** — Customer management with tags and sale history
- **Suppliers** — Supplier management with product associations
- **Categories** — Hierarchical categories with color coding
- **Analytics** — Sales trends, revenue/COGS/profit KPIs, best sellers, seasonality, cost evolution, inventory health
- **Reports** — PDF generation for sales, purchases, inventory, customers, brand, analytics
- **Public Catalog** — Customer-facing product catalog at `/catalogo`
- **Settings** — Brand configuration, pricing rules, password management, TOTP 2FA
- **Audit Logging** — All critical operations are logged with before/after data
## Tech Stack
| Layer | Technology |
|-------|-----------|
| Framework | Next.js 16 (App Router) |
| Language | TypeScript (strict mode) |
| Styling | Tailwind CSS 4 |
| UI Components | shadcn/ui (Radix UI primitives) |
| Database | SQLite (better-sqlite3) |
| ORM | Drizzle ORM |
| Authentication | JWT (jose) + TOTP (otplib) + Argon2 password hashing |
| Charts | Recharts |
| PDF Generation | pdf-lib (client-side) |
| Image Processing | Sharp |
| Testing | Vitest |
| Drag & Drop | @dnd-kit |
| Validation | Zod |
| Container | Docker (multi-stage build) |
## Prerequisites
- **Node.js 22+** and npm (for development)
- **Docker & Docker Compose** (for container deployment)
- **A domain name** (for production HTTPS, optional for local use)
## Quick Start (Docker)
The fastest way to get ByRachel running:
```bash
# 1. Clone the repository
git clone <your-repo-url> byrachel
cd byrachel
# 2. Create environment file
cp .env.example .env
# Edit .env and change JWT_SECRET to a random secure value:
# openssl rand -base64 32
# 3. Start the application
docker compose up -d
# 4. Open in browser
# http://localhost:3000
# You'll be redirected to /setup to create your admin account
```
## Development Setup
```bash
# 1. Clone and install
git clone <your-repo-url> byrachel
cd byrachel
npm install
# 2. Set up environment
cp .env.example .env
# 3. Initialize the database
npm run db:push
# 4. (Optional) Seed sample data
npm run seed
# 5. Start development server
npm run dev
# Open http://localhost:3000
```
### Development Commands
| Command | Description |
|---------|-------------|
| `npm run dev` | Start development server with hot reload |
| `npm run build` | Create production build |
| `npm run start` | Start production server |
| `npm run test` | Run all tests |
| `npm run test:watch` | Run tests in watch mode |
| `npm run test:coverage` | Run tests with coverage report |
| `npm run db:push` | Push schema changes to database |
| `npm run db:generate` | Generate migration files |
| `npm run db:migrate` | Run pending migrations |
| `npm run db:studio` | Open Drizzle Studio (database GUI) |
| `npm run seed` | Seed database with sample data |
## Environment Variables
| Variable | Description | Default | Required |
|----------|-------------|---------|----------|
| `DATABASE_URL` | SQLite database file path. Dev: `./data/db.sqlite`, Docker: `/app/data/db.sqlite` | `./data/db.sqlite` | Yes |
| `JWT_SECRET` | Secret key for JWT signing. **MUST be changed in production.** Generate with: `openssl rand -base64 32` | `change-me-to-a-256-bit-secret` | Yes |
| `UPLOAD_DIR` | Directory for uploaded images. Dev: `./public/uploads`, Docker: `/app/public/uploads` | `./public/uploads` | Yes |
| `STORAGE_ADAPTER` | Storage backend. Currently only `local` (filesystem) is supported | `local` | Yes |
| `NODE_ENV` | Node environment: `development` or `production` | `production` | Yes |
| `NEXT_TELEMETRY_DISABLED` | Disable Next.js telemetry. Set to `1` to disable | `1` | No |
## API Routes Reference
All API routes are under `/api/`. Admin routes require a valid JWT cookie (except `/api/health` and `/api/auth/*`).
### Authentication
| Method | Route | Description |
|--------|-------|-------------|
| POST | `/api/auth/setup` | Initial admin account creation (first run only) |
| POST | `/api/auth/login` | Login with email/password |
| POST | `/api/auth/verify-totp` | Verify TOTP code |
| GET | `/api/auth/me` | Get current user info |
| POST | `/api/auth/logout` | Logout (clear session) |
| PUT | `/api/auth/change-password` | Change password |
| POST | `/api/auth/regenerate-recovery-codes` | Generate new recovery codes |
### Products
| Method | Route | Description |
|--------|-------|-------------|
| GET/POST | `/api/products` | List / Create products |
| GET/PUT/DELETE | `/api/products/[id]` | Get / Update / Delete product |
| POST | `/api/products/[id]/duplicate` | Duplicate a product |
| GET/POST | `/api/products/[id]/images` | List / Upload product images |
| DELETE | `/api/products/[id]/images/[imageId]` | Delete product image |
| PUT | `/api/products/[id]/images/reorder` | Reorder product images |
| GET/POST | `/api/products/[id]/variants` | List / Create variants |
| PUT/DELETE | `/api/products/[id]/variants/[variantId]` | Update / Delete variant |
| GET | `/api/products/[id]/variants/[variantId]/movements` | Get variant movements |
### Categories
| Method | Route | Description |
|--------|-------|-------------|
| GET/POST | `/api/categories` | List / Create categories |
| PUT/DELETE | `/api/categories/[id]` | Update / Delete category |
### Sales
| Method | Route | Description |
|--------|-------|-------------|
| GET/POST | `/api/sales` | List / Create sales |
| GET/PUT/DELETE | `/api/sales/[id]` | Get / Update / Delete sale |
| POST | `/api/sales/[id]/confirm` | Confirm sale |
| POST | `/api/sales/[id]/deliver` | Mark sale as delivered |
| POST | `/api/sales/[id]/cancel` | Cancel sale |
| POST | `/api/sales/[id]/return` | Process sale return |
### Purchases
| Method | Route | Description |
|--------|-------|-------------|
| GET/POST | `/api/purchases` | List / Create purchases |
| GET/PUT/DELETE | `/api/purchases/[id]` | Get / Update / Delete purchase |
| POST | `/api/purchases/[id]/receive` | Receive purchase items |
| POST | `/api/purchases/[id]/confirm` | Confirm purchase |
| POST | `/api/purchases/[id]/cancel` | Cancel purchase |
### Inventory
| Method | Route | Description |
|--------|-------|-------------|
| GET | `/api/inventory` | Get inventory status |
| POST | `/api/inventory/restock` | Restock variants |
| POST | `/api/inventory/adjust` | Manual stock adjustment |
| GET | `/api/inventory/movements` | List stock movements |
### Customers
| Method | Route | Description |
|--------|-------|-------------|
| GET/POST | `/api/customers` | List / Create customers |
| GET/PUT/DELETE | `/api/customers/[id]` | Get / Update / Delete customer |
| GET/POST | `/api/customers/[id]/tags` | List / Add customer tags |
| DELETE | `/api/customers/[id]/tags/[tagId]` | Remove customer tag |
| GET | `/api/customer-tags` | List all customer tags |
### Suppliers
| Method | Route | Description |
|--------|-------|-------------|
| GET/POST | `/api/suppliers` | List / Create suppliers |
| GET/PUT/DELETE | `/api/suppliers/[id]` | Get / Update / Delete supplier |
| GET | `/api/suppliers/[id]/products` | Get supplier's products |
### Analytics
| Method | Route | Description |
|--------|-------|-------------|
| GET | `/api/analytics/summary` | Sales summary KPIs |
| GET | `/api/analytics/sales-trend` | Revenue/units over time |
| GET | `/api/analytics/categories` | Sales by category |
| GET | `/api/analytics/products` | Product performance |
| GET | `/api/analytics/customers` | Customer analytics |
| GET | `/api/analytics/cost-evolution` | Cost price evolution |
| GET | `/api/analytics/inventory-health` | Inventory health metrics |
| GET | `/api/analytics/seasonality` | Seasonal sales patterns |
### Reports (PDF Generation)
| Method | Route | Description |
|--------|-------|-------------|
| GET | `/api/reports/sales` | Sales report data |
| GET | `/api/reports/sales/[id]` | Single sale report |
| GET | `/api/reports/sales/profits` | Profit report |
| GET | `/api/reports/purchases` | Purchases report data |
| GET | `/api/reports/purchases/[id]` | Single purchase report |
| GET | `/api/reports/inventory` | Inventory report |
| GET | `/api/reports/customers` | Customers report |
| GET | `/api/reports/customers/[id]` | Single customer report |
| GET | `/api/reports/brand` | Brand/catalog report |
| GET | `/api/reports/analytics/best-sellers` | Best sellers report |
| GET | `/api/reports/analytics/low-rotation` | Low rotation report |
### Settings
| Method | Route | Description |
|--------|-------|-------------|
| GET/PUT | `/api/settings/brand` | Get / Update brand settings |
| GET/PUT | `/api/settings/pricing` | Get / Update pricing settings |
### Admin
| Method | Route | Description |
|--------|-------|-------------|
| GET | `/api/admin/backup` | Download full backup (tar.gz) |
### Other
| Method | Route | Description |
|--------|-------|-------------|
| GET | `/api/health` | Health check (public, no auth) |
| POST | `/api/upload` | Upload image file |
## Backup & Restore
### Automated Backup (Admin Endpoint)
Admin users can download a full backup from the app:
```
GET /api/admin/backup
```
This returns a `tar.gz` archive containing:
- `data/` — SQLite database files (main DB, WAL, SHM)
- `public/uploads/` — All uploaded images
**Requirements**: Must be authenticated as admin. Download over HTTPS only.
### Manual SQLite Backup
SQLite with WAL mode requires a safe backup procedure to avoid corruption:
```bash
# Option 1: Using SQLite .backup command (safest)
docker compose exec app sh -c 'sqlite3 /app/data/db.sqlite ".backup /app/data/backup.db"'
# Then copy backup.db from the container
# Option 2: Copy WAL-safe (stop writes first)
# 1. Temporarily stop the application
docker compose stop app
# 2. Copy the database files
docker compose cp app:/app/data/db.sqlite ./backup/db.sqlite
docker compose cp app:/app/data/db.sqlite-wal ./backup/db.sqlite-wal
docker compose cp app:/app/data/db.sqlite-shm ./backup/db.sqlite-shm
# 3. Restart the application
docker compose start app
# Option 3: Using docker volume backup
docker run --rm -v byrachel_db-data:/data -v $(pwd)/backup:/backup alpine \
tar czf /backup/byrachel-db-$(date +%Y%m%d).tar.gz -C /data .
```
### Image Volume Backup
```bash
docker run --rm -v byrachel_uploads:/uploads -v $(pwd)/backup:/backup alpine \
tar czf /backup/byrachel-uploads-$(date +%Y%m%d).tar.gz -C /uploads .
```
### Restore Procedure
```bash
# 1. Stop the application
docker compose stop app
# 2. Restore database
docker run --rm -v byrachel_db-data:/data -v $(pwd)/backup:/backup alpine \
sh -c 'rm -rf /data/* && tar xzf /backup/byrachel-db-YYYYMMDD.tar.gz -C /data'
# 3. Restore uploads
docker run --rm -v byrachel_uploads:/uploads -v $(pwd)/backup:/backup alpine \
sh -c 'rm -rf /uploads/* && tar xzf /backup/byrachel-uploads-YYYYMMDD.tar.gz -C /uploads'
# 4. Start the application
docker compose start app
# 5. Verify
docker compose ps # Check health status
curl http://localhost:3000/api/health # Should return {"status":"ok"}
```
## Production Deployment
### Docker Deployment
```bash
# 1. Clone on your server
git clone <your-repo-url> byrachel
cd byrachel
# 2. Configure environment
cp .env.example .env
nano .env
# Set JWT_SECRET to a random value: openssl rand -base64 32
# 3. Build and start
docker compose up -d --build
# 4. Verify
docker compose ps
docker compose logs -f app
```
### Nginx Proxy Manager Setup
[Nginx Proxy Manager](https://nginxproxymanager.com/) is the recommended reverse proxy for HTTPS and domain routing.
#### 1. Install Nginx Proxy Manager
```bash
# Add to your docker-compose.yml or run separately
docker run -d \
--name nginx-proxy-manager \
-p 80:80 -p 81:81 -p 443:443 \
-v ./npm-data:/data \
-v ./npm-le:/etc/letsencrypt \
jc21/nginx-proxy-manager:latest
```
#### 2. Configure Proxy Host
1. Open NPM admin: `http://your-server-ip:81`
2. Default login: `admin@example.com` / `changeme`
3. Go to **Hosts → Proxy Hosts → Add Proxy Host**
4. Configure:
- **Domain Names**: `yourdomain.com`
- **Scheme**: `http`
- **Hostname**: `byrachel-app` (Docker container name) or your server's Docker IP
- **Port**: `3000`
- **Common Settings**:
- Enable: `Block Common Exploits`
- Enable: `Websockets Support`
- **SSL Tab**:
- SSL Certificate: Request a new Let's Encrypt certificate
- Enable: `Force SSL`
- Enable: `HTTP/2 Support`
#### 3. Docker Network (recommended)
If both NPM and ByRachel are in the same Docker network:
```yaml
# docker-compose.yml
services:
app:
# ... existing config ...
networks:
- proxy-net
networks:
proxy-net:
external: true
```
Then in NPM, use the container name `byrachel-app` as the hostname.
### HTTPS Configuration
HTTPS is configured through Nginx Proxy Manager:
1. **Request Certificate**: In the Proxy Host SSL tab, select "Request a new SSL Certificate"
2. **Let's Encrypt**: NPM handles Let's Encrypt automatically
3. **Force SSL**: Enable to redirect HTTP → HTTPS
4. **Auto-renewal**: Certificates renew automatically
### Domain Configuration
1. **DNS Record**: Create an A record pointing your domain to your server's IP
```
Type: A
Name: yourdomain.com (or @)
Value: YOUR_SERVER_IP
TTL: 300
```
2. **Wait for DNS propagation** (usually minutes, up to 48h)
3. **Configure in NPM**: Add the domain in the Proxy Host settings (see above)
### Auto-restart & Log Management
#### Auto-restart
The Docker Compose configuration includes `restart: unless-stopped`, which means:
- Container restarts automatically on crash
- Container starts on Docker daemon startup (server reboot)
- Container only stays stopped if you explicitly `docker compose stop`
For additional server-level auto-start, create a systemd service:
```ini
# /etc/systemd/system/byrachel.service
[Unit]
Description=ByRachel Docker Compose
Requires=docker.service
After=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/path/to/byrachel
ExecStart=/usr/bin/docker compose up -d
ExecStop=/usr/bin/docker compose down
TimeoutStartSec=0
[Install]
WantedBy=multi-user.target
```
```bash
sudo systemctl enable byrachel.service
sudo systemctl start byrachel.service
```
#### Log Management
```bash
# View live logs
docker compose logs -f app
# View last 100 lines
docker compose logs --tail=100 app
# Configure log rotation (in docker-compose.yml, add to app service):
# logging:
# driver: json-file
# options:
# max-size: "10m"
# max-file: "3"
```
## SQLite → PostgreSQL Migration Plan
ByRachel uses Drizzle ORM, which supports multiple database dialects. Migration to PostgreSQL is possible when needed:
### Steps
1. **Install PostgreSQL driver**:
```bash
npm install pg
npm install @types/pg
```
2. **Update Drizzle config** (`drizzle.config.ts`):
```typescript
export default defineConfig({
schema: './src/lib/db/schema.ts',
out: './src/lib/db/migrations',
dialect: 'postgresql', // Change from 'sqlite'
dbCredentials: {
host: process.env.PGHOST,
port: Number(process.env.PGPORT),
user: process.env.PGUSER,
password: process.env.PGPASSWORD,
database: process.env.PGDATABASE,
},
});
```
3. **Update database connection** (`src/lib/db/index.ts`):
- Replace `drizzle(betterSqlite3(...))` with `drizzle(pgPool)` or `drizzle(nodePostgres(...))`
4. **Update environment variables**:
```env
DATABASE_URL=postgresql://user:password@localhost:5432/byrachel
```
5. **Generate and run migrations**:
```bash
npm run db:generate
npm run db:migrate
```
6. **Data migration**: Export data from SQLite and import into PostgreSQL using a migration script.
### Considerations
- SQLite uses `integer` primary keys with auto-increment — PostgreSQL uses `serial` or `identity`
- SQLite `boolean` (integer 0/1) maps directly to PostgreSQL `boolean`
- SQLite `text` timestamps work in both, but PostgreSQL has native `timestamp` types
- Test thoroughly in a staging environment before migrating production data
## First-Run Setup
### 1. Initial Admin Account
1. Open the app in your browser (e.g., `http://localhost:3000`)
2. You'll be redirected to `/setup` (only available when no users exist)
3. Fill in:
- **Email**: Admin email address
- **Password**: Strong password (min 8 characters)
- **Name**: Admin display name (optional)
4. Click "Create Account"
### 2. TOTP Configuration
After creating the account, TOTP (Time-based One-Time Password) setup begins automatically:
1. A **QR code** is displayed on screen
2. Open your authenticator app (Google Authenticator, Authy, 1Password, etc.)
3. Scan the QR code (or enter the secret key manually)
4. Enter the 6-digit code from your authenticator app to verify
5. **Save your recovery codes** in a secure location — these are your backup if you lose access to your authenticator
### 3. Brand Configuration
After logging in for the first time:
1. Navigate to **Settings** (gear icon in sidebar)
2. Configure your brand:
- **Store Name**: Your business name
- **Logo**: Upload your logo image
- **Tagline**: Short description
- **Colors**: Primary and secondary brand colors
- **Contact Info**: Phone, email, WhatsApp
- **Address**: Business address
- **Footer Text**: Custom footer for catalog pages
3. Click "Save Settings"
4. To publish your public catalog, check "Publish Catalog" in settings
## Known Limitations
- **SQLite write concurrency**: SQLite handles concurrent reads well but writes are serialized. For a single-admin small business, this is not an issue. If you need multi-writer support, migrate to PostgreSQL (see migration plan above).
- **No built-in email**: The app does not send emails. Password recovery uses local recovery codes instead of email-based reset.
- **Single admin**: The app supports admin and operator roles, but the initial setup creates only one admin user. Additional users must be created manually via the database.
- **No automated backups**: The backup endpoint provides on-demand backups. Set up cron jobs or external tools for scheduled backups.
- **Local file storage only**: Images are stored on the local filesystem. For multi-instance deployments, a shared storage solution (S3, etc.) would be needed.
- **No real-time updates**: The app uses standard HTTP requests. Changes made in one browser tab are not reflected in other tabs until refresh.
- **Catalog is read-only for customers**: The public catalog at `/catalogo` is for browsing only. Customers cannot place orders through the app.
## License
Private — All rights reserved.
+20
View File
@@ -0,0 +1,20 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "zinc",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
+33
View File
@@ -0,0 +1,33 @@
services:
byrachel:
image: byrachel:amd64
container_name: byrachel
restart: unless-stopped
environment:
- NODE_ENV=production
- DATABASE_URL=/app/data/db.sqlite
- JWT_SECRET=a23e0bc4ca9f706efb26f34de4e40530f4f2c201cb8f6684e2dde65b93fb694a
- ENCRYPTION_KEY=8e66c3ea971ce10924a92d5581b59ecc796f6a400c9423e7ae221f8cee6aeb9d
volumes:
- byrachel-data:/app/data
- byrachel-uploads:/app/public/uploads
healthcheck:
test: ["CMD-SHELL", "node -e \"const http = require('http'); http.get('http://127.0.0.1:3017/api/health', r => { process.exit(r.statusCode === 200 ? 0 : 1) }).on('error', () => process.exit(1))\""]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
networks:
- proxy
volumes:
byrachel-data:
byrachel-uploads:
networks:
proxy:
external: true
+31
View File
@@ -0,0 +1,31 @@
services:
app:
build:
context: .
dockerfile: Dockerfile
container_name: byrachel-app
ports:
- "3000:3000"
volumes:
- ./data:/app/data
- ./uploads:/app/public/uploads
environment:
- DATABASE_URL=/app/data/db.sqlite
- JWT_SECRET=${JWT_SECRET:-change-me-to-a-256-bit-secret}
- UPLOAD_DIR=/app/public/uploads
- STORAGE_ADAPTER=local
- NODE_ENV=production
- NEXT_TELEMETRY_DISABLED=1
- ENCRYPTION_KEY=${ENCRYPTION_KEY:-change-me-to-a-secure-key}
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
restart: unless-stopped
deploy:
resources:
limits:
memory: 512M
cpus: '1.0'
+12
View File
@@ -0,0 +1,12 @@
#!/bin/sh
set -e
DB_FILE="/app/data/db.sqlite"
# If DB doesn't exist or is empty, seed it
if [ ! -f "$DB_FILE" ] || [ ! -s "$DB_FILE" ]; then
echo "Database not found, seeding from schema..."
cp /app/seed/db.sqlite "$DB_FILE"
fi
exec node server.js
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/lib/db/schema.ts',
out: './src/lib/db/migrations',
dialect: 'sqlite',
dbCredentials: {
url: process.env.DATABASE_URL || './data/db.sqlite',
},
});
+18
View 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;
+726
View File
@@ -0,0 +1,726 @@
# Exploration: PDF Reports Module
## Executive Summary
Design client-side PDF generation for 18 report types across inventory, products, movements, purchases, customers, sales, profits, and analytics. All PDFs generated in browser using pdf-lib (~150KB), dynamically imported on demand. Reusable template pattern for headers, footers, and tables ensures consistency and reduces code duplication.
## Current State
### Existing Architecture
- **Framework**: Next.js 16.2.11 App Router (TypeScript strict)
- **Database**: SQLite via Drizzle ORM (better-sqlite3)
- **Services**: inventory, sales, purchases, customers, suppliers modules with service functions
- **UI**: shadcn/ui components, Tailwind CSS, ARS currency (es-AR locale)
- **Admin Shell**: Sidebar navigation, header, mobile drawer
- **Reports Page**: Placeholder at `/admin/reports` (coming soon)
### Existing Data Sources
```typescript
// Inventory
- computeTrafficLight() stock status (green/yellow/red)
- getMovements() inventory movement history
- getRestockSuggestions() low stock variants
// Sales
- getSales(filters) list sales with pagination
- getSaleById(id) single sale with items
// Purchases
- getPurchases(filters) list purchases
- getPurchaseById(id) single purchase with items
- allocateAdditionalCosts() cost allocation logic
// Customers
- Customer service with CRUD operations
// Suppliers
- Supplier service with CRUD operations
```
### Missing Components
- No PDF libraries installed (pdf-lib, jsPDF, html2canvas)
- No report generation code
- No export functionality
- No print-optimized CSS
---
## Required Reports (18 total)
### Inventory Reports (4)
1. **Inventory General** — All variants with current stock, min stock, location
2. **Inventory Critical** — Variants below minimum stock (red traffic light)
3. **Products to Restock** — Variants needing reorder (yellow + red traffic light)
4. **Inventory Valuation** — Stock value by variant (quantity × cost)
### Product Reports (2)
5. **Products by Category** — Grouped by category with counts and totals
6. **Products by Supplier** — Grouped by preferred supplier
### Movement Reports (1)
7. **Movement History** — All inventory movements with filters (date range, type, variant)
### Purchase Reports (2)
8. **Purchases** — List of purchases with totals, status, supplier
9. **Purchase Detail** — Single purchase with items, costs, allocation
### Customer Reports (2)
10. **Customers** — List of customers with contact info, type, total purchases
11. **Customer Detail** — Single customer with purchase history
### Sales Reports (2)
12. **Sales** — List of sales with totals, status, payment method
13. **Sale Detail** — Single sale with items, discounts, taxes
### Profit Reports (2)
14. **Profits** — Gross profit by period (sale price - cost)
15. **Cost Evolution** — Average cost trends over time
### Analytics Reports (3)
16. **Seasonality Analysis** — Sales by month/quarter, identify patterns
17. **Best Sellers** — Top variants by quantity sold
18. **Low Rotation** — Variants with low sales velocity
---
## Technical Requirements
### Client-Side Generation
- **No server Puppeteer** — all PDFs generated in browser
- **Libraries**: pdf-lib (~150KB) — already chosen in foundation
- **Dynamic Import**: Load pdf-lib only when user clicks export
- **Bundle Impact**: ~150KB added to client bundle (lazy-loaded chunk)
### PDF Structure (per report)
```
┌─────────────────────────────────────┐
│ [Logo] Report Title │ ← Header (page 1)
│ Generated: 2026-07-23 14:30 │
│ Period: 2026-01-01 to 2026-06-30 │
│ Filters: Category=Shirts, Status=… │
├─────────────────────────────────────┤
│ Data Table │
│ ┌──────┬────────┬───────┬────────┐ │
│ │ SKU │ Name │ Stock │ Value │ │
│ ├──────┼────────┼───────┼────────┤ │
│ │ ... │ ... │ ... │ ... │ │
│ └──────┴────────┴───────┴────────┘ │
│ │
│ [Continues on next page if needed] │
├─────────────────────────────────────┤
│ Totals: $1,234,567 │ ← Footer
│ Page 1 of 5 │
│ byrachel — www.byrachel.com │
└─────────────────────────────────────┘
```
### Format & Layout
- **Paper**: A4 (210mm × 297mm)
- **Orientation**: Portrait (default) or Landscape (wide tables)
- **Margins**: 15mm all sides
- **Fonts**: Helvetica (built-in pdf-lib font)
- **Colors**: Black text, gray borders, accent color for headers
### Pagination
- Calculate rows per page based on available height
- Split table rows across pages
- Repeat header row on each page
- Page numbers in footer (Page X of Y)
### Progress Indicator
- Show loading state during data fetching (server-side)
- Show progress during PDF generation (client-side)
- Use React state + spinner component
### Alternative: @media print CSS
- Provide print-optimized CSS for browser's native print
- Fallback for users who prefer Ctrl+P
- Hide navigation, show only report content
---
## Approaches
### Approach 1: pdf-lib (Programmatic PDF Generation) ✅ RECOMMENDED
**Description**: Build PDFs from scratch using pdf-lib's low-level API. Manually position text, draw tables, embed images. Create reusable template functions for headers, footers, and tables.
**Implementation**:
```typescript
// src/lib/reports/templates/header.ts
import { PDFDocument, PDFFont, rgb } from 'pdf-lib';
export function drawHeader(
page: PDFPage,
font: PDFFont,
options: {
logo?: Uint8Array;
title: string;
subtitle?: string;
generatedAt: Date;
}
) {
const { width, height } = page.getSize();
// Draw logo (if provided)
if (options.logo) {
const logoImage = await pdfDoc.embedPng(options.logo);
page.drawImage(logoImage, { x: 15, y: height - 40, width: 30, height: 30 });
}
// Draw title
page.drawText(options.title, {
x: options.logo ? 50 : 15,
y: height - 30,
size: 18,
font,
color: rgb(0, 0, 0),
});
// Draw generation date
page.drawText(`Generated: ${formatDate(options.generatedAt)}`, {
x: 15,
y: height - 50,
size: 10,
font,
color: rgb(0.4, 0.4, 0.4),
});
}
```
**Pros**:
- ✅ Smallest bundle (~150KB vs 500KB+ for alternatives)
- ✅ Modern, TypeScript-first API
- ✅ Full control over layout, pagination, positioning
- ✅ Fast (no DOM rendering overhead)
- ✅ Already chosen in foundation architecture
- ✅ Sufficient for all 18 report types
**Cons**:
- ❌ Manual layout (no CSS/HTML)
- ❌ More code to write (table drawing, pagination logic)
- ❌ Steeper learning curve for team unfamiliar with pdf-lib
**Effort**: Medium-High (40-60 hours)
---
### Approach 2: jsPDF + html2canvas (HTML-to-PDF)
**Description**: Render React components to HTML, capture with html2canvas, convert to PDF with jsPDF. Easier styling with CSS but larger bundle and slower performance.
**Implementation**:
```typescript
// Client component
import jsPDF from 'jspdf';
import html2canvas from 'html2canvas';
async function exportToPDF(elementId: string) {
const element = document.getElementById(elementId);
const canvas = await html2canvas(element);
const imgData = canvas.toDataURL('image/png');
const pdf = new jsPDF('p', 'mm', 'a4');
const imgWidth = 210; // A4 width in mm
const imgHeight = (canvas.height * imgWidth) / canvas.width;
pdf.addImage(imgData, 'PNG', 0, 0, imgWidth, imgHeight);
pdf.save('report.pdf');
}
```
**Pros**:
- ✅ Easier styling (use existing CSS/Tailwind)
- ✅ Less code (no manual table drawing)
- ✅ Familiar workflow (HTML → PDF)
**Cons**:
- ❌ Large bundle (~500KB+ combined)
- ❌ Slow (DOM rendering + canvas conversion)
- ❌ Inconsistent rendering (browser differences)
- ❌ Pagination issues (hard to split tables across pages)
- ❌ Not suitable for large reports (memory issues)
**Effort**: Low-Medium (20-30 hours)
---
### Approach 3: @react-pdf/renderer (React PDF Components)
**Description**: Use React components to define PDF layout (similar to React Native). Declarative API with `<Document>`, `<Page>`, `<View>`, `<Text>` components.
**Implementation**:
```typescript
import { Document, Page, Text, View, StyleSheet } from '@react-pdf/renderer';
const styles = StyleSheet.create({
page: { padding: 15 },
header: { fontSize: 18, marginBottom: 10 },
table: { display: 'flex', flexDirection: 'column' },
row: { flexDirection: 'row', borderBottom: 1 },
cell: { flex: 1, padding: 5 },
});
function InventoryReport({ data }) {
return (
<Document>
<Page size="A4" style={styles.page}>
<Text style={styles.header}>Inventory Report</Text>
<View style={styles.table}>
{data.map((item) => (
<View key={item.id} style={styles.row}>
<Text style={styles.cell}>{item.sku}</Text>
<Text style={styles.cell}>{item.name}</Text>
<Text style={styles.cell}>{item.stock}</Text>
</View>
))}
</View>
</Page>
</Document>
);
}
```
**Pros**:
- ✅ React components (familiar API)
- ✅ Declarative layout
- ✅ Built-in pagination
- ✅ Good for complex layouts
**Cons**:
- ❌ Adds ~200KB to bundle
- ❌ Different rendering engine (not browser DOM)
- ❌ Learning curve (different from web React)
- ❌ Limited CSS support (subset of Flexbox)
**Effort**: Medium (30-40 hours)
---
### Approach 4: Hybrid (pdf-lib + @media print CSS)
**Description**: Use pdf-lib for complex reports (analytics, multi-page tables), provide @media print CSS for simpler reports (single-page lists). Fallback option for users who prefer browser print.
**Pros**:
- ✅ Flexibility (choose best tool per report)
- ✅ Fallback option (Ctrl+P always works)
- ✅ Progressive enhancement
**Cons**:
- ❌ Two code paths to maintain
- ❌ Inconsistent user experience
- ❌ More complex implementation
**Effort**: High (50-70 hours)
---
## Recommendation: **Approach 1 — pdf-lib**
### Rationale
1. **Already Decided**: Foundation architecture chose pdf-lib for small bundle size and modern API.
2. **Bundle Size**: ~150KB vs 500KB+ for jsPDF+html2canvas. Critical for Oracle VPS with limited RAM.
3. **Performance**: No DOM rendering overhead. Faster than html2canvas for large reports.
4. **Control**: Full control over pagination, headers, footers, table layout. Essential for professional reports.
5. **Sufficiency**: All 18 report types can be built with pdf-lib. No need for HTML-to-PDF complexity.
6. **TypeScript**: Modern, type-safe API. Better DX than jsPDF.
### Tradeoffs Accepted
- **Manual Layout**: More code to write, but reusable templates reduce duplication.
- **Learning Curve**: Team needs to learn pdf-lib API, but documentation is good.
- **No CSS**: Can't reuse existing Tailwind styles, but consistent branding via templates.
---
## Architecture
### File Structure
```
src/lib/reports/
├── templates/
│ ├── header.ts # Reusable header (logo, title, date, filters)
│ ├── footer.ts # Reusable footer (page numbers, generation info, branding)
│ ├── table.ts # Reusable table component (columns, rows, pagination)
│ ├── page.ts # Page management (new page, margins, orientation)
│ └── types.ts # Shared types (ReportOptions, TableColumn, etc.)
├── generators/
│ ├── inventory.ts # Inventory reports (general, critical, restock, valuation)
│ ├── products.ts # Product reports (by category, by supplier)
│ ├── movements.ts # Movement history report
│ ├── purchases.ts # Purchase reports (list, detail)
│ ├── customers.ts # Customer reports (list, detail)
│ ├── sales.ts # Sales reports (list, detail)
│ ├── profits.ts # Profit reports (profits, cost evolution)
│ └── analytics.ts # Analytics reports (seasonality, best sellers, low rotation)
├── utils.ts # PDF utilities (formatting, calculations, helpers)
└── index.ts # Public API (export all generators)
src/components/reports/
├── export-button.tsx # Reusable export button with progress indicator
├── report-filters.tsx # Filter form component (date range, categories, etc.)
├── report-header.tsx # Client-side report header (for @media print alternative)
└── print-styles.css # @media print styles (hide nav, show report)
src/app/(admin)/reports/
├── page.tsx # Reports dashboard (list all 18 reports)
├── inventory/
│ ├── general/page.tsx
│ ├── critical/page.tsx
│ ├── restock/page.tsx
│ └── valuation/page.tsx
├── products/
│ ├── by-category/page.tsx
│ └── by-supplier/page.tsx
├── movements/page.tsx
├── purchases/
│ ├── page.tsx
│ └── [id]/page.tsx
├── customers/
│ ├── page.tsx
│ └── [id]/page.tsx
├── sales/
│ ├── page.tsx
│ └── [id]/page.tsx
├── profits/page.tsx
├── cost-evolution/page.tsx
└── analytics/
├── seasonality/page.tsx
├── best-sellers/page.tsx
└── low-rotation/page.tsx
```
### Template Pattern
**Header Template** (`src/lib/reports/templates/header.ts`):
```typescript
export interface HeaderOptions {
title: string;
subtitle?: string;
generatedAt: Date;
period?: { from: Date; to: Date };
filters?: Array<{ label: string; value: string }>;
logo?: Uint8Array; // PNG bytes
}
export function drawHeader(
pdfDoc: PDFDocument,
page: PDFPage,
font: PDFFont,
options: HeaderOptions
): number {
// Returns Y position after header (for content placement)
}
```
**Footer Template** (`src/lib/reports/templates/footer.ts`):
```typescript
export interface FooterOptions {
pageNumber: number;
totalPages: number;
totals?: Array<{ label: string; value: string }>;
branding?: string; // "byrachel — www.byrachel.com"
}
export function drawFooter(
page: PDFPage,
font: PDFFont,
options: FooterOptions
) {
// Draw page numbers, totals, branding
}
```
**Table Template** (`src/lib/reports/templates/table.ts`):
```typescript
export interface TableColumn {
header: string;
key: string;
width: number; // percentage (0-1)
align?: 'left' | 'center' | 'right';
format?: (value: any) => string;
}
export interface TableOptions {
columns: TableColumn[];
rows: any[];
startY: number;
fontSize?: number;
rowHeight?: number;
headerColor?: RGB;
alternateRowColor?: RGB;
}
export function drawTable(
pdfDoc: PDFDocument,
page: PDFPage,
font: PDFFont,
options: TableOptions
): { endY: number; pagesUsed: number } {
// Handles pagination, row breaks, header repetition
}
```
### Data Fetching Strategy
**Server-Side (API Routes / Server Actions)**:
```typescript
// src/app/api/reports/inventory/general/route.ts
import { db } from '@/lib/db';
import { variants, products, inventory, categories } from '@/lib/db/schema';
import { eq, and, sql } from 'drizzle-orm';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const categoryId = searchParams.get('categoryId');
const location = searchParams.get('location');
// Fetch data with filters
const data = await db
.select({
sku: variants.sku,
productName: products.name,
size: variants.size,
color: variants.color,
stock: inventory.quantity,
minStock: inventory.minStock,
location: inventory.location,
cost: variants.averageCost,
value: sql`${inventory.quantity} * ${variants.averageCost}`,
})
.from(variants)
.innerJoin(products, eq(variants.productId, products.id))
.innerJoin(inventory, eq(variants.id, inventory.variantId))
.leftJoin(categories, eq(products.categoryId, categories.id))
.where(
and(
categoryId ? eq(products.categoryId, Number(categoryId)) : undefined,
location ? eq(inventory.location, location) : undefined,
eq(products.isActive, true),
eq(variants.isActive, true)
)
);
return Response.json(data);
}
```
**Client-Side (Export Button)**:
```typescript
// src/components/reports/export-button.tsx
'use client';
import { useState } from 'react';
export function ExportButton({ reportType, filters }: { reportType: string; filters: any }) {
const [loading, setLoading] = useState(false);
const [progress, setProgress] = useState(0);
async function handleExport() {
setLoading(true);
setProgress(10);
try {
// Fetch data from API
const response = await fetch(`/api/reports/${reportType}?${new URLSearchParams(filters)}`);
const data = await response.json();
setProgress(50);
// Dynamically import pdf-lib
const { PDFDocument } = await import('pdf-lib');
setProgress(60);
// Generate PDF
const pdfDoc = await PDFDocument.create();
const font = await pdfDoc.embedFont('Helvetica');
// Call report generator
const generator = await import(`@/lib/reports/generators/${reportType}`);
await generator.default(pdfDoc, font, data);
setProgress(90);
// Download PDF
const pdfBytes = await pdfDoc.save();
const blob = new Blob([pdfBytes], { type: 'application/pdf' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${reportType}-${Date.now()}.pdf`;
a.click();
setProgress(100);
} finally {
setLoading(false);
setTimeout(() => setProgress(0), 1000);
}
}
return (
<button onClick={handleExport} disabled={loading}>
{loading ? `Exporting... ${progress}%` : 'Export PDF'}
</button>
);
}
```
### PDF Generation Flow
```
User clicks "Export PDF"
Client calls API route with filters
Server fetches data from DB (with joins, aggregations)
Server returns JSON data
Client dynamically imports pdf-lib (~150KB)
Client creates PDFDocument
Client calls report generator (e.g., generateInventoryReport)
Generator uses templates (header, footer, table)
Generator handles pagination (split rows across pages)
Client downloads PDF blob
```
### Pagination Logic
```typescript
// src/lib/reports/templates/table.ts
const A4_HEIGHT = 842; // points (297mm)
const MARGIN = 42.5; // 15mm
const FOOTER_HEIGHT = 50;
function calculateRowsPerPage(pageHeight: number, rowHeight: number, headerHeight: number): number {
const availableHeight = pageHeight - MARGIN * 2 - FOOTER_HEIGHT - headerHeight;
return Math.floor(availableHeight / rowHeight);
}
function paginateRows(rows: any[], rowsPerPage: number): any[][] {
const pages: any[][] = [];
for (let i = 0; i < rows.length; i += rowsPerPage) {
pages.push(rows.slice(i, i + rowsPerPage));
}
return pages;
}
```
### Logo Handling
```typescript
// src/lib/reports/utils.ts
export async function loadLogo(pdfDoc: PDFDocument): Promise<Uint8Array | undefined> {
try {
const response = await fetch('/images/logo.png');
if (!response.ok) return undefined;
const arrayBuffer = await response.arrayBuffer();
return new Uint8Array(arrayBuffer);
} catch {
return undefined; // Fallback to text-only header
}
}
```
---
## Affected Areas
### New Files
- `src/lib/reports/` — 15+ files (templates, generators, utils)
- `src/components/reports/` — 4 files (export button, filters, print styles)
- `src/app/(admin)/reports/` — 20+ files (report pages)
- `src/app/api/reports/` — 18+ API routes (one per report type)
### Modified Files
- `package.json` — Add `pdf-lib` dependency
- `src/app/(admin)/reports/page.tsx` — Replace placeholder with dashboard
- `src/app/globals.css` — Add @media print styles (optional)
### Dependencies
- **pdf-lib** (~150KB) — PDF generation library
- **No other dependencies** — use existing Drizzle, React, Tailwind
### Performance Impact
- **Bundle Size**: +150KB (lazy-loaded chunk, only loaded when user clicks export)
- **Memory**: Minimal (PDF generation is short-lived, garbage collected after download)
- **CPU**: Low (pdf-lib is fast, no DOM rendering)
---
## Risks
### 1. Manual Layout Complexity
**Risk**: Writing table drawing, pagination, and positioning code is time-consuming and error-prone.
**Mitigation**: Create reusable templates (header, footer, table) to reduce duplication. Start with simple reports, iterate to complex ones.
**Severity**: Medium
### 2. Large Report Performance
**Risk**: Reports with thousands of rows (e.g., movement history) may be slow to generate or cause memory issues.
**Mitigation**:
- Limit default date range (e.g., last 90 days)
- Paginate data (fetch in chunks)
- Show progress indicator
- Test with large datasets early
**Severity**: Medium
### 3. Font Limitations
**Risk**: pdf-lib's built-in fonts (Helvetica, Times, Courier) may not support special characters (ñ, á, é, í, ó, ú).
**Mitigation**:
- Use UTF-8 encoding (pdf-lib supports it)
- Test with Spanish text early
- If issues, embed custom font (adds ~50KB)
**Severity**: Low
### 4. Logo Embedding
**Risk**: Logo may not be available (user hasn't uploaded it) or may be wrong format (JPEG instead of PNG).
**Mitigation**:
- Fallback to text-only header if logo not found
- Support both PNG and JPEG (pdf-lib has `embedPng` and `embedJpg`)
- Provide default logo in `public/images/logo.png`
**Severity**: Low
### 5. Browser Compatibility
**Risk**: Dynamic import of pdf-lib may fail in older browsers.
**Mitigation**:
- pdf-lib supports all modern browsers (Chrome, Firefox, Safari, Edge)
- Provide fallback message for unsupported browsers
- Test on target browsers
**Severity**: Low
### 6. Data Aggregation Complexity
**Risk**: Some reports (profits, analytics) require complex aggregations (JOINs, GROUP BY, calculations).
**Mitigation**:
- Create dedicated service functions for complex queries
- Test queries with realistic data volumes
- Use SQL aggregations (faster than in-memory)
**Severity**: Medium
### 7. Print CSS Maintenance
**Risk**: Maintaining two code paths (pdf-lib + @media print) increases complexity.
**Mitigation**:
- Make @media print optional (not required for MVP)
- Focus on pdf-lib first, add print CSS later if needed
**Severity**: Low
---
## Ready for Proposal
**Status**: ✅ Ready
**Next Steps**:
1. Create proposal document with scope, approach, and acceptance criteria
2. Define report specifications (columns, filters, sorting for each of 18 reports)
3. Implement template system (header, footer, table)
4. Implement 2-3 sample reports (inventory general, sales list, sale detail)
5. Test with realistic data volumes
6. Iterate on remaining reports
**Recommendation**: Proceed to proposal phase with pdf-lib, reusable templates, and server-side data fetching. Start with inventory and sales reports as proof of concept.
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+11
View File
@@ -0,0 +1,11 @@
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
output: 'standalone',
serverExternalPackages: ['better-sqlite3', 'argon2', '@radix-ui/react-slot', '@radix-ui/react-select'],
images: {
remotePatterns: [],
},
};
export default nextConfig;
+12511
View File
File diff suppressed because it is too large Load Diff
+69
View File
@@ -0,0 +1,69 @@
{
"name": "byrachel",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev -p 3017",
"build": "next build",
"start": "next start -p 3017",
"lint": "eslint",
"format": "prettier --write .",
"format:check": "prettier --check .",
"seed": "npx tsx src/lib/db/seed.ts",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@radix-ui/react-dialog": "^1.1.21",
"@radix-ui/react-label": "^2.1.13",
"@radix-ui/react-select": "^2.3.5",
"@radix-ui/react-slot": "^1.3.1",
"@radix-ui/react-toast": "^1.2.21",
"@types/tar": "^6.1.13",
"adm-zip": "^0.6.0",
"archiver": "^8.0.0",
"argon2": "^0.45.1",
"better-sqlite3": "^13.0.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"drizzle-orm": "^0.45.2",
"jose": "^6.2.4",
"lucide-react": "^1.25.0",
"next": "16.2.11",
"otplib": "^13.4.1",
"pdf-lib": "^1.17.1",
"react": "19.2.4",
"react-dom": "19.2.4",
"recharts": "^3.10.0",
"sharp": "^0.35.3",
"tailwind-merge": "^3.6.0",
"tar": "^7.5.21",
"zod": "^4.4.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/adm-zip": "^0.5.8",
"@types/archiver": "^8.0.0",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitest/coverage-v8": "^4.1.10",
"drizzle-kit": "^0.31.10",
"eslint": "^9",
"eslint-config-next": "16.2.11",
"prettier": "^3.9.6",
"tailwindcss": "^4",
"tsx": "^4.23.1",
"typescript": "^5",
"vitest": "^4.1.10"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+1
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

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

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

+7
View File
@@ -0,0 +1,7 @@
export default function AuthLayout({
children,
}: {
children: React.ReactNode;
}) {
return <>{children}</>;
}
+228
View File
@@ -0,0 +1,228 @@
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
type Step = 'credentials' | 'totp';
export function LoginForm() {
const [step, setStep] = useState<Step>('credentials');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
// Credentials form
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
// TOTP form
const [totpCode, setTotpCode] = useState('');
const [rememberDevice, setRememberDevice] = useState(false);
async function handleLogin(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError('');
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Error al iniciar sesión');
if (data.requiresTotp) {
setStep('totp');
} else {
window.location.href = '/admin21/dashboard';
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al iniciar sesión');
} finally {
setLoading(false);
}
}
async function handleVerifyTotp(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError('');
try {
const res = await fetch('/api/auth/verify-totp', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: totpCode, rememberDevice }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Error de verificación');
window.location.href = '/admin21/dashboard';
} catch (err) {
setError(err instanceof Error ? err.message : 'Error de verificación');
} finally {
setLoading(false);
}
}
return (
<div className="min-h-screen flex items-center justify-center p-4 relative">
{/* Background effects */}
<div className="absolute inset-0 overflow-hidden">
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-neon-purple/10 rounded-full blur-[100px] animate-pulse-glow" />
<div className="absolute bottom-1/4 right-1/4 w-96 h-96 bg-neon-blue/10 rounded-full blur-[100px] animate-pulse-glow" style={{ animationDelay: '1.5s' }} />
</div>
{/* Login card */}
<div className="relative w-full max-w-md">
{/* Glow effect behind card */}
<div className="absolute -inset-1 gradient-primary rounded-2xl opacity-20 blur-xl" />
<div className="relative glass-strong rounded-2xl p-8 space-y-6">
{/* Header */}
<div className="text-center space-y-2">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl gradient-primary mb-4 glow-purple">
<svg className="w-8 h-8 text-white" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" />
</svg>
</div>
<h1 className="text-2xl font-bold bg-gradient-to-r from-neon-purple to-neon-cyan bg-clip-text text-transparent">
byrachel
</h1>
<p className="text-muted-foreground text-sm">
{step === 'credentials' ? 'Accedé a tu panel de administración' : 'Ingresá el código de tu autenticador'}
</p>
</div>
{/* Error */}
{error && (
<div className="rounded-lg bg-destructive/10 border border-destructive/20 p-3 text-sm text-destructive">
{error}
</div>
)}
{/* Credentials form */}
{step === 'credentials' && (
<form onSubmit={handleLogin} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email" className="text-sm font-medium">
Email
</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="admin@byrachel.com"
required
autoFocus
className="h-11 bg-secondary/50 border-glass-border focus:border-neon-purple focus:ring-neon-purple/20"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password" className="text-sm font-medium">
Contraseña
</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Tu contraseña"
required
className="h-11 bg-secondary/50 border-glass-border focus:border-neon-purple focus:ring-neon-purple/20"
/>
</div>
<Button
type="submit"
variant="gradient"
className="w-full h-11 text-base font-semibold"
disabled={loading}
>
{loading ? (
<div className="flex items-center gap-2">
<div className="h-4 w-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
Ingresando...
</div>
) : (
'Ingresar'
)}
</Button>
</form>
)}
{/* TOTP form */}
{step === 'totp' && (
<form onSubmit={handleVerifyTotp} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="totp" className="text-sm font-medium">
Código de verificación
</Label>
<Input
id="totp"
value={totpCode}
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
placeholder="000000"
maxLength={6}
className="h-14 text-center text-3xl font-mono tracking-[0.5em] bg-secondary/50 border-glass-border focus:border-neon-cyan focus:ring-neon-cyan/20"
required
autoFocus
/>
<p className="text-xs text-muted-foreground text-center mt-2">
Abrí Google Authenticator y copiá el código de 6 dígitos
</p>
</div>
<div className="flex items-center space-x-3 p-3 rounded-lg bg-secondary/30 border border-glass-border">
<input
type="checkbox"
id="remember"
checked={rememberDevice}
onChange={(e) => setRememberDevice(e.target.checked)}
className="h-4 w-4 rounded border-glass-border text-neon-purple focus:ring-neon-purple/20"
/>
<Label htmlFor="remember" className="text-sm font-normal cursor-pointer">
Recordar este dispositivo por 30 días
</Label>
</div>
<Button
type="submit"
variant="gradient"
className="w-full h-11 text-base font-semibold"
disabled={loading || totpCode.length !== 6}
>
{loading ? (
<div className="flex items-center gap-2">
<div className="h-4 w-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
Verificando...
</div>
) : (
'Verificar'
)}
</Button>
<Button
type="button"
variant="ghost"
className="w-full text-muted-foreground hover:text-foreground"
onClick={() => {
setStep('credentials');
setTotpCode('');
setError('');
}}
>
Volver al login
</Button>
</form>
)}
</div>
</div>
</div>
);
}
+17
View File
@@ -0,0 +1,17 @@
import { redirect } from 'next/navigation';
import { db } from '@/lib/db';
import { users } from '@/lib/db/schema';
import { count } from 'drizzle-orm';
import { LoginForm } from './login-form';
export const dynamic = 'force-dynamic';
export default async function LoginPage() {
// If no users exist, redirect to setup
const userCount = await db.select({ value: count() }).from(users);
if (userCount[0].value === 0) {
redirect('/setup');
}
return <LoginForm />;
}
+17
View File
@@ -0,0 +1,17 @@
import { redirect } from 'next/navigation';
import { db } from '@/lib/db';
import { users } from '@/lib/db/schema';
import { count } from 'drizzle-orm';
import { SetupForm } from './setup-form';
export const dynamic = 'force-dynamic';
export default async function SetupPage() {
// If users already exist, redirect to login
const userCount = await db.select({ value: count() }).from(users);
if (userCount[0].value > 0) {
redirect('/login');
}
return <SetupForm />;
}
+216
View File
@@ -0,0 +1,216 @@
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
type Step = 'account' | 'qrcode' | 'verify' | 'recovery';
interface SetupData {
userId: number;
qrDataUrl: string;
recoveryCodes: string[];
}
export function SetupForm() {
const [step, setStep] = useState<Step>('account');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
// Account form
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [name, setName] = useState('');
// Setup result
const [setupData, setSetupData] = useState<SetupData | null>(null);
// TOTP verification
const [totpCode, setTotpCode] = useState('');
async function handleCreateAccount(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError('');
try {
const res = await fetch('/api/auth/setup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password, name }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Error en la configuración');
setSetupData(data);
setStep('qrcode');
} catch (err) {
setError(err instanceof Error ? err.message : 'Error en la configuración');
} finally {
setLoading(false);
}
}
async function handleVerifyTotp(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError('');
try {
const res = await fetch('/api/auth/verify-totp', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: totpCode }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Error de verificación');
setStep('recovery');
} catch (err) {
setError(err instanceof Error ? err.message : 'Error de verificación');
} finally {
setLoading(false);
}
}
return (
<Card>
<CardHeader>
<CardTitle>Configuración Inicial</CardTitle>
<CardDescription>
{step === 'account' && 'Creá tu cuenta de administrador'}
{step === 'qrcode' && 'Escaneá el código QR con tu app de autenticación'}
{step === 'verify' && 'Ingresá el código de 6 dígitos de tu autenticador'}
{step === 'recovery' && 'Guardá tus códigos de recuperación'}
</CardDescription>
</CardHeader>
<CardContent>
{error && (
<div className="mb-4 rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
{step === 'account' && (
<form onSubmit={handleCreateAccount} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Nombre</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Tu nombre"
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">Correo electrónico</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="admin@example.com"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Contraseña</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Mínimo 8 caracteres"
minLength={8}
required
/>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? 'Creando cuenta...' : 'Crear Cuenta'}
</Button>
</form>
)}
{step === 'qrcode' && setupData && (
<div className="space-y-4">
<div className="flex justify-center">
<img
src={`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(setupData.qrDataUrl)}`}
alt="TOTP QR Code"
width={200}
height={200}
className="rounded-lg border"
/>
</div>
<p className="text-center text-sm text-muted-foreground">
Escaneá este código QR con Google Authenticator, Authy u otra app de autenticación TOTP.
</p>
<div className="rounded-md bg-muted p-3">
<p className="text-xs font-mono break-all text-muted-foreground">
{setupData.qrDataUrl}
</p>
</div>
<Button onClick={() => setStep('verify')} className="w-full">
Ya escaneé el código Continuar
</Button>
</div>
)}
{step === 'verify' && (
<form onSubmit={handleVerifyTotp} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="totp">Código de Verificación</Label>
<Input
id="totp"
value={totpCode}
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
placeholder="000000"
maxLength={6}
className="text-center text-2xl tracking-widest"
required
autoFocus
/>
</div>
<Button type="submit" className="w-full" disabled={loading || totpCode.length !== 6}>
{loading ? 'Verificando...' : 'Verificar y Completar Configuración'}
</Button>
</form>
)}
{step === 'recovery' && setupData && (
<div className="space-y-4">
<div className="rounded-md bg-destructive/10 p-4">
<p className="text-sm font-semibold text-destructive">
¡Guardá estos códigos de recuperación!
</p>
<p className="text-xs text-muted-foreground mt-1">
Cada código solo se puede usar una vez. Guardalos en un lugar seguro. Si perdés
el acceso a tu app de autenticación, podés usar estos códigos para iniciar sesión.
</p>
</div>
<div className="grid grid-cols-2 gap-2">
{setupData.recoveryCodes.map((code, i) => (
<div
key={i}
className="rounded-md bg-muted p-2 text-center font-mono text-sm"
>
{code}
</div>
))}
</div>
<Button
onClick={() => (window.location.href = '/login')}
className="w-full"
>
Ya guardé mis códigos Ir al Login
</Button>
</div>
)}
</CardContent>
</Card>
);
}
+139
View File
@@ -0,0 +1,139 @@
import { notFound } from 'next/navigation';
import type { Metadata } from 'next';
import { getCatalogProduct } from '@/lib/data/catalog';
import { getBrandSettings } from '@/lib/data/brand-settings';
import { ImageGallery } from '@/components/catalog/ImageGallery';
import { VariantSelector } from '@/components/catalog/VariantSelector';
import { ShareButtons } from '@/components/catalog/ShareButtons';
import { AvailabilityBadge } from '@/components/catalog/AvailabilityBadge';
import { CatalogHeader } from '@/components/catalog/CatalogHeader';
import { formatCurrency } from '@/lib/utils/format';
import Link from 'next/link';
interface ProductPageProps {
params: Promise<{ slug: string }>;
}
export async function generateMetadata({ params }: ProductPageProps): Promise<Metadata> {
const { slug } = await params;
const product = await getCatalogProduct(slug);
if (!product) return { title: 'Producto no encontrado' };
const brand = await getBrandSettings();
return {
title: `${product.name} | ${brand.store_name}`,
description: product.shortDescription || `${product.name} - ${brand.store_name}`,
openGraph: {
title: product.name,
description: product.shortDescription || '',
images: product.images.length > 0 ? [product.images[0].url] : [],
},
};
}
export default async function ProductPage({ params }: ProductPageProps) {
const { slug } = await params;
const [product, brand] = await Promise.all([
getCatalogProduct(slug),
getBrandSettings(),
]);
if (!product) notFound();
const productUrl = `${process.env.NEXT_PUBLIC_BASE_URL || ''}/${slug}`;
const shareText = `${product.name} - ${formatCurrency(product.basePrice)}`;
return (
<div className="catalog-theme min-h-screen flex flex-col">
<CatalogHeader brand={brand} />
<main className="flex-1">
<div className="mx-auto max-w-6xl px-6 py-12">
{/* Breadcrumb */}
<nav className="mb-8">
<ol className="flex items-center gap-2 text-sm" style={{ color: 'var(--catalog-text-muted)' }}>
<li><Link href="/" className="hover:text-black transition-colors">Inicio</Link></li>
<li>/</li>
<li className="text-black">{product.name}</li>
</ol>
</nav>
{/* Product grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-12">
{/* Images */}
<div>
<ImageGallery images={product.images} productName={product.name} />
</div>
{/* Product info */}
<div className="space-y-8">
<div>
<h1 className="text-3xl font-light" style={{ letterSpacing: '-0.02em' }}>
{product.name}
</h1>
{product.brand && (
<p className="mt-2 text-sm" style={{ color: 'var(--catalog-text-muted)', textTransform: 'uppercase', letterSpacing: '0.1em' }}>
{product.brand}
</p>
)}
</div>
{/* Variant selector */}
{product.variants.length > 0 && (
<VariantSelector variants={product.variants} basePrice={product.basePrice} />
)}
{/* Description */}
{product.fullDescription && (
<div className="space-y-4">
<h2 className="text-sm font-medium" style={{ letterSpacing: '0.1em', textTransform: 'uppercase', color: 'var(--catalog-text-muted)' }}>
Descripción
</h2>
<p className="text-sm leading-relaxed" style={{ color: 'var(--catalog-text-light)' }}>
{product.fullDescription}
</p>
</div>
)}
{/* Category */}
{product.category && (
<div className="space-y-2">
<span className="text-xs" style={{ color: 'var(--catalog-text-muted)', letterSpacing: '0.1em', textTransform: 'uppercase' }}>
Categoría
</span>
<div className="flex flex-wrap gap-2">
<Link
href={`/?category=${product.category.slug}`}
className="text-xs px-3 py-1 border transition-colors hover:bg-black hover:text-white"
style={{ borderColor: 'var(--catalog-border)', letterSpacing: '0.05em', textTransform: 'uppercase' }}
>
{product.category.name}
</Link>
</div>
</div>
)}
{/* Share */}
<div className="pt-4" style={{ borderTop: '1px solid var(--catalog-border)' }}>
<ShareButtons
productName={product.name}
productUrl={productUrl}
shareText={shareText}
/>
</div>
</div>
</div>
</div>
</main>
{/* Footer */}
<footer className="catalog-footer">
<div className="mx-auto max-w-6xl px-6 text-center">
<p className="text-xs" style={{ color: 'var(--catalog-text-muted)', letterSpacing: '0.05em' }}>
{brand.footer_text || `${brand.store_name} © ${new Date().getFullYear()}`}
</p>
</div>
</footer>
</div>
);
}
+15
View File
@@ -0,0 +1,15 @@
'use client';
import dynamic from 'next/dynamic';
export const AdminShell = dynamic(
() => import('./admin-shell').then((mod) => mod.AdminShell),
{
ssr: false,
loading: () => (
<div className="flex h-screen items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
),
},
);
+40
View File
@@ -0,0 +1,40 @@
'use client';
import { useState } from 'react';
import { Sidebar } from '@/components/admin/sidebar';
import { Header } from '@/components/admin/header';
import { MobileNav, MobileNavToggle } from '@/components/admin/mobile-nav';
export function AdminShell({ children }: { children: React.ReactNode }) {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [mobileNavOpen, setMobileNavOpen] = useState(false);
return (
<div className="flex h-screen overflow-hidden">
{/* Desktop sidebar */}
<div className="hidden md:flex">
<Sidebar
collapsed={sidebarCollapsed}
onToggle={() => setSidebarCollapsed(!sidebarCollapsed)}
/>
</div>
{/* Mobile drawer */}
<MobileNav isOpen={mobileNavOpen} onClose={() => setMobileNavOpen(false)} />
{/* Main content */}
<div className="flex flex-1 flex-col overflow-hidden">
<div className="flex h-16 items-center px-4 md:hidden" style={{ borderBottom: '1px solid var(--glass-border)', background: 'var(--glass-bg)', backdropFilter: 'blur(20px) saturate(1.5)' }}>
<MobileNavToggle onOpen={() => setMobileNavOpen(true)} />
<span className="ml-3 text-lg font-bold bg-gradient-to-r from-neon-purple to-neon-cyan bg-clip-text text-transparent">byrachel</span>
</div>
<div className="hidden md:block">
<Header />
</div>
<main className="flex-1 overflow-y-auto p-6">{children}</main>
</div>
</div>
);
}
+223
View File
@@ -0,0 +1,223 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { DateRangePicker } from '@/components/analytics/date-range-picker';
import { PeriodComparison } from '@/components/analytics/period-comparison';
import { KPICards } from '@/components/analytics/kpi-cards';
import { SalesTrendChart } from '@/components/analytics/sales-trend-chart';
import { ProductRankingTable } from '@/components/analytics/product-ranking-table';
import { CategoryBreakdown } from '@/components/analytics/category-breakdown';
import { CustomerAnalytics } from '@/components/analytics/customer-analytics';
import { SeasonalityHeatmap } from '@/components/analytics/seasonality-heatmap';
import { CostEvolutionChart } from '@/components/analytics/cost-evolution-chart';
import { InventoryHealthPanel } from '@/components/analytics/inventory-health-panel';
import { cn } from '@/lib/utils';
import type { DatePreset, TimeSeriesPoint } from '@/lib/analytics/shared';
type Tab = 'overview' | 'products' | 'customers' | 'seasonality' | 'costs' | 'inventory';
const TABS: { value: Tab; label: string }[] = [
{ value: 'overview', label: 'Resumen' },
{ value: 'products', label: 'Productos' },
{ value: 'customers', label: 'Clientes' },
{ value: 'seasonality', label: 'Estacionalidad' },
{ value: 'costs', label: 'Costos' },
{ value: 'inventory', label: 'Inventario' },
];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function useAnalyticsData() {
const [loading, setLoading] = useState(true);
const [data, setData] = useState<Record<string, any>>({});
const buildQuery = (preset: DatePreset, from: string, to: string, comparison: boolean) => {
const params = new URLSearchParams();
if (preset === 'custom') {
if (from) params.set('from', from);
if (to) params.set('to', to);
} else {
params.set('preset', preset);
}
if (comparison) params.set('comparison', 'true');
return params.toString();
};
const loadAll = useCallback(
async (preset: DatePreset, from: string, to: string, comparison: boolean) => {
setLoading(true);
const q = buildQuery(preset, from, to, comparison);
const jsonOr = (r: Response) => (r.ok ? r.json() : null);
try {
const [summary, trend, products, noSales, categories, customerAnalytics, topCustomers, dayOfWeek, monthOfYear, costEvo, priceEvo, turnover, coverage, capital, stockout] =
await Promise.all([
fetch(`/api/analytics/summary?${q}`).then(jsonOr),
fetch(`/api/analytics/sales-trend?${q}`).then(jsonOr),
fetch(`/api/analytics/products?${q}`).then(jsonOr),
fetch(`/api/analytics/products?${q}&view=no-sales`).then(jsonOr),
fetch(`/api/analytics/categories?${q}`).then(jsonOr),
fetch(`/api/analytics/customers?${q}`).then(jsonOr),
fetch(`/api/analytics/customers?${q}&view=top`).then(jsonOr),
fetch(`/api/analytics/seasonality?${q}&view=day-of-week`).then(jsonOr),
fetch(`/api/analytics/seasonality?${q}&view=month-of-year`).then(jsonOr),
fetch(`/api/analytics/cost-evolution?${q}&view=cost`).then(jsonOr),
fetch(`/api/analytics/cost-evolution?${q}&view=sale-price`).then(jsonOr),
fetch(`/api/analytics/inventory-health?${q}&view=turnover`).then(jsonOr),
fetch(`/api/analytics/inventory-health?${q}&view=coverage`).then(jsonOr),
fetch(`/api/analytics/inventory-health?${q}&view=capital`).then(jsonOr),
fetch(`/api/analytics/inventory-health?${q}&view=stockout-prone`).then(jsonOr),
]);
setData({
summary,
trend,
products,
noSales,
categories,
customerAnalytics,
topCustomers,
dayOfWeek,
monthOfYear,
costEvo,
priceEvo,
turnover,
coverage,
capital,
stockout,
});
} catch (err) {
console.error('Analytics load error:', err);
} finally {
setLoading(false);
}
},
[],
);
return { loading, data, loadAll };
}
export default function AnalyticsPage() {
const [tab, setTab] = useState<Tab>('overview');
const [from, setFrom] = useState('');
const [to, setTo] = useState('');
const [preset, setPreset] = useState<DatePreset>('this_month');
const [comparison, setComparison] = useState(false);
const { loading, data, loadAll } = useAnalyticsData();
useEffect(() => {
loadAll(preset, from, to, comparison);
}, [loadAll, preset, from, to, comparison]);
function handleDateChange(newFrom: string, newTo: string, newPreset: DatePreset) {
setFrom(newFrom);
setTo(newTo);
setPreset(newPreset);
}
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold tracking-tight">Análisis</h1>
<p className="text-sm text-muted-foreground">
Análisis de ventas, productos, clientes e inventario
</p>
</div>
{/* Filters */}
<div className="flex flex-wrap items-center gap-3">
<DateRangePicker from={from} to={to} preset={preset} onChange={handleDateChange} />
<PeriodComparison enabled={comparison} onChange={setComparison} />
</div>
{loading && !data.summary ? (
<div className="flex items-center justify-center py-12">
<p className="text-sm text-muted-foreground">Cargando analytics...</p>
</div>
) : (
<>
{/* KPI Cards */}
{data.summary?.current && (
<KPICards
data={data.summary.current}
deltas={data.summary.deltas}
comparisonEnabled={comparison}
/>
)}
{/* Tab Navigation */}
<div className="flex gap-1" style={{ borderBottom: '1px solid var(--border)' }}>
{TABS.map((t) => (
<button
key={t.value}
className={cn(
'px-4 py-2 text-sm font-medium transition-colors',
tab === t.value
? 'border-b-2 border-primary text-primary'
: 'text-muted-foreground hover:text-foreground',
)}
onClick={() => setTab(t.value)}
>
{t.label}
</button>
))}
</div>
{/* Tab Content */}
{tab === 'overview' && (
<div className="space-y-4">
<SalesTrendChart data={(data.trend as TimeSeriesPoint[]) || []} />
<CategoryBreakdown data={data.categories || []} />
</div>
)}
{tab === 'products' && (
<ProductRankingTable
performance={data.products || []}
noSales={data.noSales || []}
/>
)}
{tab === 'customers' && (
<CustomerAnalytics
analytics={
data.customerAnalytics || {
newCount: 0,
recurringCount: 0,
newRevenue: 0,
recurringRevenue: 0,
}
}
topCustomers={data.topCustomers || []}
/>
)}
{tab === 'seasonality' && (
<SeasonalityHeatmap
dayOfWeek={data.dayOfWeek || []}
monthOfYear={data.monthOfYear || []}
/>
)}
{tab === 'costs' && (
<CostEvolutionChart costData={data.costEvo || []} priceData={data.priceEvo || []} />
)}
{tab === 'inventory' && (
<InventoryHealthPanel
turnover={
data.turnover || {
overall: { turnover: 0, cogs: 0, avgInventoryValue: 0 },
byCategory: [],
}
}
coverage={data.coverage || []}
capital={data.capital || { total: 0, byCategory: [] }}
stockoutProne={data.stockout || []}
/>
)}
</>
)}
</div>
);
}
@@ -0,0 +1,52 @@
'use client';
import { useTransition } from 'react';
import { useRouter } from 'next/navigation';
import { AdminPagination } from '@/components/ui/admin-pagination';
interface CategoryPaginationProps {
page: number;
totalPages: number;
totalItems: number;
limit: number;
}
export function CategoryPagination({
page,
totalPages,
totalItems,
limit,
}: CategoryPaginationProps) {
const router = useRouter();
const [, startTransition] = useTransition();
function buildUrl(newPage?: number, newLimit?: number) {
const params = new URLSearchParams();
params.set('page', String(newPage ?? page));
params.set('limit', String(newLimit ?? limit));
return `/admin21/categories?${params.toString()}`;
}
function handlePageChange(newPage: number) {
startTransition(() => {
router.push(buildUrl(newPage));
});
}
function handleLimitChange(newLimit: number) {
startTransition(() => {
router.push(buildUrl(1, newLimit));
});
}
return (
<AdminPagination
page={page}
totalPages={totalPages}
totalItems={totalItems}
limit={limit}
onPageChange={handlePageChange}
onLimitChange={handleLimitChange}
/>
);
}
@@ -0,0 +1,117 @@
'use client';
import { useTransition } from 'react';
import { useRouter } from 'next/navigation';
import {
Table,
TableBody,
TableCell,
TableHead,
SortableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { CategoryPagination } from './category-pagination';
import type { categories } from '@/lib/db/schema';
type Category = typeof categories.$inferSelect;
interface CategoryTableClientProps {
categories: Category[];
page: number;
totalPages: number;
totalItems: number;
limit: number;
sort: string;
order: 'asc' | 'desc';
}
export function CategoryTableClient({
categories,
page,
totalPages,
totalItems,
limit,
sort,
order,
}: CategoryTableClientProps) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
function handleSort(key: string) {
startTransition(() => {
const newOrder = sort === key && order === 'asc' ? 'desc' : 'asc';
const params = new URLSearchParams();
params.set('sort', key);
params.set('order', newOrder);
params.set('page', '1');
params.set('limit', String(limit));
router.push(`/admin21/categories?${params.toString()}`);
});
}
return (
<div className="flex flex-col h-full">
<div className="flex-1 overflow-y-auto">
<Table noScroll>
<TableHeader>
<TableRow>
<TableHead>Color</TableHead>
<SortableHead sortKey="name" currentSort={sort} currentOrder={order} onSort={handleSort}>Nombre</SortableHead>
<SortableHead sortKey="description" currentSort={sort} currentOrder={order} onSort={handleSort}>Descripción</SortableHead>
<SortableHead sortKey="status" currentSort={sort} currentOrder={order} onSort={handleSort}>Estado</SortableHead>
</TableRow>
</TableHeader>
<TableBody>
{categories.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-center text-muted-foreground py-8">
Todavía no hay categorías
</TableCell>
</TableRow>
) : (
categories.map((cat) => (
<TableRow key={cat.id}>
<TableCell>
{cat.color ? (
<span
className="h-6 w-6 rounded-full border"
style={{ backgroundColor: cat.color }}
/>
) : (
<span className="text-muted-foreground text-xs"></span>
)}
</TableCell>
<TableCell className="font-medium">{cat.name}</TableCell>
<TableCell className="text-muted-foreground max-w-[200px] truncate">
{cat.description || '—'}
</TableCell>
<TableCell>
{cat.isActive ? (
<span className="inline-flex items-center rounded-full bg-green-100 px-2 py-1 text-xs text-green-800">
Activo
</span>
) : (
<span className="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-xs text-gray-800">
Inactivo
</span>
)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<div className="flex-shrink-0 pt-4">
<CategoryPagination
page={page}
totalPages={totalPages}
totalItems={totalItems}
limit={limit}
/>
</div>
</div>
);
}
+157
View File
@@ -0,0 +1,157 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
const PRESET_COLORS = [
'#EF4444', '#F97316', '#F59E0B', '#84CC16', '#22C55E',
'#14B8A6', '#06B6D4', '#3B82F6', '#6366F1', '#8B5CF6',
'#A855F7', '#D946EF', '#EC4899', '#F43F5E', '#78716C',
'#000000',
];
export default function NewCategoryPage() {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [color, setColor] = useState('');
const [defaultMarginPercentage, setDefaultMarginPercentage] = useState('');
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
const payload = {
name,
description: description || undefined,
color: color || undefined,
defaultMarginPercentage: defaultMarginPercentage
? parseFloat(defaultMarginPercentage)
: undefined,
};
try {
const res = await fetch('/api/categories', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || 'Error al crear la categoría');
}
router.push('/categories');
router.refresh();
} catch (err) {
setError(err instanceof Error ? err.message : 'Ocurrió un error');
} finally {
setLoading(false);
}
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">Nueva Categoría</h1>
<p className="text-muted-foreground">Creá una nueva categoría de productos</p>
</div>
<form onSubmit={handleSubmit} className="max-w-lg">
<Card>
<CardHeader>
<CardTitle>Detalles de la Categoría</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{error && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
<div className="space-y-2">
<Label htmlFor="name">Nombre *</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
required
placeholder="Nombre de la categoría"
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Descripción</Label>
<textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
className="flex min-h-[80px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
placeholder="Descripción de la categoría"
/>
</div>
<div className="space-y-2">
<Label>Color</Label>
<div className="flex flex-wrap gap-2">
{PRESET_COLORS.map((c) => (
<button
key={c}
type="button"
onClick={() => setColor(color === c ? '' : c)}
className={`h-8 w-8 rounded-full border-2 transition-all ${
color === c ? 'border-primary scale-110' : 'border-transparent'
}`}
style={{ backgroundColor: c }}
title={c}
/>
))}
<div className="flex items-center gap-2 ml-2">
<input
type="color"
value={color || '#000000'}
onChange={(e) => setColor(e.target.value)}
className="h-8 w-8 cursor-pointer rounded border"
/>
<span className="text-xs text-muted-foreground">Personalizado</span>
</div>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="defaultMarginPercentage">Margen por Defecto %</Label>
<Input
id="defaultMarginPercentage"
type="number"
step="0.1"
min="0"
max="100"
value={defaultMarginPercentage}
onChange={(e) => setDefaultMarginPercentage(e.target.value)}
placeholder="50"
/>
</div>
</CardContent>
</Card>
<div className="flex gap-3 mt-4">
<Button type="submit" variant="gradient" disabled={loading}>
{loading ? 'Creando...' : 'Crear Categoría'}
</Button>
<Button type="button" variant="outline" onClick={() => router.back()}>
Cancelar
</Button>
</div>
</form>
</div>
);
}
+82
View File
@@ -0,0 +1,82 @@
export const dynamic = 'force-dynamic';
import Link from 'next/link';
import { db } from '@/lib/db';
import { categories } from '@/lib/db/schema';
import { eq, desc, asc, count, type AnyColumn } from 'drizzle-orm';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Plus } from 'lucide-react';
import { CategoryTableClient } from './category-table-client';
const SORT_MAP: Record<string, AnyColumn> = {
name: categories.name,
description: categories.description,
status: categories.isActive,
};
async function getCategories(page = 1, limit = 20, sort?: string, order?: string) {
const [countResult] = await db
.select({ total: count() })
.from(categories);
const total = countResult?.total ?? 0;
const sortCol = sort ? SORT_MAP[sort] : undefined;
const orderDir = order === 'asc' ? asc : desc;
const items = await db
.select()
.from(categories)
.orderBy(sortCol ? orderDir(sortCol) : categories.name)
.limit(limit)
.offset((page - 1) * limit);
return { items, total };
}
export default async function CategoriesPage({
searchParams,
}: {
searchParams: Promise<{ page?: string; limit?: string; sort?: string; order?: string }>;
}) {
const params = await searchParams;
const page = Math.max(1, parseInt(params.page ?? '1') || 1);
const limit = Math.min(100, Math.max(5, parseInt(params.limit ?? '20') || 20));
const { items: categoryList, total } = await getCategories(page, limit, params.sort, params.order);
const totalPages = Math.ceil(total / limit);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Categorías</h1>
<p className="text-muted-foreground">Gestioná las categorías de productos</p>
</div>
<Button asChild>
<Link href="/admin21/categories/new">
<Plus className="mr-2 h-4 w-4" />
Nueva Categoría
</Link>
</Button>
</div>
<Card scrollable>
<CardHeader scrollable>
<CardTitle>Lista de Categorías</CardTitle>
</CardHeader>
<CardContent scrollable>
<CategoryTableClient
categories={categoryList}
page={page}
totalPages={totalPages}
totalItems={total}
limit={limit}
sort={params.sort || ''}
order={(params.order === 'asc' || params.order === 'desc' ? params.order : 'asc') as 'asc' | 'desc'}
/>
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,467 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { ArrowLeft, Trash2, Pencil, X } from 'lucide-react';
import { formatCurrency, formatDate } from '@/lib/utils/format';
interface Customer {
id: number;
firstName: string;
lastName: string;
displayName: string;
email: string | null;
phone: string | null;
whatsapp: string | null;
address: string | null;
city: string | null;
notes: string | null;
customerType: string;
isActive: boolean;
tags: Array<{ id: number; name: string; color: string | null }>;
}
interface Stats {
orderCount: number;
totalSpent: number;
avgTicket: number;
lastPurchaseDate: string | null;
topProducts: Array<{
variantId: number;
totalQuantity: number;
productName: string;
sku: string;
size: string;
color: string | null;
}>;
}
interface PurchaseHistory {
id: number;
saleNumber: string;
saleDate: Date;
totalAmount: number;
status: string;
paymentMethod: string;
}
type Tab = 'info' | 'history' | 'stats';
interface CustomerDetailClientProps {
customer: Customer;
stats: Stats;
purchaseHistory: PurchaseHistory[];
}
function SaleStatusBadge({ status }: { status: string }) {
const colors: Record<string, string> = {
DRAFT: 'bg-gray-100 text-gray-800',
CONFIRMED: 'bg-blue-100 text-blue-800',
DELIVERED: 'bg-green-100 text-green-800',
CANCELLED: 'bg-red-100 text-red-800',
PARTIALLY_RETURNED: 'bg-yellow-100 text-yellow-800',
RETURNED: 'bg-orange-100 text-orange-800',
};
return (
<span className={`inline-flex items-center rounded-full px-2 py-1 text-xs ${colors[status] || 'bg-gray-100 text-gray-800'}`}>
{status}
</span>
);
}
export function CustomerDetailClient({
customer,
stats,
purchaseHistory,
}: CustomerDetailClientProps) {
const router = useRouter();
const [activeTab, setActiveTab] = useState<Tab>('info');
const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
function handleDeactivate() {
if (!confirm('¿Estás seguro de que querés desactivar este cliente?')) return;
fetch(`/api/customers/${customer.id}`, { method: 'DELETE' }).then(() => router.refresh());
}
async function handleSave(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setSaving(true);
setError(null);
const formData = new FormData(e.currentTarget);
const body = {
firstName: formData.get('firstName') as string,
lastName: formData.get('lastName') as string,
email: (formData.get('email') as string) || undefined,
phone: (formData.get('phone') as string) || undefined,
whatsapp: (formData.get('whatsapp') as string) || undefined,
address: (formData.get('address') as string) || undefined,
city: (formData.get('city') as string) || undefined,
notes: (formData.get('notes') as string) || undefined,
customerType: (formData.get('customerType') as string) || 'RETAIL',
};
try {
const res = await fetch(`/api/customers/${customer.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const data = await res.json();
setError(data.error || 'Error al guardar');
return;
}
setEditing(false);
router.refresh();
} catch {
setError('Error al guardar');
} finally {
setSaving(false);
}
}
const tabs: { key: Tab; label: string }[] = [
{ key: 'info', label: 'Info' },
{ key: 'history', label: `Historial (${purchaseHistory.length})` },
{ key: 'stats', label: 'Estadísticas' },
];
const typeLabels: Record<string, string> = {
RETAIL: 'Minorista',
WHOLESALE: 'Mayorista',
VIP: 'VIP',
};
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" asChild>
<Link href="/admin21/customers">
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
<div className="flex-1">
<h1 className="text-2xl font-bold tracking-tight">{customer.displayName}</h1>
<p className="text-muted-foreground">
{typeLabels[customer.customerType] || customer.customerType}
{customer.tags.length > 0 && ` · ${customer.tags.map((t) => t.name).join(', ')}`}
</p>
</div>
{customer.isActive && !editing && (
<>
<Button variant="outline" onClick={() => setEditing(true)}>
<Pencil className="mr-2 h-4 w-4" />
Editar
</Button>
<Button variant="outline" onClick={handleDeactivate}>
<Trash2 className="mr-2 h-4 w-4" />
Desactivar
</Button>
</>
)}
{editing && (
<Button variant="ghost" onClick={() => { setEditing(false); setError(null); }}>
<X className="mr-2 h-4 w-4" />
Cancelar
</Button>
)}
{!customer.isActive && (
<span className="inline-flex items-center rounded-full bg-red-500/10 px-3 py-1 text-sm text-red-400 border border-red-500/30">
Inactivo
</span>
)}
</div>
{/* Tabs */}
<div className="flex gap-1 border-b">
{tabs.map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`px-4 py-2 text-sm font-medium transition-colors ${
activeTab === tab.key
? 'border-b-2 border-primary text-primary'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{tab.label}
</button>
))}
</div>
{/* Tab content */}
{activeTab === 'info' && !editing && (
<Card>
<CardHeader>
<CardTitle>Información del Cliente</CardTitle>
</CardHeader>
<CardContent>
<dl className="grid grid-cols-2 gap-4">
<div>
<dt className="text-sm font-medium text-muted-foreground">Nombre</dt>
<dd>{customer.firstName} {customer.lastName}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">Tipo</dt>
<dd>{typeLabels[customer.customerType] || customer.customerType}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">Correo electrónico</dt>
<dd>{customer.email || '—'}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">Teléfono</dt>
<dd>{customer.phone || '—'}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">WhatsApp</dt>
<dd>{customer.whatsapp || '—'}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">Ciudad</dt>
<dd>{customer.city || '—'}</dd>
</div>
<div className="col-span-2">
<dt className="text-sm font-medium text-muted-foreground">Dirección</dt>
<dd>{customer.address || '—'}</dd>
</div>
<div className="col-span-2">
<dt className="text-sm font-medium text-muted-foreground">Notas</dt>
<dd>{customer.notes || '—'}</dd>
</div>
</dl>
</CardContent>
</Card>
)}
{activeTab === 'info' && editing && (
<Card className="max-w-2xl">
<CardHeader>
<CardTitle>Editar Cliente</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSave} className="space-y-4">
{error && (
<div className="rounded-lg border border-red-500/30 bg-red-500/10 p-3 text-sm text-red-400">{error}</div>
)}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="firstName">Nombre *</Label>
<Input id="firstName" name="firstName" defaultValue={customer.firstName} required />
</div>
<div className="space-y-2">
<Label htmlFor="lastName">Apellido *</Label>
<Input id="lastName" name="lastName" defaultValue={customer.lastName} required />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="email">Correo electrónico</Label>
<Input id="email" name="email" type="email" defaultValue={customer.email ?? ''} />
</div>
<div className="space-y-2">
<Label htmlFor="phone">Teléfono</Label>
<Input id="phone" name="phone" defaultValue={customer.phone ?? ''} />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="whatsapp">WhatsApp</Label>
<Input id="whatsapp" name="whatsapp" defaultValue={customer.whatsapp ?? ''} />
</div>
<div className="space-y-2">
<Label htmlFor="city">Ciudad</Label>
<Input id="city" name="city" defaultValue={customer.city ?? ''} />
</div>
</div>
<div className="space-y-2">
<Label htmlFor="address">Dirección</Label>
<Input id="address" name="address" defaultValue={customer.address ?? ''} />
</div>
<div className="space-y-2">
<Label htmlFor="customerType">Tipo de Cliente</Label>
<Select name="customerType" defaultValue={customer.customerType}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="RETAIL">Minorista</SelectItem>
<SelectItem value="WHOLESALE">Mayorista</SelectItem>
<SelectItem value="VIP">VIP</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="notes">Notas</Label>
<textarea
id="notes"
name="notes"
rows={3}
defaultValue={customer.notes ?? ''}
className="flex w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</div>
<div className="flex gap-3">
<Button type="submit" disabled={saving}>
{saving ? 'Guardando...' : 'Guardar'}
</Button>
<Button type="button" variant="outline" onClick={() => { setEditing(false); setError(null); }}>
Cancelar
</Button>
</div>
</form>
</CardContent>
</Card>
)}
{activeTab === 'history' && (
<Card>
<CardHeader>
<CardTitle>Historial de Compras</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Número</TableHead>
<TableHead>Fecha</TableHead>
<TableHead>Total</TableHead>
<TableHead>Pago</TableHead>
<TableHead>Estado</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{purchaseHistory.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground py-8">
Sin compras aún
</TableCell>
</TableRow>
) : (
purchaseHistory.map((sale) => (
<TableRow key={sale.id}>
<TableCell className="font-medium">
<Link href={`/admin21/sales/${sale.id}`} className="hover:underline">
{sale.saleNumber}
</Link>
</TableCell>
<TableCell>{formatDate(sale.saleDate)}</TableCell>
<TableCell>{formatCurrency(sale.totalAmount)}</TableCell>
<TableCell className="capitalize">{sale.paymentMethod}</TableCell>
<TableCell>
<SaleStatusBadge status={sale.status} />
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
)}
{activeTab === 'stats' && (
<div className="space-y-6">
<div className="grid grid-cols-4 gap-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Pedidos</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{stats.orderCount}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Total Gastado</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{formatCurrency(stats.totalSpent)}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Ticket Promedio</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{formatCurrency(stats.avgTicket)}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Última Compra</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">
{stats.lastPurchaseDate ? formatDate(stats.lastPurchaseDate) : '—'}
</p>
</CardContent>
</Card>
</div>
{stats.topProducts.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Productos Más Comprados</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Producto</TableHead>
<TableHead>SKU</TableHead>
<TableHead>Variante</TableHead>
<TableHead className="text-right">Unidades</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{stats.topProducts.map((p) => (
<TableRow key={p.variantId}>
<TableCell className="font-medium">{p.productName}</TableCell>
<TableCell className="text-muted-foreground">{p.sku}</TableCell>
<TableCell>{p.size}{p.color ? ` / ${p.color}` : ''}</TableCell>
<TableCell className="text-right">{p.totalQuantity}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
</div>
)}
</div>
);
}
+50
View File
@@ -0,0 +1,50 @@
export const dynamic = 'force-dynamic';
import { notFound } from 'next/navigation';
import { getCustomerById, getCustomerStats } from '@/lib/customers';
import { db } from '@/lib/db';
import { sales, customers } from '@/lib/db/schema';
import { eq, desc, and, sql } from 'drizzle-orm';
import { CustomerDetailClient } from './customer-detail-client';
type RouteContext = { params: Promise<{ id: string }> };
export default async function CustomerDetailPage({ params }: RouteContext) {
const { id } = await params;
const customerId = parseInt(id);
if (isNaN(customerId)) notFound();
let customer;
let stats;
try {
[customer, stats] = await Promise.all([
getCustomerById(customerId),
getCustomerStats(customerId),
]);
} catch {
notFound();
}
// Get purchase history
const purchaseHistory = await db
.select({
id: sales.id,
saleNumber: sales.saleNumber,
saleDate: sales.saleDate,
totalAmount: sales.totalAmount,
status: sales.status,
paymentMethod: sales.paymentMethod,
})
.from(sales)
.where(eq(sales.customerId, customerId))
.orderBy(desc(sales.saleDate))
.limit(50);
return (
<CustomerDetailClient
customer={customer}
stats={stats}
purchaseHistory={purchaseHistory}
/>
);
}
@@ -0,0 +1,282 @@
'use client';
import { useState, useTransition } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
SortableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Search, Trash2 } from 'lucide-react';
import { AdminPagination } from '@/components/ui/admin-pagination';
interface Customer {
id: number;
firstName: string;
lastName: string;
displayName: string;
email: string | null;
phone: string | null;
city: string | null;
customerType: string;
isActive: boolean;
}
interface Tag {
id: number;
name: string;
color: string | null;
}
interface CustomerListClientProps {
customers: Customer[];
tags: Tag[];
initialSearch: string;
initialCustomerType: string;
initialTagId: string;
initialIsActive: string;
initialSort: string;
initialOrder: string;
page: number;
totalPages: number;
totalItems: number;
limit: number;
}
export function CustomerListClient({
customers,
tags,
initialSearch,
initialCustomerType,
initialTagId,
initialIsActive,
initialSort,
initialOrder,
page,
totalPages,
totalItems,
limit,
}: CustomerListClientProps) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [search, setSearch] = useState(initialSearch);
const [customerType, setCustomerType] = useState(initialCustomerType);
const [tagId, setTagId] = useState(initialTagId);
const [isActive, setIsActive] = useState(initialIsActive);
const [sort, setSort] = useState(initialSort);
const [order, setOrder] = useState<'asc' | 'desc'>(initialOrder === 'asc' || initialOrder === 'desc' ? initialOrder : 'asc');
function buildUrl(newPage?: number, newLimit?: number) {
const params = new URLSearchParams();
if (search) params.set('search', search);
if (customerType) params.set('customerType', customerType);
if (tagId) params.set('tagId', tagId);
if (isActive && isActive !== 'all') params.set('isActive', isActive);
if (sort) params.set('sort', sort);
if (sort) params.set('order', order);
params.set('page', String(newPage ?? page));
params.set('limit', String(newLimit ?? limit));
return `/admin21/customers?${params.toString()}`;
}
function handleSort(key: string) {
startTransition(() => {
const newOrder = sort === key && order === 'asc' ? 'desc' : 'asc';
setSort(key);
setOrder(newOrder);
const params = new URLSearchParams();
if (search) params.set('search', search);
if (customerType) params.set('customerType', customerType);
if (tagId) params.set('tagId', tagId);
if (isActive && isActive !== 'all') params.set('isActive', isActive);
params.set('sort', key);
params.set('order', newOrder);
params.set('page', '1');
params.set('limit', String(limit));
router.push(`/admin21/customers?${params.toString()}`);
});
}
function applyFilters() {
startTransition(() => {
router.push(buildUrl(1));
});
}
function handlePageChange(newPage: number) {
startTransition(() => {
router.push(buildUrl(newPage));
});
}
function handleLimitChange(newLimit: number) {
startTransition(() => {
router.push(buildUrl(1, newLimit));
});
}
function handleDeactivate(customerId: number) {
if (!confirm('¿Estás seguro de que querés desactivar este cliente?')) return;
fetch(`/api/customers/${customerId}`, { method: 'DELETE' }).then(() => router.refresh());
}
const typeLabels: Record<string, string> = {
RETAIL: 'Retail',
WHOLESALE: 'Wholesale',
VIP: 'VIP',
};
return (
<div className="flex flex-col h-full">
{/* Filters - fixed at top */}
<div className="flex flex-wrap gap-3 pb-4 flex-shrink-0">
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Buscar por nombre, email, teléfono..."
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && applyFilters()}
className="pl-9"
/>
</div>
<Select value={customerType} onValueChange={setCustomerType}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="Todos los tipos" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">Todos los tipos</SelectItem>
<SelectItem value="RETAIL">Minorista</SelectItem>
<SelectItem value="WHOLESALE">Mayorista</SelectItem>
<SelectItem value="VIP">VIP</SelectItem>
</SelectContent>
</Select>
<Select value={tagId} onValueChange={setTagId}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="Todas las etiquetas" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">Todas las etiquetas</SelectItem>
{tags.map((tag) => (
<SelectItem key={tag.id} value={String(tag.id)}>
{tag.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={isActive} onValueChange={setIsActive}>
<SelectTrigger className="w-[120px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">Activo</SelectItem>
<SelectItem value="false">Inactivo</SelectItem>
<SelectItem value="all">Todos</SelectItem>
</SelectContent>
</Select>
<Button onClick={applyFilters} disabled={isPending} variant="outline">
Filtrar
</Button>
</div>
{/* Table - scrollable */}
<div className="flex-1 overflow-y-auto">
<Table noScroll>
<TableHeader>
<TableRow>
<SortableHead sortKey="name" currentSort={sort} currentOrder={order} onSort={handleSort}>Nombre</SortableHead>
<SortableHead sortKey="email" currentSort={sort} currentOrder={order} onSort={handleSort}>Correo electrónico</SortableHead>
<SortableHead sortKey="phone" currentSort={sort} currentOrder={order} onSort={handleSort}>Teléfono</SortableHead>
<SortableHead sortKey="city" currentSort={sort} currentOrder={order} onSort={handleSort}>Ciudad</SortableHead>
<SortableHead sortKey="type" currentSort={sort} currentOrder={order} onSort={handleSort}>Tipo</SortableHead>
<SortableHead sortKey="status" currentSort={sort} currentOrder={order} onSort={handleSort}>Estado</SortableHead>
<TableHead className="text-right">Acciones</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{customers.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center text-muted-foreground py-8">
No customers found
</TableCell>
</TableRow>
) : (
customers.map((customer) => (
<TableRow key={customer.id}>
<TableCell className="font-medium">
<Link href={`/admin21/customers/${customer.id}`} className="hover:underline">
{customer.displayName}
</Link>
</TableCell>
<TableCell className="text-muted-foreground">
{customer.email || '—'}
</TableCell>
<TableCell className="text-muted-foreground">
{customer.phone || '—'}
</TableCell>
<TableCell className="text-muted-foreground">
{customer.city || '—'}
</TableCell>
<TableCell>
<span className="inline-flex items-center rounded-full bg-blue-100 px-2 py-1 text-xs text-blue-800">
{typeLabels[customer.customerType] || customer.customerType}
</span>
</TableCell>
<TableCell>
{customer.isActive ? (
<span className="inline-flex items-center rounded-full bg-green-100 px-2 py-1 text-xs text-green-800">
Active
</span>
) : (
<span className="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-xs text-gray-800">
Inactive
</span>
)}
</TableCell>
<TableCell className="text-right">
{customer.isActive && (
<Button
variant="ghost"
size="icon"
onClick={() => handleDeactivate(customer.id)}
title="Deactivate"
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Pagination - fixed at bottom */}
<div className="flex-shrink-0 pt-4">
<AdminPagination
page={page}
totalPages={totalPages}
totalItems={totalItems}
limit={limit}
onPageChange={handlePageChange}
onLimitChange={handleLimitChange}
/>
</div>
</div>
);
}
+203
View File
@@ -0,0 +1,203 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
interface Tag {
id: number;
name: string;
color: string | null;
}
export default function NewCustomerPage() {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [tags, setTags] = useState<Tag[]>([]);
const [selectedTagIds, setSelectedTagIds] = useState<number[]>([]);
useEffect(() => {
fetch('/api/customer-tags')
.then((res) => res.json())
.then((data) => setTags(data.items || []))
.catch(() => {});
}, []);
function toggleTag(tagId: number) {
setSelectedTagIds((prev) =>
prev.includes(tagId) ? prev.filter((id) => id !== tagId) : [...prev, tagId],
);
}
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setLoading(true);
setError(null);
const formData = new FormData(e.currentTarget);
const body = {
firstName: formData.get('firstName') as string,
lastName: formData.get('lastName') as string,
email: (formData.get('email') as string) || undefined,
phone: (formData.get('phone') as string) || undefined,
whatsapp: (formData.get('whatsapp') as string) || undefined,
address: (formData.get('address') as string) || undefined,
city: (formData.get('city') as string) || undefined,
notes: (formData.get('notes') as string) || undefined,
customerType: (formData.get('customerType') as string) || 'RETAIL',
tagIds: selectedTagIds.length > 0 ? selectedTagIds : undefined,
};
try {
const res = await fetch('/api/customers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const data = await res.json();
setError(data.error || 'Error al crear cliente');
return;
}
const customer = await res.json();
router.push(`/admin21/customers/${customer.id}`);
} catch {
setError('Error al crear cliente');
} finally {
setLoading(false);
}
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">Nuevo Cliente</h1>
<p className="text-muted-foreground">Agregá un nuevo cliente</p>
</div>
<Card className="max-w-2xl">
<CardHeader>
<CardTitle>Información del Cliente</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="rounded-lg border border-red-500/30 bg-red-500/10 p-3 text-sm text-red-400">{error}</div>
)}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="firstName">Nombre *</Label>
<Input id="firstName" name="firstName" required />
</div>
<div className="space-y-2">
<Label htmlFor="lastName">Apellido *</Label>
<Input id="lastName" name="lastName" required />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="email">Correo electrónico</Label>
<Input id="email" name="email" type="email" />
</div>
<div className="space-y-2">
<Label htmlFor="phone">Teléfono</Label>
<Input id="phone" name="phone" />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="whatsapp">WhatsApp</Label>
<Input id="whatsapp" name="whatsapp" />
</div>
<div className="space-y-2">
<Label htmlFor="city">Ciudad</Label>
<Input id="city" name="city" />
</div>
</div>
<div className="space-y-2">
<Label htmlFor="address">Dirección</Label>
<Input id="address" name="address" />
</div>
<div className="space-y-2">
<Label htmlFor="customerType">Tipo de Cliente</Label>
<Select name="customerType" defaultValue="RETAIL">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="RETAIL">Minorista</SelectItem>
<SelectItem value="WHOLESALE">Mayorista</SelectItem>
<SelectItem value="VIP">VIP</SelectItem>
</SelectContent>
</Select>
</div>
{tags.length > 0 && (
<div className="space-y-2">
<Label>Etiquetas</Label>
<div className="flex flex-wrap gap-2">
{tags.map((tag) => (
<button
key={tag.id}
type="button"
onClick={() => toggleTag(tag.id)}
className={`rounded-full px-3 py-1 text-xs font-medium transition-colors ${
selectedTagIds.includes(tag.id)
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-muted/80'
}`}
style={
selectedTagIds.includes(tag.id) && tag.color
? { backgroundColor: tag.color }
: undefined
}
>
{tag.name}
</button>
))}
</div>
</div>
)}
<div className="space-y-2">
<Label htmlFor="notes">Notas</Label>
<textarea
id="notes"
name="notes"
rows={3}
className="flex w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</div>
<div className="flex gap-3">
<Button type="submit" disabled={loading}>
{loading ? 'Creando...' : 'Crear Cliente'}
</Button>
<Button type="button" variant="outline" onClick={() => router.push('/admin21/customers')}>
Cancelar
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
);
}
+91
View File
@@ -0,0 +1,91 @@
export const dynamic = 'force-dynamic';
import Link from 'next/link';
import { getCustomers, getTags } from '@/lib/customers';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Plus } from 'lucide-react';
import { CustomerListClient } from './customer-list-client';
import { ExportButton } from '@/components/reports/export-button';
export default async function CustomersPage({
searchParams,
}: {
searchParams: Promise<{ search?: string; customerType?: string; tagId?: string; isActive?: string; page?: string; limit?: string; sort?: string; order?: string }>;
}) {
const params = await searchParams;
const page = Math.max(1, parseInt(params.page ?? '1') || 1);
const limit = Math.min(100, Math.max(5, parseInt(params.limit ?? '20') || 20));
const filters: {
search?: string;
customerType?: string;
tagId?: number;
isActive?: boolean;
} = {};
if (params.search) filters.search = params.search;
if (params.customerType) filters.customerType = params.customerType;
if (params.tagId) filters.tagId = parseInt(params.tagId);
if (params.isActive === 'true') filters.isActive = true;
else if (params.isActive === 'false') filters.isActive = false;
const [result, tags] = await Promise.all([
getCustomers({ ...filters, page, limit, sort: params.sort, order: params.order }),
getTags(),
]);
const totalPages = Math.ceil(result.pagination.total / limit);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Clientes</h1>
<p className="text-muted-foreground">Gestioná tus clientes</p>
</div>
<div className="flex gap-2">
<ExportButton reportType="customers" fetchUrl="/api/reports/customers" />
<Button asChild>
<Link href="/admin21/customers/new">
<Plus className="mr-2 h-4 w-4" />
Nuevo Cliente
</Link>
</Button>
</div>
</div>
<Card scrollable>
<CardHeader scrollable>
<CardTitle>Lista de Clientes</CardTitle>
</CardHeader>
<CardContent scrollable>
<div className="flex-1 overflow-y-auto">
<CustomerListClient
customers={result.items}
tags={tags}
initialSearch={params.search || ''}
initialCustomerType={params.customerType || ''}
initialTagId={params.tagId || ''}
initialIsActive={params.isActive || 'all'}
initialSort={params.sort || ''}
initialOrder={params.order || 'asc'}
page={page}
totalPages={totalPages}
totalItems={result.pagination.total}
limit={limit}
/>
</div>
</CardContent>
</Card>
</div>
);
}
+192
View File
@@ -0,0 +1,192 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { KPICards } from '@/components/analytics/kpi-cards';
import { SalesTrendChart } from '@/components/analytics/sales-trend-chart';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { EmptyState } from '@/components/analytics/empty-state';
import { formatCurrency, formatNumber } from '@/lib/analytics/shared';
export default function DashboardPage() {
const [loading, setLoading] = useState(true);
const [summary, setSummary] = useState<{
current?: {
revenue: number;
cogs: number;
grossProfit: number;
marginPct: number;
saleCount: number;
avgTicket: number;
unitsSold: number;
};
} | null>(null);
const [trend, setTrend] = useState<Array<{ period: string; revenue: number; cogs: number; profit: number; saleCount: number; unitsSold: number }>>([]);
const [topProducts, setTopProducts] = useState<
Array<{
productName: string;
sku: string;
size: string;
unitsSold: number;
revenue: number;
}>
>([]);
const [lowStock, setLowStock] = useState<
Array<{
productName: string;
sku: string;
size: string;
currentStock: number;
coverageDays: number;
}>
>([]);
useEffect(() => {
async function load() {
try {
const [summaryRes, trendRes, productsRes, coverageRes] = await Promise.all([
fetch('/api/analytics/summary?preset=this_month'),
fetch('/api/analytics/sales-trend?preset=this_month&granularity=day'),
fetch('/api/analytics/products?preset=this_month&limit=5'),
fetch('/api/analytics/inventory-health?view=coverage'),
]);
if (summaryRes.ok) setSummary(await summaryRes.json());
if (trendRes.ok) setTrend(await trendRes.json());
if (productsRes.ok) setTopProducts(await productsRes.json());
if (coverageRes.ok) {
const coverage = await coverageRes.json();
setLowStock(
(coverage as Array<Record<string, unknown>>)
.filter((c) => {
const days = c.coverageDays as number;
return days !== -1 && days <= 14;
})
.slice(0, 5) as typeof lowStock,
);
}
} catch (err) {
console.error('Dashboard load error:', err);
} finally {
setLoading(false);
}
}
load();
}, []);
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<p className="text-sm text-muted-foreground">Cargando dashboard...</p>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Dashboard</h1>
<p className="text-sm text-muted-foreground">Resumen del mes actual</p>
</div>
<Link href="/admin21/analytics">
<Button variant="outline" size="sm">
Ver Analytics completo
</Button>
</Link>
</div>
{/* KPI Cards */}
{summary?.current && <KPICards data={summary.current} />}
{/* Trend + Top Products */}
<div className="grid gap-4 lg:grid-cols-3">
<div className="lg:col-span-2">
<SalesTrendChart data={trend} title="Ventas del Mes" />
</div>
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium">Top 5 Productos</CardTitle>
</CardHeader>
<CardContent>
{topProducts.length === 0 ? (
<EmptyState description="Sin ventas este mes." />
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Producto</TableHead>
<TableHead className="text-right">Uds</TableHead>
<TableHead className="text-right">$</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{topProducts.map((p) => (
<TableRow key={p.sku}>
<TableCell className="font-medium">
<span className="text-sm">{p.productName}</span>
<span className="ml-1 text-xs text-muted-foreground">({p.size})</span>
</TableCell>
<TableCell className="text-right text-sm">
{formatNumber(p.unitsSold)}
</TableCell>
<TableCell className="text-right text-sm">
{formatCurrency(p.revenue)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
{/* Low Stock Alerts */}
{lowStock.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium text-red-600">
Alertas de Stock Bajo
</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Producto</TableHead>
<TableHead>SKU</TableHead>
<TableHead>Talle</TableHead>
<TableHead className="text-right">Stock</TableHead>
<TableHead className="text-right">Cobertura</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{lowStock.map((item) => (
<TableRow key={item.sku}>
<TableCell className="font-medium">{item.productName}</TableCell>
<TableCell className="text-xs text-muted-foreground">{item.sku}</TableCell>
<TableCell>{item.size}</TableCell>
<TableCell className="text-right">{formatNumber(item.currentStock)}</TableCell>
<TableCell className="text-right text-red-600">
{item.coverageDays}d
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
</div>
);
}
+37
View File
@@ -0,0 +1,37 @@
'use client';
export default function AdminError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div className="flex min-h-[400px] flex-col items-center justify-center gap-4">
<svg
className="h-12 w-12 text-destructive"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
/>
</svg>
<h2 className="text-xl font-semibold">Something went wrong</h2>
<p className="max-w-md text-center text-muted-foreground">
{error.message || 'An unexpected error occurred. Please try again.'}
</p>
<button
onClick={reset}
className="inline-flex items-center rounded-md border border-input bg-background px-4 py-2 text-sm font-medium shadow-sm hover:bg-accent hover:text-accent-foreground"
>
Try again
</button>
</div>
);
}
@@ -0,0 +1,310 @@
'use client';
import { useState, useTransition } from 'react';
import { useRouter } from 'next/navigation';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
SortableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Search } from 'lucide-react';
import { TrafficLightBadge } from '@/components/inventory/traffic-light-badge';
import { AdjustmentDialog } from '@/components/inventory/adjustment-dialog';
import type { TrafficLightResult } from '@/lib/inventory';
import { formatCurrency } from '@/lib/utils/format';
import { AdminPagination } from '@/components/ui/admin-pagination';
interface InventoryItem {
id: number;
sku: string;
size: string;
color: string | null;
productName: string;
productSlug: string;
categoryName: string | null;
supplierName: string | null;
currentStock: number | null;
reservedStock: number | null;
minimumStock: number | null;
idealStock: number | null;
averageCost: number | null;
trafficLight: TrafficLightResult;
totalValue: number;
}
interface SupplierOption {
id: number;
name: string;
}
interface InventoryClientProps {
items: InventoryItem[];
suppliers: SupplierOption[];
initialSearch: string;
initialSupplier: string;
initialState: string;
initialSort: string;
initialOrder: string;
page: number;
totalPages: number;
totalItems: number;
limit: number;
}
export function InventoryClient({
items,
suppliers,
initialSearch,
initialSupplier,
initialState,
initialSort,
initialOrder,
page,
totalPages,
totalItems,
limit,
}: InventoryClientProps) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [search, setSearch] = useState(initialSearch);
const [supplier, setSupplier] = useState(initialSupplier);
const [state, setState] = useState(initialState);
const [sort, setSort] = useState(initialSort);
const [order, setOrder] = useState<'asc' | 'desc'>(initialOrder === 'asc' || initialOrder === 'desc' ? initialOrder : 'asc');
const [adjustmentOpen, setAdjustmentOpen] = useState(false);
const [selectedVariant, setSelectedVariant] = useState<{
id: number;
label: string;
} | null>(null);
function buildUrl(newPage?: number, newLimit?: number) {
const params = new URLSearchParams();
if (search) params.set('search', search);
if (supplier) params.set('supplier', supplier);
if (state) params.set('state', state);
if (sort) params.set('sort', sort);
if (sort) params.set('order', order);
params.set('page', String(newPage ?? page));
params.set('limit', String(newLimit ?? limit));
return `/admin21/inventory?${params.toString()}`;
}
function handleSort(key: string) {
startTransition(() => {
const newOrder = sort === key && order === 'asc' ? 'desc' : 'asc';
setSort(key);
setOrder(newOrder);
const params = new URLSearchParams();
if (search) params.set('search', search);
if (supplier) params.set('supplier', supplier);
if (state) params.set('state', state);
params.set('sort', key);
params.set('order', newOrder);
params.set('page', '1');
params.set('limit', String(limit));
router.push(`/admin21/inventory?${params.toString()}`);
});
}
function applyFilters() {
startTransition(() => {
router.push(buildUrl(1));
});
}
function handlePageChange(newPage: number) {
startTransition(() => {
router.push(buildUrl(newPage));
});
}
function handleLimitChange(newLimit: number) {
startTransition(() => {
router.push(buildUrl(1, newLimit));
});
}
function openAdjustment(variantId: number, label: string) {
setSelectedVariant({ id: variantId, label });
setAdjustmentOpen(true);
}
return (
<div className="flex flex-col h-full">
{/* Filters - fixed at top */}
<div className="flex flex-wrap gap-3 pb-4 flex-shrink-0">
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Buscar por SKU o nombre de producto..."
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && applyFilters()}
className="pl-9"
/>
</div>
<Select
value={state}
onValueChange={(v) => {
setState(v);
}}
>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="Todos los estados" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos los estados</SelectItem>
<SelectItem value="red">Sin Stock</SelectItem>
<SelectItem value="yellow">Stock Bajo</SelectItem>
<SelectItem value="green">En Stock</SelectItem>
<SelectItem value="gray">Sin Referencia</SelectItem>
</SelectContent>
</Select>
<Select
value={supplier}
onValueChange={(v) => {
setSupplier(v);
}}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Todos los proveedores" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos los proveedores</SelectItem>
{suppliers.map((s) => (
<SelectItem key={s.id} value={String(s.id)}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button onClick={applyFilters} disabled={isPending} variant="outline">
Filtrar
</Button>
</div>
{/* Table - scrollable */}
<div className="flex-1 overflow-y-auto">
<Table noScroll>
<TableHeader>
<TableRow>
<SortableHead sortKey="product" currentSort={sort} currentOrder={order} onSort={handleSort}>Producto / Variante</SortableHead>
<SortableHead sortKey="sku" currentSort={sort} currentOrder={order} onSort={handleSort}>SKU</SortableHead>
<TableHead>Estado</TableHead>
<SortableHead sortKey="stock" currentSort={sort} currentOrder={order} onSort={handleSort} className="text-right">Stock</SortableHead>
<SortableHead sortKey="cost" currentSort={sort} currentOrder={order} onSort={handleSort} className="text-right">Costo Unitario</SortableHead>
<TableHead className="text-right">Valor Total</TableHead>
<TableHead className="text-right">Acciones</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.length === 0 ? (
<TableRow>
<TableCell
colSpan={7}
className="text-center text-muted-foreground py-8"
>
No se encontraron variantes
</TableCell>
</TableRow>
) : (
items.map((item) => {
const variantLabel = [item.size, item.color]
.filter(Boolean)
.join(' / ');
const fullLabel = `${item.productName}${variantLabel || item.sku}`;
return (
<TableRow key={item.id}>
<TableCell>
<div className="font-medium">{item.productName}</div>
{variantLabel && (
<div className="text-xs text-muted-foreground">
{variantLabel}
</div>
)}
{item.categoryName && (
<div className="text-xs text-muted-foreground">
{item.categoryName}
</div>
)}
</TableCell>
<TableCell className="font-mono text-sm">
{item.sku}
</TableCell>
<TableCell>
<TrafficLightBadge result={item.trafficLight} />
</TableCell>
<TableCell className="text-right font-mono">
<div>{item.currentStock ?? 0}</div>
{(item.reservedStock ?? 0) > 0 && (
<div className="text-xs text-muted-foreground">
({item.trafficLight.availableStock} disp.)
</div>
)}
</TableCell>
<TableCell className="text-right font-mono">
{item.averageCost != null
? formatCurrency(item.averageCost)
: '—'}
</TableCell>
<TableCell className="text-right font-mono">
{item.totalValue > 0
? formatCurrency(item.totalValue)
: '—'}
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={() => openAdjustment(item.id, fullLabel)}
>
Ajustar
</Button>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
{/* Adjustment Dialog */}
{selectedVariant && (
<AdjustmentDialog
open={adjustmentOpen}
onOpenChange={setAdjustmentOpen}
variantId={selectedVariant.id}
variantLabel={selectedVariant.label}
onSuccess={() => router.refresh()}
/>
)}
{/* Pagination - fixed at bottom */}
<div className="flex-shrink-0 pt-4">
<AdminPagination
page={page}
totalPages={totalPages}
totalItems={totalItems}
limit={limit}
onPageChange={handlePageChange}
onLimitChange={handleLimitChange}
/>
</div>
</div>
);
}
+207
View File
@@ -0,0 +1,207 @@
export const dynamic = 'force-dynamic';
import { Suspense } from 'react';
import { db } from '@/lib/db';
import { variants, products, categories, suppliers } from '@/lib/db/schema';
import { eq, and, like, asc, desc, sql, count, type AnyColumn } from 'drizzle-orm';
import { computeTrafficLight } from '@/lib/inventory';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import Link from 'next/link';
import { Package, RefreshCw } from 'lucide-react';
import { InventoryClient } from './inventory-client';
import { ExportButton } from '@/components/reports/export-button';
const INVENTORY_SORT_MAP: Record<string, AnyColumn> = {
product: products.name,
sku: variants.sku,
stock: variants.currentStock,
cost: variants.averageCost,
};
async function getInventoryData(
search?: string,
supplierId?: string,
state?: string,
page = 1,
limit = 20,
sort?: string,
order?: string,
) {
const conditions = [];
conditions.push(eq(variants.isActive, true));
if (search) {
const searchTerm = `%${search}%`;
conditions.push(
sql`(${variants.sku} LIKE ${searchTerm} OR ${products.name} LIKE ${searchTerm})`,
);
}
if (supplierId) {
const supId = parseInt(supplierId);
if (!isNaN(supId)) {
conditions.push(eq(variants.preferredSupplierId, supId));
}
}
// Traffic light state filter in SQL
const availableStock = sql`COALESCE(${variants.currentStock}, 0) - COALESCE(${variants.reservedStock}, 0)`;
if (state === 'gray') {
conditions.push(sql`(${variants.minimumStock} IS NULL OR ${variants.idealStock} IS NULL)`);
} else if (state === 'red') {
conditions.push(
sql`(${variants.minimumStock} IS NOT NULL AND ${variants.idealStock} IS NOT NULL AND (${availableStock} <= ${variants.minimumStock} OR ${availableStock} <= 0))`,
);
} else if (state === 'yellow') {
conditions.push(
sql`(${variants.minimumStock} IS NOT NULL AND ${variants.idealStock} IS NOT NULL AND ${availableStock} > ${variants.minimumStock} AND ${availableStock} < ${variants.idealStock})`,
);
} else if (state === 'green') {
conditions.push(
sql`(${variants.minimumStock} IS NOT NULL AND ${variants.idealStock} IS NOT NULL AND ${availableStock} >= ${variants.idealStock})`,
);
}
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
const [countResult] = await db
.select({ total: count() })
.from(variants)
.innerJoin(products, eq(variants.productId, products.id))
.leftJoin(suppliers, eq(variants.preferredSupplierId, suppliers.id))
.where(whereClause);
const total = countResult?.total ?? 0;
const sortCol = sort ? INVENTORY_SORT_MAP[sort] : undefined;
const orderDir = order === 'asc' ? asc : desc;
const rows = await db
.select({
variant: variants,
productName: products.name,
productSlug: products.slug,
categoryName: categories.name,
supplierName: suppliers.name,
})
.from(variants)
.innerJoin(products, eq(variants.productId, products.id))
.leftJoin(categories, eq(products.categoryId, categories.id))
.leftJoin(suppliers, eq(variants.preferredSupplierId, suppliers.id))
.where(whereClause)
.orderBy(sortCol ? orderDir(sortCol) : sql`${variants.sku} ASC`)
.limit(limit)
.offset((page - 1) * limit);
const items = rows.map((row) => {
const v = row.variant;
const trafficLight = computeTrafficLight({
currentStock: v.currentStock,
reservedStock: v.reservedStock,
minimumStock: v.minimumStock,
idealStock: v.idealStock,
});
return {
...v,
productName: row.productName,
productSlug: row.productSlug,
categoryName: row.categoryName,
supplierName: row.supplierName,
trafficLight,
totalValue: (v.averageCost ?? 0) * (v.currentStock ?? 0),
};
});
return { items, total };
}
async function getSupplierList() {
return db
.select({ id: suppliers.id, name: suppliers.name })
.from(suppliers)
.where(eq(suppliers.isActive, true))
.orderBy(suppliers.name);
}
export default async function InventoryPage({
searchParams,
}: {
searchParams: Promise<{ search?: string; supplier?: string; state?: string; page?: string; limit?: string; sort?: string; order?: string }>;
}) {
const params = await searchParams;
const page = Math.max(1, parseInt(params.page ?? '1') || 1);
const limit = Math.min(100, Math.max(5, parseInt(params.limit ?? '20') || 20));
const { items: inventoryItems, total } = await getInventoryData(
params.search,
params.supplier,
params.state,
page,
limit,
params.sort,
params.order,
);
const supplierList = await getSupplierList();
const totalPages = Math.ceil(total / limit);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Inventario</h1>
<p className="text-muted-foreground">
Niveles de stock y seguimiento de movimientos
</p>
</div>
<div className="flex gap-2">
<ExportButton reportType="inventory" fetchUrl="/api/reports/inventory" />
<Button variant="outline" asChild>
<Link href="/admin21/inventory/restock">
<RefreshCw className="mr-2 h-4 w-4" />
Reabastecer
</Link>
</Button>
</div>
</div>
<Card scrollable>
<CardHeader scrollable>
<div className="flex items-center gap-2">
<Package className="h-5 w-5 text-muted-foreground" />
<CardTitle>Resumen de Stock</CardTitle>
<span className="ml-auto text-sm text-muted-foreground">
{total} variante{total !== 1 ? 's' : ''}
</span>
</div>
</CardHeader>
<CardContent scrollable>
<div className="flex-1 overflow-y-auto">
<Suspense
fallback={
<div className="py-8 text-center text-muted-foreground">
Cargando inventario...
</div>
}
>
<InventoryClient
items={inventoryItems}
suppliers={supplierList}
initialSearch={params.search || ''}
initialSupplier={params.supplier || ''}
initialState={params.state || ''}
initialSort={params.sort || ''}
initialOrder={params.order || 'asc'}
page={page}
totalPages={totalPages}
totalItems={total}
limit={limit}
/>
</Suspense>
</div>
</CardContent>
</Card>
</div>
);
}
+147
View File
@@ -0,0 +1,147 @@
export const dynamic = 'force-dynamic';
import { getRestockSuggestions } from '@/lib/inventory';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Package, ShoppingCart, Truck } from 'lucide-react';
import Link from 'next/link';
import { formatCurrency } from '@/lib/utils/format';
export default async function RestockPage() {
const groups = await getRestockSuggestions();
const totalItems = groups.reduce((sum, g) => sum + g.items.length, 0);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Reposición de Stock</h1>
<p className="text-muted-foreground">
Variantes por debajo del stock ideal, agrupadas por proveedor
</p>
</div>
<Button variant="outline" asChild>
<Link href="/admin21/inventory">
<Package className="mr-2 h-4 w-4" />
Volver al Inventario
</Link>
</Button>
</div>
{groups.length === 0 ? (
<Card>
<CardContent className="py-12 text-center">
<Package className="mx-auto h-12 w-12 text-muted-foreground" />
<h3 className="mt-4 text-lg font-medium">Todo en orden</h3>
<p className="mt-2 text-sm text-muted-foreground">
No hay variantes que necesiten reposición en este momento.
</p>
</CardContent>
</Card>
) : (
<div className="space-y-6">
<p className="text-sm text-muted-foreground">
{totalItems} variante{totalItems !== 1 ? 's' : ''} en{' '}
{groups.length} proveedor{groups.length !== 1 ? 'es' : ''} necesitan
reposición.
</p>
{groups.map((group) => (
<Card key={group.supplierId ?? 'none'}>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Truck className="h-5 w-5 text-muted-foreground" />
<CardTitle>{group.supplierName}</CardTitle>
<span className="text-sm text-muted-foreground">
({group.items.length} item
{group.items.length !== 1 ? 's' : ''})
</span>
</div>
<Button size="sm" asChild>
<Link
href={{
pathname: '/admin21/purchases/new',
query: {
supplierId: group.supplierId?.toString() ?? '',
items: JSON.stringify(
group.items.map((i) => ({
variantId: i.variantId,
quantity: i.suggestedQty,
unitCost: i.unitCost ?? 0,
})),
),
},
}}
>
<ShoppingCart className="mr-2 h-4 w-4" />
Crear Borrador de Compra
</Link>
</Button>
</div>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Producto</TableHead>
<TableHead>SKU</TableHead>
<TableHead className="text-right">Disponible</TableHead>
<TableHead className="text-right">Mínimo</TableHead>
<TableHead className="text-right">Ideal</TableHead>
<TableHead className="text-right">Sugerido</TableHead>
<TableHead className="text-right">Costo Unitario</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{group.items.map((item) => (
<TableRow key={item.variantId}>
<TableCell>
<div className="font-medium">{item.productName}</div>
{item.variantLabel && (
<div className="text-xs text-muted-foreground">
{item.variantLabel}
</div>
)}
</TableCell>
<TableCell className="font-mono text-sm">
{item.sku}
</TableCell>
<TableCell className="text-right font-mono">
{item.availableStock}
</TableCell>
<TableCell className="text-right font-mono text-muted-foreground">
{item.minimumStock}
</TableCell>
<TableCell className="text-right font-mono text-muted-foreground">
{item.idealStock}
</TableCell>
<TableCell className="text-right font-mono font-medium">
{item.suggestedQty}
</TableCell>
<TableCell className="text-right font-mono">
{item.unitCost != null
? formatCurrency(item.unitCost)
: '—'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
import { AdminShell } from './admin-shell-dynamic';
export default function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="dark min-h-full flex flex-col relative overflow-hidden">
{/* Ambient background effects */}
<div className="fixed inset-0 pointer-events-none z-0">
<div className="absolute top-[-20%] right-[-10%] w-[600px] h-[600px] rounded-full bg-neon-purple/5 blur-[120px]" />
<div className="absolute bottom-[-20%] left-[-10%] w-[600px] h-[600px] rounded-full bg-neon-blue/5 blur-[120px]" />
<div className="absolute top-[40%] left-[30%] w-[400px] h-[400px] rounded-full bg-neon-cyan/3 blur-[100px]" />
<div
className="absolute inset-0 opacity-[0.02]"
style={{
backgroundImage: `linear-gradient(var(--neon-purple) 1px, transparent 1px),
linear-gradient(90deg, var(--neon-purple) 1px, transparent 1px)`,
backgroundSize: '60px 60px',
}}
/>
</div>
{/* Content */}
<div className="relative z-10 flex-1 flex flex-col">
<AdminShell>{children}</AdminShell>
</div>
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
export default function AdminLoading() {
return (
<div className="flex min-h-[400px] items-center justify-center">
<div className="flex flex-col items-center gap-4">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
<p className="text-sm text-muted-foreground">Cargando...</p>
</div>
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
import Link from 'next/link';
export default function AdminNotFound() {
return (
<div className="flex min-h-[400px] flex-col items-center justify-center gap-4">
<svg
className="h-12 w-12 text-muted-foreground"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
/>
</svg>
<h2 className="text-xl font-semibold">Page not found</h2>
<p className="text-muted-foreground">
The page you are looking for does not exist or has been moved.
</p>
<Link
href="/admin21/dashboard"
className="inline-flex items-center rounded-md border border-input bg-background px-4 py-2 text-sm font-medium shadow-sm hover:bg-accent hover:text-accent-foreground"
>
Go to Dashboard
</Link>
</div>
);
}
@@ -0,0 +1,269 @@
'use client';
import { useState, useRef } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
rectSortingStrategy,
useSortable,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Upload, Trash2, Star, GripVertical } from 'lucide-react';
import type { productImages } from '@/lib/db/schema';
type ProductImage = typeof productImages.$inferSelect;
interface ImageManagerProps {
productId: number;
images: ProductImage[];
onUpdate: (images: ProductImage[]) => void;
}
function SortableImage({
image,
onDelete,
onSetPrimary,
}: {
image: ProductImage;
onDelete: () => void;
onSetPrimary: () => void;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: image.id,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
return (
<div
ref={setNodeRef}
style={style}
className="relative group rounded-lg border border-glass-border overflow-hidden bg-muted aspect-square"
>
{/* Drag handle */}
<div
{...attributes}
{...listeners}
className="absolute top-1 left-1 z-10 cursor-grab rounded bg-black/50 p-1 text-white opacity-0 group-hover:opacity-100 transition-opacity"
>
<GripVertical className="h-4 w-4" />
</div>
{/* Image */}
<img
src={image.url}
alt={image.alt || 'Imagen del producto'}
className="h-full w-full object-cover"
/>
{/* Primary badge */}
{image.isPrimary && (
<div className="absolute top-1 right-1 rounded bg-primary p-1 text-primary-foreground">
<Star className="h-3 w-3 fill-current" />
</div>
)}
{/* Actions overlay */}
<div className="absolute inset-x-0 bottom-0 flex justify-center gap-1 bg-black/60 p-2 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-white hover:bg-white/20"
onClick={onSetPrimary}
title="Establecer como principal"
>
<Star className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:bg-destructive/20"
onClick={onDelete}
title="Eliminar imagen"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
);
}
export function ImageManager({ productId, images, onUpdate }: ImageManagerProps) {
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
async function handleUpload(files: FileList | null) {
if (!files || files.length === 0) return;
setUploading(true);
try {
const newImages: ProductImage[] = [];
for (const file of Array.from(files)) {
// Upload file
const formData = new FormData();
formData.append('file', file);
const uploadRes = await fetch('/api/upload', { method: 'POST', body: formData });
if (!uploadRes.ok) throw new Error('Error al subir');
const { url } = await uploadRes.json();
// Link to product
const linkRes = await fetch(`/api/products/${productId}/images`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url, isPrimary: images.length === 0 && newImages.length === 0 }),
});
if (!linkRes.ok) {
const err = await linkRes.json();
throw new Error(err.error || 'Error al vincular imagen');
}
const linkedImage = await linkRes.json();
newImages.push(linkedImage);
}
// Update local state with new images
const updatedImages = [...images, ...newImages];
onUpdate(updatedImages);
} catch (err) {
console.error('Error de carga:', err);
alert(err instanceof Error ? err.message : 'Error al subir');
} finally {
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = '';
}
}
async function handleDelete(imageId: number) {
if (!confirm('¿Eliminar esta imagen?')) return;
try {
const res = await fetch(`/api/products/${productId}/images/${imageId}`, {
method: 'DELETE',
});
if (!res.ok) throw new Error('Error al eliminar');
const updatedImages = images.filter((img) => img.id !== imageId);
onUpdate(updatedImages);
} catch (err) {
console.error('Error al eliminar imagen:', err);
}
}
async function handleSetPrimary(imageId: number) {
// Reorder so this image is first
const reordered = [
images.find((img) => img.id === imageId)!,
...images.filter((img) => img.id !== imageId),
];
await handleReorder(reordered.map((img) => img.id));
}
async function handleReorder(imageIds: number[]) {
try {
const res = await fetch(`/api/products/${productId}/images/reorder`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ imageIds }),
});
if (!res.ok) throw new Error('Error al reordenar');
} catch (err) {
console.error('Error al reordenar:', err);
}
}
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = images.findIndex((img) => img.id === active.id);
const newIndex = images.findIndex((img) => img.id === over.id);
const reordered = arrayMove(images, oldIndex, newIndex);
onUpdate(reordered);
handleReorder(reordered.map((img) => img.id));
}
function handleDrop(e: React.DragEvent) {
e.preventDefault();
handleUpload(e.dataTransfer.files);
}
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Imágenes ({images.length}/10)</CardTitle>
<div className="flex gap-2">
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
multiple
className="hidden"
onChange={(e) => handleUpload(e.target.files)}
/>
<Button
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={uploading || images.length >= 10}
>
<Upload className="mr-1 h-4 w-4" />
{uploading ? 'Subiendo...' : 'Subir'}
</Button>
</div>
</CardHeader>
<CardContent>
{/* Drop zone */}
<div
onDragOver={(e) => e.preventDefault()}
onDrop={handleDrop}
className="mb-4 rounded-lg border-2 border-dashed border-glass-border p-8 text-center text-muted-foreground hover:border-primary/50 transition-colors"
>
<Upload className="mx-auto h-8 w-8 mb-2 opacity-50" />
<p className="text-sm">Arrastrá imágenes acá, o hacé click en Subir</p>
<p className="text-xs mt-1">JPEG, PNG, WebP Máx 5MB Máx 10 imágenes</p>
</div>
{/* Image grid */}
{images.length === 0 ? (
<p className="text-center text-muted-foreground py-4">Todavía no hay imágenes</p>
) : (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={images.map((img) => img.id)} strategy={rectSortingStrategy}>
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-3">
{images.map((image) => (
<SortableImage
key={image.id}
image={image}
onDelete={() => handleDelete(image.id)}
onSetPrimary={() => handleSetPrimary(image.id)}
/>
))}
</div>
</SortableContext>
</DndContext>
)}
</CardContent>
</Card>
);
}
+72
View File
@@ -0,0 +1,72 @@
export const dynamic = 'force-dynamic';
import { db } from '@/lib/db';
import {
products,
variants,
productImages,
productCategories,
categories,
} from '@/lib/db/schema';
import { eq } from 'drizzle-orm';
import { notFound } from 'next/navigation';
import { ProductDetailClient } from './product-detail-client';
async function getProductWithRelations(productId: number) {
const productResult = await db
.select()
.from(products)
.where(eq(products.id, productId))
.limit(1);
if (productResult.length === 0) return null;
const product = productResult[0];
const [productVariants, images, assignedCategories, allCategories] = await Promise.all([
db.select().from(variants).where(eq(variants.productId, productId)),
db
.select()
.from(productImages)
.where(eq(productImages.productId, productId))
.orderBy(productImages.sortOrder),
db
.select({
id: categories.id,
name: categories.name,
slug: categories.slug,
color: categories.color,
})
.from(categories)
.innerJoin(productCategories, eq(categories.id, productCategories.categoryId))
.where(eq(productCategories.productId, productId)),
db
.select()
.from(categories)
.where(eq(categories.isActive, true))
.orderBy(categories.name),
]);
return {
product,
variants: productVariants,
images,
assignedCategories,
allCategories,
};
}
export default async function ProductDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const productId = parseInt(id);
if (isNaN(productId)) notFound();
const data = await getProductWithRelations(productId);
if (!data) notFound();
return <ProductDetailClient {...data} />;
}
@@ -0,0 +1,183 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { ProductForm } from '../product-form';
import { VariantEditor } from './variant-editor';
import { ImageManager } from './image-manager';
import {
Info,
Package,
Image as ImageIcon,
Tags,
} from 'lucide-react';
import type { products, variants, productImages, categories } from '@/lib/db/schema';
type Product = typeof products.$inferSelect;
type Variant = typeof variants.$inferSelect;
type ProductImage = typeof productImages.$inferSelect;
type Category = typeof categories.$inferSelect;
interface ProductDetailClientProps {
product: Product;
variants: Variant[];
images: ProductImage[];
assignedCategories: Pick<Category, 'id' | 'name' | 'slug' | 'color'>[];
allCategories: Category[];
}
type Tab = 'info' | 'variants' | 'images' | 'categories';
export function ProductDetailClient({
product,
variants: initialVariants,
images: initialImages,
assignedCategories,
allCategories,
}: ProductDetailClientProps) {
const router = useRouter();
const [activeTab, setActiveTab] = useState<Tab>('info');
const [variants, setVariants] = useState(initialVariants);
const [images, setImages] = useState(initialImages);
const [selectedCategories, setSelectedCategories] = useState<number[]>(
assignedCategories.map((c) => c.id),
);
const tabs: { id: Tab; label: string; icon: React.ReactNode }[] = [
{ id: 'info', label: 'Info', icon: <Info className="h-4 w-4" /> },
{ id: 'variants', label: 'Variantes', icon: <Package className="h-4 w-4" /> },
{ id: 'images', label: 'Imágenes', icon: <ImageIcon className="h-4 w-4" /> },
{ id: 'categories', label: 'Categorías', icon: <Tags className="h-4 w-4" /> },
];
function toggleCategory(catId: number) {
const newCats = selectedCategories.includes(catId)
? selectedCategories.filter((id) => id !== catId)
: [...selectedCategories, catId];
setSelectedCategories(newCats);
}
async function saveCategories() {
await fetch(`/api/products/${product.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ categoryIds: selectedCategories }),
});
router.refresh();
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">{product.name}</h1>
<p className="text-muted-foreground">
Slug: {product.slug} {product.archivedAt && '• Archivado'}
</p>
</div>
</div>
{/* Tabs */}
<div className="flex gap-1 border-b border-glass-border">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab.id
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
>
{tab.icon}
{tab.label}
</button>
))}
</div>
{/* Tab Content */}
{activeTab === 'info' && (
<ProductForm
categories={allCategories}
initialData={{
id: product.id,
name: product.name,
internalCode: product.internalCode,
shortDescription: product.shortDescription,
fullDescription: product.fullDescription,
brand: product.brand,
basePrice: product.basePrice,
costPrice: product.costPrice,
baseMarginPercentage: product.baseMarginPercentage,
publishedInCatalog: product.publishedInCatalog ?? false,
featured: product.featured ?? false,
categoryIds: assignedCategories.map((c) => c.id),
}}
/>
)}
{activeTab === 'variants' && (
<VariantEditor
productId={product.id}
variants={variants}
onUpdate={(updated) => setVariants(updated)}
/>
)}
{activeTab === 'images' && (
<ImageManager
productId={product.id}
images={images}
onUpdate={(updated) => setImages(updated)}
/>
)}
{activeTab === 'categories' && (
<Card>
<CardHeader>
<CardTitle>Asignar Categorías</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{allCategories.length === 0 ? (
<p className="text-sm text-muted-foreground">No hay categorías disponibles.</p>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{allCategories.map((cat) => (
<label
key={cat.id}
className={`flex items-center gap-2 rounded-lg border p-3 cursor-pointer transition-all duration-200 ${
selectedCategories.includes(cat.id)
? 'border-neon-purple/50 bg-neon-purple/10'
: 'border-glass-border hover:border-muted-foreground/30 hover:bg-accent/30'
}`}
>
<input
type="checkbox"
checked={selectedCategories.includes(cat.id)}
onChange={() => toggleCategory(cat.id)}
className="rounded border-glass-border text-neon-purple focus:ring-neon-purple/20"
/>
<div className="flex items-center gap-2">
{cat.color && (
<span
className="h-3 w-3 rounded-full"
style={{ backgroundColor: cat.color }}
/>
)}
<span className="text-sm">{cat.name}</span>
</div>
</label>
))}
</div>
)}
<Button onClick={saveCategories} variant="gradient">Guardar Categorías</Button>
</CardContent>
</Card>
)}
</div>
);
}
@@ -0,0 +1,323 @@
'use client';
import { useState, useEffect, useMemo } from 'react';
import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Plus, Trash2, Save } from 'lucide-react';
import type { variants } from '@/lib/db/schema';
import { buildPriceBreakdown, type CalcMethod, type PriceBreakdown } from '@/lib/pricing/price-engine';
import { PriceBreakdown as PriceBreakdownDisplay } from '@/components/pricing/price-breakdown';
type Variant = typeof variants.$inferSelect;
interface PricingSettings {
calc_method: CalcMethod;
rounding_multiple: number;
min_margin_warning: number;
}
interface VariantEditorProps {
productId: number;
variants: Variant[];
onUpdate: (variants: Variant[]) => void;
}
interface VariantDraft {
id?: number;
sku: string;
size: string;
color: string;
model: string;
barcode: string;
currentStock: number;
reservedStock: number;
minimumStock: number;
idealStock: number;
averageCost: number | null;
lastPurchaseCost: number | null;
marginPercentage: number | null;
manualSalePrice: number | null;
isActive: boolean;
}
function variantToDraft(v: Variant): VariantDraft {
return {
id: v.id,
sku: v.sku,
size: v.size,
color: v.color || '',
model: v.model || '',
barcode: v.barcode || '',
currentStock: v.currentStock || 0,
reservedStock: v.reservedStock || 0,
minimumStock: v.minimumStock || 0,
idealStock: v.idealStock || 0,
averageCost: v.averageCost,
lastPurchaseCost: v.lastPurchaseCost,
marginPercentage: v.marginPercentage,
manualSalePrice: v.manualSalePrice,
isActive: v.isActive ?? true,
};
}
function emptyDraft(): VariantDraft {
return {
sku: '',
size: '',
color: '',
model: '',
barcode: '',
currentStock: 0,
reservedStock: 0,
minimumStock: 0,
idealStock: 0,
averageCost: null,
lastPurchaseCost: null,
marginPercentage: null,
manualSalePrice: null,
isActive: true,
};
}
export function VariantEditor({ productId, variants, onUpdate }: VariantEditorProps) {
const router = useRouter();
const [drafts, setDrafts] = useState<VariantDraft[]>(
variants.length > 0 ? variants.map(variantToDraft) : [],
);
const [saving, setSaving] = useState(false);
const [pricingSettings, setPricingSettings] = useState<PricingSettings>({
calc_method: 'markup',
rounding_multiple: 500,
min_margin_warning: 20,
});
useEffect(() => {
async function fetchSettings() {
try {
const res = await fetch('/api/settings/pricing');
if (res.ok) {
const data = await res.json();
setPricingSettings({
calc_method: data.calc_method,
rounding_multiple: data.rounding_multiple,
min_margin_warning: data.min_margin_warning,
});
}
} catch {
// Use defaults
}
}
fetchSettings();
}, []);
function addVariant() {
setDrafts([...drafts, emptyDraft()]);
}
function removeVariant(index: number) {
setDrafts(drafts.filter((_, i) => i !== index));
}
function updateDraft(index: number, field: keyof VariantDraft, value: string | number | boolean | null) {
setDrafts(
drafts.map((d, i) => (i === index ? { ...d, [field]: value } : d)),
);
}
function getBreakdown(draft: VariantDraft): PriceBreakdown | null {
if (draft.averageCost == null || draft.averageCost <= 0) return null;
const marginPct = draft.marginPercentage ?? 0;
return buildPriceBreakdown({
cost: draft.averageCost,
marginPct,
method: pricingSettings.calc_method,
manualPrice: draft.manualSalePrice,
roundingMultiple: pricingSettings.rounding_multiple,
minMarginWarning: pricingSettings.min_margin_warning,
});
}
async function handleSave() {
setSaving(true);
try {
const res = await fetch(`/api/products/${productId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ variants: drafts }),
});
if (!res.ok) {
const errorData = await res.json().catch(() => null);
throw new Error(errorData?.error || 'Error al guardar variantes');
}
const data = await res.json();
if (data.variants) {
onUpdate(data.variants);
}
router.refresh();
} catch (err) {
console.error('Error al guardar variantes:', err);
} finally {
setSaving(false);
}
}
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Variantes</CardTitle>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={addVariant}>
<Plus className="mr-1 h-4 w-4" />
Agregar Variante
</Button>
<Button size="sm" variant="gradient" onClick={handleSave} disabled={saving}>
<Save className="mr-1 h-4 w-4" />
{saving ? 'Guardando...' : 'Guardar'}
</Button>
</div>
</CardHeader>
<CardContent>
{drafts.length === 0 ? (
<p className="text-center text-muted-foreground py-8">
Todavía no hay variantes. Hacé click en &quot;Agregar Variante&quot; para crear una.
</p>
) : (
<div className="space-y-4">
{drafts.map((draft, index) => (
<div key={index} className="rounded-lg border border-glass-border p-4 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Variante #{index + 1}</span>
<Button
variant="ghost"
size="icon"
onClick={() => removeVariant(index)}
className="text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div className="space-y-1">
<Label className="text-xs">SKU *</Label>
<Input
value={draft.sku}
onChange={(e) => updateDraft(index, 'sku', e.target.value)}
placeholder="SKU"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Talle *</Label>
<Input
value={draft.size}
onChange={(e) => updateDraft(index, 'size', e.target.value)}
placeholder="S, M, L..."
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Color</Label>
<Input
value={draft.color}
onChange={(e) => updateDraft(index, 'color', e.target.value)}
placeholder="Color"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Modelo</Label>
<Input
value={draft.model}
onChange={(e) => updateDraft(index, 'model', e.target.value)}
placeholder="Modelo"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Código de barras</Label>
<Input
value={draft.barcode}
onChange={(e) => updateDraft(index, 'barcode', e.target.value)}
placeholder="Código de barras"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Stock Actual</Label>
<Input
type="number"
min="0"
value={draft.currentStock}
onChange={(e) => updateDraft(index, 'currentStock', parseInt(e.target.value) || 0)}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Costo Promedio</Label>
<Input
type="number"
step="0.01"
min="0"
value={draft.averageCost ?? ''}
onChange={(e) =>
updateDraft(index, 'averageCost', e.target.value ? parseFloat(e.target.value) : null)
}
placeholder="0.00"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Margen %</Label>
<Input
type="number"
step="0.1"
min="0"
max="100"
value={draft.marginPercentage ?? ''}
onChange={(e) =>
updateDraft(
index,
'marginPercentage',
e.target.value ? parseFloat(e.target.value) : null,
)
}
placeholder="50"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Precio Manual</Label>
<Input
type="number"
step="0.01"
min="0"
value={draft.manualSalePrice ?? ''}
onChange={(e) =>
updateDraft(
index,
'manualSalePrice',
e.target.value ? parseFloat(e.target.value) : null,
)
}
placeholder="Override"
/>
</div>
</div>
{(() => {
const breakdown = getBreakdown(draft);
return breakdown ? <PriceBreakdownDisplay breakdown={breakdown} /> : null;
})()}
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}
+28
View File
@@ -0,0 +1,28 @@
export const dynamic = 'force-dynamic';
import { db } from '@/lib/db';
import { categories } from '@/lib/db/schema';
import { eq } from 'drizzle-orm';
import { ProductForm } from '../product-form';
async function getCategories() {
return db
.select()
.from(categories)
.where(eq(categories.isActive, true))
.orderBy(categories.name);
}
export default async function NewProductPage() {
const categoryList = await getCategories();
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">Nuevo Producto</h1>
<p className="text-muted-foreground">Creá un nuevo producto con variantes e imágenes</p>
</div>
<ProductForm categories={categoryList} />
</div>
);
}
+149
View File
@@ -0,0 +1,149 @@
export const dynamic = 'force-dynamic';
import { Suspense } from 'react';
import Link from 'next/link';
import { db } from '@/lib/db';
import { products, categories } from '@/lib/db/schema';
import { eq, desc, asc, and, isNull, sql, count, type AnyColumn } from 'drizzle-orm';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Plus } from 'lucide-react';
import { ProductListClient } from './product-list-client';
const SORT_MAP: Record<string, AnyColumn> = {
name: products.name,
code: products.internalCode,
brand: products.brand,
price: products.basePrice,
status: products.archivedAt,
catalog: products.publishedInCatalog,
};
async function getProducts(
search?: string,
categoryId?: string,
status?: string,
page = 1,
limit = 20,
sort?: string,
order?: string,
) {
const conditions = [];
if (status === 'archived') {
conditions.push(sql`${products.archivedAt} IS NOT NULL`);
} else if (status !== 'all') {
conditions.push(isNull(products.archivedAt));
}
if (search) {
conditions.push(
sql`(${products.name} LIKE ${`%${search}%`} OR ${products.internalCode} LIKE ${`%${search}%`})`,
);
}
if (categoryId && categoryId !== 'all') {
const catId = parseInt(categoryId);
if (!isNaN(catId)) {
conditions.push(
sql`${products.id} IN (SELECT product_id FROM product_categories WHERE category_id = ${catId})`,
);
}
}
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
const countResult = await db
.select({ total: count() })
.from(products)
.where(whereClause);
const total = countResult[0]?.total ?? 0;
const sortCol = sort ? SORT_MAP[sort] : undefined;
const orderDir = order === 'asc' ? asc : desc;
const items = await db
.select()
.from(products)
.where(whereClause)
.orderBy(sortCol ? orderDir(sortCol) : desc(products.createdAt))
.limit(limit)
.offset((page - 1) * limit);
return { items, total };
}
async function getCategories() {
return db
.select()
.from(categories)
.where(eq(categories.isActive, true))
.orderBy(categories.name);
}
export default async function ProductsPage({
searchParams,
}: {
searchParams: Promise<{
search?: string;
category?: string;
status?: string;
page?: string;
limit?: string;
sort?: string;
order?: string;
}>;
}) {
const params = await searchParams;
const page = Math.max(1, parseInt(params.page ?? '1') || 1);
const limit = Math.min(100, Math.max(5, parseInt(params.limit ?? '20') || 20));
const [{ items: productList, total }, categoryList] = await Promise.all([
getProducts(params.search, params.category, params.status, page, limit, params.sort, params.order),
getCategories(),
]);
const totalPages = Math.ceil(total / limit);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Productos</h1>
<p className="text-muted-foreground">Gestioná tu catálogo de productos</p>
</div>
<Button asChild>
<Link href="/admin21/products/new">
<Plus className="mr-2 h-4 w-4" />
Nuevo Producto
</Link>
</Button>
</div>
<Card scrollable>
<CardHeader scrollable>
<CardTitle>Lista de Productos</CardTitle>
</CardHeader>
<CardContent scrollable>
<div className="flex-1 overflow-y-auto">
<Suspense fallback={<div className="py-8 text-center text-muted-foreground">Cargando productos...</div>}>
<ProductListClient
products={productList}
categories={categoryList}
initialSearch={params.search || ''}
initialCategory={params.category || ''}
initialStatus={params.status || 'active'}
initialSort={params.sort || ''}
initialOrder={params.order || 'asc'}
page={page}
totalPages={totalPages}
totalItems={total}
limit={limit}
/>
</Suspense>
</div>
</CardContent>
</Card>
</div>
);
}
+258
View File
@@ -0,0 +1,258 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import type { categories } from '@/lib/db/schema';
type Category = typeof categories.$inferSelect;
interface ProductFormProps {
categories: Category[];
initialData?: {
id: number;
name: string;
internalCode?: string | null;
shortDescription?: string | null;
fullDescription?: string | null;
brand?: string | null;
basePrice: number;
costPrice?: number | null;
baseMarginPercentage?: number | null;
publishedInCatalog?: boolean;
featured?: boolean;
categoryIds?: number[];
};
}
export function ProductForm({ categories, initialData }: ProductFormProps) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [name, setName] = useState(initialData?.name ?? '');
const [internalCode, setInternalCode] = useState(initialData?.internalCode ?? '');
const [shortDescription, setShortDescription] = useState(initialData?.shortDescription ?? '');
const [fullDescription, setFullDescription] = useState(initialData?.fullDescription ?? '');
const [brand, setBrand] = useState(initialData?.brand ?? '');
const [basePrice, setBasePrice] = useState(String(initialData?.basePrice ?? ''));
const [costPrice, setCostPrice] = useState(String(initialData?.costPrice ?? ''));
const [baseMarginPercentage, setBaseMarginPercentage] = useState(
String(initialData?.baseMarginPercentage ?? ''),
);
const [selectedCategories, setSelectedCategories] = useState<number[]>(
initialData?.categoryIds ?? [],
);
const isEdit = !!initialData;
function toggleCategory(catId: number) {
setSelectedCategories((prev) =>
prev.includes(catId) ? prev.filter((id) => id !== catId) : [...prev, catId],
);
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
const payload = {
name,
internalCode: internalCode || undefined,
shortDescription: shortDescription || undefined,
fullDescription: fullDescription || undefined,
brand: brand || undefined,
basePrice: parseFloat(basePrice) || 0,
costPrice: costPrice ? parseFloat(costPrice) : undefined,
baseMarginPercentage: baseMarginPercentage ? parseFloat(baseMarginPercentage) : undefined,
categoryIds: selectedCategories,
};
try {
const url = isEdit ? `/api/products/${initialData.id}` : '/api/products';
const method = isEdit ? 'PUT' : 'POST';
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || 'Error al guardar el producto');
}
const product = await res.json();
router.push(`/admin21/products/${product.id}`);
router.refresh();
} catch (err) {
setError(err instanceof Error ? err.message : 'Ocurrió un error');
} finally {
setLoading(false);
}
}
return (
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">{error}</div>
)}
<Card>
<CardHeader>
<CardTitle>Detalles del Producto</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="name">Nombre *</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
required
placeholder="Nombre del producto"
/>
</div>
<div className="space-y-2">
<Label htmlFor="internalCode">Código Interno</Label>
<Input
id="internalCode"
value={internalCode}
onChange={(e) => setInternalCode(e.target.value)}
placeholder="ej: VEST-001"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="shortDescription">Descripción Corta</Label>
<Input
id="shortDescription"
value={shortDescription}
onChange={(e) => setShortDescription(e.target.value)}
maxLength={500}
placeholder="Descripción breve (máx. 500 caracteres)"
/>
</div>
<div className="space-y-2">
<Label htmlFor="fullDescription">Descripción Completa</Label>
<textarea
id="fullDescription"
value={fullDescription}
onChange={(e) => setFullDescription(e.target.value)}
className="flex min-h-[120px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
placeholder="Descripción detallada del producto"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="brand">Marca</Label>
<Input
id="brand"
value={brand}
onChange={(e) => setBrand(e.target.value)}
placeholder="Nombre de la marca"
/>
</div>
<div className="space-y-2">
<Label htmlFor="basePrice">Precio Base *</Label>
<Input
id="basePrice"
type="number"
step="0.01"
min="0"
value={basePrice}
onChange={(e) => setBasePrice(e.target.value)}
required
placeholder="0.00"
/>
</div>
<div className="space-y-2">
<Label htmlFor="costPrice">Precio de Costo</Label>
<Input
id="costPrice"
type="number"
step="0.01"
min="0"
value={costPrice}
onChange={(e) => setCostPrice(e.target.value)}
placeholder="0.00"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="baseMarginPercentage">Margen Base %</Label>
<Input
id="baseMarginPercentage"
type="number"
step="0.1"
min="0"
max="100"
value={baseMarginPercentage}
onChange={(e) => setBaseMarginPercentage(e.target.value)}
placeholder="50"
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Categorías</CardTitle>
</CardHeader>
<CardContent>
{categories.length === 0 ? (
<p className="text-sm text-muted-foreground">No hay categorías disponibles. Creá algunas primero.</p>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{categories.map((cat) => (
<label
key={cat.id}
className={`flex items-center gap-2 rounded-lg border p-3 cursor-pointer transition-all duration-200 ${
selectedCategories.includes(cat.id)
? 'border-neon-purple/50 bg-neon-purple/10'
: 'border-glass-border hover:border-muted-foreground/30 hover:bg-accent/30'
}`}
>
<input
type="checkbox"
checked={selectedCategories.includes(cat.id)}
onChange={() => toggleCategory(cat.id)}
className="rounded border-glass-border text-neon-purple focus:ring-neon-purple/20"
/>
<div className="flex items-center gap-2">
{cat.color && (
<span
className="h-3 w-3 rounded-full"
style={{ backgroundColor: cat.color }}
/>
)}
<span className="text-sm">{cat.name}</span>
</div>
</label>
))}
</div>
)}
</CardContent>
</Card>
<div className="flex gap-3">
<Button type="submit" variant="gradient" disabled={loading}>
{loading ? 'Guardando...' : isEdit ? 'Actualizar Producto' : 'Crear Producto'}
</Button>
<Button type="button" variant="outline" onClick={() => router.back()}>
Cancelar
</Button>
</div>
</form>
);
}
@@ -0,0 +1,275 @@
'use client';
import { useState, useTransition } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
SortableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Search, Archive, Eye, Copy } from 'lucide-react';
import Link from 'next/link';
import { AdminPagination } from '@/components/ui/admin-pagination';
import type { products, categories } from '@/lib/db/schema';
type Product = typeof products.$inferSelect;
type Category = typeof categories.$inferSelect;
interface ProductListClientProps {
products: Product[];
categories: Category[];
initialSearch: string;
initialCategory: string;
initialStatus: string;
initialSort: string;
initialOrder: string;
page: number;
totalPages: number;
totalItems: number;
limit: number;
}
export function ProductListClient({
products,
categories,
initialSearch,
initialCategory,
initialStatus,
initialSort,
initialOrder,
page,
totalPages,
totalItems,
limit,
}: ProductListClientProps) {
const router = useRouter();
const searchParams = useSearchParams();
const [isPending, startTransition] = useTransition();
const [search, setSearch] = useState(initialSearch);
const [category, setCategory] = useState(initialCategory);
const [status, setStatus] = useState(initialStatus);
const [sort, setSort] = useState(initialSort);
const [order, setOrder] = useState<'asc' | 'desc'>(initialOrder === 'asc' || initialOrder === 'desc' ? initialOrder : 'asc');
function buildUrl(newPage?: number, newLimit?: number) {
const params = new URLSearchParams();
if (search) params.set('search', search);
if (category && category !== 'all') params.set('category', category);
if (status && status !== 'active') params.set('status', status);
if (sort) params.set('sort', sort);
if (sort) params.set('order', order);
params.set('page', String(newPage ?? page));
params.set('limit', String(newLimit ?? limit));
return `/admin21/products?${params.toString()}`;
}
function handleSort(key: string) {
startTransition(() => {
const newOrder = sort === key && order === 'asc' ? 'desc' : 'asc';
setSort(key);
setOrder(newOrder);
const params = new URLSearchParams();
if (search) params.set('search', search);
if (category && category !== 'all') params.set('category', category);
if (status && status !== 'active') params.set('status', status);
params.set('sort', key);
params.set('order', newOrder);
params.set('page', '1');
params.set('limit', String(limit));
router.push(`/admin21/products?${params.toString()}`);
});
}
function applyFilters() {
startTransition(() => {
router.push(buildUrl(1));
});
}
function handlePageChange(newPage: number) {
startTransition(() => {
router.push(buildUrl(newPage));
});
}
function handleLimitChange(newLimit: number) {
startTransition(() => {
router.push(buildUrl(1, newLimit));
});
}
function handleArchive(productId: number) {
if (!confirm('¿Estás seguro de que querés archivar este producto?')) return;
fetch(`/api/products/${productId}`, { method: 'DELETE' }).then(() => router.refresh());
}
function handleDuplicate(productId: number) {
fetch(`/api/products/${productId}/duplicate`, { method: 'POST' }).then(() => router.refresh());
}
function handlePublish(productId: number, published: boolean) {
fetch(`/api/products/${productId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ publishedInCatalog: published }),
}).then(() => router.refresh());
}
return (
<div className="flex flex-col h-full">
{/* Filters */}
<div className="flex flex-wrap gap-3 pb-4 flex-shrink-0">
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Buscar productos..."
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && applyFilters()}
className="pl-9"
/>
</div>
<Select value={category} onValueChange={(v) => { setCategory(v); }}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Todas las categorías" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todas las categorías</SelectItem>
{categories.map((cat) => (
<SelectItem key={cat.id} value={String(cat.id)}>
{cat.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={status} onValueChange={(v) => { setStatus(v); }}>
<SelectTrigger className="w-[150px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="active">Activo</SelectItem>
<SelectItem value="archived">Archivado</SelectItem>
<SelectItem value="all">Todos</SelectItem>
</SelectContent>
</Select>
<Button onClick={applyFilters} disabled={isPending} variant="outline">
Filtrar
</Button>
</div>
{/* Table - scrollable */}
<div className="flex-1 overflow-y-auto">
<Table noScroll>
<TableHeader>
<TableRow>
<SortableHead sortKey="name" currentSort={sort} currentOrder={order} onSort={handleSort}>Nombre</SortableHead>
<SortableHead sortKey="code" currentSort={sort} currentOrder={order} onSort={handleSort}>Código</SortableHead>
<SortableHead sortKey="brand" currentSort={sort} currentOrder={order} onSort={handleSort}>Marca</SortableHead>
<SortableHead sortKey="price" currentSort={sort} currentOrder={order} onSort={handleSort}>Precio</SortableHead>
<SortableHead sortKey="status" currentSort={sort} currentOrder={order} onSort={handleSort}>Estado</SortableHead>
<SortableHead sortKey="catalog" currentSort={sort} currentOrder={order} onSort={handleSort}>Catálogo</SortableHead>
<TableHead className="text-right">Acciones</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{products.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center text-muted-foreground py-8">
No se encontraron productos
</TableCell>
</TableRow>
) : (
products.map((product) => (
<TableRow key={product.id}>
<TableCell className="font-medium">
<Link href={`/admin21/products/${product.id}`} className="hover:underline">
{product.name}
</Link>
</TableCell>
<TableCell className="text-muted-foreground">{product.internalCode || '—'}</TableCell>
<TableCell>{product.brand || '—'}</TableCell>
<TableCell>${product.basePrice?.toLocaleString() ?? '—'}</TableCell>
<TableCell>
{product.archivedAt ? (
<span className="inline-flex items-center rounded-full bg-yellow-100 px-2 py-1 text-xs text-yellow-800">
Archivado
</span>
) : (
<span className="inline-flex items-center rounded-full bg-green-100 px-2 py-1 text-xs text-green-800">
Activo
</span>
)}
</TableCell>
<TableCell>
{product.publishedInCatalog ? (
<span className="inline-flex items-center rounded-full bg-blue-100 px-2 py-1 text-xs text-blue-800">
Publicado
</span>
) : (
<span className="text-muted-foreground text-xs">Borrador</span>
)}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => handlePublish(product.id, !product.publishedInCatalog)}
title={product.publishedInCatalog ? 'Despublicar' : 'Publicar'}
>
<Eye className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDuplicate(product.id)}
title="Duplicar"
>
<Copy className="h-4 w-4" />
</Button>
{!product.archivedAt && (
<Button
variant="ghost"
size="icon"
onClick={() => handleArchive(product.id)}
title="Archivar"
>
<Archive className="h-4 w-4" />
</Button>
)}
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Pagination - fixed at bottom */}
<div className="flex-shrink-0 pt-4">
<AdminPagination
page={page}
totalPages={totalPages}
totalItems={totalItems}
limit={limit}
onPageChange={handlePageChange}
onLimitChange={handleLimitChange}
/>
</div>
</div>
);
}
+72
View File
@@ -0,0 +1,72 @@
export const dynamic = 'force-dynamic';
import { notFound } from 'next/navigation';
import { db } from '@/lib/db';
import { purchases, purchaseItems, suppliers, variants, products, inventoryMovements } from '@/lib/db/schema';
import { eq, and, desc } from 'drizzle-orm';
import { PurchaseDetailClient } from './purchase-detail-client';
type RouteContext = { params: Promise<{ id: string }> };
export default async function PurchaseDetailPage({ params }: RouteContext) {
const { id } = await params;
const purchaseId = parseInt(id);
if (isNaN(purchaseId)) notFound();
const [purchase] = await db
.select()
.from(purchases)
.where(eq(purchases.id, purchaseId))
.limit(1);
if (!purchase) notFound();
const [supplier] = await db
.select()
.from(suppliers)
.where(eq(suppliers.id, purchase.supplierId))
.limit(1);
const items = await db
.select({
id: purchaseItems.id,
purchaseId: purchaseItems.purchaseId,
variantId: purchaseItems.variantId,
quantity: purchaseItems.quantity,
quantityOrdered: purchaseItems.quantityOrdered,
quantityReceived: purchaseItems.quantityReceived,
unitCost: purchaseItems.unitCost,
allocatedAdditionalCost: purchaseItems.allocatedAdditionalCost,
realUnitCost: purchaseItems.realUnitCost,
lineSubtotal: purchaseItems.lineSubtotal,
totalCost: purchaseItems.totalCost,
sku: variants.sku,
size: variants.size,
color: variants.color,
productName: products.name,
})
.from(purchaseItems)
.leftJoin(variants, eq(purchaseItems.variantId, variants.id))
.leftJoin(products, eq(variants.productId, products.id))
.where(eq(purchaseItems.purchaseId, purchaseId));
const movements = await db
.select()
.from(inventoryMovements)
.where(
and(
eq(inventoryMovements.referenceType, 'purchase'),
eq(inventoryMovements.referenceId, String(purchaseId)),
),
)
.orderBy(desc(inventoryMovements.createdAt));
return (
<PurchaseDetailClient
purchase={purchase}
supplier={supplier || null}
items={items}
movements={movements}
/>
);
}
@@ -0,0 +1,490 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { ArrowLeft, CheckCircle, XCircle, Pencil, X } from 'lucide-react';
import { formatCurrency, formatDate, formatDateTime } from '@/lib/utils/format';
interface Purchase {
id: number;
supplierId: number;
purchaseNumber: string;
purchaseDate: Date;
subtotal: number;
shippingCost: number;
commissions: number;
taxes: number;
packagingCost: number;
otherCosts: number;
totalCost: number;
allocationMethod: string;
status: string;
notes: string | null;
}
interface Supplier {
id: number;
name: string;
}
interface PurchaseItem {
id: number;
variantId: number;
quantityOrdered: number;
quantityReceived: number;
unitCost: number;
allocatedAdditionalCost: number;
realUnitCost: number;
lineSubtotal: number;
sku: string | null;
size: string | null;
color: string | null;
productName: string | null;
}
interface Movement {
id: number;
productVariantId: number;
movementType: string;
quantity: number;
previousStock: number;
resultingStock: number;
unitCost: number | null;
createdAt: Date;
}
function StatusBadge({ status }: { status: string }) {
const colors: Record<string, string> = {
draft: 'bg-gray-100 text-gray-800',
confirmed: 'bg-blue-100 text-blue-800',
partially_received: 'bg-yellow-100 text-yellow-800',
received: 'bg-green-100 text-green-800',
cancelled: 'bg-red-100 text-red-800',
};
const labels: Record<string, string> = {
draft: 'Borrador',
confirmed: 'Confirmada',
partially_received: 'Recibido Parcialmente',
received: 'Recibido',
cancelled: 'Cancelada',
};
return (
<span className={`inline-flex items-center rounded-full px-3 py-1 text-sm ${colors[status] || 'bg-gray-100 text-gray-800'}`}>
{labels[status] || status}
</span>
);
}
interface PurchaseDetailClientProps {
purchase: Purchase;
supplier: Supplier | null;
items: PurchaseItem[];
movements: Movement[];
}
export function PurchaseDetailClient({
purchase,
supplier,
items,
movements,
}: PurchaseDetailClientProps) {
const router = useRouter();
const [receiveQuantities, setReceiveQuantities] = useState<Record<number, string>>({});
const [actionLoading, setActionLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false);
const canEdit = purchase.status === 'draft';
const canConfirm = purchase.status === 'draft';
const canReceive = purchase.status === 'confirmed' || purchase.status === 'partially_received';
const canCancel = purchase.status === 'draft' || purchase.status === 'confirmed';
async function handleConfirm() {
if (!confirm('¿Confirmar esta compra? Los items quedarán bloqueados para edición.')) return;
setActionLoading(true);
setError(null);
try {
const res = await fetch(`/api/purchases/${purchase.id}/confirm`, { method: 'POST' });
if (!res.ok) {
const data = await res.json();
setError(data.error || 'Error al confirmar');
return;
}
router.refresh();
} catch {
setError('Error al confirmar la compra');
} finally {
setActionLoading(false);
}
}
async function handleReceive() {
const receiveItems = Object.entries(receiveQuantities)
.filter(([, qty]) => parseInt(qty) > 0)
.map(([itemId, qty]) => ({
purchaseItemId: parseInt(itemId),
quantityReceived: parseInt(qty),
}));
if (receiveItems.length === 0) {
setError('Ingresá al menos una cantidad para recibir');
return;
}
setActionLoading(true);
setError(null);
try {
const res = await fetch(`/api/purchases/${purchase.id}/receive`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items: receiveItems }),
});
if (!res.ok) {
const data = await res.json();
setError(data.error || 'Error al recibir');
return;
}
setReceiveQuantities({});
router.refresh();
} catch {
setError('Error al recibir los items');
} finally {
setActionLoading(false);
}
}
async function handleCancel() {
if (!confirm('¿Cancelar esta compra? Esta acción no se puede deshacer.')) return;
setActionLoading(true);
setError(null);
try {
const res = await fetch(`/api/purchases/${purchase.id}/cancel`, { method: 'POST' });
if (!res.ok) {
const data = await res.json();
setError(data.error || 'Error al cancelar');
return;
}
router.refresh();
} catch {
setError('Error al cancelar la compra');
} finally {
setActionLoading(false);
}
}
async function handleSave(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setSaving(true);
setError(null);
const formData = new FormData(e.currentTarget);
const body = {
shippingCost: parseFloat(formData.get('shippingCost') as string) || 0,
commissions: parseFloat(formData.get('commissions') as string) || 0,
taxes: parseFloat(formData.get('taxes') as string) || 0,
packagingCost: parseFloat(formData.get('packagingCost') as string) || 0,
otherCosts: parseFloat(formData.get('otherCosts') as string) || 0,
notes: (formData.get('notes') as string) || undefined,
};
try {
const res = await fetch(`/api/purchases/${purchase.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const data = await res.json();
setError(data.error || 'Error al guardar');
return;
}
setEditing(false);
router.refresh();
} catch {
setError('Error al guardar');
} finally {
setSaving(false);
}
}
const additionalCosts =
purchase.shippingCost +
purchase.commissions +
purchase.taxes +
purchase.packagingCost +
purchase.otherCosts;
const allocationLabels: Record<string, string> = {
BY_QUANTITY: 'Por cantidad',
BY_VALUE: 'Por valor',
MANUAL: 'Manual',
};
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" asChild>
<Link href="/admin21/purchases">
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
<div className="flex-1">
<h1 className="text-2xl font-bold tracking-tight">{purchase.purchaseNumber}</h1>
<p className="text-muted-foreground">
{supplier?.name || 'Proveedor desconocido'} · {formatDate(purchase.purchaseDate)}
</p>
</div>
<StatusBadge status={purchase.status} />
{canEdit && !editing && (
<Button variant="outline" onClick={() => setEditing(true)}>
<Pencil className="mr-2 h-4 w-4" />
Editar
</Button>
)}
{editing && (
<Button variant="ghost" onClick={() => { setEditing(false); setError(null); }}>
<X className="mr-2 h-4 w-4" />
Cancelar
</Button>
)}
</div>
{error && (
<div className="rounded-lg border border-red-500/30 bg-red-500/10 p-3 text-sm text-red-400">{error}</div>
)}
{/* Actions */}
<div className="flex gap-3">
{canConfirm && (
<Button onClick={handleConfirm} disabled={actionLoading}>
<CheckCircle className="mr-2 h-4 w-4" />
Confirmar Compra
</Button>
)}
{canCancel && (
<Button variant="outline" onClick={handleCancel} disabled={actionLoading}>
<XCircle className="mr-2 h-4 w-4" />
Cancelar
</Button>
)}
</div>
{/* Items */}
<Card>
<CardHeader>
<CardTitle>Items</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Producto</TableHead>
<TableHead>SKU</TableHead>
<TableHead>Pedidos</TableHead>
<TableHead>Recibidos</TableHead>
<TableHead>Costo Unit.</TableHead>
<TableHead>Costo Asignado</TableHead>
<TableHead>Costo Real Unit.</TableHead>
<TableHead>Subtotal</TableHead>
{canReceive && <TableHead>Recibir</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{items.map((item) => {
const remaining = item.quantityOrdered - item.quantityReceived;
return (
<TableRow key={item.id}>
<TableCell className="font-medium">
{item.productName || 'Desconocido'}
</TableCell>
<TableCell className="text-muted-foreground">{item.sku || '—'}</TableCell>
<TableCell>{item.quantityOrdered}</TableCell>
<TableCell>{item.quantityReceived}</TableCell>
<TableCell>{formatCurrency(item.unitCost)}</TableCell>
<TableCell>{formatCurrency(item.allocatedAdditionalCost)}</TableCell>
<TableCell>{formatCurrency(item.realUnitCost)}</TableCell>
<TableCell>{formatCurrency(item.lineSubtotal)}</TableCell>
{canReceive && (
<TableCell>
<Input
type="number"
min="0"
max={remaining}
placeholder={`0 / ${remaining}`}
value={receiveQuantities[item.id] || ''}
onChange={(e) =>
setReceiveQuantities((prev) => ({
...prev,
[item.id]: e.target.value,
}))
}
className="w-[100px]"
/>
</TableCell>
)}
</TableRow>
);
})}
</TableBody>
</Table>
{canReceive && (
<div className="mt-4">
<Button onClick={handleReceive} disabled={actionLoading}>
Recibir Items
</Button>
</div>
)}
</CardContent>
</Card>
{/* Cost Summary — Read mode */}
{!editing && (
<Card>
<CardHeader>
<CardTitle>Resumen de Costos</CardTitle>
</CardHeader>
<CardContent>
<dl className="grid grid-cols-2 gap-3 text-sm">
<div>Subtotal</div>
<div className="text-right">{formatCurrency(purchase.subtotal)}</div>
<div>Envío</div>
<div className="text-right">{formatCurrency(purchase.shippingCost)}</div>
<div>Comisiones</div>
<div className="text-right">{formatCurrency(purchase.commissions)}</div>
<div>Impuestos</div>
<div className="text-right">{formatCurrency(purchase.taxes)}</div>
<div>Empaque</div>
<div className="text-right">{formatCurrency(purchase.packagingCost)}</div>
<div>Otros</div>
<div className="text-right">{formatCurrency(purchase.otherCosts)}</div>
<dt className="font-bold border-t pt-2">Total</dt>
<dd className="text-right font-bold border-t pt-2">{formatCurrency(purchase.totalCost)}</dd>
</dl>
<p className="mt-2 text-xs text-muted-foreground">
Método de asignación: {allocationLabels[purchase.allocationMethod] || purchase.allocationMethod}
</p>
</CardContent>
</Card>
)}
{/* Cost Summary — Edit mode */}
{editing && (
<Card>
<CardHeader>
<CardTitle>Editar Costos</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSave} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="shippingCost">Envío</Label>
<Input id="shippingCost" name="shippingCost" type="number" step="0.01" min="0" defaultValue={purchase.shippingCost} />
</div>
<div className="space-y-2">
<Label htmlFor="commissions">Comisiones</Label>
<Input id="commissions" name="commissions" type="number" step="0.01" min="0" defaultValue={purchase.commissions} />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="taxes">Impuestos</Label>
<Input id="taxes" name="taxes" type="number" step="0.01" min="0" defaultValue={purchase.taxes} />
</div>
<div className="space-y-2">
<Label htmlFor="packagingCost">Empaque</Label>
<Input id="packagingCost" name="packagingCost" type="number" step="0.01" min="0" defaultValue={purchase.packagingCost} />
</div>
</div>
<div className="space-y-2">
<Label htmlFor="otherCosts">Otros Costos</Label>
<Input id="otherCosts" name="otherCosts" type="number" step="0.01" min="0" defaultValue={purchase.otherCosts} />
</div>
<div className="space-y-2">
<Label htmlFor="notes">Notas</Label>
<textarea
id="notes"
name="notes"
rows={3}
defaultValue={purchase.notes ?? ''}
className="flex w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</div>
<div className="flex gap-3">
<Button type="submit" disabled={saving}>
{saving ? 'Guardando...' : 'Guardar'}
</Button>
<Button type="button" variant="outline" onClick={() => { setEditing(false); setError(null); }}>
Cancelar
</Button>
</div>
</form>
</CardContent>
</Card>
)}
{/* Movement History */}
{movements.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Historial de Movimientos</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Fecha</TableHead>
<TableHead>Variante</TableHead>
<TableHead>Cantidad</TableHead>
<TableHead>Stock Anterior</TableHead>
<TableHead>Stock Nuevo</TableHead>
<TableHead>Costo Unit.</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{movements.map((m) => (
<TableRow key={m.id}>
<TableCell>{formatDateTime(m.createdAt)}</TableCell>
<TableCell>{m.productVariantId}</TableCell>
<TableCell>{m.quantity}</TableCell>
<TableCell>{m.previousStock}</TableCell>
<TableCell>{m.resultingStock}</TableCell>
<TableCell>{m.unitCost != null ? formatCurrency(m.unitCost) : '—'}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
{/* Notes (read mode) */}
{!editing && purchase.notes && (
<Card>
<CardHeader>
<CardTitle>Notas</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">{purchase.notes}</p>
</CardContent>
</Card>
)}
</div>
);
}
+365
View File
@@ -0,0 +1,365 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Plus, Trash2 } from 'lucide-react';
interface Supplier {
id: number;
name: string;
}
interface Variant {
id: number;
sku: string;
size: string;
color: string | null;
productName: string;
}
interface LineItem {
variantId: number;
variantLabel: string;
quantityOrdered: number;
unitCost: number;
allocatedAdditionalCost: number;
}
export default function NewPurchasePage() {
const router = useRouter();
const [suppliers, setSuppliers] = useState<Supplier[]>([]);
const [variants, setVariants] = useState<Variant[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [supplierId, setSupplierId] = useState('');
const [shippingCost, setShippingCost] = useState(0);
const [commissions, setCommissions] = useState(0);
const [taxes, setTaxes] = useState(0);
const [packagingCost, setPackagingCost] = useState(0);
const [otherCosts, setOtherCosts] = useState(0);
const [allocationMethod, setAllocationMethod] = useState<'BY_QUANTITY' | 'BY_VALUE' | 'MANUAL'>('BY_QUANTITY');
const [items, setItems] = useState<LineItem[]>([]);
const [notes, setNotes] = useState('');
// Form for adding items
const [selectedVariantId, setSelectedVariantId] = useState('');
const [itemQty, setItemQty] = useState('');
const [itemUnitCost, setItemUnitCost] = useState('');
useEffect(() => {
fetch('/api/suppliers?active=true')
.then((r) => r.json())
.then((d) => setSuppliers(d.items || []));
// Fetch all variants with product names
fetch('/api/products?status=all&limit=100')
.then((r) => r.json())
.then(async (d) => {
const allVariants: Variant[] = [];
for (const product of d.items || []) {
const detail = await fetch(`/api/products/${product.id}`).then((r) => r.json());
for (const v of detail.variants || []) {
if (v.isActive) {
allVariants.push({
id: v.id,
sku: v.sku,
size: v.size,
color: v.color,
productName: product.name,
});
}
}
}
setVariants(allVariants);
});
}, []);
function addItem() {
const variantId = parseInt(selectedVariantId);
const qty = parseInt(itemQty);
const cost = parseFloat(itemUnitCost);
if (isNaN(variantId) || isNaN(qty) || qty <= 0 || isNaN(cost) || cost < 0) return;
const variant = variants.find((v) => v.id === variantId);
if (!variant) return;
const label = `${variant.productName} - ${variant.size}${variant.color ? ` / ${variant.color}` : ''} (${variant.sku})`;
setItems((prev) => [
...prev,
{
variantId,
variantLabel: label,
quantityOrdered: qty,
unitCost: cost,
allocatedAdditionalCost: 0,
},
]);
setSelectedVariantId('');
setItemQty('');
setItemUnitCost('');
}
function removeItem(index: number) {
setItems((prev) => prev.filter((_, i) => i !== index));
}
const subtotal = items.reduce((sum, item) => sum + item.quantityOrdered * item.unitCost, 0);
const additionalCosts = shippingCost + commissions + taxes + packagingCost + otherCosts;
const totalCost = subtotal + additionalCosts;
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!supplierId || items.length === 0) {
setError('Seleccioná un proveedor y agregá al menos un item');
return;
}
setLoading(true);
setError(null);
try {
const res = await fetch('/api/purchases', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
supplierId: parseInt(supplierId),
shippingCost,
commissions,
taxes,
packagingCost,
otherCosts,
allocationMethod,
notes: notes || undefined,
items: items.map((item) => ({
variantId: item.variantId,
quantityOrdered: item.quantityOrdered,
unitCost: item.unitCost,
allocatedAdditionalCost: item.allocatedAdditionalCost,
})),
}),
});
if (!res.ok) {
const data = await res.json();
setError(data.error || 'Error al crear la compra');
return;
}
const purchase = await res.json();
router.push(`/admin21/purchases/${purchase.id}`);
} catch {
setError('Error al crear la compra');
} finally {
setLoading(false);
}
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">Nueva Compra</h1>
<p className="text-muted-foreground">Creá una nueva orden de compra</p>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="rounded-lg border border-red-500/30 bg-red-500/10 p-3 text-sm text-red-400">{error}</div>
)}
<Card>
<CardHeader>
<CardTitle>Proveedor</CardTitle>
</CardHeader>
<CardContent>
<Select value={supplierId} onValueChange={setSupplierId}>
<SelectTrigger className="w-[300px]">
<SelectValue placeholder="Seleccionar proveedor..." />
</SelectTrigger>
<SelectContent>
{suppliers.map((s) => (
<SelectItem key={s.id} value={String(s.id)}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Items</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap gap-3 items-end">
<div className="space-y-1">
<Label>Variante</Label>
<Select value={selectedVariantId} onValueChange={setSelectedVariantId}>
<SelectTrigger className="w-[300px]">
<SelectValue placeholder="Seleccionar variante..." />
</SelectTrigger>
<SelectContent>
{variants.map((v) => (
<SelectItem key={v.id} value={String(v.id)}>
{v.productName} - {v.size}{v.color ? ` / ${v.color}` : ''} ({v.sku})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label>Cantidad</Label>
<Input
type="number"
min="1"
value={itemQty}
onChange={(e) => setItemQty(e.target.value)}
className="w-[100px]"
/>
</div>
<div className="space-y-1">
<Label>Costo Unitario</Label>
<Input
type="number"
min="0"
step="0.01"
value={itemUnitCost}
onChange={(e) => setItemUnitCost(e.target.value)}
className="w-[120px]"
/>
</div>
<Button type="button" onClick={addItem} variant="outline">
<Plus className="mr-1 h-4 w-4" />
Agregar
</Button>
</div>
{items.length > 0 && (
<Table>
<TableHeader>
<TableRow>
<TableHead>Variante</TableHead>
<TableHead>Cant.</TableHead>
<TableHead>Costo Unit.</TableHead>
<TableHead>Subtotal</TableHead>
<TableHead className="w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item, idx) => (
<TableRow key={idx}>
<TableCell className="font-medium">{item.variantLabel}</TableCell>
<TableCell>{item.quantityOrdered}</TableCell>
<TableCell>${item.unitCost.toFixed(2)}</TableCell>
<TableCell>${(item.quantityOrdered * item.unitCost).toFixed(2)}</TableCell>
<TableCell>
<Button type="button" variant="ghost" size="icon" onClick={() => removeItem(idx)}>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Costos Adicionales</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-1">
<Label>Envío</Label>
<Input type="number" min="0" step="0.01" value={shippingCost} onChange={(e) => setShippingCost(parseFloat(e.target.value) || 0)} />
</div>
<div className="space-y-1">
<Label>Comisiones</Label>
<Input type="number" min="0" step="0.01" value={commissions} onChange={(e) => setCommissions(parseFloat(e.target.value) || 0)} />
</div>
<div className="space-y-1">
<Label>Impuestos</Label>
<Input type="number" min="0" step="0.01" value={taxes} onChange={(e) => setTaxes(parseFloat(e.target.value) || 0)} />
</div>
<div className="space-y-1">
<Label>Empaque</Label>
<Input type="number" min="0" step="0.01" value={packagingCost} onChange={(e) => setPackagingCost(parseFloat(e.target.value) || 0)} />
</div>
<div className="space-y-1">
<Label>Otros Costos</Label>
<Input type="number" min="0" step="0.01" value={otherCosts} onChange={(e) => setOtherCosts(parseFloat(e.target.value) || 0)} />
</div>
<div className="space-y-1">
<Label>Método de Asignación</Label>
<Select value={allocationMethod} onValueChange={(v) => setAllocationMethod(v as 'BY_QUANTITY' | 'BY_VALUE' | 'MANUAL')}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="BY_QUANTITY">Por Cantidad</SelectItem>
<SelectItem value="BY_VALUE">Por Valor</SelectItem>
<SelectItem value="MANUAL">Manual</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="mt-4 flex gap-8 text-sm">
<div>Subtotal: <strong>${subtotal.toFixed(2)}</strong></div>
<div>Additional: <strong>${additionalCosts.toFixed(2)}</strong></div>
<div>Total: <strong>${totalCost.toFixed(2)}</strong></div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Notas</CardTitle>
</CardHeader>
<CardContent>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={3}
className="flex w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
placeholder="Notas opcionales..."
/>
</CardContent>
</Card>
<div className="flex gap-3">
<Button type="submit" disabled={loading}>
{loading ? 'Creando...' : 'Crear Compra'}
</Button>
<Button type="button" variant="outline" onClick={() => router.push('/admin21/purchases')}>
Cancelar
</Button>
</div>
</form>
</div>
);
}
+129
View File
@@ -0,0 +1,129 @@
export const dynamic = 'force-dynamic';
import Link from 'next/link';
import { db } from '@/lib/db';
import { purchases, suppliers } from '@/lib/db/schema';
import { eq, desc, asc, and, sql, count, type SQL, type AnyColumn } from 'drizzle-orm';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Plus } from 'lucide-react';
import { PurchaseListClient } from './purchase-list-client';
import { formatCurrency } from '@/lib/utils/format';
import { ExportButton } from '@/components/reports/export-button';
const PURCHASE_SORT_MAP: Record<string, AnyColumn> = {
number: purchases.purchaseNumber,
supplier: suppliers.name,
date: purchases.purchaseDate,
total: purchases.totalCost,
status: purchases.status,
};
async function getPurchases(status?: string, supplierId?: string, page = 1, limit = 20, sort?: string, order?: string) {
const conditions: SQL[] = [];
if (status && status !== 'all') {
conditions.push(eq(purchases.status, status as typeof purchases.status.enumValues[number]));
}
if (supplierId) {
const sid = parseInt(supplierId);
if (!isNaN(sid)) {
conditions.push(eq(purchases.supplierId, sid));
}
}
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
const [countResult] = await db
.select({ total: count() })
.from(purchases)
.where(whereClause);
const total = countResult?.total ?? 0;
const sortCol = sort ? PURCHASE_SORT_MAP[sort] : undefined;
const orderDir = order === 'asc' ? asc : desc;
const items = await db
.select({
id: purchases.id,
purchaseNumber: purchases.purchaseNumber,
purchaseDate: purchases.purchaseDate,
totalCost: purchases.totalCost,
status: purchases.status,
supplierId: purchases.supplierId,
supplierName: suppliers.name,
})
.from(purchases)
.leftJoin(suppliers, eq(purchases.supplierId, suppliers.id))
.where(whereClause)
.orderBy(sortCol ? orderDir(sortCol) : desc(purchases.purchaseDate))
.limit(limit)
.offset((page - 1) * limit);
return { items, total };
}
async function getSuppliers() {
return db
.select({ id: suppliers.id, name: suppliers.name })
.from(suppliers)
.where(eq(suppliers.isActive, true))
.orderBy(suppliers.name);
}
export default async function PurchasesPage({
searchParams,
}: {
searchParams: Promise<{ status?: string; supplierId?: string; page?: string; limit?: string; sort?: string; order?: string }>;
}) {
const params = await searchParams;
const page = Math.max(1, parseInt(params.page ?? '1') || 1);
const limit = Math.min(100, Math.max(5, parseInt(params.limit ?? '20') || 20));
const [{ items: purchaseList, total }, supplierList] = await Promise.all([
getPurchases(params.status, params.supplierId, page, limit, params.sort, params.order),
getSuppliers(),
]);
const totalPages = Math.ceil(total / limit);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Compras</h1>
<p className="text-muted-foreground">Gestioná las órdenes de compra</p>
</div>
<div className="flex gap-2">
<ExportButton reportType="purchases" fetchUrl="/api/reports/purchases" />
<Button asChild>
<Link href="/admin21/purchases/new">
<Plus className="mr-2 h-4 w-4" />
Nueva Compra
</Link>
</Button>
</div>
</div>
<Card scrollable>
<CardHeader scrollable>
<CardTitle>Lista de Compras</CardTitle>
</CardHeader>
<CardContent scrollable>
<div className="flex-1 overflow-y-auto">
<PurchaseListClient
purchases={purchaseList}
suppliers={supplierList}
initialStatus={params.status || 'all'}
initialSupplierId={params.supplierId || ''}
initialSort={params.sort || ''}
initialOrder={params.order || 'asc'}
page={page}
totalPages={totalPages}
totalItems={total}
limit={limit}
/>
</div>
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,228 @@
'use client';
import { useState, useTransition } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
SortableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { formatCurrency, formatDate } from '@/lib/utils/format';
import { AdminPagination } from '@/components/ui/admin-pagination';
interface Purchase {
id: number;
purchaseNumber: string;
purchaseDate: Date;
totalCost: number;
status: string;
supplierId: number;
supplierName: string | null;
}
interface SupplierOption {
id: number;
name: string;
}
interface PurchaseListClientProps {
purchases: Purchase[];
suppliers: SupplierOption[];
initialStatus: string;
initialSupplierId: string;
initialSort: string;
initialOrder: string;
page: number;
totalPages: number;
totalItems: number;
limit: number;
}
function StatusBadge({ status }: { status: string }) {
const colors: Record<string, string> = {
draft: 'bg-gray-100 text-gray-800',
confirmed: 'bg-blue-100 text-blue-800',
partially_received: 'bg-yellow-100 text-yellow-800',
received: 'bg-green-100 text-green-800',
cancelled: 'bg-red-100 text-red-800',
};
const labels: Record<string, string> = {
draft: 'Draft',
confirmed: 'Confirmada',
partially_received: 'Partial',
received: 'Received',
cancelled: 'Cancelada',
};
return (
<span className={`inline-flex items-center rounded-full px-2 py-1 text-xs ${colors[status] || 'bg-gray-100 text-gray-800'}`}>
{labels[status] || status}
</span>
);
}
export function PurchaseListClient({
purchases,
suppliers,
initialStatus,
initialSupplierId,
initialSort,
initialOrder,
page,
totalPages,
totalItems,
limit,
}: PurchaseListClientProps) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [status, setStatus] = useState(initialStatus);
const [supplierId, setSupplierId] = useState(initialSupplierId);
const [sort, setSort] = useState(initialSort);
const [order, setOrder] = useState<'asc' | 'desc'>(initialOrder === 'asc' || initialOrder === 'desc' ? initialOrder : 'asc');
function buildUrl(newPage?: number, newLimit?: number) {
const params = new URLSearchParams();
if (status && status !== 'all') params.set('status', status);
if (supplierId) params.set('supplierId', supplierId);
if (sort) params.set('sort', sort);
if (sort) params.set('order', order);
params.set('page', String(newPage ?? page));
params.set('limit', String(newLimit ?? limit));
return `/admin21/purchases?${params.toString()}`;
}
function handleSort(key: string) {
startTransition(() => {
const newOrder = sort === key && order === 'asc' ? 'desc' : 'asc';
setSort(key);
setOrder(newOrder);
const params = new URLSearchParams();
if (status && status !== 'all') params.set('status', status);
if (supplierId) params.set('supplierId', supplierId);
params.set('sort', key);
params.set('order', newOrder);
params.set('page', '1');
params.set('limit', String(limit));
router.push(`/admin21/purchases?${params.toString()}`);
});
}
function applyFilters() {
startTransition(() => {
router.push(buildUrl(1));
});
}
function handlePageChange(newPage: number) {
startTransition(() => {
router.push(buildUrl(newPage));
});
}
function handleLimitChange(newLimit: number) {
startTransition(() => {
router.push(buildUrl(1, newLimit));
});
}
return (
<div className="flex flex-col h-full">
{/* Filters - fixed at top */}
<div className="flex flex-wrap gap-3 pb-4 flex-shrink-0">
<Select value={status} onValueChange={setStatus}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="All statuses" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All statuses</SelectItem>
<SelectItem value="draft">Draft</SelectItem>
<SelectItem value="confirmed">Confirmed</SelectItem>
<SelectItem value="partially_received">Partial</SelectItem>
<SelectItem value="received">Received</SelectItem>
<SelectItem value="cancelled">Cancelled</SelectItem>
</SelectContent>
</Select>
<Select value={supplierId} onValueChange={setSupplierId}>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="All suppliers" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All suppliers</SelectItem>
{suppliers.map((s) => (
<SelectItem key={s.id} value={String(s.id)}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button onClick={applyFilters} disabled={isPending} variant="outline">
Filtrar
</Button>
</div>
{/* Table - scrollable */}
<div className="flex-1 overflow-y-auto">
<Table noScroll>
<TableHeader>
<TableRow>
<SortableHead sortKey="number" currentSort={sort} currentOrder={order} onSort={handleSort}>Number</SortableHead>
<SortableHead sortKey="supplier" currentSort={sort} currentOrder={order} onSort={handleSort}>Supplier</SortableHead>
<SortableHead sortKey="date" currentSort={sort} currentOrder={order} onSort={handleSort}>Date</SortableHead>
<SortableHead sortKey="total" currentSort={sort} currentOrder={order} onSort={handleSort}>Total</SortableHead>
<SortableHead sortKey="status" currentSort={sort} currentOrder={order} onSort={handleSort}>Status</SortableHead>
</TableRow>
</TableHeader>
<TableBody>
{purchases.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground py-8">
No purchases found
</TableCell>
</TableRow>
) : (
purchases.map((p) => (
<TableRow key={p.id}>
<TableCell className="font-medium">
<Link href={`/admin21/purchases/${p.id}`} className="hover:underline">
{p.purchaseNumber}
</Link>
</TableCell>
<TableCell>{p.supplierName || '—'}</TableCell>
<TableCell>{formatDate(p.purchaseDate)}</TableCell>
<TableCell>{formatCurrency(p.totalCost)}</TableCell>
<TableCell>
<StatusBadge status={p.status} />
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Pagination - fixed at bottom */}
<div className="flex-shrink-0 pt-4">
<AdminPagination
page={page}
totalPages={totalPages}
totalItems={totalItems}
limit={limit}
onPageChange={handlePageChange}
onLimitChange={handleLimitChange}
/>
</div>
</div>
);
}
@@ -0,0 +1,136 @@
export const dynamic = 'force-dynamic';
import { db } from '@/lib/db';
import { saleItems, sales, variants, products } from '@/lib/db/schema';
import { eq, and, desc, sql } from 'drizzle-orm';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { ExportButton } from '@/components/reports/export-button';
import { formatCurrency, formatNumber } from '@/lib/utils/format';
import { Star } from 'lucide-react';
async function getBestSellersData() {
const rows = await db
.select({
productName: products.name,
sku: variants.sku,
totalQtySold: sql<number>`SUM(${saleItems.quantity})`,
totalRevenue: sql<number>`SUM(${saleItems.lineSubtotal})`,
avgUnitPrice: sql<number>`AVG(${saleItems.unitPrice})`,
})
.from(saleItems)
.innerJoin(sales, eq(saleItems.saleId, sales.id))
.innerJoin(variants, eq(saleItems.variantId, variants.id))
.innerJoin(products, eq(variants.productId, products.id))
.where(
sql`${sales.status} IN ('CONFIRMED', 'DELIVERED')`,
)
.groupBy(variants.id)
.orderBy(desc(sql`SUM(${saleItems.quantity})`))
.limit(25);
return rows.map((r, idx) => ({
rank: idx + 1,
productName: r.productName,
sku: r.sku,
totalQtySold: r.totalQtySold,
totalRevenue: r.totalRevenue,
avgUnitPrice: Math.round((r.avgUnitPrice ?? 0) * 100) / 100,
}));
}
export default async function BestSellersReportPage() {
const items = await getBestSellersData();
const totalQty = items.reduce((sum, i) => sum + i.totalQtySold, 0);
const totalRevenue = items.reduce((sum, i) => sum + i.totalRevenue, 0);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Productos Mas Vendidos</h1>
<p className="text-muted-foreground">Ranking por cantidad vendida</p>
</div>
<ExportButton reportType="best-sellers" fetchUrl="/api/reports/analytics/best-sellers" />
</div>
<div className="grid gap-4 sm:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground">Productos</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{items.length}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground">Unidades Vendidas</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{formatNumber(totalQty)}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground">Ingreso Total</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{formatCurrency(totalRevenue)}</p>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Star className="h-5 w-5 text-muted-foreground" />
<CardTitle>Top 25 Productos</CardTitle>
</div>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">#</TableHead>
<TableHead>SKU</TableHead>
<TableHead>Producto</TableHead>
<TableHead className="text-right">Cant. Vendida</TableHead>
<TableHead className="text-right">Ingreso Total</TableHead>
<TableHead className="text-right">Precio Prom.</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item) => (
<TableRow key={item.rank}>
<TableCell className="font-medium">{item.rank}</TableCell>
<TableCell className="font-mono text-xs">{item.sku}</TableCell>
<TableCell>{item.productName}</TableCell>
<TableCell className="text-right font-medium">
{formatNumber(item.totalQtySold)}
</TableCell>
<TableCell className="text-right">
{formatCurrency(item.totalRevenue)}
</TableCell>
<TableCell className="text-right">
{formatCurrency(item.avgUnitPrice)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,187 @@
export const dynamic = 'force-dynamic';
import { db } from '@/lib/db';
import { variants, products, saleItems, sales } from '@/lib/db/schema';
import { eq, and, sql, gt } from 'drizzle-orm';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { ExportButton } from '@/components/reports/export-button';
import { formatNumber } from '@/lib/utils/format';
import { BarChart3 } from 'lucide-react';
async function getLowRotationData() {
// Get all active variants with stock
const variantsWithStock = await db
.select({
id: variants.id,
sku: variants.sku,
productName: products.name,
size: variants.size,
color: variants.color,
currentStock: variants.currentStock,
})
.from(variants)
.innerJoin(products, eq(variants.productId, products.id))
.where(
and(
eq(variants.isActive, true),
gt(variants.currentStock, 0),
),
)
.limit(500);
const now = new Date();
const results = [];
for (const v of variantsWithStock) {
const [saleStats] = await db
.select({
qtySold: sql<number>`COALESCE(SUM(${saleItems.quantity}), 0)`,
lastSaleDate: sql<string>`MAX(${sales.saleDate})`,
})
.from(saleItems)
.innerJoin(sales, eq(saleItems.saleId, sales.id))
.where(
and(
eq(saleItems.variantId, v.id),
sql`${sales.status} IN ('CONFIRMED', 'DELIVERED')`,
),
);
const qtySold = saleStats?.qtySold ?? 0;
const lastSaleDate = saleStats?.lastSaleDate;
let daysSinceLastSale: number | null = null;
if (lastSaleDate) {
const lastDate = new Date(lastSaleDate);
daysSinceLastSale = Math.floor(
(now.getTime() - lastDate.getTime()) / (1000 * 60 * 60 * 24),
);
}
// Include variants with zero sales or last sale > 30 days ago
if (qtySold === 0 || (daysSinceLastSale !== null && daysSinceLastSale > 30)) {
results.push({
sku: v.sku,
productName: v.productName,
variant: [v.size, v.color].filter(Boolean).join(' / '),
currentStock: v.currentStock ?? 0,
qtySoldInPeriod: qtySold,
daysSinceLastSale,
});
}
}
// Sort by stock descending (highest risk first)
results.sort((a, b) => b.currentStock - a.currentStock);
return results.slice(0, 200);
}
export default async function LowRotationReportPage() {
const items = await getLowRotationData();
const totalVariants = items.length;
const totalStock = items.reduce((sum, i) => sum + i.currentStock, 0);
const zeroSales = items.filter((i) => i.qtySoldInPeriod === 0).length;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Productos de Baja Rotacion</h1>
<p className="text-muted-foreground">
Variantes con stock pero pocas o nulas ventas
</p>
</div>
<ExportButton reportType="low-rotation" fetchUrl="/api/reports/analytics/low-rotation" />
</div>
<div className="grid gap-4 sm:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground">Variantes</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{totalVariants}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground">Stock en Riesgo</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{formatNumber(totalStock)}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground">Sin Ventas</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold text-destructive">{zeroSales}</p>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<BarChart3 className="h-5 w-5 text-muted-foreground" />
<CardTitle>Variantes con Baja Rotacion</CardTitle>
</div>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>SKU</TableHead>
<TableHead>Producto</TableHead>
<TableHead>Variante</TableHead>
<TableHead className="text-right">Stock Actual</TableHead>
<TableHead className="text-right">Vendidos</TableHead>
<TableHead className="text-right">Dias sin Venta</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item, idx) => (
<TableRow key={idx}>
<TableCell className="font-mono text-xs">{item.sku}</TableCell>
<TableCell>{item.productName}</TableCell>
<TableCell>{item.variant}</TableCell>
<TableCell className="text-right font-medium">
{item.currentStock}
</TableCell>
<TableCell className="text-right">
{item.qtySoldInPeriod === 0 ? (
<span className="text-destructive font-medium">0</span>
) : (
item.qtySoldInPeriod
)}
</TableCell>
<TableCell className="text-right">
{item.daysSinceLastSale != null ? (
<span className={item.daysSinceLastSale > 90 ? 'text-destructive' : ''}>
{item.daysSinceLastSale} dias
</span>
) : (
<span className="text-destructive">Nunca vendido</span>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,148 @@
export const dynamic = 'force-dynamic';
import { notFound } from 'next/navigation';
import { getCustomerById, getCustomerStats } from '@/lib/customers/customer.service';
import { db } from '@/lib/db';
import { sales } from '@/lib/db/schema';
import { eq, and, desc, sql } from 'drizzle-orm';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { ExportButton } from '@/components/reports/export-button';
import { formatCurrency, formatDate } from '@/lib/utils/format';
export default async function CustomerDetailReportPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const customerId = parseInt(id);
let customer;
try {
customer = await getCustomerById(customerId);
} catch {
notFound();
}
const stats = await getCustomerStats(customerId);
// Get purchase history
const purchaseHistory = await db
.select({
saleNumber: sales.saleNumber,
saleDate: sales.saleDate,
totalAmount: sales.totalAmount,
status: sales.status,
itemCount: sql<number>`(SELECT COUNT(*) FROM sale_items WHERE sale_items.sale_id = ${sales.id})`,
})
.from(sales)
.where(
and(
eq(sales.customerId, customerId),
sql`${sales.status} IN ('CONFIRMED', 'DELIVERED')`,
),
)
.orderBy(desc(sales.saleDate))
.limit(100);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">{customer.displayName}</h1>
<p className="text-muted-foreground">
{customer.email || ''} {customer.phone ? `${customer.phone}` : ''}
</p>
</div>
<ExportButton
reportType="customers"
fetchUrl={`/api/reports/customers/${customerId}`}
/>
</div>
<div className="grid gap-4 sm:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground">Total Compras</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{stats.orderCount}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground">Total Gastado</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{formatCurrency(stats.totalSpent)}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground">Ticket Promedio</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{formatCurrency(stats.avgTicket)}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground">Tipo</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold capitalize">{customer.customerType.toLowerCase()}</p>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Historial de Compras</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Nro Venta</TableHead>
<TableHead>Fecha</TableHead>
<TableHead className="text-right">Items</TableHead>
<TableHead className="text-right">Total</TableHead>
<TableHead className="text-center">Estado</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{purchaseHistory.map((item, idx) => (
<TableRow key={idx}>
<TableCell className="font-mono text-xs">{item.saleNumber}</TableCell>
<TableCell>{formatDate(item.saleDate)}</TableCell>
<TableCell className="text-right">{item.itemCount}</TableCell>
<TableCell className="text-right font-medium">
{formatCurrency(item.totalAmount)}
</TableCell>
<TableCell className="text-center capitalize">{item.status.toLowerCase()}</TableCell>
</TableRow>
))}
{purchaseHistory.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground py-8">
Sin compras registradas
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</div>
);
}
+169
View File
@@ -0,0 +1,169 @@
export const dynamic = 'force-dynamic';
import { db } from '@/lib/db';
import { customers, sales } from '@/lib/db/schema';
import { eq, and, sql } from 'drizzle-orm';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { ExportButton } from '@/components/reports/export-button';
import { formatCurrency, formatDate } from '@/lib/utils/format';
import { Users } from 'lucide-react';
async function getCustomersData() {
const rows = await db
.select({
id: customers.id,
displayName: customers.displayName,
email: customers.email,
phone: customers.phone,
customerType: customers.customerType,
totalPurchases: sql<number>`COALESCE(COUNT(${sales.id}), 0)`,
totalSpent: sql<number>`COALESCE(SUM(${sales.totalAmount}), 0)`,
lastPurchaseDate: sql<string>`MAX(${sales.saleDate})`,
})
.from(customers)
.leftJoin(
sales,
and(
eq(customers.id, sales.customerId),
sql`${sales.status} IN ('CONFIRMED', 'DELIVERED')`,
),
)
.groupBy(customers.id)
.orderBy(customers.displayName)
.limit(500);
return rows;
}
export default async function CustomersReportPage() {
const items = await getCustomersData();
const totalCustomers = items.length;
const totalRevenue = items.reduce((sum, c) => sum + c.totalSpent, 0);
const byType = items.reduce(
(acc, c) => {
acc[c.customerType] = (acc[c.customerType] || 0) + 1;
return acc;
},
{} as Record<string, number>,
);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Reporte de Clientes</h1>
<p className="text-muted-foreground">Listado de clientes con estadisticas</p>
</div>
<ExportButton reportType="customers" fetchUrl="/api/reports/customers" />
</div>
<div className="grid gap-4 sm:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground">Total Clientes</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{totalCustomers}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground">Ingreso Total</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{formatCurrency(totalRevenue)}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground">Retail</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{byType['RETAIL'] || 0}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground">Mayorista</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{byType['WHOLESALE'] || 0}</p>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Users className="h-5 w-5 text-muted-foreground" />
<CardTitle>Clientes</CardTitle>
</div>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Nombre</TableHead>
<TableHead>Email</TableHead>
<TableHead>Telefono</TableHead>
<TableHead className="text-center">Tipo</TableHead>
<TableHead className="text-right">Compras</TableHead>
<TableHead className="text-right">Total Gastado</TableHead>
<TableHead>Ultima Compra</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item) => (
<TableRow key={item.id}>
<TableCell className="font-medium">{item.displayName}</TableCell>
<TableCell>{item.email || '-'}</TableCell>
<TableCell>{item.phone || '-'}</TableCell>
<TableCell className="text-center">
<TypeBadge type={item.customerType} />
</TableCell>
<TableCell className="text-right">{item.totalPurchases}</TableCell>
<TableCell className="text-right font-medium">
{formatCurrency(item.totalSpent)}
</TableCell>
<TableCell>
{item.lastPurchaseDate ? formatDate(item.lastPurchaseDate) : '-'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</div>
);
}
function TypeBadge({ type }: { type: string }) {
const colors: Record<string, string> = {
RETAIL: 'bg-blue-100 text-blue-800',
WHOLESALE: 'bg-purple-100 text-purple-800',
VIP: 'bg-amber-100 text-amber-800',
};
const labels: Record<string, string> = {
RETAIL: 'Retail',
WHOLESALE: 'Mayorista',
VIP: 'VIP',
};
return (
<span className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${colors[type] || 'bg-gray-100 text-gray-700'}`}>
{labels[type] || type}
</span>
);
}
@@ -0,0 +1,147 @@
export const dynamic = 'force-dynamic';
import { db } from '@/lib/db';
import { variants, products, suppliers } from '@/lib/db/schema';
import { eq, and } from 'drizzle-orm';
import { computeTrafficLight } from '@/lib/inventory';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { ExportButton } from '@/components/reports/export-button';
import { AlertTriangle } from 'lucide-react';
async function getCriticalData() {
const rows = await db
.select({
variant: variants,
productName: products.name,
supplierName: suppliers.name,
})
.from(variants)
.innerJoin(products, eq(variants.productId, products.id))
.leftJoin(suppliers, eq(variants.preferredSupplierId, suppliers.id))
.where(eq(variants.isActive, true))
.limit(500);
return rows
.map((row) => {
const v = row.variant;
const available = (v.currentStock ?? 0) - (v.reservedStock ?? 0);
const minimum = v.minimumStock ?? 0;
const ideal = v.idealStock ?? 0;
return {
sku: v.sku,
productName: row.productName,
size: v.size,
color: v.color,
currentStock: available,
minimumStock: minimum,
idealStock: ideal,
supplierName: row.supplierName,
isOutOfStock: available === 0,
restockQty: Math.max(0, ideal - available),
};
})
.filter((item) => item.currentStock <= item.minimumStock);
}
export default async function CriticalStockReportPage() {
const items = await getCriticalData();
const outOfStock = items.filter((i) => i.isOutOfStock).length;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Stock Critico</h1>
<p className="text-muted-foreground">
Variantes con stock al o bajo el minimo
</p>
</div>
<ExportButton reportType="inventory-critical" fetchUrl="/api/reports/inventory?stockStatus=critical" />
</div>
<div className="grid gap-4 sm:grid-cols-2">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground">Variantes Criticas</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{items.length}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground">Sin Stock</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold text-destructive">{outOfStock}</p>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-destructive" />
<CardTitle>Variantes en Estado Critico</CardTitle>
</div>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>SKU</TableHead>
<TableHead>Producto</TableHead>
<TableHead>Variante</TableHead>
<TableHead className="text-right">Stock Actual</TableHead>
<TableHead className="text-right">Stock Min.</TableHead>
<TableHead className="text-center">Estado</TableHead>
<TableHead className="text-right">A Reponer</TableHead>
<TableHead>Proveedor</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item, idx) => (
<TableRow key={idx}>
<TableCell className="font-mono text-xs">{item.sku}</TableCell>
<TableCell>{item.productName}</TableCell>
<TableCell>{[item.size, item.color].filter(Boolean).join(' / ')}</TableCell>
<TableCell className="text-right font-medium">
{item.currentStock}
</TableCell>
<TableCell className="text-right">{item.minimumStock}</TableCell>
<TableCell className="text-center">
{item.isOutOfStock ? (
<span className="inline-flex rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-800">
SIN STOCK
</span>
) : (
<span className="inline-flex rounded-full bg-yellow-100 px-2 py-0.5 text-xs font-medium text-yellow-800">
BAJO MINIMO
</span>
)}
</TableCell>
<TableCell className="text-right font-medium">
{item.restockQty}
</TableCell>
<TableCell>{item.supplierName || '-'}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</div>
);
}
+186
View File
@@ -0,0 +1,186 @@
export const dynamic = 'force-dynamic';
import { db } from '@/lib/db';
import { variants, products, categories, suppliers } from '@/lib/db/schema';
import { eq, and, sql } from 'drizzle-orm';
import { computeTrafficLight } from '@/lib/inventory';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { ExportButton } from '@/components/reports/export-button';
import { formatCurrency } from '@/lib/utils/format';
import { Package } from 'lucide-react';
async function getInventoryData() {
const rows = await db
.select({
variant: variants,
productName: products.name,
categoryName: categories.name,
supplierName: suppliers.name,
})
.from(variants)
.innerJoin(products, eq(variants.productId, products.id))
.leftJoin(categories, eq(products.categoryId, categories.id))
.leftJoin(suppliers, eq(variants.preferredSupplierId, suppliers.id))
.where(eq(variants.isActive, true))
.limit(500);
return rows.map((row) => {
const v = row.variant;
const trafficLight = computeTrafficLight({
currentStock: v.currentStock,
reservedStock: v.reservedStock,
minimumStock: v.minimumStock,
idealStock: v.idealStock,
});
const available = (v.currentStock ?? 0) - (v.reservedStock ?? 0);
return {
sku: v.sku,
productName: row.productName,
size: v.size,
color: v.color,
currentStock: available,
minimumStock: v.minimumStock ?? 0,
idealStock: v.idealStock ?? 0,
averageCost: v.averageCost,
categoryName: row.categoryName,
supplierName: row.supplierName,
trafficLight: trafficLight.state,
totalValue: (v.averageCost ?? 0) * (v.currentStock ?? 0),
};
});
}
export default async function InventoryReportPage() {
const items = await getInventoryData();
const totalVariants = items.length;
const totalStock = items.reduce((sum, i) => sum + i.currentStock, 0);
const totalValue = items.reduce((sum, i) => sum + i.totalValue, 0);
const belowMin = items.filter((i) => i.currentStock <= i.minimumStock).length;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Reporte de Inventario</h1>
<p className="text-muted-foreground">Listado completo de stock y valuacion</p>
</div>
<ExportButton reportType="inventory" fetchUrl="/api/reports/inventory" />
</div>
<div className="grid gap-4 sm:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground">Variantes</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{totalVariants}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground">Stock Total</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{totalStock}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground">Valor Total</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{formatCurrency(totalValue)}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground">Bajo Minimo</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{belowMin}</p>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Package className="h-5 w-5 text-muted-foreground" />
<CardTitle>Inventario General</CardTitle>
</div>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>SKU</TableHead>
<TableHead>Producto</TableHead>
<TableHead>Variante</TableHead>
<TableHead>Categoria</TableHead>
<TableHead className="text-right">Stock</TableHead>
<TableHead className="text-right">Min.</TableHead>
<TableHead className="text-center">Estado</TableHead>
<TableHead className="text-right">Costo Prom.</TableHead>
<TableHead className="text-right">Valor Total</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item, idx) => (
<TableRow key={idx}>
<TableCell className="font-mono text-xs">{item.sku}</TableCell>
<TableCell>{item.productName}</TableCell>
<TableCell>{[item.size, item.color].filter(Boolean).join(' / ')}</TableCell>
<TableCell>{item.categoryName || '-'}</TableCell>
<TableCell className="text-right">{item.currentStock}</TableCell>
<TableCell className="text-right">{item.minimumStock}</TableCell>
<TableCell className="text-center">
<TrafficBadge state={item.trafficLight} />
</TableCell>
<TableCell className="text-right">
{formatCurrency(item.averageCost ?? 0)}
</TableCell>
<TableCell className="text-right">
{formatCurrency(item.totalValue)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</div>
);
}
function TrafficBadge({ state }: { state: string }) {
const colors: Record<string, string> = {
green: 'bg-green-100 text-green-800',
yellow: 'bg-yellow-100 text-yellow-800',
red: 'bg-red-100 text-red-800',
gray: 'bg-gray-100 text-gray-600',
};
const labels: Record<string, string> = {
green: 'En Stock',
yellow: 'Bajo',
red: 'Critico',
gray: 'Sin Ref.',
};
return (
<span className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${colors[state] || colors.gray}`}>
{labels[state] || state}
</span>
);
}
@@ -0,0 +1,138 @@
export const dynamic = 'force-dynamic';
import { getRestockSuggestions } from '@/lib/inventory';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { ExportButton } from '@/components/reports/export-button';
import { formatCurrency } from '@/lib/utils/format';
import { RefreshCw } from 'lucide-react';
export default async function RestockReportPage() {
const groups = await getRestockSuggestions();
const totalItems = groups.reduce((sum, g) => sum + g.items.length, 0);
const totalCost = groups.reduce(
(sum, g) =>
sum +
g.items.reduce(
(s, i) => s + (i.unitCost ?? 0) * i.suggestedQty,
0,
),
0,
);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Reporte de Reposicion</h1>
<p className="text-muted-foreground">
Sugerencias de compra agrupadas por proveedor
</p>
</div>
<ExportButton reportType="inventory" fetchUrl="/api/reports/inventory?stockStatus=restock" />
</div>
<div className="grid gap-4 sm:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground">Proveedores</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{groups.length}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground">Items a Reponer</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{totalItems}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground">Costo Estimado</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{formatCurrency(totalCost)}</p>
</CardContent>
</Card>
</div>
{groups.map((group, gIdx) => {
const groupTotal = group.items.reduce(
(s, i) => s + (i.unitCost ?? 0) * i.suggestedQty,
0,
);
return (
<Card key={gIdx}>
<CardHeader>
<div className="flex items-center gap-2">
<RefreshCw className="h-5 w-5 text-muted-foreground" />
<CardTitle>{group.supplierName}</CardTitle>
<span className="ml-auto text-sm text-muted-foreground">
{group.items.length} items Subtotal: {formatCurrency(groupTotal)}
</span>
</div>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>SKU</TableHead>
<TableHead>Producto</TableHead>
<TableHead>Variante</TableHead>
<TableHead className="text-right">Disponible</TableHead>
<TableHead className="text-right">Ideal</TableHead>
<TableHead className="text-right">A Comprar</TableHead>
<TableHead className="text-right">Costo Unit.</TableHead>
<TableHead className="text-right">Subtotal</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{group.items.map((item, idx) => (
<TableRow key={idx}>
<TableCell className="font-mono text-xs">{item.sku}</TableCell>
<TableCell>{item.productName}</TableCell>
<TableCell>{item.variantLabel}</TableCell>
<TableCell className="text-right">{item.availableStock}</TableCell>
<TableCell className="text-right">{item.idealStock}</TableCell>
<TableCell className="text-right font-medium">
{item.suggestedQty}
</TableCell>
<TableCell className="text-right">
{formatCurrency(item.unitCost ?? 0)}
</TableCell>
<TableCell className="text-right">
{formatCurrency((item.unitCost ?? 0) * item.suggestedQty)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
);
})}
{groups.length === 0 && (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
No hay variantes que necesiten reposicion en este momento.
</CardContent>
</Card>
)}
</div>
);
}
@@ -0,0 +1,189 @@
export const dynamic = 'force-dynamic';
import { db } from '@/lib/db';
import { variants, products, categories } from '@/lib/db/schema';
import { eq, sql } from 'drizzle-orm';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { ExportButton } from '@/components/reports/export-button';
import { formatCurrency, formatNumber } from '@/lib/utils/format';
import { DollarSign } from 'lucide-react';
async function getValuationData() {
const rows = await db
.select({
variant: variants,
productName: products.name,
categoryName: categories.name,
})
.from(variants)
.innerJoin(products, eq(variants.productId, products.id))
.leftJoin(categories, eq(products.categoryId, categories.id))
.where(eq(variants.isActive, true))
.limit(1000);
// Group by category
const categoryMap = new Map<string, { count: number; value: number }>();
const allVariants = rows.map((row) => {
const v = row.variant;
const totalValue = (v.averageCost ?? 0) * (v.currentStock ?? 0);
const cat = row.categoryName || 'Sin Categoria';
const existing = categoryMap.get(cat) ?? { count: 0, value: 0 };
categoryMap.set(cat, {
count: existing.count + 1,
value: existing.value + totalValue,
});
return {
sku: v.sku,
productName: row.productName,
size: v.size,
color: v.color,
currentStock: v.currentStock ?? 0,
averageCost: v.averageCost,
totalValue,
categoryName: cat,
};
});
const categoryBreakdown = Array.from(categoryMap.entries())
.map(([name, data]) => ({
categoryName: name,
variantCount: data.count,
totalValue: data.value,
}))
.sort((a, b) => b.totalValue - a.totalValue);
// Top 10 most valuable
const topVariants = [...allVariants]
.sort((a, b) => b.totalValue - a.totalValue)
.slice(0, 10);
const grandTotal = allVariants.reduce((sum, v) => sum + v.totalValue, 0);
return { categoryBreakdown, topVariants, grandTotal, totalVariants: allVariants.length };
}
export default async function ValuationReportPage() {
const { categoryBreakdown, topVariants, grandTotal, totalVariants } = await getValuationData();
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Valuacion de Inventario</h1>
<p className="text-muted-foreground">Valor total del inventario por categoria</p>
</div>
<ExportButton reportType="inventory" fetchUrl="/api/reports/inventory" orientation="portrait" />
</div>
<div className="grid gap-4 sm:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground">Variantes</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{formatNumber(totalVariants)}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground">Categorias</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{categoryBreakdown.length}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground">Valor Total</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{formatCurrency(grandTotal)}</p>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<DollarSign className="h-5 w-5 text-muted-foreground" />
<CardTitle>Valor por Categoria</CardTitle>
</div>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Categoria</TableHead>
<TableHead className="text-right">Variantes</TableHead>
<TableHead className="text-right">Valor Total</TableHead>
<TableHead className="text-right">% del Total</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{categoryBreakdown.map((cat, idx) => (
<TableRow key={idx}>
<TableCell className="font-medium">{cat.categoryName}</TableCell>
<TableCell className="text-right">{cat.variantCount}</TableCell>
<TableCell className="text-right">{formatCurrency(cat.totalValue)}</TableCell>
<TableCell className="text-right">
{grandTotal > 0
? `${((cat.totalValue / grandTotal) * 100).toFixed(1)}%`
: '0%'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Top 10 Variantes mas Valiosas</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">#</TableHead>
<TableHead>SKU</TableHead>
<TableHead>Producto</TableHead>
<TableHead>Variante</TableHead>
<TableHead className="text-right">Stock</TableHead>
<TableHead className="text-right">Costo Prom.</TableHead>
<TableHead className="text-right">Valor</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{topVariants.map((v, idx) => (
<TableRow key={idx}>
<TableCell className="font-medium">{idx + 1}</TableCell>
<TableCell className="font-mono text-xs">{v.sku}</TableCell>
<TableCell>{v.productName}</TableCell>
<TableCell>{[v.size, v.color].filter(Boolean).join(' / ')}</TableCell>
<TableCell className="text-right">{v.currentStock}</TableCell>
<TableCell className="text-right">
{formatCurrency(v.averageCost ?? 0)}
</TableCell>
<TableCell className="text-right font-medium">
{formatCurrency(v.totalValue)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}
+135
View File
@@ -0,0 +1,135 @@
export const dynamic = 'force-dynamic';
import Link from 'next/link';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Package,
AlertTriangle,
RefreshCw,
DollarSign,
ShoppingCart,
TrendingUp,
Users,
Star,
BarChart3,
} from 'lucide-react';
interface ReportCard {
title: string;
description: string;
href: string;
icon: React.ReactNode;
}
const inventoryReports: ReportCard[] = [
{
title: 'Inventario General',
description: 'Listado completo de variantes con stock, costos y valores',
href: '/admin21/reports/inventory',
icon: <Package className="h-5 w-5" />,
},
{
title: 'Stock Critico',
description: 'Variantes con stock al o bajo el minimo',
href: '/admin21/reports/inventory/critical',
icon: <AlertTriangle className="h-5 w-5" />,
},
{
title: 'Reposicion',
description: 'Sugerencias de compra agrupadas por proveedor',
href: '/admin21/reports/inventory/restock',
icon: <RefreshCw className="h-5 w-5" />,
},
{
title: 'Valuacion',
description: 'Valor total del inventario por categoria',
href: '/admin21/reports/inventory/valuation',
icon: <DollarSign className="h-5 w-5" />,
},
];
const salesReports: ReportCard[] = [
{
title: 'Ventas',
description: 'Listado de ventas con filtros por fecha y estado',
href: '/admin21/reports/sales',
icon: <ShoppingCart className="h-5 w-5" />,
},
{
title: 'Ganancias',
description: 'Analisis de margen y rentabilidad por venta',
href: '/admin21/reports/sales/profits',
icon: <TrendingUp className="h-5 w-5" />,
},
];
const otherReports: ReportCard[] = [
{
title: 'Clientes',
description: 'Listado de clientes con estadisticas de compra',
href: '/admin21/reports/customers',
icon: <Users className="h-5 w-5" />,
},
{
title: 'Mas Vendidos',
description: 'Ranking de productos por cantidad vendida',
href: '/admin21/reports/analytics/best-sellers',
icon: <Star className="h-5 w-5" />,
},
{
title: 'Baja Rotacion',
description: 'Productos con stock pero pocas o nulas ventas',
href: '/admin21/reports/analytics/low-rotation',
icon: <BarChart3 className="h-5 w-5" />,
},
];
function ReportCardGrid({ cards }: { cards: ReportCard[] }) {
return (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{cards.map((card) => (
<Link key={card.href} href={card.href}>
<Card className="transition-colors hover:border-primary/50 hover:shadow-md cursor-pointer h-full">
<CardHeader className="flex flex-row items-center gap-3 pb-2">
<div className="rounded-md bg-primary/10 p-2 text-primary">
{card.icon}
</div>
<CardTitle className="text-base">{card.title}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">{card.description}</p>
</CardContent>
</Card>
</Link>
))}
</div>
);
}
export default function ReportsPage() {
return (
<div className="space-y-8">
<div>
<h1 className="text-2xl font-bold tracking-tight">Reportes</h1>
<p className="mt-2 text-muted-foreground">
Genera reportes en PDF con la informacion de tu negocio
</p>
</div>
<section className="space-y-4">
<h2 className="text-lg font-semibold">Inventario</h2>
<ReportCardGrid cards={inventoryReports} />
</section>
<section className="space-y-4">
<h2 className="text-lg font-semibold">Ventas</h2>
<ReportCardGrid cards={salesReports} />
</section>
<section className="space-y-4">
<h2 className="text-lg font-semibold">Clientes y Analisis</h2>
<ReportCardGrid cards={otherReports} />
</section>
</div>
);
}
@@ -0,0 +1,221 @@
export const dynamic = 'force-dynamic';
import { notFound } from 'next/navigation';
import { getPurchaseById } from '@/lib/purchases/purchase.service';
import { db } from '@/lib/db';
import { suppliers, variants, products } from '@/lib/db/schema';
import { eq } from 'drizzle-orm';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { ExportButton } from '@/components/reports/export-button';
import { formatCurrency, formatDate } from '@/lib/utils/format';
export default async function PurchaseDetailReportPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const purchaseId = parseInt(id);
let purchase;
try {
purchase = await getPurchaseById(purchaseId);
} catch {
notFound();
}
// Get supplier name
const [supplier] = await db
.select({ name: suppliers.name })
.from(suppliers)
.where(eq(suppliers.id, purchase.supplierId))
.limit(1);
// Enrich items with product/variant names
const enrichedItems = await Promise.all(
purchase.items.map(async (item) => {
const [variant] = await db
.select({
sku: variants.sku,
size: variants.size,
color: variants.color,
productName: products.name,
})
.from(variants)
.innerJoin(products, eq(variants.productId, products.id))
.where(eq(variants.id, item.variantId))
.limit(1);
return {
...item,
sku: variant?.sku ?? '-',
productName: variant?.productName ?? '-',
size: variant?.size ?? '',
color: variant?.color,
};
}),
);
const totalAdditional =
purchase.shippingCost +
purchase.commissions +
purchase.taxes +
purchase.packagingCost +
purchase.otherCosts;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">
Compra {purchase.purchaseNumber}
</h1>
<p className="text-muted-foreground">
{formatDate(purchase.purchaseDate)} {supplier?.name || 'Desconocido'}
</p>
</div>
<ExportButton
reportType="purchase-detail"
fetchUrl={`/api/reports/purchases/${purchaseId}`}
/>
</div>
<div className="grid gap-4 sm:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground">Subtotal</CardTitle>
</CardHeader>
<CardContent>
<p className="text-xl font-bold">{formatCurrency(purchase.subtotal)}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground">Costos Adicionales</CardTitle>
</CardHeader>
<CardContent>
<p className="text-xl font-bold">{formatCurrency(totalAdditional)}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground">Costo Total</CardTitle>
</CardHeader>
<CardContent>
<p className="text-xl font-bold">{formatCurrency(purchase.totalCost)}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground">Estado</CardTitle>
</CardHeader>
<CardContent>
<p className="text-xl font-bold capitalize">{purchase.status}</p>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Items de la Compra</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>SKU</TableHead>
<TableHead>Producto</TableHead>
<TableHead>Variante</TableHead>
<TableHead className="text-right">Ped.</TableHead>
<TableHead className="text-right">Rec.</TableHead>
<TableHead className="text-right">Costo Unit.</TableHead>
<TableHead className="text-right">Costo Adic.</TableHead>
<TableHead className="text-right">Costo Real</TableHead>
<TableHead className="text-right">Total Linea</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{enrichedItems.map((item, idx) => (
<TableRow key={idx}>
<TableCell className="font-mono text-xs">{item.sku}</TableCell>
<TableCell>{item.productName}</TableCell>
<TableCell>{[item.size, item.color].filter(Boolean).join(' / ')}</TableCell>
<TableCell className="text-right">{item.quantityOrdered}</TableCell>
<TableCell className="text-right">{item.quantityReceived}</TableCell>
<TableCell className="text-right">{formatCurrency(item.unitCost)}</TableCell>
<TableCell className="text-right">
{formatCurrency(item.allocatedAdditionalCost)}
</TableCell>
<TableCell className="text-right">
{formatCurrency(item.realUnitCost)}
</TableCell>
<TableCell className="text-right font-medium">
{formatCurrency(item.lineSubtotal)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Desglose de Costos</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableBody>
<TableRow>
<TableCell className="font-medium">Subtotal Productos</TableCell>
<TableCell className="text-right">{formatCurrency(purchase.subtotal)}</TableCell>
</TableRow>
<TableRow>
<TableCell>Envio</TableCell>
<TableCell className="text-right">{formatCurrency(purchase.shippingCost)}</TableCell>
</TableRow>
<TableRow>
<TableCell>Comisiones</TableCell>
<TableCell className="text-right">{formatCurrency(purchase.commissions)}</TableCell>
</TableRow>
<TableRow>
<TableCell>Impuestos</TableCell>
<TableCell className="text-right">{formatCurrency(purchase.taxes)}</TableCell>
</TableRow>
<TableRow>
<TableCell>Embalaje</TableCell>
<TableCell className="text-right">{formatCurrency(purchase.packagingCost)}</TableCell>
</TableRow>
<TableRow>
<TableCell>Otros Costos</TableCell>
<TableCell className="text-right">{formatCurrency(purchase.otherCosts)}</TableCell>
</TableRow>
<TableRow className="border-t-2">
<TableCell className="font-bold">Total Costos Adicionales</TableCell>
<TableCell className="text-right font-bold">
{formatCurrency(totalAdditional)}
</TableCell>
</TableRow>
<TableRow className="border-t-2">
<TableCell className="font-bold">COSTO TOTAL</TableCell>
<TableCell className="text-right font-bold text-lg">
{formatCurrency(purchase.totalCost)}
</TableCell>
</TableRow>
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}
+147
View File
@@ -0,0 +1,147 @@
export const dynamic = 'force-dynamic';
import { db } from '@/lib/db';
import { purchases, suppliers } from '@/lib/db/schema';
import { eq, and, desc, sql, type SQL } from 'drizzle-orm';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { ExportButton } from '@/components/reports/export-button';
import { formatCurrency, formatDate } from '@/lib/utils/format';
import { Package } from 'lucide-react';
async function getPurchasesData() {
const rows = await db
.select({
id: purchases.id,
purchaseNumber: purchases.purchaseNumber,
purchaseDate: purchases.purchaseDate,
totalCost: purchases.totalCost,
status: purchases.status,
supplierName: suppliers.name,
itemCount: sql<number>`(SELECT COUNT(*) FROM purchase_items WHERE purchase_items.purchase_id = ${purchases.id})`,
})
.from(purchases)
.leftJoin(suppliers, eq(purchases.supplierId, suppliers.id))
.orderBy(desc(purchases.purchaseDate))
.limit(200);
return rows;
}
export default async function PurchasesReportPage() {
const items = await getPurchasesData();
const totalPurchases = items.length;
const totalSpent = items.reduce((sum, p) => sum + p.totalCost, 0);
const avgOrder = totalPurchases > 0 ? totalSpent / totalPurchases : 0;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Reporte de Compras</h1>
<p className="text-muted-foreground">Listado de compras a proveedores</p>
</div>
<ExportButton reportType="purchases" fetchUrl="/api/reports/purchases" />
</div>
<div className="grid gap-4 sm:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground">Total Compras</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{totalPurchases}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground">Total Gastado</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{formatCurrency(totalSpent)}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground">Valor Promedio</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{formatCurrency(avgOrder)}</p>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Package className="h-5 w-5 text-muted-foreground" />
<CardTitle>Compras</CardTitle>
</div>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Nro Compra</TableHead>
<TableHead>Fecha</TableHead>
<TableHead>Proveedor</TableHead>
<TableHead className="text-right">Items</TableHead>
<TableHead className="text-right">Costo Total</TableHead>
<TableHead className="text-center">Estado</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item) => (
<TableRow key={item.id}>
<TableCell className="font-mono text-xs">{item.purchaseNumber}</TableCell>
<TableCell>{formatDate(item.purchaseDate)}</TableCell>
<TableCell>{item.supplierName || 'Desconocido'}</TableCell>
<TableCell className="text-right">{item.itemCount}</TableCell>
<TableCell className="text-right font-medium">
{formatCurrency(item.totalCost)}
</TableCell>
<TableCell className="text-center">
<PurchaseStatusBadge status={item.status} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</div>
);
}
function PurchaseStatusBadge({ status }: { status: string }) {
const colors: Record<string, string> = {
draft: 'bg-gray-100 text-gray-700',
confirmed: 'bg-blue-100 text-blue-800',
partially_received: 'bg-yellow-100 text-yellow-800',
received: 'bg-green-100 text-green-800',
cancelled: 'bg-red-100 text-red-800',
};
const labels: Record<string, string> = {
draft: 'Borrador',
confirmed: 'Confirmada',
partially_received: 'Parc. Recibida',
received: 'Recibida',
cancelled: 'Cancelada',
};
return (
<span className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${colors[status] || colors.draft}`}>
{labels[status] || status}
</span>
);
}
+122
View File
@@ -0,0 +1,122 @@
export const dynamic = 'force-dynamic';
import { notFound } from 'next/navigation';
import { getSaleById } from '@/lib/sales/sale.service';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { ExportButton } from '@/components/reports/export-button';
import { formatCurrency, formatDate } from '@/lib/utils/format';
export default async function SaleDetailReportPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const saleId = parseInt(id);
let sale;
try {
sale = await getSaleById(saleId);
} catch {
notFound();
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Venta {sale.saleNumber}</h1>
<p className="text-muted-foreground">
{formatDate(sale.saleDate)} {sale.customerDisplayName || 'Mostrador'}
</p>
</div>
<ExportButton reportType="sale-detail" fetchUrl={`/api/reports/sales/${saleId}`} />
</div>
<div className="grid gap-4 sm:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground">Subtotal</CardTitle>
</CardHeader>
<CardContent>
<p className="text-xl font-bold">{formatCurrency(sale.subtotal)}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground">Descuento</CardTitle>
</CardHeader>
<CardContent>
<p className="text-xl font-bold">{formatCurrency(sale.discount ?? 0)}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground">Total</CardTitle>
</CardHeader>
<CardContent>
<p className="text-xl font-bold">{formatCurrency(sale.totalAmount)}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground">Ganancia</CardTitle>
</CardHeader>
<CardContent>
<p className="text-xl font-bold text-green-600">
{formatCurrency(sale.totalProfit)}
</p>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Detalle de Items</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>SKU</TableHead>
<TableHead>Producto</TableHead>
<TableHead>Variante</TableHead>
<TableHead className="text-right">Cant.</TableHead>
<TableHead className="text-right">Precio Unit.</TableHead>
<TableHead className="text-right">Descuento</TableHead>
<TableHead className="text-right">Subtotal</TableHead>
<TableHead className="text-right">Ganancia</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sale.items.map((item) => (
<TableRow key={item.id}>
<TableCell className="font-mono text-xs">{item.sku}</TableCell>
<TableCell>{item.productName}</TableCell>
<TableCell>{[item.size, item.color].filter(Boolean).join(' / ')}</TableCell>
<TableCell className="text-right">{item.quantity}</TableCell>
<TableCell className="text-right">{formatCurrency(item.unitPrice)}</TableCell>
<TableCell className="text-right">{formatCurrency(item.discount ?? 0)}</TableCell>
<TableCell className="text-right font-medium">
{formatCurrency(item.lineSubtotal)}
</TableCell>
<TableCell className="text-right text-green-600">
{formatCurrency(item.grossProfit ?? 0)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}
+162
View File
@@ -0,0 +1,162 @@
export const dynamic = 'force-dynamic';
import { db } from '@/lib/db';
import { sales, customers } from '@/lib/db/schema';
import { eq, and, desc, sql, type SQL } from 'drizzle-orm';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { ExportButton } from '@/components/reports/export-button';
import { formatCurrency, formatDate } from '@/lib/utils/format';
import { ShoppingCart } from 'lucide-react';
async function getSalesData() {
const rows = await db
.select({
id: sales.id,
saleNumber: sales.saleNumber,
saleDate: sales.saleDate,
totalAmount: sales.totalAmount,
status: sales.status,
paymentMethod: sales.paymentMethod,
customerName: sql<string>`COALESCE(${customers.displayName}, 'Mostrador')`,
itemCount: sql<number>`(SELECT COUNT(*) FROM sale_items WHERE sale_items.sale_id = ${sales.id})`,
})
.from(sales)
.leftJoin(customers, eq(sales.customerId, customers.id))
.orderBy(desc(sales.saleDate))
.limit(200);
return rows;
}
export default async function SalesReportPage() {
const items = await getSalesData();
const totalSales = items.length;
const totalRevenue = items.reduce((sum, s) => sum + s.totalAmount, 0);
const avgTicket = totalSales > 0 ? totalRevenue / totalSales : 0;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Reporte de Ventas</h1>
<p className="text-muted-foreground">Listado de ventas con resumen</p>
</div>
<ExportButton reportType="sales" fetchUrl="/api/reports/sales" />
</div>
<div className="grid gap-4 sm:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground">Total Ventas</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{totalSales}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground">Ingreso Total</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{formatCurrency(totalRevenue)}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground">Ticket Promedio</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{formatCurrency(avgTicket)}</p>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<ShoppingCart className="h-5 w-5 text-muted-foreground" />
<CardTitle>Ventas</CardTitle>
</div>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Nro Venta</TableHead>
<TableHead>Fecha</TableHead>
<TableHead>Cliente</TableHead>
<TableHead className="text-right">Items</TableHead>
<TableHead className="text-right">Total</TableHead>
<TableHead className="text-center">Estado</TableHead>
<TableHead className="text-center">Pago</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item) => (
<TableRow key={item.id}>
<TableCell className="font-mono text-xs">{item.saleNumber}</TableCell>
<TableCell>{formatDate(item.saleDate)}</TableCell>
<TableCell>{item.customerName}</TableCell>
<TableCell className="text-right">{item.itemCount}</TableCell>
<TableCell className="text-right font-medium">
{formatCurrency(item.totalAmount)}
</TableCell>
<TableCell className="text-center">
<StatusBadge status={item.status} />
</TableCell>
<TableCell className="text-center">{paymentLabel(item.paymentMethod)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</div>
);
}
function StatusBadge({ status }: { status: string }) {
const colors: Record<string, string> = {
DRAFT: 'bg-gray-100 text-gray-700',
CONFIRMED: 'bg-blue-100 text-blue-800',
DELIVERED: 'bg-green-100 text-green-800',
CANCELLED: 'bg-red-100 text-red-800',
RETURNED: 'bg-orange-100 text-orange-800',
PARTIALLY_RETURNED: 'bg-yellow-100 text-yellow-800',
};
const labels: Record<string, string> = {
DRAFT: 'Borrador',
CONFIRMED: 'Confirmada',
DELIVERED: 'Entregada',
CANCELLED: 'Cancelada',
RETURNED: 'Devuelta',
PARTIALLY_RETURNED: 'Parc. Devuelta',
};
return (
<span className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${colors[status] || colors.DRAFT}`}>
{labels[status] || status}
</span>
);
}
function paymentLabel(method: string): string {
const labels: Record<string, string> = {
cash: 'Efectivo',
card: 'Tarjeta',
transfer: 'Transfer.',
mixed: 'Mixto',
};
return labels[method] || method;
}
@@ -0,0 +1,150 @@
export const dynamic = 'force-dynamic';
import { db } from '@/lib/db';
import { sales, saleItems } from '@/lib/db/schema';
import { and, desc, sql } from 'drizzle-orm';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { ExportButton } from '@/components/reports/export-button';
import { formatCurrency, formatDate, formatPercent } from '@/lib/utils/format';
import { TrendingUp } from 'lucide-react';
async function getProfitsData() {
const rows = await db
.select({
saleId: sales.id,
saleNumber: sales.saleNumber,
saleDate: sales.saleDate,
totalAmount: sales.totalAmount,
totalCost: sql<number>`COALESCE(SUM(${saleItems.unitCostAtSale} * ${saleItems.quantity}), 0)`,
totalProfit: sql<number>`COALESCE(SUM(${saleItems.grossProfit}), 0)`,
})
.from(sales)
.leftJoin(saleItems, sql`${sales.id} = ${saleItems.saleId}`)
.where(
and(
sql`${sales.status} IN ('CONFIRMED', 'DELIVERED')`,
),
)
.groupBy(sales.id)
.orderBy(desc(sales.saleDate))
.limit(200);
return rows.map((r) => {
const revenue = r.totalAmount;
const cost = r.totalCost;
const profit = r.totalProfit;
const margin = revenue > 0 ? (profit / revenue) * 100 : 0;
return {
saleNumber: r.saleNumber,
saleDate: r.saleDate,
revenue,
cost,
profit,
margin: Math.round(margin * 100) / 100,
};
});
}
export default async function ProfitsReportPage() {
const items = await getProfitsData();
const totalRevenue = items.reduce((sum, i) => sum + i.revenue, 0);
const totalCost = items.reduce((sum, i) => sum + i.cost, 0);
const grossProfit = items.reduce((sum, i) => sum + i.profit, 0);
const avgMargin = totalRevenue > 0 ? (grossProfit / totalRevenue) * 100 : 0;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Reporte de Ganancias</h1>
<p className="text-muted-foreground">Analisis de margen y rentabilidad</p>
</div>
<ExportButton reportType="sales" fetchUrl="/api/reports/sales/profits" />
</div>
<div className="grid gap-4 sm:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground">Ingreso Total</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{formatCurrency(totalRevenue)}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground">Costo Total</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{formatCurrency(totalCost)}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground">Ganancia Bruta</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold text-green-600">{formatCurrency(grossProfit)}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground">Margen Promedio</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{formatPercent(avgMargin)}</p>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<TrendingUp className="h-5 w-5 text-muted-foreground" />
<CardTitle>Ganancias por Venta</CardTitle>
</div>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Nro Venta</TableHead>
<TableHead>Fecha</TableHead>
<TableHead className="text-right">Ingreso</TableHead>
<TableHead className="text-right">Costo</TableHead>
<TableHead className="text-right">Ganancia</TableHead>
<TableHead className="text-right">Margen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item, idx) => (
<TableRow key={idx}>
<TableCell className="font-mono text-xs">{item.saleNumber}</TableCell>
<TableCell>{formatDate(item.saleDate)}</TableCell>
<TableCell className="text-right">{formatCurrency(item.revenue)}</TableCell>
<TableCell className="text-right">{formatCurrency(item.cost)}</TableCell>
<TableCell className="text-right font-medium text-green-600">
{formatCurrency(item.profit)}
</TableCell>
<TableCell className="text-right">{formatPercent(item.margin)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
export const dynamic = 'force-dynamic';
import { notFound } from 'next/navigation';
import { getSaleById } from '@/lib/sales';
import { SaleDetailClient } from './sale-detail-client';
type RouteContext = { params: Promise<{ id: string }> };
export default async function SaleDetailPage({ params }: RouteContext) {
const { id } = await params;
const saleId = parseInt(id);
if (isNaN(saleId)) notFound();
let sale;
try {
sale = await getSaleById(saleId);
} catch {
notFound();
}
return <SaleDetailClient sale={sale} />;
}
@@ -0,0 +1,298 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { ArrowLeft, CheckCircle, XCircle, RotateCcw, Truck } from 'lucide-react';
import { formatCurrency, formatDate } from '@/lib/utils/format';
interface SaleItem {
id: number;
saleId: number;
variantId: number;
quantity: number;
unitPrice: number;
discount: number | null;
totalPrice: number;
lineSubtotal: number;
unitCostAtSale: number | null;
grossProfit: number | null;
marginPercentageAtSale: number | null;
sku: string | null;
size: string | null;
color: string | null;
productName: string | null;
}
interface Sale {
id: number;
customerId: number | null;
saleNumber: string;
saleDate: Date;
subtotal: number;
discount: number | null;
shippingCharge: number | null;
tax: number | null;
totalAmount: number;
paymentMethod: string;
status: string;
notes: string | null;
createdAt: Date;
updatedAt: Date;
customerDisplayName: string | null;
customerFirstName: string | null;
customerLastName: string | null;
customerPhone: string | null;
customerEmail: string | null;
items: SaleItem[];
totalProfit: number;
}
function SaleStatusBadge({ status }: { status: string }) {
const colors: Record<string, string> = {
DRAFT: 'bg-gray-100 text-gray-800',
CONFIRMED: 'bg-blue-100 text-blue-800',
DELIVERED: 'bg-green-100 text-green-800',
CANCELLED: 'bg-red-100 text-red-800',
PARTIALLY_RETURNED: 'bg-yellow-100 text-yellow-800',
RETURNED: 'bg-orange-100 text-orange-800',
};
return (
<span className={`inline-flex items-center rounded-full px-3 py-1 text-sm font-medium ${colors[status] || 'bg-gray-100 text-gray-800'}`}>
{status}
</span>
);
}
function actionLabel(action: string): string {
const labels: Record<string, string> = {
confirm: 'confirmar',
deliver: 'entregar',
cancel: 'cancelar',
return: 'devolver',
};
return labels[action] || action;
}
export function SaleDetailClient({ sale }: { sale: Sale }) {
const router = useRouter();
const [loading, setLoading] = useState<string | null>(null);
async function handleAction(action: string) {
setLoading(action);
try {
const res = await fetch(`/api/sales/${sale.id}/${action}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: action === 'return' ? JSON.stringify({ items: [] }) : '{}',
});
if (!res.ok) {
const data = await res.json();
alert(data.error || `Error al ${actionLabel(action)} venta`);
return;
}
router.refresh();
} catch {
alert(`Error al ${actionLabel(action)} venta`);
} finally {
setLoading(null);
}
}
const customerName = sale.customerDisplayName
|| (sale.customerFirstName && sale.customerLastName
? `${sale.customerFirstName} ${sale.customerLastName}`
: null);
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" asChild>
<Link href="/admin21/sales">
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
<div className="flex-1">
<h1 className="text-2xl font-bold tracking-tight">{sale.saleNumber}</h1>
<p className="text-muted-foreground">
{formatDate(sale.saleDate)}
{customerName && ` · ${customerName}`}
{' · '}
<SaleStatusBadge status={sale.status} />
</p>
</div>
{/* Action buttons */}
<div className="flex gap-2">
{sale.status === 'DRAFT' && (
<Button onClick={() => handleAction('confirm')} disabled={loading !== null}>
<CheckCircle className="mr-2 h-4 w-4" />
Confirmar
</Button>
)}
{sale.status === 'CONFIRMED' && (
<>
<Button variant="outline" onClick={() => handleAction('deliver')} disabled={loading !== null}>
<Truck className="mr-2 h-4 w-4" />
Entregar
</Button>
<Button variant="destructive" onClick={() => handleAction('cancel')} disabled={loading !== null}>
<XCircle className="mr-2 h-4 w-4" />
Cancelar
</Button>
</>
)}
{(sale.status === 'CONFIRMED' || sale.status === 'DELIVERED' || sale.status === 'PARTIALLY_RETURNED') && (
<Button variant="outline" onClick={() => handleAction('return')} disabled={loading !== null}>
<RotateCcw className="mr-2 h-4 w-4" />
Devolver
</Button>
)}
</div>
</div>
<div className="grid grid-cols-3 gap-6">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Subtotal</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{formatCurrency(sale.subtotal)}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Total</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{formatCurrency(sale.totalAmount)}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Gross Profit</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{formatCurrency(sale.totalProfit)}</p>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Details</CardTitle>
</CardHeader>
<CardContent>
<dl className="grid grid-cols-4 gap-4">
<div>
<dt className="text-sm font-medium text-muted-foreground">Payment Method</dt>
<dd className="capitalize">{sale.paymentMethod}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">Discount</dt>
<dd>{formatCurrency(sale.discount ?? 0)}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">Shipping</dt>
<dd>{formatCurrency(sale.shippingCharge ?? 0)}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">Tax</dt>
<dd>{formatCurrency(sale.tax ?? 0)}</dd>
</div>
{sale.notes && (
<div className="col-span-4">
<dt className="text-sm font-medium text-muted-foreground">Notes</dt>
<dd>{sale.notes}</dd>
</div>
)}
</dl>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Items</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Product</TableHead>
<TableHead>SKU</TableHead>
<TableHead>Variant</TableHead>
<TableHead className="text-right">Qty</TableHead>
<TableHead className="text-right">Unit Price</TableHead>
<TableHead className="text-right">Line Total</TableHead>
<TableHead className="text-right">Cost</TableHead>
<TableHead className="text-right">Profit</TableHead>
<TableHead className="text-right">Margin %</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sale.items.map((item) => (
<TableRow key={item.id}>
<TableCell className="font-medium">{item.productName || '—'}</TableCell>
<TableCell className="text-muted-foreground">{item.sku || '—'}</TableCell>
<TableCell>{item.size}{item.color ? ` / ${item.color}` : ''}</TableCell>
<TableCell className="text-right">{item.quantity}</TableCell>
<TableCell className="text-right">{formatCurrency(item.unitPrice)}</TableCell>
<TableCell className="text-right">{formatCurrency(item.lineSubtotal || item.totalPrice)}</TableCell>
<TableCell className="text-right">
{item.unitCostAtSale != null ? formatCurrency(item.unitCostAtSale) : '—'}
</TableCell>
<TableCell className="text-right">
{item.grossProfit != null ? formatCurrency(item.grossProfit) : '—'}
</TableCell>
<TableCell className="text-right">
{item.marginPercentageAtSale != null ? `${item.marginPercentageAtSale.toFixed(1)}%` : '—'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
{sale.customerId && (
<Card>
<CardHeader>
<CardTitle>Customer</CardTitle>
</CardHeader>
<CardContent>
<dl className="grid grid-cols-4 gap-4">
<div>
<dt className="text-sm font-medium text-muted-foreground">Name</dt>
<dd>
<Link href={`/customers/${sale.customerId}`} className="hover:underline">
{customerName || '—'}
</Link>
</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">Email</dt>
<dd>{sale.customerEmail || '—'}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">Phone</dt>
<dd>{sale.customerPhone || '—'}</dd>
</div>
</dl>
</CardContent>
</Card>
)}
</div>
);
}
+417
View File
@@ -0,0 +1,417 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Plus, Trash2, Search } from 'lucide-react';
import { formatCurrency } from '@/lib/utils/format';
interface Customer {
id: number;
displayName: string;
}
interface VariantOption {
id: number;
sku: string;
productName: string;
size: string;
color: string | null;
currentStock: number;
finalSalePrice: number | null;
}
interface SaleItem {
variantId: number;
sku: string;
label: string;
quantity: number;
unitPrice: number;
discount: number;
}
export default function NewSalePage() {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [customers, setCustomers] = useState<Customer[]>([]);
const [variants, setVariants] = useState<VariantOption[]>([]);
const [selectedCustomerId, setSelectedCustomerId] = useState<string>('');
const [items, setItems] = useState<SaleItem[]>([]);
const [discount, setDiscount] = useState(0);
const [shippingCharge, setShippingCharge] = useState(0);
const [paymentMethod, setPaymentMethod] = useState('cash');
const [notes, setNotes] = useState('');
const [variantSearch, setVariantSearch] = useState('');
const [showVariantPicker, setShowVariantPicker] = useState(false);
useEffect(() => {
fetch('/api/customers?isActive=true&limit=200')
.then((res) => res.json())
.then((data) => setCustomers(data.items || []))
.catch(() => {});
}, []);
useEffect(() => {
if (!variantSearch) {
setVariants([]);
return;
}
const timeout = setTimeout(() => {
fetch(`/api/inventory?search=${encodeURIComponent(variantSearch)}&limit=20`)
.then((res) => res.json())
.then((data) => setVariants((data.items || []).map((v: Record<string, unknown>) => ({
id: v.id as number,
sku: v.sku as string,
productName: v.productName as string,
size: v.size as string,
color: (v.color as string) ?? null,
currentStock: (v.currentStock as number) ?? 0,
finalSalePrice: (v.finalSalePrice as number) ?? null,
}))))
.catch(() => setVariants([]));
}, 300);
return () => clearTimeout(timeout);
}, [variantSearch]);
function addVariant(variant: VariantOption) {
if (items.find((i) => i.variantId === variant.id)) return;
setItems((prev) => [
...prev,
{
variantId: variant.id,
sku: variant.sku,
label: `${variant.productName} - ${variant.size}${variant.color ? ` / ${variant.color}` : ''}`,
quantity: 1,
unitPrice: variant.finalSalePrice ?? 0,
discount: 0,
},
]);
setShowVariantPicker(false);
setVariantSearch('');
}
function updateItem(index: number, field: keyof SaleItem, value: number) {
setItems((prev) =>
prev.map((item, i) => (i === index ? { ...item, [field]: value } : item)),
);
}
function removeItem(index: number) {
setItems((prev) => prev.filter((_, i) => i !== index));
}
const subtotal = items.reduce(
(sum, item) => sum + item.unitPrice * item.quantity - item.discount,
0,
);
const totalAmount = subtotal - discount + shippingCharge;
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (items.length === 0) {
setError('Agregá al menos un item');
return;
}
setLoading(true);
setError(null);
const body = {
customerId: selectedCustomerId ? parseInt(selectedCustomerId) : null,
paymentMethod,
discount,
shippingCharge,
notes: notes || null,
items: items.map((item) => ({
variantId: item.variantId,
quantity: item.quantity,
unitPrice: item.unitPrice,
discount: item.discount,
})),
};
try {
const res = await fetch('/api/sales', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const data = await res.json();
setError(data.error || 'Error al crear la venta');
return;
}
const sale = await res.json();
router.push(`/admin21/sales/${sale.id}`);
} catch {
setError('Error al crear la venta');
} finally {
setLoading(false);
}
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">Nueva Venta</h1>
<p className="text-muted-foreground">Creá un borrador de venta</p>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="rounded-lg border border-red-500/30 bg-red-500/10 p-3 text-sm text-red-400">{error}</div>
)}
<div className="grid grid-cols-2 gap-6">
<Card>
<CardHeader>
<CardTitle>Detalles de la Venta</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Cliente (opcional)</Label>
<Select value={selectedCustomerId} onValueChange={setSelectedCustomerId}>
<SelectTrigger>
<SelectValue placeholder="Cliente sin registrar" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">Cliente sin registrar</SelectItem>
{customers.map((c) => (
<SelectItem key={c.id} value={String(c.id)}>
{c.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Método de Pago</Label>
<Select value={paymentMethod} onValueChange={setPaymentMethod}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="cash">Efectivo</SelectItem>
<SelectItem value="card">Tarjeta</SelectItem>
<SelectItem value="transfer">Transferencia</SelectItem>
<SelectItem value="mixed">Mixto</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Descuento</Label>
<Input
type="number"
min={0}
step={0.01}
value={discount}
onChange={(e) => setDiscount(parseFloat(e.target.value) || 0)}
/>
</div>
<div className="space-y-2">
<Label>Envío</Label>
<Input
type="number"
min={0}
step={0.01}
value={shippingCharge}
onChange={(e) => setShippingCharge(parseFloat(e.target.value) || 0)}
/>
</div>
</div>
<div className="space-y-2">
<Label>Notas</Label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={2}
className="flex w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Totales</CardTitle>
</CardHeader>
<CardContent>
<dl className="space-y-2">
<div className="flex justify-between">
<dt className="text-muted-foreground">Subtotal</dt>
<dd className="font-medium">{formatCurrency(subtotal)}</dd>
</div>
<div className="flex justify-between">
<dt className="text-muted-foreground">Descuento</dt>
<dd className="font-medium">-{formatCurrency(discount)}</dd>
</div>
<div className="flex justify-between">
<dt className="text-muted-foreground">Envío</dt>
<dd className="font-medium">{formatCurrency(shippingCharge)}</dd>
</div>
<div className="border-t pt-2 flex justify-between">
<dt className="font-semibold">Total</dt>
<dd className="text-lg font-bold">{formatCurrency(totalAmount)}</dd>
</div>
</dl>
</CardContent>
</Card>
</div>
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Items</CardTitle>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setShowVariantPicker(!showVariantPicker)}
>
<Plus className="mr-2 h-4 w-4" />
Agregar Item
</Button>
</CardHeader>
<CardContent className="space-y-4">
{showVariantPicker && (
<div className="space-y-2 rounded-md border p-3">
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Buscar variantes por nombre, SKU..."
value={variantSearch}
onChange={(e) => setVariantSearch(e.target.value)}
className="pl-9"
autoFocus
/>
</div>
{variants.length > 0 && (
<div className="max-h-48 overflow-y-auto space-y-1">
{variants.map((v) => (
<button
key={v.id}
type="button"
onClick={() => addVariant(v)}
className="flex w-full items-center justify-between rounded px-2 py-1 text-left text-sm hover:bg-accent"
>
<span>{v.productName} {v.size}{v.color ? ` / ${v.color}` : ''}</span>
<span className="text-muted-foreground">
{v.sku} · Stock: {v.currentStock}
</span>
</button>
))}
</div>
)}
</div>
)}
<Table>
<TableHeader>
<TableRow>
<TableHead>Producto</TableHead>
<TableHead>SKU</TableHead>
<TableHead className="w-[100px]">Cant.</TableHead>
<TableHead className="w-[120px]">Precio Unit.</TableHead>
<TableHead className="w-[100px]">Descuento</TableHead>
<TableHead className="text-right w-[120px]">Total Línea</TableHead>
<TableHead className="w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center text-muted-foreground py-8">
Todavía no hay items agregados
</TableCell>
</TableRow>
) : (
items.map((item, index) => (
<TableRow key={item.variantId}>
<TableCell className="font-medium">{item.label}</TableCell>
<TableCell className="text-muted-foreground">{item.sku}</TableCell>
<TableCell>
<Input
type="number"
min={1}
value={item.quantity}
onChange={(e) => updateItem(index, 'quantity', parseInt(e.target.value) || 1)}
className="h-8 w-full"
/>
</TableCell>
<TableCell>
<Input
type="number"
min={0}
step={0.01}
value={item.unitPrice}
onChange={(e) => updateItem(index, 'unitPrice', parseFloat(e.target.value) || 0)}
className="h-8 w-full"
/>
</TableCell>
<TableCell>
<Input
type="number"
min={0}
step={0.01}
value={item.discount}
onChange={(e) => updateItem(index, 'discount', parseFloat(e.target.value) || 0)}
className="h-8 w-full"
/>
</TableCell>
<TableCell className="text-right font-medium">
{formatCurrency(item.unitPrice * item.quantity - item.discount)}
</TableCell>
<TableCell>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeItem(index)}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
<div className="flex gap-3">
<Button type="submit" variant="gradient" disabled={loading || items.length === 0}>
{loading ? 'Creando...' : 'Crear Borrador de Venta'}
</Button>
<Button type="button" variant="outline" onClick={() => router.push('/admin21/sales')}>
Cancelar
</Button>
</div>
</form>
</div>
);
}
+91
View File
@@ -0,0 +1,91 @@
export const dynamic = 'force-dynamic';
import Link from 'next/link';
import { getSales } from '@/lib/sales';
import { getCustomers } from '@/lib/customers';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Plus } from 'lucide-react';
import { SaleListClient } from './sale-list-client';
import { ExportButton } from '@/components/reports/export-button';
export default async function SalesPage({
searchParams,
}: {
searchParams: Promise<{ status?: string; customerId?: string; dateFrom?: string; dateTo?: string; page?: string; limit?: string; sort?: string; order?: string }>;
}) {
const params = await searchParams;
const page = Math.max(1, parseInt(params.page ?? '1') || 1);
const limit = Math.min(100, Math.max(5, parseInt(params.limit ?? '20') || 20));
const filters: {
status?: string;
customerId?: number;
dateFrom?: Date;
dateTo?: Date;
} = {};
if (params.status) filters.status = params.status;
if (params.customerId) filters.customerId = parseInt(params.customerId);
if (params.dateFrom) filters.dateFrom = new Date(params.dateFrom);
if (params.dateTo) filters.dateTo = new Date(params.dateTo);
const [result, customersResult] = await Promise.all([
getSales({ ...filters, page, limit, sort: params.sort, order: params.order }),
getCustomers({ isActive: true, page: 1, limit: 200 }),
]);
const totalPages = Math.ceil(result.pagination.total / limit);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Ventas</h1>
<p className="text-muted-foreground">Gestioná tus ventas</p>
</div>
<div className="flex gap-2">
<ExportButton reportType="sales" fetchUrl="/api/reports/sales" />
<Button asChild>
<Link href="/admin21/sales/new">
<Plus className="mr-2 h-4 w-4" />
Nueva Venta
</Link>
</Button>
</div>
</div>
<Card scrollable>
<CardHeader scrollable>
<CardTitle>Lista de Ventas</CardTitle>
</CardHeader>
<CardContent scrollable>
<div className="flex-1 overflow-y-auto">
<SaleListClient
sales={result.items}
customers={customersResult.items}
initialStatus={params.status || ''}
initialCustomerId={params.customerId || ''}
initialDateFrom={params.dateFrom || ''}
initialDateTo={params.dateTo || ''}
initialSort={params.sort || ''}
initialOrder={params.order || 'asc'}
page={page}
totalPages={totalPages}
totalItems={result.pagination.total}
limit={limit}
/>
</div>
</CardContent>
</Card>
</div>
);
}
+270
View File
@@ -0,0 +1,270 @@
'use client';
import { useState, useTransition } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
SortableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Search } from 'lucide-react';
import { formatCurrency, formatDate } from '@/lib/utils/format';
import { AdminPagination } from '@/components/ui/admin-pagination';
interface Sale {
id: number;
customerId: number | null;
saleNumber: string;
saleDate: Date;
subtotal: number;
discount: number | null;
shippingCharge: number | null;
tax: number | null;
totalAmount: number;
paymentMethod: string;
status: string;
notes: string | null;
createdAt: Date;
updatedAt: Date;
customerDisplayName: string | null;
customerName: string | null;
}
interface Customer {
id: number;
displayName: string;
}
interface SaleListClientProps {
sales: Sale[];
customers: Customer[];
initialStatus: string;
initialCustomerId: string;
initialDateFrom: string;
initialDateTo: string;
initialSort: string;
initialOrder: string;
page: number;
totalPages: number;
totalItems: number;
limit: number;
}
const STATUS_OPTIONS = [
{ value: '', label: 'All statuses' },
{ value: 'DRAFT', label: 'Draft' },
{ value: 'CONFIRMED', label: 'Confirmed' },
{ value: 'DELIVERED', label: 'Delivered' },
{ value: 'CANCELLED', label: 'Cancelled' },
{ value: 'PARTIALLY_RETURNED', label: 'Partially Returned' },
{ value: 'RETURNED', label: 'Returned' },
];
function SaleStatusBadge({ status }: { status: string }) {
const colors: Record<string, string> = {
DRAFT: 'bg-gray-100 text-gray-800',
CONFIRMED: 'bg-blue-100 text-blue-800',
DELIVERED: 'bg-green-100 text-green-800',
CANCELLED: 'bg-red-100 text-red-800',
PARTIALLY_RETURNED: 'bg-yellow-100 text-yellow-800',
RETURNED: 'bg-orange-100 text-orange-800',
};
return (
<span className={`inline-flex items-center rounded-full px-2 py-1 text-xs ${colors[status] || 'bg-gray-100 text-gray-800'}`}>
{status}
</span>
);
}
export function SaleListClient({
sales,
customers,
initialStatus,
initialCustomerId,
initialDateFrom,
initialDateTo,
initialSort,
initialOrder,
page,
totalPages,
totalItems,
limit,
}: SaleListClientProps) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [status, setStatus] = useState(initialStatus);
const [customerId, setCustomerId] = useState(initialCustomerId);
const [dateFrom, setDateFrom] = useState(initialDateFrom);
const [dateTo, setDateTo] = useState(initialDateTo);
const [sort, setSort] = useState(initialSort);
const [order, setOrder] = useState<'asc' | 'desc'>(initialOrder === 'asc' || initialOrder === 'desc' ? initialOrder : 'asc');
function buildUrl(newPage?: number, newLimit?: number) {
const params = new URLSearchParams();
if (status) params.set('status', status);
if (customerId) params.set('customerId', customerId);
if (dateFrom) params.set('dateFrom', dateFrom);
if (dateTo) params.set('dateTo', dateTo);
if (sort) params.set('sort', sort);
if (sort) params.set('order', order);
params.set('page', String(newPage ?? page));
params.set('limit', String(newLimit ?? limit));
return `/admin21/sales?${params.toString()}`;
}
function handleSort(key: string) {
startTransition(() => {
const newOrder = sort === key && order === 'asc' ? 'desc' : 'asc';
setSort(key);
setOrder(newOrder);
const params = new URLSearchParams();
if (status) params.set('status', status);
if (customerId) params.set('customerId', customerId);
if (dateFrom) params.set('dateFrom', dateFrom);
if (dateTo) params.set('dateTo', dateTo);
params.set('sort', key);
params.set('order', newOrder);
params.set('page', '1');
params.set('limit', String(limit));
router.push(`/admin21/sales?${params.toString()}`);
});
}
function applyFilters() {
startTransition(() => {
router.push(buildUrl(1));
});
}
function handlePageChange(newPage: number) {
startTransition(() => {
router.push(buildUrl(newPage));
});
}
function handleLimitChange(newLimit: number) {
startTransition(() => {
router.push(buildUrl(1, newLimit));
});
}
return (
<div className="flex flex-col h-full">
{/* Filters - fixed at top */}
<div className="flex flex-wrap gap-3 pb-4 flex-shrink-0">
<Select value={status} onValueChange={setStatus}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="All statuses" />
</SelectTrigger>
<SelectContent>
{STATUS_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={customerId} onValueChange={setCustomerId}>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="All customers" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">All customers</SelectItem>
{customers.map((c) => (
<SelectItem key={c.id} value={String(c.id)}>
{c.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
className="w-[160px]"
placeholder="From date"
/>
<Input
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
className="w-[160px]"
placeholder="To date"
/>
<Button onClick={applyFilters} disabled={isPending} variant="outline">
Filter
</Button>
</div>
{/* Table - scrollable */}
<div className="flex-1 overflow-y-auto">
<Table noScroll>
<TableHeader>
<TableRow>
<SortableHead sortKey="number" currentSort={sort} currentOrder={order} onSort={handleSort}>Sale Number</SortableHead>
<SortableHead sortKey="date" currentSort={sort} currentOrder={order} onSort={handleSort}>Date</SortableHead>
<SortableHead sortKey="customer" currentSort={sort} currentOrder={order} onSort={handleSort}>Customer</SortableHead>
<SortableHead sortKey="total" currentSort={sort} currentOrder={order} onSort={handleSort} className="text-right">Total</SortableHead>
<SortableHead sortKey="payment" currentSort={sort} currentOrder={order} onSort={handleSort}>Payment</SortableHead>
<SortableHead sortKey="status" currentSort={sort} currentOrder={order} onSort={handleSort}>Status</SortableHead>
</TableRow>
</TableHeader>
<TableBody>
{sales.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground py-8">
No sales found
</TableCell>
</TableRow>
) : (
sales.map((sale) => (
<TableRow key={sale.id}>
<TableCell className="font-medium">
<Link href={`/admin21/sales/${sale.id}`} className="hover:underline">
{sale.saleNumber}
</Link>
</TableCell>
<TableCell>{formatDate(sale.saleDate)}</TableCell>
<TableCell className="text-muted-foreground">
{sale.customerDisplayName || sale.customerName || 'Walk-in'}
</TableCell>
<TableCell className="text-right">{formatCurrency(sale.totalAmount)}</TableCell>
<TableCell className="capitalize">{sale.paymentMethod}</TableCell>
<TableCell>
<SaleStatusBadge status={sale.status} />
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Pagination - fixed at bottom */}
<div className="flex-shrink-0 pt-4">
<AdminPagination
page={page}
totalPages={totalPages}
totalItems={totalItems}
limit={limit}
onPageChange={handlePageChange}
onLimitChange={handleLimitChange}
/>
</div>
</div>
);
}
+767
View File
@@ -0,0 +1,767 @@
'use client';
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
interface PricingSettings {
global_margin: number;
calc_method: 'markup' | 'margin_on_sale';
rounding_multiple: number;
min_margin_warning: number;
}
interface BrandSettings {
store_name: string;
logo_url: string;
tagline: string;
primary_color: string;
secondary_color: string;
contact_phone: string;
contact_email: string;
contact_whatsapp: string;
address: string;
footer_text: string;
footer_info: string;
catalog_published: boolean;
}
export default function SettingsPage() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
// Password change form
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
// Recovery codes
const [recoveryCodes, setRecoveryCodes] = useState<string[]>([]);
const [regenerating, setRegenerating] = useState(false);
// Pricing settings
const [pricingSettings, setPricingSettings] = useState<PricingSettings>({
global_margin: 50,
calc_method: 'markup',
rounding_multiple: 500,
min_margin_warning: 20,
});
const [pricingLoading, setPricingLoading] = useState(false);
// Danger Zone
const [showDangerZone, setShowDangerZone] = useState(false);
const [resetConfirmText, setResetConfirmText] = useState('');
const [resetLoading, setResetLoading] = useState(false);
// Brand settings
const [brandSettings, setBrandSettings] = useState<BrandSettings>({
store_name: 'By Rachel',
logo_url: '',
tagline: '',
primary_color: '#E91E63',
secondary_color: '#9C27B0',
contact_phone: '',
contact_email: '',
contact_whatsapp: '',
address: '',
footer_text: '',
footer_info: 'Atención personalizada',
catalog_published: false,
});
const [brandLoading, setBrandLoading] = useState(false);
useEffect(() => {
async function fetchSettings() {
try {
const pricingRes = await fetch('/api/settings/pricing');
if (pricingRes.ok) {
const data = await pricingRes.json();
setPricingSettings(data);
}
} catch {
// Use defaults on error
}
try {
const brandRes = await fetch('/api/settings/brand');
if (brandRes.ok) {
const data = await brandRes.json();
setBrandSettings(data);
}
} catch {
// Use defaults on error
}
}
fetchSettings();
}, []);
async function handleChangePassword(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError('');
setSuccess('');
if (newPassword !== confirmPassword) {
setError('Las nuevas contraseñas no coinciden');
setLoading(false);
return;
}
try {
const res = await fetch('/api/auth/change-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ currentPassword, newPassword }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Error al cambiar la contraseña');
setSuccess(data.message);
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cambiar la contraseña');
} finally {
setLoading(false);
}
}
async function handleRegenerateCodes() {
setRegenerating(true);
setError('');
setRecoveryCodes([]);
try {
const res = await fetch('/api/auth/regenerate-recovery-codes', {
method: 'POST',
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Error al regenerar códigos');
setRecoveryCodes(data.recoveryCodes);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al regenerar códigos');
} finally {
setRegenerating(false);
}
}
async function handleSavePricing(e: React.FormEvent) {
e.preventDefault();
setPricingLoading(true);
setError('');
setSuccess('');
try {
const res = await fetch('/api/settings/pricing', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(pricingSettings),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Error al guardar configuración de precios');
setPricingSettings(data);
setSuccess('Configuración de precios guardada correctamente');
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al guardar configuración de precios');
} finally {
setPricingLoading(false);
}
}
async function handleSaveBrand(e: React.FormEvent) {
e.preventDefault();
setBrandLoading(true);
setError('');
setSuccess('');
try {
const res = await fetch('/api/settings/brand', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(brandSettings),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Error al guardar configuración de marca');
setBrandSettings(data);
setSuccess('Configuración de marca guardada correctamente');
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al guardar configuración de marca');
} finally {
setBrandLoading(false);
}
}
async function handleResetDatabase() {
if (resetConfirmText !== 'RESETEAR') return;
setResetLoading(true);
setError('');
setSuccess('');
try {
const res = await fetch('/api/admin/reset-db', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ confirmationToken: 'RESETEAR' }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Error al resetear la base de datos');
setSuccess(data.message);
setResetConfirmText('');
setShowDangerZone(false);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al resetear la base de datos');
} finally {
setResetLoading(false);
}
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">Configuración</h1>
<p className="mt-1 text-sm text-muted-foreground">
Gestioná la configuración de tu cuenta y seguridad.
</p>
</div>
{error && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
{success && (
<div className="rounded-md bg-green-500/10 p-3 text-sm text-green-600">
{success}
</div>
)}
{/* Change Password */}
<Card>
<CardHeader>
<CardTitle>Cambiar Contraseña</CardTitle>
<CardDescription>
Cambiar tu contraseña invalidará todas las sesiones activas y dispositivos de confianza.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleChangePassword} className="space-y-4 max-w-md">
<div className="space-y-2">
<Label htmlFor="currentPassword">Contraseña Actual</Label>
<Input
id="currentPassword"
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="newPassword">Nueva Contraseña</Label>
<Input
id="newPassword"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
minLength={8}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Confirmar Nueva Contraseña</Label>
<Input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
minLength={8}
required
/>
</div>
<Button type="submit" disabled={loading}>
{loading ? 'Cambiando contraseña...' : 'Cambiar Contraseña'}
</Button>
</form>
</CardContent>
</Card>
{/* Recovery Codes */}
<Card>
<CardHeader>
<CardTitle>Códigos de Recuperación</CardTitle>
<CardDescription>
Regenerá tus códigos de recuperación. Los códigos anteriores serán invalidados.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Button
variant="outline"
onClick={handleRegenerateCodes}
disabled={regenerating}
>
{regenerating ? 'Generando...' : 'Regenerar Códigos de Recuperación'}
</Button>
{recoveryCodes.length > 0 && (
<div className="space-y-2">
<p className="text-sm font-semibold text-destructive">
¡Guardá estos códigos! No se volverán a mostrar.
</p>
<div className="grid grid-cols-2 gap-2 max-w-md">
{recoveryCodes.map((code, i) => (
<div
key={i}
className="rounded-md bg-muted p-2 text-center font-mono text-sm"
>
{code}
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
{/* Pricing Settings */}
<Card>
<CardHeader>
<CardTitle>Precios</CardTitle>
<CardDescription>
Configurá los métodos de cálculo de precios y márgenes por defecto.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSavePricing} className="space-y-4 max-w-md">
<div className="space-y-2">
<Label htmlFor="global_margin">Margen Global (%)</Label>
<Input
id="global_margin"
type="number"
min="0"
max="100"
step="1"
value={pricingSettings.global_margin}
onChange={(e) =>
setPricingSettings({
...pricingSettings,
global_margin: parseInt(e.target.value) || 0,
})
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="calc_method">Método de Cálculo</Label>
<Select
value={pricingSettings.calc_method}
onValueChange={(value: 'markup' | 'margin_on_sale') =>
setPricingSettings({ ...pricingSettings, calc_method: value })
}
>
<SelectTrigger id="calc_method">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="markup">Markup sobre Costo</SelectItem>
<SelectItem value="margin_on_sale">Margen sobre Precio de Venta</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="rounding_multiple">Múltiplo de Redondeo</Label>
<Select
value={String(pricingSettings.rounding_multiple)}
onValueChange={(value) =>
setPricingSettings({
...pricingSettings,
rounding_multiple: parseInt(value),
})
}
>
<SelectTrigger id="rounding_multiple">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="100">$100</SelectItem>
<SelectItem value="500">$500</SelectItem>
<SelectItem value="1000">$1,000</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="min_margin_warning">Advertencia de Margen Mínimo (%)</Label>
<Input
id="min_margin_warning"
type="number"
min="0"
max="100"
step="1"
value={pricingSettings.min_margin_warning}
onChange={(e) =>
setPricingSettings({
...pricingSettings,
min_margin_warning: parseInt(e.target.value) || 0,
})
}
/>
<p className="text-xs text-muted-foreground">
Mostrar advertencia cuando el margen efectivo caiga por debajo de este umbral.
</p>
</div>
<Button type="submit" disabled={pricingLoading}>
{pricingLoading ? 'Guardando...' : 'Guardar Configuración de Precios'}
</Button>
</form>
</CardContent>
</Card>
{/* Brand & Catalog Settings */}
<Card>
<CardHeader>
<CardTitle>Marca y Catálogo</CardTitle>
<CardDescription>
Configurá la apariencia de la marca y la visibilidad del catálogo público.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSaveBrand} className="space-y-4 max-w-md">
<div className="space-y-2">
<Label htmlFor="store_name">Nombre de la Tienda</Label>
<Input
id="store_name"
value={brandSettings.store_name}
onChange={(e) =>
setBrandSettings({ ...brandSettings, store_name: e.target.value })
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="logo_url">URL del Logo</Label>
<Input
id="logo_url"
placeholder="https://ejemplo.com/logo.png"
value={brandSettings.logo_url}
onChange={(e) =>
setBrandSettings({ ...brandSettings, logo_url: e.target.value })
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="tagline">Eslogan</Label>
<Input
id="tagline"
placeholder="Descripción corta para SEO"
value={brandSettings.tagline}
onChange={(e) =>
setBrandSettings({ ...brandSettings, tagline: e.target.value })
}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="primary_color">Color Principal</Label>
<div className="flex gap-2">
<input
type="color"
id="primary_color"
value={brandSettings.primary_color}
onChange={(e) =>
setBrandSettings({ ...brandSettings, primary_color: e.target.value })
}
className="h-9 w-9 rounded border cursor-pointer"
/>
<Input
value={brandSettings.primary_color}
onChange={(e) =>
setBrandSettings({ ...brandSettings, primary_color: e.target.value })
}
className="flex-1"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="secondary_color">Color Secundario</Label>
<div className="flex gap-2">
<input
type="color"
id="secondary_color"
value={brandSettings.secondary_color}
onChange={(e) =>
setBrandSettings({ ...brandSettings, secondary_color: e.target.value })
}
className="h-9 w-9 rounded border cursor-pointer"
/>
<Input
value={brandSettings.secondary_color}
onChange={(e) =>
setBrandSettings({ ...brandSettings, secondary_color: e.target.value })
}
className="flex-1"
/>
</div>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="contact_phone">Teléfono</Label>
<Input
id="contact_phone"
value={brandSettings.contact_phone}
onChange={(e) =>
setBrandSettings({ ...brandSettings, contact_phone: e.target.value })
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="contact_email">Correo Electrónico</Label>
<Input
id="contact_email"
type="email"
value={brandSettings.contact_email}
onChange={(e) =>
setBrandSettings({ ...brandSettings, contact_email: e.target.value })
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="contact_whatsapp">WhatsApp</Label>
<Input
id="contact_whatsapp"
placeholder="+54911..."
value={brandSettings.contact_whatsapp}
onChange={(e) =>
setBrandSettings({ ...brandSettings, contact_whatsapp: e.target.value })
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="address">Dirección</Label>
<Input
id="address"
value={brandSettings.address}
onChange={(e) =>
setBrandSettings({ ...brandSettings, address: e.target.value })
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="footer_text">Texto del Pie de Página</Label>
<Input
id="footer_text"
placeholder="Dejar vacío para el texto por defecto"
value={brandSettings.footer_text}
onChange={(e) =>
setBrandSettings({ ...brandSettings, footer_text: e.target.value })
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="footer_info">Texto de Información del Pie</Label>
<Input
id="footer_info"
placeholder="Ej: Atención personalizada"
value={brandSettings.footer_info}
onChange={(e) =>
setBrandSettings({ ...brandSettings, footer_info: e.target.value })
}
/>
<p className="text-xs text-muted-foreground">
Texto que aparece en la sección &quot;Información&quot; del footer del catálogo
</p>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="catalog_published"
checked={brandSettings.catalog_published}
onChange={(e) =>
setBrandSettings({ ...brandSettings, catalog_published: e.target.checked })
}
className="h-4 w-4 rounded border-gray-300"
/>
<Label htmlFor="catalog_published" className="font-normal">
Publicar catálogo (visible para el público)
</Label>
</div>
<Button type="submit" disabled={brandLoading}>
{brandLoading ? 'Guardando...' : 'Guardar Configuración de Marca'}
</Button>
</form>
</CardContent>
</Card>
{/* Backup Section */}
<Card>
<CardHeader>
<CardTitle>Copias de Seguridad</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* Download Backup */}
<div className="space-y-2">
<h3 className="text-sm font-medium">Descargar Backup</h3>
<p className="text-sm text-muted-foreground">
Descargá una copia de seguridad de la base de datos y las imágenes.
</p>
<Button
variant="outline"
onClick={async () => {
try {
const res = await fetch('/api/admin/backup');
if (!res.ok) throw new Error('Error al generar backup');
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `byrachel-backup-${new Date().toISOString().slice(0, 10)}.tar.gz`;
a.click();
URL.revokeObjectURL(url);
} catch {
alert('Error al descargar el backup');
}
}}
>
Descargar Backup
</Button>
</div>
{/* Restore Backup */}
<div className="space-y-2 pt-4" style={{ borderTop: '1px solid var(--border)' }}>
<h3 className="text-sm font-medium">Restaurar Backup</h3>
<p className="text-sm text-muted-foreground">
Restaurá una copia de seguridad previa. Esto reemplazará la base de datos y las imágenes actuales.
</p>
<div className="flex items-center gap-3">
<input
type="file"
accept=".gz,.tgz,application/gzip,application/x-gzip"
id="restore-file"
className="hidden"
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
if (!confirm('¿Estás seguro? Esto reemplazará la base de datos y imágenes actuales.')) {
e.target.value = '';
return;
}
const formData = new FormData();
formData.append('file', file);
try {
const res = await fetch('/api/admin/restore', {
method: 'POST',
body: formData,
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
alert(data.message || 'Backup restaurado correctamente. Recargá la página.');
window.location.reload();
} catch (err) {
alert(err instanceof Error ? err.message : 'Error al restaurar backup');
} finally {
e.target.value = '';
}
}}
/>
<Button
variant="outline"
onClick={() => document.getElementById('restore-file')?.click()}
>
Seleccionar Backup para Restaurar
</Button>
</div>
</div>
</CardContent>
</Card>
{/* Danger Zone */}
<Card className="border-red-500/30">
<CardHeader>
<CardTitle className="text-red-600">Zona de Peligro</CardTitle>
<CardDescription>
Acciones irreversibles que afectan todos los datos del sistema.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{!showDangerZone ? (
<Button
variant="outline"
className="border-red-500/30 text-red-600 hover:bg-red-500/10"
onClick={() => setShowDangerZone(true)}
>
Mostrar Zona de Peligro
</Button>
) : (
<div className="space-y-4 rounded-lg border border-red-500/30 bg-red-500/5 p-4">
<div className="space-y-2">
<h3 className="text-sm font-semibold text-red-600">
Resetear Base de Datos
</h3>
<p className="text-sm text-muted-foreground">
Esto eliminará <strong className="text-foreground">todos</strong> los productos,
variantes, proveedores, compras, ventas, clientes, inventario y auditoría.
</p>
<p className="text-sm text-muted-foreground">
Se conservarán: configuración de marca y catálogo, configuración de precios,
y el usuario administrador.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="resetConfirm">
Escribí <span className="font-mono font-bold">RESETEAR</span> para confirmar
</Label>
<Input
id="resetConfirm"
value={resetConfirmText}
onChange={(e) => setResetConfirmText(e.target.value)}
placeholder="RESETEAR"
className="max-w-xs"
/>
</div>
<div className="flex items-center gap-3">
<Button
variant="destructive"
disabled={resetConfirmText !== 'RESETEAR' || resetLoading}
onClick={handleResetDatabase}
>
{resetLoading ? 'Reseteando...' : 'Resetear Base de Datos'}
</Button>
<Button
variant="ghost"
onClick={() => {
setShowDangerZone(false);
setResetConfirmText('');
}}
>
Cancelar
</Button>
</div>
</div>
)}
</CardContent>
</Card>
</div>
);
}
+55
View File
@@ -0,0 +1,55 @@
export const dynamic = 'force-dynamic';
import { notFound } from 'next/navigation';
import { db } from '@/lib/db';
import { suppliers, purchases, supplierProducts, variants, products } from '@/lib/db/schema';
import { eq, desc } from 'drizzle-orm';
import { SupplierDetailClient } from './supplier-detail-client';
type RouteContext = { params: Promise<{ id: string }> };
export default async function SupplierDetailPage({ params }: RouteContext) {
const { id } = await params;
const supplierId = parseInt(id);
if (isNaN(supplierId)) notFound();
const [supplier] = await db
.select()
.from(suppliers)
.where(eq(suppliers.id, supplierId))
.limit(1);
if (!supplier) notFound();
const supplierPurchases = await db
.select()
.from(purchases)
.where(eq(purchases.supplierId, supplierId))
.orderBy(desc(purchases.purchaseDate));
const linkedProducts = await db
.select({
id: supplierProducts.id,
productVariantId: supplierProducts.productVariantId,
supplierCode: supplierProducts.supplierCode,
lastCost: supplierProducts.lastCost,
isPreferred: supplierProducts.isPreferred,
estimatedLeadTimeDays: supplierProducts.estimatedLeadTimeDays,
sku: variants.sku,
size: variants.size,
color: variants.color,
productName: products.name,
})
.from(supplierProducts)
.innerJoin(variants, eq(supplierProducts.productVariantId, variants.id))
.innerJoin(products, eq(variants.productId, products.id))
.where(eq(supplierProducts.supplierId, supplierId));
return (
<SupplierDetailClient
supplier={supplier}
purchases={supplierPurchases}
linkedProducts={linkedProducts}
/>
);
}
@@ -0,0 +1,430 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { ArrowLeft, Trash2, Pencil, X } from 'lucide-react';
import { formatCurrency, formatDate } from '@/lib/utils/format';
interface Supplier {
id: number;
name: string;
businessName: string | null;
taxId: string | null;
contactName: string | null;
email: string | null;
phone: string | null;
address: string | null;
notes: string | null;
isActive: boolean;
}
interface Purchase {
id: number;
purchaseNumber: string;
purchaseDate: Date;
totalCost: number;
status: string;
}
interface LinkedProduct {
id: number;
productVariantId: number;
supplierCode: string | null;
lastCost: number | null;
isPreferred: boolean | null;
estimatedLeadTimeDays: number | null;
sku: string;
size: string;
color: string | null;
productName: string;
}
type Tab = 'info' | 'products' | 'purchases';
interface SupplierDetailClientProps {
supplier: Supplier;
purchases: Purchase[];
linkedProducts: LinkedProduct[];
}
function PurchaseStatusBadge({ status }: { status: string }) {
const colors: Record<string, string> = {
draft: 'bg-gray-100 text-gray-800',
confirmed: 'bg-blue-100 text-blue-800',
partially_received: 'bg-yellow-100 text-yellow-800',
received: 'bg-green-100 text-green-800',
cancelled: 'bg-red-100 text-red-800',
};
const labels: Record<string, string> = {
draft: 'Borrador',
confirmed: 'Confirmado',
partially_received: 'Parcial',
received: 'Recibido',
cancelled: 'Cancelado',
};
return (
<span className={`inline-flex items-center rounded-full px-2 py-1 text-xs ${colors[status] || 'bg-gray-100 text-gray-800'}`}>
{labels[status] || status}
</span>
);
}
export function SupplierDetailClient({
supplier,
purchases,
linkedProducts,
}: SupplierDetailClientProps) {
const router = useRouter();
const [activeTab, setActiveTab] = useState<Tab>('info');
const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
function handleDeactivate() {
if (!confirm('¿Estás seguro de que querés desactivar este proveedor?')) return;
fetch(`/api/suppliers/${supplier.id}`, { method: 'DELETE' }).then(() => router.refresh());
}
function handleUnlinkProduct(variantId: number) {
if (!confirm('¿Desvincular este producto del proveedor?')) return;
fetch(`/api/suppliers/${supplier.id}/products?variantId=${variantId}`, {
method: 'DELETE',
}).then(() => router.refresh());
}
async function handleSave(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setSaving(true);
setError(null);
const formData = new FormData(e.currentTarget);
const body = {
name: formData.get('name') as string,
businessName: (formData.get('businessName') as string) || undefined,
taxId: (formData.get('taxId') as string) || undefined,
contactName: (formData.get('contactName') as string) || undefined,
email: (formData.get('email') as string) || undefined,
phone: (formData.get('phone') as string) || undefined,
address: (formData.get('address') as string) || undefined,
notes: (formData.get('notes') as string) || undefined,
};
try {
const res = await fetch(`/api/suppliers/${supplier.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const data = await res.json();
setError(data.error || 'Error al guardar');
return;
}
setEditing(false);
router.refresh();
} catch {
setError('Error al guardar');
} finally {
setSaving(false);
}
}
const tabs: { key: Tab; label: string }[] = [
{ key: 'info', label: 'Info' },
{ key: 'products', label: `Productos (${linkedProducts.length})` },
{ key: 'purchases', label: `Compras (${purchases.length})` },
];
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" asChild>
<Link href="/admin21/suppliers">
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
<div className="flex-1">
<h1 className="text-2xl font-bold tracking-tight">{supplier.name}</h1>
<p className="text-muted-foreground">
{supplier.businessName || 'Sin razón social'}
{supplier.taxId ? ` · CUIT/DNI: ${supplier.taxId}` : ''}
</p>
</div>
{supplier.isActive && !editing && (
<>
<Button variant="outline" onClick={() => setEditing(true)}>
<Pencil className="mr-2 h-4 w-4" />
Editar
</Button>
<Button variant="outline" onClick={handleDeactivate}>
<Trash2 className="mr-2 h-4 w-4" />
Desactivar
</Button>
</>
)}
{editing && (
<Button variant="ghost" onClick={() => { setEditing(false); setError(null); }}>
<X className="mr-2 h-4 w-4" />
Cancelar
</Button>
)}
{!supplier.isActive && (
<span className="inline-flex items-center rounded-full bg-red-500/10 px-3 py-1 text-sm text-red-400 border border-red-500/30">
Inactivo
</span>
)}
</div>
{/* Tabs */}
<div className="flex gap-1 border-b">
{tabs.map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`px-4 py-2 text-sm font-medium transition-colors ${
activeTab === tab.key
? 'border-b-2 border-primary text-primary'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{tab.label}
</button>
))}
</div>
{/* Tab: Info — Read mode */}
{activeTab === 'info' && !editing && (
<Card>
<CardHeader>
<CardTitle>Información del Proveedor</CardTitle>
</CardHeader>
<CardContent>
<dl className="grid grid-cols-2 gap-4">
<div>
<dt className="text-sm font-medium text-muted-foreground">Nombre</dt>
<dd>{supplier.name}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">Razón Social</dt>
<dd>{supplier.businessName || '—'}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">CUIT/DNI</dt>
<dd>{supplier.taxId || '—'}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">Contacto</dt>
<dd>{supplier.contactName || '—'}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">Correo electrónico</dt>
<dd>{supplier.email || '—'}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">Teléfono</dt>
<dd>{supplier.phone || '—'}</dd>
</div>
<div className="col-span-2">
<dt className="text-sm font-medium text-muted-foreground">Dirección</dt>
<dd>{supplier.address || '—'}</dd>
</div>
<div className="col-span-2">
<dt className="text-sm font-medium text-muted-foreground">Notas</dt>
<dd>{supplier.notes || '—'}</dd>
</div>
</dl>
</CardContent>
</Card>
)}
{/* Tab: Info — Edit mode */}
{activeTab === 'info' && editing && (
<Card className="max-w-2xl">
<CardHeader>
<CardTitle>Editar Proveedor</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSave} className="space-y-4">
{error && (
<div className="rounded-lg border border-red-500/30 bg-red-500/10 p-3 text-sm text-red-400">{error}</div>
)}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="name">Nombre *</Label>
<Input id="name" name="name" defaultValue={supplier.name} required />
</div>
<div className="space-y-2">
<Label htmlFor="businessName">Razón Social</Label>
<Input id="businessName" name="businessName" defaultValue={supplier.businessName ?? ''} />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="taxId">CUIT/DNI</Label>
<Input id="taxId" name="taxId" defaultValue={supplier.taxId ?? ''} />
</div>
<div className="space-y-2">
<Label htmlFor="contactName">Nombre de Contacto</Label>
<Input id="contactName" name="contactName" defaultValue={supplier.contactName ?? ''} />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="email">Correo electrónico</Label>
<Input id="email" name="email" type="email" defaultValue={supplier.email ?? ''} />
</div>
<div className="space-y-2">
<Label htmlFor="phone">Teléfono</Label>
<Input id="phone" name="phone" defaultValue={supplier.phone ?? ''} />
</div>
</div>
<div className="space-y-2">
<Label htmlFor="address">Dirección</Label>
<Input id="address" name="address" defaultValue={supplier.address ?? ''} />
</div>
<div className="space-y-2">
<Label htmlFor="notes">Notas</Label>
<textarea
id="notes"
name="notes"
rows={3}
defaultValue={supplier.notes ?? ''}
className="flex w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</div>
<div className="flex gap-3">
<Button type="submit" disabled={saving}>
{saving ? 'Guardando...' : 'Guardar'}
</Button>
<Button type="button" variant="outline" onClick={() => { setEditing(false); setError(null); }}>
Cancelar
</Button>
</div>
</form>
</CardContent>
</Card>
)}
{/* Tab: Products */}
{activeTab === 'products' && (
<Card>
<CardHeader>
<CardTitle>Productos Vinculados</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Producto</TableHead>
<TableHead>SKU</TableHead>
<TableHead>Talle</TableHead>
<TableHead>Código Prov.</TableHead>
<TableHead>Último Costo</TableHead>
<TableHead>Preferido</TableHead>
<TableHead>Lead Time</TableHead>
<TableHead className="text-right">Acciones</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{linkedProducts.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-center text-muted-foreground py-8">
Sin productos vinculados
</TableCell>
</TableRow>
) : (
linkedProducts.map((p) => (
<TableRow key={p.id}>
<TableCell className="font-medium">{p.productName}</TableCell>
<TableCell className="text-muted-foreground">{p.sku}</TableCell>
<TableCell>{p.size}{p.color ? ` / ${p.color}` : ''}</TableCell>
<TableCell>{p.supplierCode || '—'}</TableCell>
<TableCell>{p.lastCost != null ? formatCurrency(p.lastCost) : '—'}</TableCell>
<TableCell>{p.isPreferred ? '✓' : '—'}</TableCell>
<TableCell>{p.estimatedLeadTimeDays != null ? `${p.estimatedLeadTimeDays}d` : '—'}</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="icon"
onClick={() => handleUnlinkProduct(p.productVariantId)}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
)}
{/* Tab: Purchases */}
{activeTab === 'purchases' && (
<Card>
<CardHeader>
<CardTitle>Historial de Compras</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Número</TableHead>
<TableHead>Fecha</TableHead>
<TableHead>Total</TableHead>
<TableHead>Estado</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{purchases.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-center text-muted-foreground py-8">
Sin compras aún
</TableCell>
</TableRow>
) : (
purchases.map((p) => (
<TableRow key={p.id}>
<TableCell className="font-medium">
<Link href={`/admin21/purchases/${p.id}`} className="hover:underline">
{p.purchaseNumber}
</Link>
</TableCell>
<TableCell>{formatDate(p.purchaseDate)}</TableCell>
<TableCell>{formatCurrency(p.totalCost)}</TableCell>
<TableCell>
<PurchaseStatusBadge status={p.status} />
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
)}
</div>
);
}
+132
View File
@@ -0,0 +1,132 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
export default function NewSupplierPage() {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setLoading(true);
setError(null);
const formData = new FormData(e.currentTarget);
const body = {
name: formData.get('name') as string,
businessName: (formData.get('businessName') as string) || '',
taxId: (formData.get('taxId') as string) || undefined,
contactName: (formData.get('contactName') as string) || undefined,
email: (formData.get('email') as string) || undefined,
phone: (formData.get('phone') as string) || undefined,
address: (formData.get('address') as string) || undefined,
notes: (formData.get('notes') as string) || undefined,
};
try {
const res = await fetch('/api/suppliers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const data = await res.json();
setError(data.error || 'Error al crear el proveedor');
return;
}
const supplier = await res.json();
router.push(`/admin21/suppliers/${supplier.id}`);
} catch {
setError('Error al crear el proveedor');
} finally {
setLoading(false);
}
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">Nuevo Proveedor</h1>
<p className="text-muted-foreground">Agregá un nuevo proveedor a tu directorio</p>
</div>
<Card className="max-w-2xl">
<CardHeader>
<CardTitle>Información del Proveedor</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="rounded-lg border border-red-500/30 bg-red-500/10 p-3 text-sm text-red-400">{error}</div>
)}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="name">Nombre *</Label>
<Input id="name" name="name" required />
</div>
<div className="space-y-2">
<Label htmlFor="businessName">Razón Social</Label>
<Input id="businessName" name="businessName" />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="taxId">CUIT/DNI</Label>
<Input id="taxId" name="taxId" />
</div>
<div className="space-y-2">
<Label htmlFor="contactName">Nombre de Contacto</Label>
<Input id="contactName" name="contactName" />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="email">Correo electrónico</Label>
<Input id="email" name="email" type="email" />
</div>
<div className="space-y-2">
<Label htmlFor="phone">Teléfono</Label>
<Input id="phone" name="phone" />
</div>
</div>
<div className="space-y-2">
<Label htmlFor="address">Dirección</Label>
<Input id="address" name="address" />
</div>
<div className="space-y-2">
<Label htmlFor="notes">Notas</Label>
<textarea
id="notes"
name="notes"
rows={3}
className="flex w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</div>
<div className="flex gap-3">
<Button type="submit" variant="gradient" disabled={loading}>
{loading ? 'Creando...' : 'Crear Proveedor'}
</Button>
<Button type="button" variant="outline" onClick={() => router.push('/admin21/suppliers')}>
Cancelar
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
);
}
+73
View File
@@ -0,0 +1,73 @@
export const dynamic = 'force-dynamic';
import Link from 'next/link';
import { getSuppliers } from '@/lib/suppliers';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Plus } from 'lucide-react';
import { SupplierListClient } from './supplier-list-client';
export default async function SuppliersPage({
searchParams,
}: {
searchParams: Promise<{ search?: string; active?: string; page?: string; limit?: string; sort?: string; order?: string }>;
}) {
const params = await searchParams;
const page = Math.max(1, parseInt(params.page ?? '1') || 1);
const limit = Math.min(100, Math.max(5, parseInt(params.limit ?? '20') || 20));
const filters: { active?: boolean; search?: string } = {};
if (params.active === 'true') filters.active = true;
else if (params.active === 'false') filters.active = false;
if (params.search) filters.search = params.search;
const result = await getSuppliers({ ...filters, page, limit, sort: params.sort, order: params.order });
const totalPages = Math.ceil(result.pagination.total / limit);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Proveedores</h1>
<p className="text-muted-foreground">Gestioná tus proveedores</p>
</div>
<Button asChild>
<Link href="/admin21/suppliers/new">
<Plus className="mr-2 h-4 w-4" />
Nuevo Proveedor
</Link>
</Button>
</div>
<Card scrollable>
<CardHeader scrollable>
<CardTitle>Lista de Proveedores</CardTitle>
</CardHeader>
<CardContent scrollable>
<div className="flex-1 overflow-y-auto">
<SupplierListClient
suppliers={result.items}
initialSearch={params.search || ''}
initialActive={params.active || 'all'}
initialSort={params.sort || ''}
initialOrder={params.order || 'asc'}
page={page}
totalPages={totalPages}
totalItems={result.pagination.total}
limit={limit}
/>
</div>
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,227 @@
'use client';
import { useState, useTransition } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
SortableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Search, Trash2 } from 'lucide-react';
import { AdminPagination } from '@/components/ui/admin-pagination';
interface Supplier {
id: number;
name: string;
businessName: string | null;
taxId: string | null;
contactName: string | null;
email: string | null;
phone: string | null;
isActive: boolean;
}
interface SupplierListClientProps {
suppliers: Supplier[];
initialSearch: string;
initialActive: string;
initialSort: string;
initialOrder: string;
page: number;
totalPages: number;
totalItems: number;
limit: number;
}
export function SupplierListClient({
suppliers,
initialSearch,
initialActive,
initialSort,
initialOrder,
page,
totalPages,
totalItems,
limit,
}: SupplierListClientProps) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [search, setSearch] = useState(initialSearch);
const [active, setActive] = useState(initialActive);
const [sort, setSort] = useState(initialSort);
const [order, setOrder] = useState<'asc' | 'desc'>(initialOrder === 'asc' || initialOrder === 'desc' ? initialOrder : 'asc');
function buildUrl(newPage?: number, newLimit?: number) {
const params = new URLSearchParams();
if (search) params.set('search', search);
if (active && active !== 'all') params.set('active', active);
if (sort) params.set('sort', sort);
if (sort) params.set('order', order);
params.set('page', String(newPage ?? page));
params.set('limit', String(newLimit ?? limit));
return `/admin21/suppliers?${params.toString()}`;
}
function handleSort(key: string) {
startTransition(() => {
const newOrder = sort === key && order === 'asc' ? 'desc' : 'asc';
setSort(key);
setOrder(newOrder);
const params = new URLSearchParams();
if (search) params.set('search', search);
if (active && active !== 'all') params.set('active', active);
params.set('sort', key);
params.set('order', newOrder);
params.set('page', '1');
params.set('limit', String(limit));
router.push(`/admin21/suppliers?${params.toString()}`);
});
}
function applyFilters() {
startTransition(() => {
router.push(buildUrl(1));
});
}
function handlePageChange(newPage: number) {
startTransition(() => {
router.push(buildUrl(newPage));
});
}
function handleLimitChange(newLimit: number) {
startTransition(() => {
router.push(buildUrl(1, newLimit));
});
}
function handleDeactivate(supplierId: number) {
if (!confirm('¿Estás seguro de que querés desactivar este proveedor?')) return;
fetch(`/api/suppliers/${supplierId}`, { method: 'DELETE' }).then(() => router.refresh());
}
return (
<div className="flex flex-col h-full">
{/* Filters - fixed at top */}
<div className="flex flex-wrap gap-3 pb-4 flex-shrink-0">
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Buscar proveedores..."
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && applyFilters()}
className="pl-9"
/>
</div>
<Select value={active} onValueChange={setActive}>
<SelectTrigger className="w-[150px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">Activo</SelectItem>
<SelectItem value="false">Inactivo</SelectItem>
<SelectItem value="all">Todos</SelectItem>
</SelectContent>
</Select>
<Button onClick={applyFilters} disabled={isPending} variant="outline">
Filtrar
</Button>
</div>
{/* Table - scrollable */}
<div className="flex-1 overflow-y-auto">
<Table noScroll>
<TableHeader>
<TableRow>
<SortableHead sortKey="name" currentSort={sort} currentOrder={order} onSort={handleSort}>Nombre</SortableHead>
<SortableHead sortKey="businessName" currentSort={sort} currentOrder={order} onSort={handleSort}>Razón Social</SortableHead>
<SortableHead sortKey="contactName" currentSort={sort} currentOrder={order} onSort={handleSort}>Contacto</SortableHead>
<SortableHead sortKey="email" currentSort={sort} currentOrder={order} onSort={handleSort}>Email</SortableHead>
<SortableHead sortKey="status" currentSort={sort} currentOrder={order} onSort={handleSort}>Estado</SortableHead>
<TableHead className="text-right">Acciones</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{suppliers.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground py-8">
No se encontraron proveedores
</TableCell>
</TableRow>
) : (
suppliers.map((supplier) => (
<TableRow key={supplier.id}>
<TableCell className="font-medium">
<Link href={`/admin21/suppliers/${supplier.id}`} className="hover:underline">
{supplier.name}
</Link>
</TableCell>
<TableCell className="text-muted-foreground">
{supplier.businessName || '—'}
</TableCell>
<TableCell className="text-muted-foreground">
{supplier.contactName || '—'}
</TableCell>
<TableCell className="text-muted-foreground">
{supplier.email || '—'}
</TableCell>
<TableCell>
{supplier.isActive ? (
<span className="inline-flex items-center rounded-full bg-green-100 px-2 py-1 text-xs text-green-800">
Activo
</span>
) : (
<span className="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-xs text-gray-800">
Inactivo
</span>
)}
</TableCell>
<TableCell className="text-right">
{supplier.isActive && (
<Button
variant="ghost"
size="icon"
onClick={() => handleDeactivate(supplier.id)}
title="Deactivate"
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Pagination - fixed at bottom */}
<div className="flex-shrink-0 pt-4">
<AdminPagination
page={page}
totalPages={totalPages}
totalItems={totalItems}
limit={limit}
onPageChange={handlePageChange}
onLimitChange={handleLimitChange}
/>
</div>
</div>
);
}
+77
View File
@@ -0,0 +1,77 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth } from '@/lib/auth/api-auth';
import { logAction } from '@/lib/audit/audit.service';
import path from 'path';
import fs from 'fs';
import { execSync } from 'child_process';
export async function GET(request: NextRequest) {
try {
const auth = await requireAuth(request);
const dbPath = process.env.DATABASE_URL || './data/db.sqlite';
const uploadDir = process.env.UPLOAD_DIR || './public/uploads';
// Verify DB exists
if (!fs.existsSync(dbPath)) {
return NextResponse.json({ error: 'Base de datos no encontrada' }, { status: 404 });
}
// Create temp directory for backup
const timestamp = Date.now();
const tempDir = `/tmp/byrachel-backup-${timestamp}`;
const tempDbPath = `${tempDir}/db.sqlite`;
const tempUploadsDir = `${tempDir}/uploads`;
fs.mkdirSync(tempDir, { recursive: true });
fs.mkdirSync(tempUploadsDir, { recursive: true });
// Copy database (safe copy using sqlite3)
try {
execSync(`sqlite3 "${dbPath}" ".backup '${tempDbPath}'"`, { timeout: 30000 });
} catch {
// Fallback: direct copy
fs.copyFileSync(dbPath, tempDbPath);
}
// Copy uploads if exists
if (fs.existsSync(uploadDir)) {
execSync(`cp -r "${uploadDir}/"* "${tempUploadsDir}/" 2>/dev/null || true`, { timeout: 30000 });
}
// Create tar.gz
const tarPath = `/tmp/byrachel-backup-${timestamp}.tar.gz`;
execSync(`tar -czf "${tarPath}" -C "${tempDir}" .`, { timeout: 60000 });
// Read tar.gz into buffer
const tarBuffer = fs.readFileSync(tarPath);
// Cleanup temp files
fs.rmSync(tempDir, { recursive: true, force: true });
fs.unlinkSync(tarPath);
// Audit log
await logAction({
userId: auth.userId,
action: 'BACKUP_DOWNLOADED',
entityType: 'system',
entityId: 0,
});
// Return file
const dateStr = new Date().toISOString().slice(0, 10);
return new NextResponse(tarBuffer, {
headers: {
'Content-Type': 'application/gzip',
'Content-Disposition': `attachment; filename="byrachel-backup-${dateStr}.tar.gz"`,
'Content-Length': String(tarBuffer.length),
},
});
} catch (error) {
if (error instanceof Error && error.message === 'Authentication required') {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
}
console.error('Backup error:', error);
return NextResponse.json({ error: 'Error al generar backup' }, { status: 500 });
}
}
+70
View File
@@ -0,0 +1,70 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth } from '@/lib/auth/api-auth';
import { db } from '@/lib/db';
import { sql } from 'drizzle-orm';
import { logAction } from '@/lib/audit/audit.service';
const CONFIRMATION_TOKEN = 'RESETEAR';
export async function POST(request: NextRequest) {
try {
const auth = await requireAuth(request);
const body = await request.json();
if (body.confirmationToken !== CONFIRMATION_TOKEN) {
return NextResponse.json(
{ error: 'Token de confirmación inválido. Escribí RESETEAR para confirmar.' },
{ status: 400 },
);
}
db.transaction(() => {
db.run(sql`DELETE FROM audit_logs`);
db.run(sql`DELETE FROM sale_items`);
db.run(sql`DELETE FROM sales`);
db.run(sql`DELETE FROM customer_tag_assignments`);
db.run(sql`DELETE FROM customer_tags`);
db.run(sql`DELETE FROM customers`);
db.run(sql`DELETE FROM purchase_items`);
db.run(sql`DELETE FROM purchases`);
db.run(sql`DELETE FROM supplier_products`);
db.run(sql`DELETE FROM inventory_movements`);
db.run(sql`DELETE FROM inventory`);
db.run(sql`DELETE FROM product_images`);
db.run(sql`DELETE FROM product_categories`);
db.run(sql`DELETE FROM variants`);
db.run(sql`DELETE FROM products`);
db.run(sql`DELETE FROM categories`);
db.run(sql`DELETE FROM suppliers`);
db.run(sql`DELETE FROM users WHERE role != 'admin'`);
db.run(sql`DELETE FROM trusted_devices WHERE user_id NOT IN (SELECT id FROM users WHERE role = 'admin')`);
db.run(sql`DELETE FROM sqlite_sequence WHERE name IN (
'audit_logs', 'sale_items', 'sales', 'customer_tag_assignments',
'customer_tags', 'customers', 'purchase_items', 'purchases',
'supplier_products', 'inventory_movements', 'inventory',
'product_images', 'product_categories', 'variants', 'products',
'categories', 'suppliers', 'users', 'trusted_devices'
)`);
});
await logAction({
userId: auth.userId,
action: 'RESET_DATABASE',
entityType: 'database',
entityId: null,
afterData: { resetAt: new Date().toISOString() },
});
return NextResponse.json({
message: 'Base de datos reseteada correctamente. Se conservaron la configuración de marca, precios y el usuario admin.',
});
} catch (error) {
console.error('POST /api/admin/reset-db error:', error);
return NextResponse.json(
{ error: 'Error al resetear la base de datos' },
{ status: 500 },
);
}
}
+107
View File
@@ -0,0 +1,107 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth } from '@/lib/auth/api-auth';
import { logAction } from '@/lib/audit/audit.service';
import fs from 'fs';
import path from 'path';
import { execSync } from 'child_process';
export async function POST(request: NextRequest) {
try {
const auth = await requireAuth(request);
const formData = await request.formData();
const file = formData.get('file') as File | null;
if (!file) {
return NextResponse.json({ error: 'No se proporcionó archivo' }, { status: 400 });
}
// Validate file type
if (!file.name.endsWith('.tar.gz') && !file.name.endsWith('.tgz')) {
return NextResponse.json({ error: 'El archivo debe ser un .tar.gz' }, { status: 400 });
}
const dbPath = process.env.DATABASE_URL || './data/db.sqlite';
const uploadDir = process.env.UPLOAD_DIR || './public/uploads';
const timestamp = Date.now();
// Create temp directory
const tempDir = `/tmp/byrachel-restore-${timestamp}`;
fs.mkdirSync(tempDir, { recursive: true });
// Save uploaded file
const buffer = Buffer.from(await file.arrayBuffer());
const tarPath = `${tempDir}/backup.tar.gz`;
fs.writeFileSync(tarPath, buffer);
// Extract tar.gz
execSync(`tar -xzf "${tarPath}" -C "${tempDir}"`, { timeout: 60000 });
// Check if db.sqlite exists in extracted content
const extractedDbPath = `${tempDir}/db.sqlite`;
if (!fs.existsSync(extractedDbPath)) {
fs.rmSync(tempDir, { recursive: true, force: true });
return NextResponse.json({ error: 'El archivo no contiene db.sqlite' }, { status: 400 });
}
// Create backup of current DB before restore
const currentBackupPath = `${dbPath}.pre-restore-${timestamp}`;
if (fs.existsSync(dbPath)) {
fs.copyFileSync(dbPath, currentBackupPath);
}
// Restore database
const dbDir = path.dirname(dbPath);
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true });
}
fs.copyFileSync(extractedDbPath, dbPath);
// Restore uploads if present
const extractedUploadsDir = `${tempDir}/uploads`;
if (fs.existsSync(extractedUploadsDir)) {
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
execSync(`cp -r "${extractedUploadsDir}/"* "${uploadDir}/" 2>/dev/null || true`, { timeout: 30000 });
}
// Verify restored DB is valid
try {
execSync(`sqlite3 "${dbPath}" "SELECT COUNT(*) FROM sqlite_master;"`, { timeout: 5000 });
} catch {
// Restore original DB if new one is corrupted
if (fs.existsSync(currentBackupPath)) {
fs.copyFileSync(currentBackupPath, dbPath);
}
fs.rmSync(tempDir, { recursive: true, force: true });
return NextResponse.json({ error: 'La base de datos restaurada está corrupta' }, { status: 400 });
}
// Cleanup
fs.rmSync(tempDir, { recursive: true, force: true });
if (fs.existsSync(currentBackupPath)) {
fs.unlinkSync(currentBackupPath);
}
// Audit log
await logAction({
userId: auth.userId,
action: 'BACKUP_RESTORED',
entityType: 'system',
entityId: 0,
afterData: { filename: file.name, timestamp },
});
return NextResponse.json({
success: true,
message: 'Backup restaurado correctamente. Recargá la página para ver los cambios.',
});
} catch (error) {
if (error instanceof Error && error.message === 'Authentication required') {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
}
console.error('Restore error:', error);
return NextResponse.json({ error: 'Error al restaurar backup' }, { status: 500 });
}
}
+20
View File
@@ -0,0 +1,20 @@
import { NextRequest } from 'next/server';
import { parseDateRange } from '@/lib/analytics/shared';
import type { AnalyticsFilters } from '@/lib/analytics/shared';
export function parseAnalyticsParams(request: NextRequest): AnalyticsFilters {
const { searchParams } = new URL(request.url);
const from = searchParams.get('from');
const to = searchParams.get('to');
const preset = searchParams.get('preset');
const categoryId = searchParams.get('categoryId');
const productId = searchParams.get('productId');
const comparisonEnabled = searchParams.get('comparison') === 'true';
return {
dateRange: parseDateRange(from, to, preset),
categoryId: categoryId ? parseInt(categoryId) : undefined,
productId: productId ? parseInt(productId) : undefined,
comparisonEnabled,
};
}
+14
View File
@@ -0,0 +1,14 @@
import { NextRequest, NextResponse } from 'next/server';
import { getCategoryBreakdown } from '@/lib/analytics/categories.service';
import { parseAnalyticsParams } from '../_utils';
export async function GET(request: NextRequest) {
try {
const filters = parseAnalyticsParams(request);
const result = await getCategoryBreakdown(filters);
return NextResponse.json(result);
} catch (error) {
console.error('GET /api/analytics/categories error:', error);
return NextResponse.json({ error: 'Failed to fetch category analytics' }, { status: 500 });
}
}
@@ -0,0 +1,23 @@
import { NextRequest, NextResponse } from 'next/server';
import { getCostEvolution, getSalePriceEvolution } from '@/lib/analytics/cost-evolution.service';
import { parseAnalyticsParams } from '../_utils';
export async function GET(request: NextRequest) {
try {
const filters = parseAnalyticsParams(request);
const { searchParams } = new URL(request.url);
const view = searchParams.get('view') || 'cost';
let result;
if (view === 'sale-price') {
result = await getSalePriceEvolution(filters);
} else {
result = await getCostEvolution(filters);
}
return NextResponse.json(result);
} catch (error) {
console.error('GET /api/analytics/cost-evolution error:', error);
return NextResponse.json({ error: 'Failed to fetch cost evolution' }, { status: 500 });
}
}
+24
View File
@@ -0,0 +1,24 @@
import { NextRequest, NextResponse } from 'next/server';
import { getCustomerAnalytics, getTopCustomers } from '@/lib/analytics/customers.service';
import { parseAnalyticsParams } from '../_utils';
export async function GET(request: NextRequest) {
try {
const filters = parseAnalyticsParams(request);
const { searchParams } = new URL(request.url);
const view = searchParams.get('view') || 'analytics';
const limit = parseInt(searchParams.get('limit') || '10');
let result;
if (view === 'top') {
result = await getTopCustomers(filters, limit);
} else {
result = await getCustomerAnalytics(filters);
}
return NextResponse.json(result);
} catch (error) {
console.error('GET /api/analytics/customers error:', error);
return NextResponse.json({ error: 'Failed to fetch customer analytics' }, { status: 500 });
}
}
@@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from 'next/server';
import {
getInventoryTurnover,
getStockCoverage,
getCapitalInvested,
getStockoutProneProducts,
} from '@/lib/analytics/inventory-health.service';
import { parseAnalyticsParams } from '../_utils';
export async function GET(request: NextRequest) {
try {
const filters = parseAnalyticsParams(request);
const { searchParams } = new URL(request.url);
const view = searchParams.get('view') || 'turnover';
let result;
switch (view) {
case 'coverage':
result = await getStockCoverage();
break;
case 'capital':
result = await getCapitalInvested();
break;
case 'stockout-prone':
result = await getStockoutProneProducts(filters);
break;
default:
result = await getInventoryTurnover(filters);
}
return NextResponse.json(result);
} catch (error) {
console.error('GET /api/analytics/inventory-health error:', error);
return NextResponse.json(
{ error: 'Failed to fetch inventory health' },
{ status: 500 },
);
}
}
+37
View File
@@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from 'next/server';
import {
getProductPerformance,
getBestSellers,
getWorstSellers,
getProductsWithNoSales,
} from '@/lib/analytics/products.service';
import { parseAnalyticsParams } from '../_utils';
export async function GET(request: NextRequest) {
try {
const filters = parseAnalyticsParams(request);
const { searchParams } = new URL(request.url);
const view = searchParams.get('view') || 'performance';
const limit = parseInt(searchParams.get('limit') || '20');
let result;
switch (view) {
case 'best':
result = await getBestSellers(filters, limit);
break;
case 'worst':
result = await getWorstSellers(filters, limit);
break;
case 'no-sales':
result = await getProductsWithNoSales(filters);
break;
default:
result = await getProductPerformance(filters, limit);
}
return NextResponse.json(result);
} catch (error) {
console.error('GET /api/analytics/products error:', error);
return NextResponse.json({ error: 'Failed to fetch product analytics' }, { status: 500 });
}
}
@@ -0,0 +1,23 @@
import { NextRequest, NextResponse } from 'next/server';
import { getSalesTrend } from '@/lib/analytics/trend.service';
import { parseAnalyticsParams } from '../_utils';
import type { Granularity } from '@/lib/analytics/shared';
export async function GET(request: NextRequest) {
try {
const filters = parseAnalyticsParams(request);
const { searchParams } = new URL(request.url);
const granularity = searchParams.get('granularity') as Granularity | null;
const result = await getSalesTrend(
filters,
granularity && ['day', 'week', 'month', 'quarter', 'year'].includes(granularity)
? granularity
: undefined,
);
return NextResponse.json(result);
} catch (error) {
console.error('GET /api/analytics/sales-trend error:', error);
return NextResponse.json({ error: 'Failed to fetch sales trend' }, { status: 500 });
}
}
@@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from 'next/server';
import {
getDayOfWeekAnalytics,
getMonthOfYearAnalytics,
getSeasonalityByAttribute,
} from '@/lib/analytics/seasonality.service';
import { parseAnalyticsParams } from '../_utils';
export async function GET(request: NextRequest) {
try {
const filters = parseAnalyticsParams(request);
const { searchParams } = new URL(request.url);
const view = searchParams.get('view') || 'day-of-week';
const attribute = searchParams.get('attribute') as 'size' | 'color' | null;
let result;
switch (view) {
case 'month-of-year':
result = await getMonthOfYearAnalytics(filters);
break;
case 'by-attribute':
if (attribute && ['size', 'color'].includes(attribute)) {
result = await getSeasonalityByAttribute(filters, attribute);
} else {
return NextResponse.json(
{ error: 'attribute parameter required (size or color)' },
{ status: 422 },
);
}
break;
default:
result = await getDayOfWeekAnalytics(filters);
}
return NextResponse.json(result);
} catch (error) {
console.error('GET /api/analytics/seasonality error:', error);
return NextResponse.json({ error: 'Failed to fetch seasonality analytics' }, { status: 500 });
}
}

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