first commit
This commit is contained in:
@@ -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/
|
||||
@@ -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
@@ -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/
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"semi": true,
|
||||
"trailingComma": "all",
|
||||
"singleQuote": true,
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2
|
||||
}
|
||||
+59
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
@@ -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'
|
||||
@@ -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
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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
@@ -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.
|
||||
Vendored
+6
@@ -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.
|
||||
@@ -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;
|
||||
Generated
+12511
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -0,0 +1,7 @@
|
||||
export default function AuthLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
),
|
||||
},
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 "Agregar Variante" 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 "Información" 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user