Initial commit: monorepo Naturcalabacera reservas (apps/api + apps/web + packages/shared)
This commit is contained in:
64
.agents/skills/supabase-postgres-best-practices/SKILL.md
Normal file
64
.agents/skills/supabase-postgres-best-practices/SKILL.md
Normal file
@@ -0,0 +1,64 @@
|
||||
---
|
||||
name: supabase-postgres-best-practices
|
||||
description: Postgres performance optimization and best practices from Supabase. Use this skill when writing, reviewing, or optimizing Postgres queries, schema designs, or database configurations.
|
||||
license: MIT
|
||||
metadata:
|
||||
author: supabase
|
||||
version: "1.1.1"
|
||||
organization: Supabase
|
||||
date: January 2026
|
||||
abstract: Comprehensive Postgres performance optimization guide for developers using Supabase and Postgres. Contains performance rules across 8 categories, prioritized by impact from critical (query performance, connection management) to incremental (advanced features). Each rule includes detailed explanations, incorrect vs. correct SQL examples, query plan analysis, and specific performance metrics to guide automated optimization and code generation.
|
||||
---
|
||||
|
||||
# Supabase Postgres Best Practices
|
||||
|
||||
Comprehensive performance optimization guide for Postgres, maintained by Supabase. Contains rules across 8 categories, prioritized by impact to guide automated query optimization and schema design.
|
||||
|
||||
## When to Apply
|
||||
|
||||
Reference these guidelines when:
|
||||
- Writing SQL queries or designing schemas
|
||||
- Implementing indexes or query optimization
|
||||
- Reviewing database performance issues
|
||||
- Configuring connection pooling or scaling
|
||||
- Optimizing for Postgres-specific features
|
||||
- Working with Row-Level Security (RLS)
|
||||
|
||||
## Rule Categories by Priority
|
||||
|
||||
| Priority | Category | Impact | Prefix |
|
||||
|----------|----------|--------|--------|
|
||||
| 1 | Query Performance | CRITICAL | `query-` |
|
||||
| 2 | Connection Management | CRITICAL | `conn-` |
|
||||
| 3 | Security & RLS | CRITICAL | `security-` |
|
||||
| 4 | Schema Design | HIGH | `schema-` |
|
||||
| 5 | Concurrency & Locking | MEDIUM-HIGH | `lock-` |
|
||||
| 6 | Data Access Patterns | MEDIUM | `data-` |
|
||||
| 7 | Monitoring & Diagnostics | LOW-MEDIUM | `monitor-` |
|
||||
| 8 | Advanced Features | LOW | `advanced-` |
|
||||
|
||||
## How to Use
|
||||
|
||||
Read individual rule files for detailed explanations and SQL examples:
|
||||
|
||||
```
|
||||
references/query-missing-indexes.md
|
||||
references/query-partial-indexes.md
|
||||
references/_sections.md
|
||||
```
|
||||
|
||||
Each rule file contains:
|
||||
- Brief explanation of why it matters
|
||||
- Incorrect SQL example with explanation
|
||||
- Correct SQL example with explanation
|
||||
- Optional EXPLAIN output or metrics
|
||||
- Additional context and references
|
||||
- Supabase-specific notes (when applicable)
|
||||
|
||||
## References
|
||||
|
||||
- https://www.postgresql.org/docs/current/
|
||||
- https://supabase.com/docs
|
||||
- https://wiki.postgresql.org/wiki/Performance_Optimization
|
||||
- https://supabase.com/docs/guides/database/overview
|
||||
- https://supabase.com/docs/guides/auth/row-level-security
|
||||
@@ -0,0 +1,170 @@
|
||||
# Writing Guidelines for Postgres References
|
||||
|
||||
This document provides guidelines for creating effective Postgres best
|
||||
practice references that work well with AI agents and LLMs.
|
||||
|
||||
## Key Principles
|
||||
|
||||
### 1. Concrete Transformation Patterns
|
||||
|
||||
Show exact SQL rewrites. Avoid philosophical advice.
|
||||
|
||||
**Good:** "Use `WHERE id = ANY(ARRAY[...])` instead of
|
||||
`WHERE id IN (SELECT ...)`" **Bad:** "Design good schemas"
|
||||
|
||||
### 2. Error-First Structure
|
||||
|
||||
Always show the problematic pattern first, then the solution. This trains agents
|
||||
to recognize anti-patterns.
|
||||
|
||||
```markdown
|
||||
**Incorrect (sequential queries):** [bad example]
|
||||
|
||||
**Correct (batched query):** [good example]
|
||||
```
|
||||
|
||||
### 3. Quantified Impact
|
||||
|
||||
Include specific metrics. Helps agents prioritize fixes.
|
||||
|
||||
**Good:** "10x faster queries", "50% smaller index", "Eliminates N+1"
|
||||
**Bad:** "Faster", "Better", "More efficient"
|
||||
|
||||
### 4. Self-Contained Examples
|
||||
|
||||
Examples should be complete and runnable (or close to it). Include `CREATE TABLE`
|
||||
if context is needed.
|
||||
|
||||
```sql
|
||||
-- Include table definition when needed for clarity
|
||||
CREATE TABLE users (
|
||||
id bigint PRIMARY KEY,
|
||||
email text NOT NULL,
|
||||
deleted_at timestamptz
|
||||
);
|
||||
|
||||
-- Now show the index
|
||||
CREATE INDEX users_active_email_idx ON users(email) WHERE deleted_at IS NULL;
|
||||
```
|
||||
|
||||
### 5. Semantic Naming
|
||||
|
||||
Use meaningful table/column names. Names carry intent for LLMs.
|
||||
|
||||
**Good:** `users`, `email`, `created_at`, `is_active`
|
||||
**Bad:** `table1`, `col1`, `field`, `flag`
|
||||
|
||||
---
|
||||
|
||||
## Code Example Standards
|
||||
|
||||
### SQL Formatting
|
||||
|
||||
```sql
|
||||
-- Use lowercase keywords, clear formatting
|
||||
CREATE INDEX CONCURRENTLY users_email_idx
|
||||
ON users(email)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
-- Not cramped or ALL CAPS
|
||||
CREATE INDEX CONCURRENTLY USERS_EMAIL_IDX ON USERS(EMAIL) WHERE DELETED_AT IS NULL;
|
||||
```
|
||||
|
||||
### Comments
|
||||
|
||||
- Explain _why_, not _what_
|
||||
- Highlight performance implications
|
||||
- Point out common pitfalls
|
||||
|
||||
### Language Tags
|
||||
|
||||
- `sql` - Standard SQL queries
|
||||
- `plpgsql` - Stored procedures/functions
|
||||
- `typescript` - Application code (when needed)
|
||||
- `python` - Application code (when needed)
|
||||
|
||||
---
|
||||
|
||||
## When to Include Application Code
|
||||
|
||||
**Default: SQL Only**
|
||||
|
||||
Most references should focus on pure SQL patterns. This keeps examples portable.
|
||||
|
||||
**Include Application Code When:**
|
||||
|
||||
- Connection pooling configuration
|
||||
- Transaction management in application context
|
||||
- ORM anti-patterns (N+1 in Prisma/TypeORM)
|
||||
- Prepared statement usage
|
||||
|
||||
**Format for Mixed Examples:**
|
||||
|
||||
````markdown
|
||||
**Incorrect (N+1 in application):**
|
||||
|
||||
```typescript
|
||||
for (const user of users) {
|
||||
const posts = await db.query("SELECT * FROM posts WHERE user_id = $1", [
|
||||
user.id,
|
||||
]);
|
||||
}
|
||||
```
|
||||
````
|
||||
|
||||
**Correct (batch query):**
|
||||
|
||||
```typescript
|
||||
const posts = await db.query("SELECT * FROM posts WHERE user_id = ANY($1)", [
|
||||
userIds,
|
||||
]);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Impact Level Guidelines
|
||||
|
||||
| Level | Improvement | Use When |
|
||||
|-------|-------------|----------|
|
||||
| **CRITICAL** | 10-100x | Missing indexes, connection exhaustion, sequential scans on large tables |
|
||||
| **HIGH** | 5-20x | Wrong index types, poor partitioning, missing covering indexes |
|
||||
| **MEDIUM-HIGH** | 2-5x | N+1 queries, inefficient pagination, RLS optimization |
|
||||
| **MEDIUM** | 1.5-3x | Redundant indexes, query plan instability |
|
||||
| **LOW-MEDIUM** | 1.2-2x | VACUUM tuning, configuration tweaks |
|
||||
| **LOW** | Incremental | Advanced patterns, edge cases |
|
||||
|
||||
---
|
||||
|
||||
## Reference Standards
|
||||
|
||||
**Primary Sources:**
|
||||
|
||||
- Official Postgres documentation
|
||||
- Supabase documentation
|
||||
- Postgres wiki
|
||||
- Established blogs (2ndQuadrant, Crunchy Data)
|
||||
|
||||
**Format:**
|
||||
|
||||
```markdown
|
||||
Reference:
|
||||
[Postgres Indexes](https://www.postgresql.org/docs/current/indexes.html)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Review Checklist
|
||||
|
||||
Before submitting a reference:
|
||||
|
||||
- [ ] Title is clear and action-oriented
|
||||
- [ ] Impact level matches the performance gain
|
||||
- [ ] impactDescription includes quantification
|
||||
- [ ] Explanation is concise (1-2 sentences)
|
||||
- [ ] Has at least 1 **Incorrect** SQL example
|
||||
- [ ] Has at least 1 **Correct** SQL example
|
||||
- [ ] SQL uses semantic naming
|
||||
- [ ] Comments explain _why_, not _what_
|
||||
- [ ] Trade-offs mentioned if applicable
|
||||
- [ ] Reference links included
|
||||
- [ ] `mise run test` passes
|
||||
@@ -0,0 +1,39 @@
|
||||
# Section Definitions
|
||||
|
||||
This file defines the rule categories for Postgres best practices. Rules are automatically assigned to sections based on their filename prefix.
|
||||
|
||||
Take the examples below as pure demonstrative. Replace each section with the actual rule categories for Postgres best practices.
|
||||
|
||||
---
|
||||
|
||||
## 1. Query Performance (query)
|
||||
**Impact:** CRITICAL
|
||||
**Description:** Slow queries, missing indexes, inefficient query plans. The most common source of Postgres performance issues.
|
||||
|
||||
## 2. Connection Management (conn)
|
||||
**Impact:** CRITICAL
|
||||
**Description:** Connection pooling, limits, and serverless strategies. Critical for applications with high concurrency or serverless deployments.
|
||||
|
||||
## 3. Security & RLS (security)
|
||||
**Impact:** CRITICAL
|
||||
**Description:** Row-Level Security policies, privilege management, and authentication patterns.
|
||||
|
||||
## 4. Schema Design (schema)
|
||||
**Impact:** HIGH
|
||||
**Description:** Table design, index strategies, partitioning, and data type selection. Foundation for long-term performance.
|
||||
|
||||
## 5. Concurrency & Locking (lock)
|
||||
**Impact:** MEDIUM-HIGH
|
||||
**Description:** Transaction management, isolation levels, deadlock prevention, and lock contention patterns.
|
||||
|
||||
## 6. Data Access Patterns (data)
|
||||
**Impact:** MEDIUM
|
||||
**Description:** N+1 query elimination, batch operations, cursor-based pagination, and efficient data fetching.
|
||||
|
||||
## 7. Monitoring & Diagnostics (monitor)
|
||||
**Impact:** LOW-MEDIUM
|
||||
**Description:** Using pg_stat_statements, EXPLAIN ANALYZE, metrics collection, and performance diagnostics.
|
||||
|
||||
## 8. Advanced Features (advanced)
|
||||
**Impact:** LOW
|
||||
**Description:** Full-text search, JSONB optimization, PostGIS, extensions, and advanced Postgres features.
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
title: Clear, Action-Oriented Title (e.g., "Use Partial Indexes for Filtered Queries")
|
||||
impact: MEDIUM
|
||||
impactDescription: 5-20x query speedup for filtered queries
|
||||
tags: indexes, query-optimization, performance
|
||||
---
|
||||
|
||||
## [Rule Title]
|
||||
|
||||
[1-2 sentence explanation of the problem and why it matters. Focus on performance impact.]
|
||||
|
||||
**Incorrect (describe the problem):**
|
||||
|
||||
```sql
|
||||
-- Comment explaining what makes this slow/problematic
|
||||
CREATE INDEX users_email_idx ON users(email);
|
||||
|
||||
SELECT * FROM users WHERE email = 'user@example.com' AND deleted_at IS NULL;
|
||||
-- This scans deleted records unnecessarily
|
||||
```
|
||||
|
||||
**Correct (describe the solution):**
|
||||
|
||||
```sql
|
||||
-- Comment explaining why this is better
|
||||
CREATE INDEX users_active_email_idx ON users(email) WHERE deleted_at IS NULL;
|
||||
|
||||
SELECT * FROM users WHERE email = 'user@example.com' AND deleted_at IS NULL;
|
||||
-- Only indexes active users, 10x smaller index, faster queries
|
||||
```
|
||||
|
||||
[Optional: Additional context, edge cases, or trade-offs]
|
||||
|
||||
Reference: [Postgres Docs](https://www.postgresql.org/docs/current/)
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
title: Use tsvector for Full-Text Search
|
||||
impact: MEDIUM
|
||||
impactDescription: 100x faster than LIKE, with ranking support
|
||||
tags: full-text-search, tsvector, gin, search
|
||||
---
|
||||
|
||||
## Use tsvector for Full-Text Search
|
||||
|
||||
LIKE with wildcards can't use indexes. Full-text search with tsvector is orders of magnitude faster.
|
||||
|
||||
**Incorrect (LIKE pattern matching):**
|
||||
|
||||
```sql
|
||||
-- Cannot use index, scans all rows
|
||||
select * from articles where content like '%postgresql%';
|
||||
|
||||
-- Case-insensitive makes it worse
|
||||
select * from articles where lower(content) like '%postgresql%';
|
||||
```
|
||||
|
||||
**Correct (full-text search with tsvector):**
|
||||
|
||||
```sql
|
||||
-- Add tsvector column and index
|
||||
alter table articles add column search_vector tsvector
|
||||
generated always as (to_tsvector('english', coalesce(title,'') || ' ' || coalesce(content,''))) stored;
|
||||
|
||||
create index articles_search_idx on articles using gin (search_vector);
|
||||
|
||||
-- Fast full-text search
|
||||
select * from articles
|
||||
where search_vector @@ to_tsquery('english', 'postgresql & performance');
|
||||
|
||||
-- With ranking
|
||||
select *, ts_rank(search_vector, query) as rank
|
||||
from articles, to_tsquery('english', 'postgresql') query
|
||||
where search_vector @@ query
|
||||
order by rank desc;
|
||||
```
|
||||
|
||||
Search multiple terms:
|
||||
|
||||
```sql
|
||||
-- AND: both terms required
|
||||
to_tsquery('postgresql & performance')
|
||||
|
||||
-- OR: either term
|
||||
to_tsquery('postgresql | mysql')
|
||||
|
||||
-- Prefix matching
|
||||
to_tsquery('post:*')
|
||||
```
|
||||
|
||||
Reference: [Full Text Search](https://supabase.com/docs/guides/database/full-text-search)
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
title: Index JSONB Columns for Efficient Querying
|
||||
impact: MEDIUM
|
||||
impactDescription: 10-100x faster JSONB queries with proper indexing
|
||||
tags: jsonb, gin, indexes, json
|
||||
---
|
||||
|
||||
## Index JSONB Columns for Efficient Querying
|
||||
|
||||
JSONB queries without indexes scan the entire table. Use GIN indexes for containment queries.
|
||||
|
||||
**Incorrect (no index on JSONB):**
|
||||
|
||||
```sql
|
||||
create table products (
|
||||
id bigint primary key,
|
||||
attributes jsonb
|
||||
);
|
||||
|
||||
-- Full table scan for every query
|
||||
select * from products where attributes @> '{"color": "red"}';
|
||||
select * from products where attributes->>'brand' = 'Nike';
|
||||
```
|
||||
|
||||
**Correct (GIN index for JSONB):**
|
||||
|
||||
```sql
|
||||
-- GIN index for containment operators (@>, ?, ?&, ?|)
|
||||
create index products_attrs_gin on products using gin (attributes);
|
||||
|
||||
-- Now containment queries use the index
|
||||
select * from products where attributes @> '{"color": "red"}';
|
||||
|
||||
-- For specific key lookups, use expression index
|
||||
create index products_brand_idx on products ((attributes->>'brand'));
|
||||
select * from products where attributes->>'brand' = 'Nike';
|
||||
```
|
||||
|
||||
Choose the right operator class:
|
||||
|
||||
```sql
|
||||
-- jsonb_ops (default): supports all operators, larger index
|
||||
create index idx1 on products using gin (attributes);
|
||||
|
||||
-- jsonb_path_ops: only @> operator, but 2-3x smaller index
|
||||
create index idx2 on products using gin (attributes jsonb_path_ops);
|
||||
```
|
||||
|
||||
Reference: [JSONB Indexes](https://www.postgresql.org/docs/current/datatype-json.html#JSON-INDEXING)
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
title: Configure Idle Connection Timeouts
|
||||
impact: HIGH
|
||||
impactDescription: Reclaim 30-50% of connection slots from idle clients
|
||||
tags: connections, timeout, idle, resource-management
|
||||
---
|
||||
|
||||
## Configure Idle Connection Timeouts
|
||||
|
||||
Idle connections waste resources. Configure timeouts to automatically reclaim them.
|
||||
|
||||
**Incorrect (connections held indefinitely):**
|
||||
|
||||
```sql
|
||||
-- No timeout configured
|
||||
show idle_in_transaction_session_timeout; -- 0 (disabled)
|
||||
|
||||
-- Connections stay open forever, even when idle
|
||||
select pid, state, state_change, query
|
||||
from pg_stat_activity
|
||||
where state = 'idle in transaction';
|
||||
-- Shows transactions idle for hours, holding locks
|
||||
```
|
||||
|
||||
**Correct (automatic cleanup of idle connections):**
|
||||
|
||||
```sql
|
||||
-- Terminate connections idle in transaction after 30 seconds
|
||||
alter system set idle_in_transaction_session_timeout = '30s';
|
||||
|
||||
-- Terminate completely idle connections after 10 minutes
|
||||
alter system set idle_session_timeout = '10min';
|
||||
|
||||
-- Reload configuration
|
||||
select pg_reload_conf();
|
||||
```
|
||||
|
||||
For pooled connections, configure at the pooler level:
|
||||
|
||||
```ini
|
||||
# pgbouncer.ini
|
||||
server_idle_timeout = 60
|
||||
client_idle_timeout = 300
|
||||
```
|
||||
|
||||
Reference: [Connection Timeouts](https://www.postgresql.org/docs/current/runtime-config-client.html#GUC-IDLE-IN-TRANSACTION-SESSION-TIMEOUT)
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
title: Set Appropriate Connection Limits
|
||||
impact: CRITICAL
|
||||
impactDescription: Prevent database crashes and memory exhaustion
|
||||
tags: connections, max-connections, limits, stability
|
||||
---
|
||||
|
||||
## Set Appropriate Connection Limits
|
||||
|
||||
Too many connections exhaust memory and degrade performance. Set limits based on available resources.
|
||||
|
||||
**Incorrect (unlimited or excessive connections):**
|
||||
|
||||
```sql
|
||||
-- Default max_connections = 100, but often increased blindly
|
||||
show max_connections; -- 500 (way too high for 4GB RAM)
|
||||
|
||||
-- Each connection uses 1-3MB RAM
|
||||
-- 500 connections * 2MB = 1GB just for connections!
|
||||
-- Out of memory errors under load
|
||||
```
|
||||
|
||||
**Correct (calculate based on resources):**
|
||||
|
||||
```sql
|
||||
-- Formula: max_connections = (RAM in MB / 5MB per connection) - reserved
|
||||
-- For 4GB RAM: (4096 / 5) - 10 = ~800 theoretical max
|
||||
-- But practically, 100-200 is better for query performance
|
||||
|
||||
-- Recommended settings for 4GB RAM
|
||||
alter system set max_connections = 100;
|
||||
|
||||
-- Also set work_mem appropriately
|
||||
-- work_mem * max_connections should not exceed 25% of RAM
|
||||
alter system set work_mem = '8MB'; -- 8MB * 100 = 800MB max
|
||||
```
|
||||
|
||||
Monitor connection usage:
|
||||
|
||||
```sql
|
||||
select count(*), state from pg_stat_activity group by state;
|
||||
```
|
||||
|
||||
Reference: [Database Connections](https://supabase.com/docs/guides/platform/performance#connection-management)
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
title: Use Connection Pooling for All Applications
|
||||
impact: CRITICAL
|
||||
impactDescription: Handle 10-100x more concurrent users
|
||||
tags: connection-pooling, pgbouncer, performance, scalability
|
||||
---
|
||||
|
||||
## Use Connection Pooling for All Applications
|
||||
|
||||
Postgres connections are expensive (1-3MB RAM each). Without pooling, applications exhaust connections under load.
|
||||
|
||||
**Incorrect (new connection per request):**
|
||||
|
||||
```sql
|
||||
-- Each request creates a new connection
|
||||
-- Application code: db.connect() per request
|
||||
-- Result: 500 concurrent users = 500 connections = crashed database
|
||||
|
||||
-- Check current connections
|
||||
select count(*) from pg_stat_activity; -- 487 connections!
|
||||
```
|
||||
|
||||
**Correct (connection pooling):**
|
||||
|
||||
```sql
|
||||
-- Use a pooler like PgBouncer between app and database
|
||||
-- Application connects to pooler, pooler reuses a small pool to Postgres
|
||||
|
||||
-- Configure pool_size based on: (CPU cores * 2) + spindle_count
|
||||
-- Example for 4 cores: pool_size = 10
|
||||
|
||||
-- Result: 500 concurrent users share 10 actual connections
|
||||
select count(*) from pg_stat_activity; -- 10 connections
|
||||
```
|
||||
|
||||
Pool modes:
|
||||
|
||||
- **Transaction mode**: connection returned after each transaction (best for most apps)
|
||||
- **Session mode**: connection held for entire session (needed for prepared statements, temp tables)
|
||||
|
||||
Reference: [Connection Pooling](https://supabase.com/docs/guides/database/connecting-to-postgres#connection-pooler)
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
title: Use Prepared Statements Correctly with Pooling
|
||||
impact: HIGH
|
||||
impactDescription: Avoid prepared statement conflicts in pooled environments
|
||||
tags: prepared-statements, connection-pooling, transaction-mode
|
||||
---
|
||||
|
||||
## Use Prepared Statements Correctly with Pooling
|
||||
|
||||
Prepared statements are tied to individual database connections. In transaction-mode pooling, connections are shared, causing conflicts.
|
||||
|
||||
**Incorrect (named prepared statements with transaction pooling):**
|
||||
|
||||
```sql
|
||||
-- Named prepared statement
|
||||
prepare get_user as select * from users where id = $1;
|
||||
|
||||
-- In transaction mode pooling, next request may get different connection
|
||||
execute get_user(123);
|
||||
-- ERROR: prepared statement "get_user" does not exist
|
||||
```
|
||||
|
||||
**Correct (use unnamed statements or session mode):**
|
||||
|
||||
```sql
|
||||
-- Option 1: Use unnamed prepared statements (most ORMs do this automatically)
|
||||
-- The query is prepared and executed in a single protocol message
|
||||
|
||||
-- Option 2: Deallocate after use in transaction mode
|
||||
prepare get_user as select * from users where id = $1;
|
||||
execute get_user(123);
|
||||
deallocate get_user;
|
||||
|
||||
-- Option 3: Use session mode pooling (port 5432 vs 6543)
|
||||
-- Connection is held for entire session, prepared statements persist
|
||||
```
|
||||
|
||||
Check your driver settings:
|
||||
|
||||
```sql
|
||||
-- Many drivers use prepared statements by default
|
||||
-- Node.js pg: { prepare: false } to disable
|
||||
-- JDBC: prepareThreshold=0 to disable
|
||||
```
|
||||
|
||||
Reference: [Prepared Statements with Pooling](https://supabase.com/docs/guides/database/connecting-to-postgres#connection-pool-modes)
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
title: Batch INSERT Statements for Bulk Data
|
||||
impact: MEDIUM
|
||||
impactDescription: 10-50x faster bulk inserts
|
||||
tags: batch, insert, bulk, performance, copy
|
||||
---
|
||||
|
||||
## Batch INSERT Statements for Bulk Data
|
||||
|
||||
Individual INSERT statements have high overhead. Batch multiple rows in single statements or use COPY.
|
||||
|
||||
**Incorrect (individual inserts):**
|
||||
|
||||
```sql
|
||||
-- Each insert is a separate transaction and round trip
|
||||
insert into events (user_id, action) values (1, 'click');
|
||||
insert into events (user_id, action) values (1, 'view');
|
||||
insert into events (user_id, action) values (2, 'click');
|
||||
-- ... 1000 more individual inserts
|
||||
|
||||
-- 1000 inserts = 1000 round trips = slow
|
||||
```
|
||||
|
||||
**Correct (batch insert):**
|
||||
|
||||
```sql
|
||||
-- Multiple rows in single statement
|
||||
insert into events (user_id, action) values
|
||||
(1, 'click'),
|
||||
(1, 'view'),
|
||||
(2, 'click'),
|
||||
-- ... up to ~1000 rows per batch
|
||||
(999, 'view');
|
||||
|
||||
-- One round trip for 1000 rows
|
||||
```
|
||||
|
||||
For large imports, use COPY:
|
||||
|
||||
```sql
|
||||
-- COPY is fastest for bulk loading
|
||||
copy events (user_id, action, created_at)
|
||||
from '/path/to/data.csv'
|
||||
with (format csv, header true);
|
||||
|
||||
-- Or from stdin in application
|
||||
copy events (user_id, action) from stdin with (format csv);
|
||||
1,click
|
||||
1,view
|
||||
2,click
|
||||
\.
|
||||
```
|
||||
|
||||
Reference: [COPY](https://www.postgresql.org/docs/current/sql-copy.html)
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
title: Eliminate N+1 Queries with Batch Loading
|
||||
impact: MEDIUM-HIGH
|
||||
impactDescription: 10-100x fewer database round trips
|
||||
tags: n-plus-one, batch, performance, queries
|
||||
---
|
||||
|
||||
## Eliminate N+1 Queries with Batch Loading
|
||||
|
||||
N+1 queries execute one query per item in a loop. Batch them into a single query using arrays or JOINs.
|
||||
|
||||
**Incorrect (N+1 queries):**
|
||||
|
||||
```sql
|
||||
-- First query: get all users
|
||||
select id from users where active = true; -- Returns 100 IDs
|
||||
|
||||
-- Then N queries, one per user
|
||||
select * from orders where user_id = 1;
|
||||
select * from orders where user_id = 2;
|
||||
select * from orders where user_id = 3;
|
||||
-- ... 97 more queries!
|
||||
|
||||
-- Total: 101 round trips to database
|
||||
```
|
||||
|
||||
**Correct (single batch query):**
|
||||
|
||||
```sql
|
||||
-- Collect IDs and query once with ANY
|
||||
select * from orders where user_id = any(array[1, 2, 3, ...]);
|
||||
|
||||
-- Or use JOIN instead of loop
|
||||
select u.id, u.name, o.*
|
||||
from users u
|
||||
left join orders o on o.user_id = u.id
|
||||
where u.active = true;
|
||||
|
||||
-- Total: 1 round trip
|
||||
```
|
||||
|
||||
Application pattern:
|
||||
|
||||
```sql
|
||||
-- Instead of looping in application code:
|
||||
-- for user in users: db.query("SELECT * FROM orders WHERE user_id = $1", user.id)
|
||||
|
||||
-- Pass array parameter:
|
||||
select * from orders where user_id = any($1::bigint[]);
|
||||
-- Application passes: [1, 2, 3, 4, 5, ...]
|
||||
```
|
||||
|
||||
Reference: [N+1 Query Problem](https://supabase.com/docs/guides/database/query-optimization)
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
title: Use Cursor-Based Pagination Instead of OFFSET
|
||||
impact: MEDIUM-HIGH
|
||||
impactDescription: Consistent O(1) performance regardless of page depth
|
||||
tags: pagination, cursor, keyset, offset, performance
|
||||
---
|
||||
|
||||
## Use Cursor-Based Pagination Instead of OFFSET
|
||||
|
||||
OFFSET-based pagination scans all skipped rows, getting slower on deeper pages. Cursor pagination is O(1).
|
||||
|
||||
**Incorrect (OFFSET pagination):**
|
||||
|
||||
```sql
|
||||
-- Page 1: scans 20 rows
|
||||
select * from products order by id limit 20 offset 0;
|
||||
|
||||
-- Page 100: scans 2000 rows to skip 1980
|
||||
select * from products order by id limit 20 offset 1980;
|
||||
|
||||
-- Page 10000: scans 200,000 rows!
|
||||
select * from products order by id limit 20 offset 199980;
|
||||
```
|
||||
|
||||
**Correct (cursor/keyset pagination):**
|
||||
|
||||
```sql
|
||||
-- Page 1: get first 20
|
||||
select * from products order by id limit 20;
|
||||
-- Application stores last_id = 20
|
||||
|
||||
-- Page 2: start after last ID
|
||||
select * from products where id > 20 order by id limit 20;
|
||||
-- Uses index, always fast regardless of page depth
|
||||
|
||||
-- Page 10000: same speed as page 1
|
||||
select * from products where id > 199980 order by id limit 20;
|
||||
```
|
||||
|
||||
For multi-column sorting:
|
||||
|
||||
```sql
|
||||
-- Cursor must include all sort columns
|
||||
select * from products
|
||||
where (created_at, id) > ('2024-01-15 10:00:00', 12345)
|
||||
order by created_at, id
|
||||
limit 20;
|
||||
```
|
||||
|
||||
Reference: [Pagination](https://supabase.com/docs/guides/database/pagination)
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
title: Use UPSERT for Insert-or-Update Operations
|
||||
impact: MEDIUM
|
||||
impactDescription: Atomic operation, eliminates race conditions
|
||||
tags: upsert, on-conflict, insert, update
|
||||
---
|
||||
|
||||
## Use UPSERT for Insert-or-Update Operations
|
||||
|
||||
Using separate SELECT-then-INSERT/UPDATE creates race conditions. Use INSERT ... ON CONFLICT for atomic upserts.
|
||||
|
||||
**Incorrect (check-then-insert race condition):**
|
||||
|
||||
```sql
|
||||
-- Race condition: two requests check simultaneously
|
||||
select * from settings where user_id = 123 and key = 'theme';
|
||||
-- Both find nothing
|
||||
|
||||
-- Both try to insert
|
||||
insert into settings (user_id, key, value) values (123, 'theme', 'dark');
|
||||
-- One succeeds, one fails with duplicate key error!
|
||||
```
|
||||
|
||||
**Correct (atomic UPSERT):**
|
||||
|
||||
```sql
|
||||
-- Single atomic operation
|
||||
insert into settings (user_id, key, value)
|
||||
values (123, 'theme', 'dark')
|
||||
on conflict (user_id, key)
|
||||
do update set value = excluded.value, updated_at = now();
|
||||
|
||||
-- Returns the inserted/updated row
|
||||
insert into settings (user_id, key, value)
|
||||
values (123, 'theme', 'dark')
|
||||
on conflict (user_id, key)
|
||||
do update set value = excluded.value
|
||||
returning *;
|
||||
```
|
||||
|
||||
Insert-or-ignore pattern:
|
||||
|
||||
```sql
|
||||
-- Insert only if not exists (no update)
|
||||
insert into page_views (page_id, user_id)
|
||||
values (1, 123)
|
||||
on conflict (page_id, user_id) do nothing;
|
||||
```
|
||||
|
||||
Reference: [INSERT ON CONFLICT](https://www.postgresql.org/docs/current/sql-insert.html#SQL-ON-CONFLICT)
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
title: Use Advisory Locks for Application-Level Locking
|
||||
impact: MEDIUM
|
||||
impactDescription: Efficient coordination without row-level lock overhead
|
||||
tags: advisory-locks, coordination, application-locks
|
||||
---
|
||||
|
||||
## Use Advisory Locks for Application-Level Locking
|
||||
|
||||
Advisory locks provide application-level coordination without requiring database rows to lock.
|
||||
|
||||
**Incorrect (creating rows just for locking):**
|
||||
|
||||
```sql
|
||||
-- Creating dummy rows to lock on
|
||||
create table resource_locks (
|
||||
resource_name text primary key
|
||||
);
|
||||
|
||||
insert into resource_locks values ('report_generator');
|
||||
|
||||
-- Lock by selecting the row
|
||||
select * from resource_locks where resource_name = 'report_generator' for update;
|
||||
```
|
||||
|
||||
**Correct (advisory locks):**
|
||||
|
||||
```sql
|
||||
-- Session-level advisory lock (released on disconnect or unlock)
|
||||
select pg_advisory_lock(hashtext('report_generator'));
|
||||
-- ... do exclusive work ...
|
||||
select pg_advisory_unlock(hashtext('report_generator'));
|
||||
|
||||
-- Transaction-level lock (released on commit/rollback)
|
||||
begin;
|
||||
select pg_advisory_xact_lock(hashtext('daily_report'));
|
||||
-- ... do work ...
|
||||
commit; -- Lock automatically released
|
||||
```
|
||||
|
||||
Try-lock for non-blocking operations:
|
||||
|
||||
```sql
|
||||
-- Returns immediately with true/false instead of waiting
|
||||
select pg_try_advisory_lock(hashtext('resource_name'));
|
||||
|
||||
-- Use in application
|
||||
if (acquired) {
|
||||
-- Do work
|
||||
select pg_advisory_unlock(hashtext('resource_name'));
|
||||
} else {
|
||||
-- Skip or retry later
|
||||
}
|
||||
```
|
||||
|
||||
Reference: [Advisory Locks](https://www.postgresql.org/docs/current/explicit-locking.html#ADVISORY-LOCKS)
|
||||
@@ -0,0 +1,68 @@
|
||||
---
|
||||
title: Prevent Deadlocks with Consistent Lock Ordering
|
||||
impact: MEDIUM-HIGH
|
||||
impactDescription: Eliminate deadlock errors, improve reliability
|
||||
tags: deadlocks, locking, transactions, ordering
|
||||
---
|
||||
|
||||
## Prevent Deadlocks with Consistent Lock Ordering
|
||||
|
||||
Deadlocks occur when transactions lock resources in different orders. Always
|
||||
acquire locks in a consistent order.
|
||||
|
||||
**Incorrect (inconsistent lock ordering):**
|
||||
|
||||
```sql
|
||||
-- Transaction A -- Transaction B
|
||||
begin; begin;
|
||||
update accounts update accounts
|
||||
set balance = balance - 100 set balance = balance - 50
|
||||
where id = 1; where id = 2; -- B locks row 2
|
||||
|
||||
update accounts update accounts
|
||||
set balance = balance + 100 set balance = balance + 50
|
||||
where id = 2; -- A waits for B where id = 1; -- B waits for A
|
||||
|
||||
-- DEADLOCK! Both waiting for each other
|
||||
```
|
||||
|
||||
**Correct (lock rows in consistent order first):**
|
||||
|
||||
```sql
|
||||
-- Explicitly acquire locks in ID order before updating
|
||||
begin;
|
||||
select * from accounts where id in (1, 2) order by id for update;
|
||||
|
||||
-- Now perform updates in any order - locks already held
|
||||
update accounts set balance = balance - 100 where id = 1;
|
||||
update accounts set balance = balance + 100 where id = 2;
|
||||
commit;
|
||||
```
|
||||
|
||||
Alternative: use a single statement to update atomically:
|
||||
|
||||
```sql
|
||||
-- Single statement acquires all locks atomically
|
||||
begin;
|
||||
update accounts
|
||||
set balance = balance + case id
|
||||
when 1 then -100
|
||||
when 2 then 100
|
||||
end
|
||||
where id in (1, 2);
|
||||
commit;
|
||||
```
|
||||
|
||||
Detect deadlocks in logs:
|
||||
|
||||
```sql
|
||||
-- Check for recent deadlocks
|
||||
select * from pg_stat_database where deadlocks > 0;
|
||||
|
||||
-- Enable deadlock logging
|
||||
set log_lock_waits = on;
|
||||
set deadlock_timeout = '1s';
|
||||
```
|
||||
|
||||
Reference:
|
||||
[Deadlocks](https://www.postgresql.org/docs/current/explicit-locking.html#LOCKING-DEADLOCKS)
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
title: Keep Transactions Short to Reduce Lock Contention
|
||||
impact: MEDIUM-HIGH
|
||||
impactDescription: 3-5x throughput improvement, fewer deadlocks
|
||||
tags: transactions, locking, contention, performance
|
||||
---
|
||||
|
||||
## Keep Transactions Short to Reduce Lock Contention
|
||||
|
||||
Long-running transactions hold locks that block other queries. Keep transactions as short as possible.
|
||||
|
||||
**Incorrect (long transaction with external calls):**
|
||||
|
||||
```sql
|
||||
begin;
|
||||
select * from orders where id = 1 for update; -- Lock acquired
|
||||
|
||||
-- Application makes HTTP call to payment API (2-5 seconds)
|
||||
-- Other queries on this row are blocked!
|
||||
|
||||
update orders set status = 'paid' where id = 1;
|
||||
commit; -- Lock held for entire duration
|
||||
```
|
||||
|
||||
**Correct (minimal transaction scope):**
|
||||
|
||||
```sql
|
||||
-- Validate data and call APIs outside transaction
|
||||
-- Application: response = await paymentAPI.charge(...)
|
||||
|
||||
-- Only hold lock for the actual update
|
||||
begin;
|
||||
update orders
|
||||
set status = 'paid', payment_id = $1
|
||||
where id = $2 and status = 'pending'
|
||||
returning *;
|
||||
commit; -- Lock held for milliseconds
|
||||
```
|
||||
|
||||
Use `statement_timeout` to prevent runaway transactions:
|
||||
|
||||
```sql
|
||||
-- Abort queries running longer than 30 seconds
|
||||
set statement_timeout = '30s';
|
||||
|
||||
-- Or per-session
|
||||
set local statement_timeout = '5s';
|
||||
```
|
||||
|
||||
Reference: [Transaction Management](https://www.postgresql.org/docs/current/tutorial-transactions.html)
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
title: Use SKIP LOCKED for Non-Blocking Queue Processing
|
||||
impact: MEDIUM-HIGH
|
||||
impactDescription: 10x throughput for worker queues
|
||||
tags: skip-locked, queue, workers, concurrency
|
||||
---
|
||||
|
||||
## Use SKIP LOCKED for Non-Blocking Queue Processing
|
||||
|
||||
When multiple workers process a queue, SKIP LOCKED allows workers to process different rows without waiting.
|
||||
|
||||
**Incorrect (workers block each other):**
|
||||
|
||||
```sql
|
||||
-- Worker 1 and Worker 2 both try to get next job
|
||||
begin;
|
||||
select * from jobs where status = 'pending' order by created_at limit 1 for update;
|
||||
-- Worker 2 waits for Worker 1's lock to release!
|
||||
```
|
||||
|
||||
**Correct (SKIP LOCKED for parallel processing):**
|
||||
|
||||
```sql
|
||||
-- Each worker skips locked rows and gets the next available
|
||||
begin;
|
||||
select * from jobs
|
||||
where status = 'pending'
|
||||
order by created_at
|
||||
limit 1
|
||||
for update skip locked;
|
||||
|
||||
-- Worker 1 gets job 1, Worker 2 gets job 2 (no waiting)
|
||||
|
||||
update jobs set status = 'processing' where id = $1;
|
||||
commit;
|
||||
```
|
||||
|
||||
Complete queue pattern:
|
||||
|
||||
```sql
|
||||
-- Atomic claim-and-update in one statement
|
||||
update jobs
|
||||
set status = 'processing', worker_id = $1, started_at = now()
|
||||
where id = (
|
||||
select id from jobs
|
||||
where status = 'pending'
|
||||
order by created_at
|
||||
limit 1
|
||||
for update skip locked
|
||||
)
|
||||
returning *;
|
||||
```
|
||||
|
||||
Reference: [SELECT FOR UPDATE SKIP LOCKED](https://www.postgresql.org/docs/current/sql-select.html#SQL-FOR-UPDATE-SHARE)
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
title: Use EXPLAIN ANALYZE to Diagnose Slow Queries
|
||||
impact: LOW-MEDIUM
|
||||
impactDescription: Identify exact bottlenecks in query execution
|
||||
tags: explain, analyze, diagnostics, query-plan
|
||||
---
|
||||
|
||||
## Use EXPLAIN ANALYZE to Diagnose Slow Queries
|
||||
|
||||
EXPLAIN ANALYZE executes the query and shows actual timings, revealing the true performance bottlenecks.
|
||||
|
||||
**Incorrect (guessing at performance issues):**
|
||||
|
||||
```sql
|
||||
-- Query is slow, but why?
|
||||
select * from orders where customer_id = 123 and status = 'pending';
|
||||
-- "It must be missing an index" - but which one?
|
||||
```
|
||||
|
||||
**Correct (use EXPLAIN ANALYZE):**
|
||||
|
||||
```sql
|
||||
explain (analyze, buffers, format text)
|
||||
select * from orders where customer_id = 123 and status = 'pending';
|
||||
|
||||
-- Output reveals the issue:
|
||||
-- Seq Scan on orders (cost=0.00..25000.00 rows=50 width=100) (actual time=0.015..450.123 rows=50 loops=1)
|
||||
-- Filter: ((customer_id = 123) AND (status = 'pending'::text))
|
||||
-- Rows Removed by Filter: 999950
|
||||
-- Buffers: shared hit=5000 read=15000
|
||||
-- Planning Time: 0.150 ms
|
||||
-- Execution Time: 450.500 ms
|
||||
```
|
||||
|
||||
Key things to look for:
|
||||
|
||||
```sql
|
||||
-- Seq Scan on large tables = missing index
|
||||
-- Rows Removed by Filter = poor selectivity or missing index
|
||||
-- Buffers: read >> hit = data not cached, needs more memory
|
||||
-- Nested Loop with high loops = consider different join strategy
|
||||
-- Sort Method: external merge = work_mem too low
|
||||
```
|
||||
|
||||
Reference: [EXPLAIN](https://supabase.com/docs/guides/database/inspect)
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
title: Enable pg_stat_statements for Query Analysis
|
||||
impact: LOW-MEDIUM
|
||||
impactDescription: Identify top resource-consuming queries
|
||||
tags: pg-stat-statements, monitoring, statistics, performance
|
||||
---
|
||||
|
||||
## Enable pg_stat_statements for Query Analysis
|
||||
|
||||
pg_stat_statements tracks execution statistics for all queries, helping identify slow and frequent queries.
|
||||
|
||||
**Incorrect (no visibility into query patterns):**
|
||||
|
||||
```sql
|
||||
-- Database is slow, but which queries are the problem?
|
||||
-- No way to know without pg_stat_statements
|
||||
```
|
||||
|
||||
**Correct (enable and query pg_stat_statements):**
|
||||
|
||||
```sql
|
||||
-- Enable the extension
|
||||
create extension if not exists pg_stat_statements;
|
||||
|
||||
-- Find slowest queries by total time
|
||||
select
|
||||
calls,
|
||||
round(total_exec_time::numeric, 2) as total_time_ms,
|
||||
round(mean_exec_time::numeric, 2) as mean_time_ms,
|
||||
query
|
||||
from pg_stat_statements
|
||||
order by total_exec_time desc
|
||||
limit 10;
|
||||
|
||||
-- Find most frequent queries
|
||||
select calls, query
|
||||
from pg_stat_statements
|
||||
order by calls desc
|
||||
limit 10;
|
||||
|
||||
-- Reset statistics after optimization
|
||||
select pg_stat_statements_reset();
|
||||
```
|
||||
|
||||
Key metrics to monitor:
|
||||
|
||||
```sql
|
||||
-- Queries with high mean time (candidates for optimization)
|
||||
select query, mean_exec_time, calls
|
||||
from pg_stat_statements
|
||||
where mean_exec_time > 100 -- > 100ms average
|
||||
order by mean_exec_time desc;
|
||||
```
|
||||
|
||||
Reference: [pg_stat_statements](https://supabase.com/docs/guides/database/extensions/pg_stat_statements)
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
title: Maintain Table Statistics with VACUUM and ANALYZE
|
||||
impact: MEDIUM
|
||||
impactDescription: 2-10x better query plans with accurate statistics
|
||||
tags: vacuum, analyze, statistics, maintenance, autovacuum
|
||||
---
|
||||
|
||||
## Maintain Table Statistics with VACUUM and ANALYZE
|
||||
|
||||
Outdated statistics cause the query planner to make poor decisions. VACUUM reclaims space, ANALYZE updates statistics.
|
||||
|
||||
**Incorrect (stale statistics):**
|
||||
|
||||
```sql
|
||||
-- Table has 1M rows but stats say 1000
|
||||
-- Query planner chooses wrong strategy
|
||||
explain select * from orders where status = 'pending';
|
||||
-- Shows: Seq Scan (because stats show small table)
|
||||
-- Actually: Index Scan would be much faster
|
||||
```
|
||||
|
||||
**Correct (maintain fresh statistics):**
|
||||
|
||||
```sql
|
||||
-- Manually analyze after large data changes
|
||||
analyze orders;
|
||||
|
||||
-- Analyze specific columns used in WHERE clauses
|
||||
analyze orders (status, created_at);
|
||||
|
||||
-- Check when tables were last analyzed
|
||||
select
|
||||
relname,
|
||||
last_vacuum,
|
||||
last_autovacuum,
|
||||
last_analyze,
|
||||
last_autoanalyze
|
||||
from pg_stat_user_tables
|
||||
order by last_analyze nulls first;
|
||||
```
|
||||
|
||||
Autovacuum tuning for busy tables:
|
||||
|
||||
```sql
|
||||
-- Increase frequency for high-churn tables
|
||||
alter table orders set (
|
||||
autovacuum_vacuum_scale_factor = 0.05, -- Vacuum at 5% dead tuples (default 20%)
|
||||
autovacuum_analyze_scale_factor = 0.02 -- Analyze at 2% changes (default 10%)
|
||||
);
|
||||
|
||||
-- Check autovacuum status
|
||||
select * from pg_stat_progress_vacuum;
|
||||
```
|
||||
|
||||
Reference: [VACUUM](https://supabase.com/docs/guides/database/database-size#vacuum-operations)
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
title: Create Composite Indexes for Multi-Column Queries
|
||||
impact: HIGH
|
||||
impactDescription: 5-10x faster multi-column queries
|
||||
tags: indexes, composite-index, multi-column, query-optimization
|
||||
---
|
||||
|
||||
## Create Composite Indexes for Multi-Column Queries
|
||||
|
||||
When queries filter on multiple columns, a composite index is more efficient than separate single-column indexes.
|
||||
|
||||
**Incorrect (separate indexes require bitmap scan):**
|
||||
|
||||
```sql
|
||||
-- Two separate indexes
|
||||
create index orders_status_idx on orders (status);
|
||||
create index orders_created_idx on orders (created_at);
|
||||
|
||||
-- Query must combine both indexes (slower)
|
||||
select * from orders where status = 'pending' and created_at > '2024-01-01';
|
||||
```
|
||||
|
||||
**Correct (composite index):**
|
||||
|
||||
```sql
|
||||
-- Single composite index (leftmost column first for equality checks)
|
||||
create index orders_status_created_idx on orders (status, created_at);
|
||||
|
||||
-- Query uses one efficient index scan
|
||||
select * from orders where status = 'pending' and created_at > '2024-01-01';
|
||||
```
|
||||
|
||||
**Column order matters** - place equality columns first, range columns last:
|
||||
|
||||
```sql
|
||||
-- Good: status (=) before created_at (>)
|
||||
create index idx on orders (status, created_at);
|
||||
|
||||
-- Works for: WHERE status = 'pending'
|
||||
-- Works for: WHERE status = 'pending' AND created_at > '2024-01-01'
|
||||
-- Does NOT work for: WHERE created_at > '2024-01-01' (leftmost prefix rule)
|
||||
```
|
||||
|
||||
Reference: [Multicolumn Indexes](https://www.postgresql.org/docs/current/indexes-multicolumn.html)
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
title: Use Covering Indexes to Avoid Table Lookups
|
||||
impact: MEDIUM-HIGH
|
||||
impactDescription: 2-5x faster queries by eliminating heap fetches
|
||||
tags: indexes, covering-index, include, index-only-scan
|
||||
---
|
||||
|
||||
## Use Covering Indexes to Avoid Table Lookups
|
||||
|
||||
Covering indexes include all columns needed by a query, enabling index-only scans that skip the table entirely.
|
||||
|
||||
**Incorrect (index scan + heap fetch):**
|
||||
|
||||
```sql
|
||||
create index users_email_idx on users (email);
|
||||
|
||||
-- Must fetch name and created_at from table heap
|
||||
select email, name, created_at from users where email = 'user@example.com';
|
||||
```
|
||||
|
||||
**Correct (index-only scan with INCLUDE):**
|
||||
|
||||
```sql
|
||||
-- Include non-searchable columns in the index
|
||||
create index users_email_idx on users (email) include (name, created_at);
|
||||
|
||||
-- All columns served from index, no table access needed
|
||||
select email, name, created_at from users where email = 'user@example.com';
|
||||
```
|
||||
|
||||
Use INCLUDE for columns you SELECT but don't filter on:
|
||||
|
||||
```sql
|
||||
-- Searching by status, but also need customer_id and total
|
||||
create index orders_status_idx on orders (status) include (customer_id, total);
|
||||
|
||||
select status, customer_id, total from orders where status = 'shipped';
|
||||
```
|
||||
|
||||
Reference: [Index-Only Scans](https://www.postgresql.org/docs/current/indexes-index-only-scans.html)
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
title: Choose the Right Index Type for Your Data
|
||||
impact: HIGH
|
||||
impactDescription: 10-100x improvement with correct index type
|
||||
tags: indexes, btree, gin, gist, brin, hash, index-types
|
||||
---
|
||||
|
||||
## Choose the Right Index Type for Your Data
|
||||
|
||||
Different index types excel at different query patterns. The default B-tree isn't always optimal.
|
||||
|
||||
**Incorrect (B-tree for JSONB containment):**
|
||||
|
||||
```sql
|
||||
-- B-tree cannot optimize containment operators
|
||||
create index products_attrs_idx on products (attributes);
|
||||
select * from products where attributes @> '{"color": "red"}';
|
||||
-- Full table scan - B-tree doesn't support @> operator
|
||||
```
|
||||
|
||||
**Correct (GIN for JSONB):**
|
||||
|
||||
```sql
|
||||
-- GIN supports @>, ?, ?&, ?| operators
|
||||
create index products_attrs_idx on products using gin (attributes);
|
||||
select * from products where attributes @> '{"color": "red"}';
|
||||
```
|
||||
|
||||
Index type guide:
|
||||
|
||||
```sql
|
||||
-- B-tree (default): =, <, >, BETWEEN, IN, IS NULL
|
||||
create index users_created_idx on users (created_at);
|
||||
|
||||
-- GIN: arrays, JSONB, full-text search
|
||||
create index posts_tags_idx on posts using gin (tags);
|
||||
|
||||
-- GiST: geometric data, range types, nearest-neighbor (KNN) queries
|
||||
create index locations_idx on places using gist (location);
|
||||
|
||||
-- BRIN: large time-series tables (10-100x smaller)
|
||||
create index events_time_idx on events using brin (created_at);
|
||||
|
||||
-- Hash: equality-only (slightly faster than B-tree for =)
|
||||
create index sessions_token_idx on sessions using hash (token);
|
||||
```
|
||||
|
||||
Reference: [Index Types](https://www.postgresql.org/docs/current/indexes-types.html)
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
title: Add Indexes on WHERE and JOIN Columns
|
||||
impact: CRITICAL
|
||||
impactDescription: 100-1000x faster queries on large tables
|
||||
tags: indexes, performance, sequential-scan, query-optimization
|
||||
---
|
||||
|
||||
## Add Indexes on WHERE and JOIN Columns
|
||||
|
||||
Queries filtering or joining on unindexed columns cause full table scans, which become exponentially slower as tables grow.
|
||||
|
||||
**Incorrect (sequential scan on large table):**
|
||||
|
||||
```sql
|
||||
-- No index on customer_id causes full table scan
|
||||
select * from orders where customer_id = 123;
|
||||
|
||||
-- EXPLAIN shows: Seq Scan on orders (cost=0.00..25000.00 rows=100 width=85)
|
||||
```
|
||||
|
||||
**Correct (index scan):**
|
||||
|
||||
```sql
|
||||
-- Create index on frequently filtered column
|
||||
create index orders_customer_id_idx on orders (customer_id);
|
||||
|
||||
select * from orders where customer_id = 123;
|
||||
|
||||
-- EXPLAIN shows: Index Scan using orders_customer_id_idx (cost=0.42..8.44 rows=100 width=85)
|
||||
```
|
||||
|
||||
For JOIN columns, always index the foreign key side:
|
||||
|
||||
```sql
|
||||
-- Index the referencing column
|
||||
create index orders_customer_id_idx on orders (customer_id);
|
||||
|
||||
select c.name, o.total
|
||||
from customers c
|
||||
join orders o on o.customer_id = c.id;
|
||||
```
|
||||
|
||||
Reference: [Query Optimization](https://supabase.com/docs/guides/database/query-optimization)
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
title: Use Partial Indexes for Filtered Queries
|
||||
impact: HIGH
|
||||
impactDescription: 5-20x smaller indexes, faster writes and queries
|
||||
tags: indexes, partial-index, query-optimization, storage
|
||||
---
|
||||
|
||||
## Use Partial Indexes for Filtered Queries
|
||||
|
||||
Partial indexes only include rows matching a WHERE condition, making them smaller and faster when queries consistently filter on the same condition.
|
||||
|
||||
**Incorrect (full index includes irrelevant rows):**
|
||||
|
||||
```sql
|
||||
-- Index includes all rows, even soft-deleted ones
|
||||
create index users_email_idx on users (email);
|
||||
|
||||
-- Query always filters active users
|
||||
select * from users where email = 'user@example.com' and deleted_at is null;
|
||||
```
|
||||
|
||||
**Correct (partial index matches query filter):**
|
||||
|
||||
```sql
|
||||
-- Index only includes active users
|
||||
create index users_active_email_idx on users (email)
|
||||
where deleted_at is null;
|
||||
|
||||
-- Query uses the smaller, faster index
|
||||
select * from users where email = 'user@example.com' and deleted_at is null;
|
||||
```
|
||||
|
||||
Common use cases for partial indexes:
|
||||
|
||||
```sql
|
||||
-- Only pending orders (status rarely changes once completed)
|
||||
create index orders_pending_idx on orders (created_at)
|
||||
where status = 'pending';
|
||||
|
||||
-- Only non-null values
|
||||
create index products_sku_idx on products (sku)
|
||||
where sku is not null;
|
||||
```
|
||||
|
||||
Reference: [Partial Indexes](https://www.postgresql.org/docs/current/indexes-partial.html)
|
||||
@@ -0,0 +1,80 @@
|
||||
---
|
||||
title: Add Constraints Safely in Migrations
|
||||
impact: HIGH
|
||||
impactDescription: Prevents migration failures and enables idempotent schema changes
|
||||
tags: constraints, migrations, schema, alter-table
|
||||
---
|
||||
|
||||
## Add Constraints Safely in Migrations
|
||||
|
||||
PostgreSQL does not support `ADD CONSTRAINT IF NOT EXISTS`. Migrations using this syntax will fail.
|
||||
|
||||
**Incorrect (causes syntax error):**
|
||||
|
||||
```sql
|
||||
-- ERROR: syntax error at or near "not" (SQLSTATE 42601)
|
||||
alter table public.profiles
|
||||
add constraint if not exists profiles_birthchart_id_unique unique (birthchart_id);
|
||||
```
|
||||
|
||||
**Correct (idempotent constraint creation):**
|
||||
|
||||
```sql
|
||||
-- Use DO block to check before adding
|
||||
do $$
|
||||
begin
|
||||
if not exists (
|
||||
select 1 from pg_constraint
|
||||
where conname = 'profiles_birthchart_id_unique'
|
||||
and conrelid = 'public.profiles'::regclass
|
||||
) then
|
||||
alter table public.profiles
|
||||
add constraint profiles_birthchart_id_unique unique (birthchart_id);
|
||||
end if;
|
||||
end $$;
|
||||
```
|
||||
|
||||
For all constraint types:
|
||||
|
||||
```sql
|
||||
-- Check constraints
|
||||
do $$
|
||||
begin
|
||||
if not exists (
|
||||
select 1 from pg_constraint
|
||||
where conname = 'check_age_positive'
|
||||
) then
|
||||
alter table users add constraint check_age_positive check (age > 0);
|
||||
end if;
|
||||
end $$;
|
||||
|
||||
-- Foreign keys
|
||||
do $$
|
||||
begin
|
||||
if not exists (
|
||||
select 1 from pg_constraint
|
||||
where conname = 'profiles_birthchart_id_fkey'
|
||||
) then
|
||||
alter table profiles
|
||||
add constraint profiles_birthchart_id_fkey
|
||||
foreign key (birthchart_id) references birthcharts(id);
|
||||
end if;
|
||||
end $$;
|
||||
```
|
||||
|
||||
Check if constraint exists:
|
||||
|
||||
```sql
|
||||
-- Query to check constraint existence
|
||||
select conname, contype, pg_get_constraintdef(oid)
|
||||
from pg_constraint
|
||||
where conrelid = 'public.profiles'::regclass;
|
||||
|
||||
-- contype values:
|
||||
-- 'p' = PRIMARY KEY
|
||||
-- 'f' = FOREIGN KEY
|
||||
-- 'u' = UNIQUE
|
||||
-- 'c' = CHECK
|
||||
```
|
||||
|
||||
Reference: [Constraints](https://www.postgresql.org/docs/current/ddl-constraints.html)
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
title: Choose Appropriate Data Types
|
||||
impact: HIGH
|
||||
impactDescription: 50% storage reduction, faster comparisons
|
||||
tags: data-types, schema, storage, performance
|
||||
---
|
||||
|
||||
## Choose Appropriate Data Types
|
||||
|
||||
Using the right data types reduces storage, improves query performance, and prevents bugs.
|
||||
|
||||
**Incorrect (wrong data types):**
|
||||
|
||||
```sql
|
||||
create table users (
|
||||
id int, -- Will overflow at 2.1 billion
|
||||
email varchar(255), -- Unnecessary length limit
|
||||
created_at timestamp, -- Missing timezone info
|
||||
is_active varchar(5), -- String for boolean
|
||||
price varchar(20) -- String for numeric
|
||||
);
|
||||
```
|
||||
|
||||
**Correct (appropriate data types):**
|
||||
|
||||
```sql
|
||||
create table users (
|
||||
id bigint generated always as identity primary key, -- 9 quintillion max
|
||||
email text, -- No artificial limit, same performance as varchar
|
||||
created_at timestamptz, -- Always store timezone-aware timestamps
|
||||
is_active boolean default true, -- 1 byte vs variable string length
|
||||
price numeric(10,2) -- Exact decimal arithmetic
|
||||
);
|
||||
```
|
||||
|
||||
Key guidelines:
|
||||
|
||||
```sql
|
||||
-- IDs: use bigint, not int (future-proofing)
|
||||
-- Strings: use text, not varchar(n) unless constraint needed
|
||||
-- Time: use timestamptz, not timestamp
|
||||
-- Money: use numeric, not float (precision matters)
|
||||
-- Enums: use text with check constraint or create enum type
|
||||
```
|
||||
|
||||
Reference: [Data Types](https://www.postgresql.org/docs/current/datatype.html)
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
title: Index Foreign Key Columns
|
||||
impact: HIGH
|
||||
impactDescription: 10-100x faster JOINs and CASCADE operations
|
||||
tags: foreign-key, indexes, joins, schema
|
||||
---
|
||||
|
||||
## Index Foreign Key Columns
|
||||
|
||||
Postgres does not automatically index foreign key columns. Missing indexes cause slow JOINs and CASCADE operations.
|
||||
|
||||
**Incorrect (unindexed foreign key):**
|
||||
|
||||
```sql
|
||||
create table orders (
|
||||
id bigint generated always as identity primary key,
|
||||
customer_id bigint references customers(id) on delete cascade,
|
||||
total numeric(10,2)
|
||||
);
|
||||
|
||||
-- No index on customer_id!
|
||||
-- JOINs and ON DELETE CASCADE both require full table scan
|
||||
select * from orders where customer_id = 123; -- Seq Scan
|
||||
delete from customers where id = 123; -- Locks table, scans all orders
|
||||
```
|
||||
|
||||
**Correct (indexed foreign key):**
|
||||
|
||||
```sql
|
||||
create table orders (
|
||||
id bigint generated always as identity primary key,
|
||||
customer_id bigint references customers(id) on delete cascade,
|
||||
total numeric(10,2)
|
||||
);
|
||||
|
||||
-- Always index the FK column
|
||||
create index orders_customer_id_idx on orders (customer_id);
|
||||
|
||||
-- Now JOINs and cascades are fast
|
||||
select * from orders where customer_id = 123; -- Index Scan
|
||||
delete from customers where id = 123; -- Uses index, fast cascade
|
||||
```
|
||||
|
||||
Find missing FK indexes:
|
||||
|
||||
```sql
|
||||
select
|
||||
conrelid::regclass as table_name,
|
||||
a.attname as fk_column
|
||||
from pg_constraint c
|
||||
join pg_attribute a on a.attrelid = c.conrelid and a.attnum = any(c.conkey)
|
||||
where c.contype = 'f'
|
||||
and not exists (
|
||||
select 1 from pg_index i
|
||||
where i.indrelid = c.conrelid and a.attnum = any(i.indkey)
|
||||
);
|
||||
```
|
||||
|
||||
Reference: [Foreign Keys](https://www.postgresql.org/docs/current/ddl-constraints.html#DDL-CONSTRAINTS-FK)
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
title: Use Lowercase Identifiers for Compatibility
|
||||
impact: MEDIUM
|
||||
impactDescription: Avoid case-sensitivity bugs with tools, ORMs, and AI assistants
|
||||
tags: naming, identifiers, case-sensitivity, schema, conventions
|
||||
---
|
||||
|
||||
## Use Lowercase Identifiers for Compatibility
|
||||
|
||||
PostgreSQL folds unquoted identifiers to lowercase. Quoted mixed-case identifiers require quotes forever and cause issues with tools, ORMs, and AI assistants that may not recognize them.
|
||||
|
||||
**Incorrect (mixed-case identifiers):**
|
||||
|
||||
```sql
|
||||
-- Quoted identifiers preserve case but require quotes everywhere
|
||||
CREATE TABLE "Users" (
|
||||
"userId" bigint PRIMARY KEY,
|
||||
"firstName" text,
|
||||
"lastName" text
|
||||
);
|
||||
|
||||
-- Must always quote or queries fail
|
||||
SELECT "firstName" FROM "Users" WHERE "userId" = 1;
|
||||
|
||||
-- This fails - Users becomes users without quotes
|
||||
SELECT firstName FROM Users;
|
||||
-- ERROR: relation "users" does not exist
|
||||
```
|
||||
|
||||
**Correct (lowercase snake_case):**
|
||||
|
||||
```sql
|
||||
-- Unquoted lowercase identifiers are portable and tool-friendly
|
||||
CREATE TABLE users (
|
||||
user_id bigint PRIMARY KEY,
|
||||
first_name text,
|
||||
last_name text
|
||||
);
|
||||
|
||||
-- Works without quotes, recognized by all tools
|
||||
SELECT first_name FROM users WHERE user_id = 1;
|
||||
```
|
||||
|
||||
Common sources of mixed-case identifiers:
|
||||
|
||||
```sql
|
||||
-- ORMs often generate quoted camelCase - configure them to use snake_case
|
||||
-- Migrations from other databases may preserve original casing
|
||||
-- Some GUI tools quote identifiers by default - disable this
|
||||
|
||||
-- If stuck with mixed-case, create views as a compatibility layer
|
||||
CREATE VIEW users AS SELECT "userId" AS user_id, "firstName" AS first_name FROM "Users";
|
||||
```
|
||||
|
||||
Reference: [Identifiers and Key Words](https://www.postgresql.org/docs/current/sql-syntax-lexical.html#SQL-SYNTAX-IDENTIFIERS)
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
title: Partition Large Tables for Better Performance
|
||||
impact: MEDIUM-HIGH
|
||||
impactDescription: 5-20x faster queries and maintenance on large tables
|
||||
tags: partitioning, large-tables, time-series, performance
|
||||
---
|
||||
|
||||
## Partition Large Tables for Better Performance
|
||||
|
||||
Partitioning splits a large table into smaller pieces, improving query performance and maintenance operations.
|
||||
|
||||
**Incorrect (single large table):**
|
||||
|
||||
```sql
|
||||
create table events (
|
||||
id bigint generated always as identity,
|
||||
created_at timestamptz,
|
||||
data jsonb
|
||||
);
|
||||
|
||||
-- 500M rows, queries scan everything
|
||||
select * from events where created_at > '2024-01-01'; -- Slow
|
||||
vacuum events; -- Takes hours, locks table
|
||||
```
|
||||
|
||||
**Correct (partitioned by time range):**
|
||||
|
||||
```sql
|
||||
create table events (
|
||||
id bigint generated always as identity,
|
||||
created_at timestamptz not null,
|
||||
data jsonb
|
||||
) partition by range (created_at);
|
||||
|
||||
-- Create partitions for each month
|
||||
create table events_2024_01 partition of events
|
||||
for values from ('2024-01-01') to ('2024-02-01');
|
||||
|
||||
create table events_2024_02 partition of events
|
||||
for values from ('2024-02-01') to ('2024-03-01');
|
||||
|
||||
-- Queries only scan relevant partitions
|
||||
select * from events where created_at > '2024-01-15'; -- Only scans events_2024_01+
|
||||
|
||||
-- Drop old data instantly
|
||||
drop table events_2023_01; -- Instant vs DELETE taking hours
|
||||
```
|
||||
|
||||
When to partition:
|
||||
|
||||
- Tables > 100M rows
|
||||
- Time-series data with date-based queries
|
||||
- Need to efficiently drop old data
|
||||
|
||||
Reference: [Table Partitioning](https://www.postgresql.org/docs/current/ddl-partitioning.html)
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
title: Select Optimal Primary Key Strategy
|
||||
impact: HIGH
|
||||
impactDescription: Better index locality, reduced fragmentation
|
||||
tags: primary-key, identity, uuid, serial, schema
|
||||
---
|
||||
|
||||
## Select Optimal Primary Key Strategy
|
||||
|
||||
Primary key choice affects insert performance, index size, and replication
|
||||
efficiency.
|
||||
|
||||
**Incorrect (problematic PK choices):**
|
||||
|
||||
```sql
|
||||
-- identity is the SQL-standard approach
|
||||
create table users (
|
||||
id serial primary key -- Works, but IDENTITY is recommended
|
||||
);
|
||||
|
||||
-- Random UUIDs (v4) cause index fragmentation
|
||||
create table orders (
|
||||
id uuid default gen_random_uuid() primary key -- UUIDv4 = random = scattered inserts
|
||||
);
|
||||
```
|
||||
|
||||
**Correct (optimal PK strategies):**
|
||||
|
||||
```sql
|
||||
-- Use IDENTITY for sequential IDs (SQL-standard, best for most cases)
|
||||
create table users (
|
||||
id bigint generated always as identity primary key
|
||||
);
|
||||
|
||||
-- For distributed systems needing UUIDs, use UUIDv7 (time-ordered)
|
||||
-- Requires pg_uuidv7 extension: create extension pg_uuidv7;
|
||||
create table orders (
|
||||
id uuid default uuid_generate_v7() primary key -- Time-ordered, no fragmentation
|
||||
);
|
||||
|
||||
-- Alternative: time-prefixed IDs for sortable, distributed IDs (no extension needed)
|
||||
create table events (
|
||||
id text default concat(
|
||||
to_char(now() at time zone 'utc', 'YYYYMMDDHH24MISSMS'),
|
||||
gen_random_uuid()::text
|
||||
) primary key
|
||||
);
|
||||
```
|
||||
|
||||
Guidelines:
|
||||
|
||||
- Single database: `bigint identity` (sequential, 8 bytes, SQL-standard)
|
||||
- Distributed/exposed IDs: UUIDv7 (requires pg_uuidv7) or ULID (time-ordered, no
|
||||
fragmentation)
|
||||
- `serial` works but `identity` is SQL-standard and preferred for new
|
||||
applications
|
||||
- Avoid random UUIDs (v4) as primary keys on large tables (causes index
|
||||
fragmentation)
|
||||
|
||||
Reference:
|
||||
[Identity Columns](https://www.postgresql.org/docs/current/sql-createtable.html#SQL-CREATETABLE-PARMS-GENERATED-IDENTITY)
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
title: Apply Principle of Least Privilege
|
||||
impact: MEDIUM
|
||||
impactDescription: Reduced attack surface, better audit trail
|
||||
tags: privileges, security, roles, permissions
|
||||
---
|
||||
|
||||
## Apply Principle of Least Privilege
|
||||
|
||||
Grant only the minimum permissions required. Never use superuser for application queries.
|
||||
|
||||
**Incorrect (overly broad permissions):**
|
||||
|
||||
```sql
|
||||
-- Application uses superuser connection
|
||||
-- Or grants ALL to application role
|
||||
grant all privileges on all tables in schema public to app_user;
|
||||
grant all privileges on all sequences in schema public to app_user;
|
||||
|
||||
-- Any SQL injection becomes catastrophic
|
||||
-- drop table users; cascades to everything
|
||||
```
|
||||
|
||||
**Correct (minimal, specific grants):**
|
||||
|
||||
```sql
|
||||
-- Create role with no default privileges
|
||||
create role app_readonly nologin;
|
||||
|
||||
-- Grant only SELECT on specific tables
|
||||
grant usage on schema public to app_readonly;
|
||||
grant select on public.products, public.categories to app_readonly;
|
||||
|
||||
-- Create role for writes with limited scope
|
||||
create role app_writer nologin;
|
||||
grant usage on schema public to app_writer;
|
||||
grant select, insert, update on public.orders to app_writer;
|
||||
grant usage on sequence orders_id_seq to app_writer;
|
||||
-- No DELETE permission
|
||||
|
||||
-- Login role inherits from these
|
||||
create role app_user login password 'xxx';
|
||||
grant app_writer to app_user;
|
||||
```
|
||||
|
||||
Revoke public defaults:
|
||||
|
||||
```sql
|
||||
-- Revoke default public access
|
||||
revoke all on schema public from public;
|
||||
revoke all on all tables in schema public from public;
|
||||
```
|
||||
|
||||
Reference: [Roles and Privileges](https://supabase.com/blog/postgres-roles-and-privileges)
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
title: Enable Row Level Security for Multi-Tenant Data
|
||||
impact: CRITICAL
|
||||
impactDescription: Database-enforced tenant isolation, prevent data leaks
|
||||
tags: rls, row-level-security, multi-tenant, security
|
||||
---
|
||||
|
||||
## Enable Row Level Security for Multi-Tenant Data
|
||||
|
||||
Row Level Security (RLS) enforces data access at the database level, ensuring users only see their own data.
|
||||
|
||||
**Incorrect (application-level filtering only):**
|
||||
|
||||
```sql
|
||||
-- Relying only on application to filter
|
||||
select * from orders where user_id = $current_user_id;
|
||||
|
||||
-- Bug or bypass means all data is exposed!
|
||||
select * from orders; -- Returns ALL orders
|
||||
```
|
||||
|
||||
**Correct (database-enforced RLS):**
|
||||
|
||||
```sql
|
||||
-- Enable RLS on the table
|
||||
alter table orders enable row level security;
|
||||
|
||||
-- Create policy for users to see only their orders
|
||||
create policy orders_user_policy on orders
|
||||
for all
|
||||
using (user_id = current_setting('app.current_user_id')::bigint);
|
||||
|
||||
-- Force RLS even for table owners
|
||||
alter table orders force row level security;
|
||||
|
||||
-- Set user context and query
|
||||
set app.current_user_id = '123';
|
||||
select * from orders; -- Only returns orders for user 123
|
||||
```
|
||||
|
||||
Policy for authenticated role:
|
||||
|
||||
```sql
|
||||
create policy orders_user_policy on orders
|
||||
for all
|
||||
to authenticated
|
||||
using (user_id = auth.uid());
|
||||
```
|
||||
|
||||
Reference: [Row Level Security](https://supabase.com/docs/guides/database/postgres/row-level-security)
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
title: Optimize RLS Policies for Performance
|
||||
impact: HIGH
|
||||
impactDescription: 5-10x faster RLS queries with proper patterns
|
||||
tags: rls, performance, security, optimization
|
||||
---
|
||||
|
||||
## Optimize RLS Policies for Performance
|
||||
|
||||
Poorly written RLS policies can cause severe performance issues. Use subqueries and indexes strategically.
|
||||
|
||||
**Incorrect (function called for every row):**
|
||||
|
||||
```sql
|
||||
create policy orders_policy on orders
|
||||
using (auth.uid() = user_id); -- auth.uid() called per row!
|
||||
|
||||
-- With 1M rows, auth.uid() is called 1M times
|
||||
```
|
||||
|
||||
**Correct (wrap functions in SELECT):**
|
||||
|
||||
```sql
|
||||
create policy orders_policy on orders
|
||||
using ((select auth.uid()) = user_id); -- Called once, cached
|
||||
|
||||
-- 100x+ faster on large tables
|
||||
```
|
||||
|
||||
Use security definer functions for complex checks:
|
||||
|
||||
```sql
|
||||
-- Create helper function (runs as definer, bypasses RLS)
|
||||
create or replace function is_team_member(team_id bigint)
|
||||
returns boolean
|
||||
language sql
|
||||
security definer
|
||||
set search_path = ''
|
||||
as $$
|
||||
select exists (
|
||||
select 1 from public.team_members
|
||||
where team_id = $1 and user_id = (select auth.uid())
|
||||
);
|
||||
$$;
|
||||
|
||||
-- Use in policy (indexed lookup, not per-row check)
|
||||
create policy team_orders_policy on orders
|
||||
using ((select is_team_member(team_id)));
|
||||
```
|
||||
|
||||
Always add indexes on columns used in RLS policies:
|
||||
|
||||
```sql
|
||||
create index orders_user_id_idx on orders (user_id);
|
||||
```
|
||||
|
||||
Reference: [RLS Performance](https://supabase.com/docs/guides/database/postgres/row-level-security#rls-performance-recommendations)
|
||||
1
.claude/skills/supabase-postgres-best-practices
Symbolic link
1
.claude/skills/supabase-postgres-best-practices
Symbolic link
@@ -0,0 +1 @@
|
||||
../../.agents/skills/supabase-postgres-best-practices
|
||||
1
.codebuddy/skills/supabase-postgres-best-practices
Symbolic link
1
.codebuddy/skills/supabase-postgres-best-practices
Symbolic link
@@ -0,0 +1 @@
|
||||
../../.agents/skills/supabase-postgres-best-practices
|
||||
1
.commandcode/skills/supabase-postgres-best-practices
Symbolic link
1
.commandcode/skills/supabase-postgres-best-practices
Symbolic link
@@ -0,0 +1 @@
|
||||
../../.agents/skills/supabase-postgres-best-practices
|
||||
1
.continue/skills/supabase-postgres-best-practices
Symbolic link
1
.continue/skills/supabase-postgres-best-practices
Symbolic link
@@ -0,0 +1 @@
|
||||
../../.agents/skills/supabase-postgres-best-practices
|
||||
1
.crush/skills/supabase-postgres-best-practices
Symbolic link
1
.crush/skills/supabase-postgres-best-practices
Symbolic link
@@ -0,0 +1 @@
|
||||
../../.agents/skills/supabase-postgres-best-practices
|
||||
3
.env.example
Normal file
3
.env.example
Normal file
@@ -0,0 +1,3 @@
|
||||
VITE_SUPABASE_URL=
|
||||
VITE_SUPABASE_ANON_KEY=
|
||||
VITE_SUPABASE_SCHEMA=
|
||||
1
.factory/skills/supabase-postgres-best-practices
Symbolic link
1
.factory/skills/supabase-postgres-best-practices
Symbolic link
@@ -0,0 +1 @@
|
||||
../../.agents/skills/supabase-postgres-best-practices
|
||||
29
.gitignore
vendored
Normal file
29
.gitignore
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Env files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
1
.goose/skills/supabase-postgres-best-practices
Symbolic link
1
.goose/skills/supabase-postgres-best-practices
Symbolic link
@@ -0,0 +1 @@
|
||||
../../.agents/skills/supabase-postgres-best-practices
|
||||
1
.kilocode/skills/supabase-postgres-best-practices
Symbolic link
1
.kilocode/skills/supabase-postgres-best-practices
Symbolic link
@@ -0,0 +1 @@
|
||||
../../.agents/skills/supabase-postgres-best-practices
|
||||
1
.kiro/skills/supabase-postgres-best-practices
Symbolic link
1
.kiro/skills/supabase-postgres-best-practices
Symbolic link
@@ -0,0 +1 @@
|
||||
../../.agents/skills/supabase-postgres-best-practices
|
||||
1
.mcpjam/skills/supabase-postgres-best-practices
Symbolic link
1
.mcpjam/skills/supabase-postgres-best-practices
Symbolic link
@@ -0,0 +1 @@
|
||||
../../.agents/skills/supabase-postgres-best-practices
|
||||
1
.mux/skills/supabase-postgres-best-practices
Symbolic link
1
.mux/skills/supabase-postgres-best-practices
Symbolic link
@@ -0,0 +1 @@
|
||||
../../.agents/skills/supabase-postgres-best-practices
|
||||
1
.neovate/skills/supabase-postgres-best-practices
Symbolic link
1
.neovate/skills/supabase-postgres-best-practices
Symbolic link
@@ -0,0 +1 @@
|
||||
../../.agents/skills/supabase-postgres-best-practices
|
||||
1
.openhands/skills/supabase-postgres-best-practices
Symbolic link
1
.openhands/skills/supabase-postgres-best-practices
Symbolic link
@@ -0,0 +1 @@
|
||||
../../.agents/skills/supabase-postgres-best-practices
|
||||
1
.pi/skills/supabase-postgres-best-practices
Symbolic link
1
.pi/skills/supabase-postgres-best-practices
Symbolic link
@@ -0,0 +1 @@
|
||||
../../.agents/skills/supabase-postgres-best-practices
|
||||
1
.qoder/skills/supabase-postgres-best-practices
Symbolic link
1
.qoder/skills/supabase-postgres-best-practices
Symbolic link
@@ -0,0 +1 @@
|
||||
../../.agents/skills/supabase-postgres-best-practices
|
||||
1
.qwen/skills/supabase-postgres-best-practices
Symbolic link
1
.qwen/skills/supabase-postgres-best-practices
Symbolic link
@@ -0,0 +1 @@
|
||||
../../.agents/skills/supabase-postgres-best-practices
|
||||
1
.roo/skills/supabase-postgres-best-practices
Symbolic link
1
.roo/skills/supabase-postgres-best-practices
Symbolic link
@@ -0,0 +1 @@
|
||||
../../.agents/skills/supabase-postgres-best-practices
|
||||
1
.trae/skills/supabase-postgres-best-practices
Symbolic link
1
.trae/skills/supabase-postgres-best-practices
Symbolic link
@@ -0,0 +1 @@
|
||||
../../.agents/skills/supabase-postgres-best-practices
|
||||
1
.windsurf/skills/supabase-postgres-best-practices
Symbolic link
1
.windsurf/skills/supabase-postgres-best-practices
Symbolic link
@@ -0,0 +1 @@
|
||||
../../.agents/skills/supabase-postgres-best-practices
|
||||
1
.zencoder/skills/supabase-postgres-best-practices
Symbolic link
1
.zencoder/skills/supabase-postgres-best-practices
Symbolic link
@@ -0,0 +1 @@
|
||||
../../.agents/skills/supabase-postgres-best-practices
|
||||
BIN
Diseño de calendario.png
Normal file
BIN
Diseño de calendario.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 422 KiB |
BIN
Ejemplo calendario y vista.png
Normal file
BIN
Ejemplo calendario y vista.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
89
README.md
Normal file
89
README.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# Naturcalabacera — Monorepo
|
||||
|
||||
Monorepo with pnpm workspaces. Workspaces: `apps/web`, `apps/api`, `packages/shared`.
|
||||
|
||||
## Getting started
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm dev:web # start frontend
|
||||
pnpm dev:api # start API server
|
||||
pnpm build # build all
|
||||
pnpm test # run shared package tests
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## apps/web — React + TypeScript + Vite
|
||||
|
||||
Minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
30
apps/api/.env.example
Normal file
30
apps/api/.env.example
Normal file
@@ -0,0 +1,30 @@
|
||||
# ─────────────────────────────────────────────
|
||||
# apps/api — Variables de entorno
|
||||
# Copia este archivo como .env y rellena los valores.
|
||||
# NUNCA expongas estas variables al frontend.
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
# Servidor
|
||||
API_PORT=3001
|
||||
NODE_ENV=development
|
||||
|
||||
# Supabase (self-hosted) — usar service_role key (bypasea RLS)
|
||||
SUPABASE_URL=https://tu-supabase.tudominio.com
|
||||
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||
|
||||
# Resend — servicio de email
|
||||
# Crear cuenta en https://resend.com y configurar dominio
|
||||
RESEND_API_KEY=re_...
|
||||
EMAIL_FROM=Naturcalabacera <reservas@tudominio.com>
|
||||
|
||||
# Destinatarios de notificaciones (parametrizables, sin hardcodear)
|
||||
NOTIFICATION_EMAIL_TENERIFFA=contacto-teneriffa@ejemplo.com
|
||||
NOTIFICATION_EMAIL_NATUR=admin@naturcalabacera.com
|
||||
NOTIFICATION_EMAIL_POOL_HEATING=jonathan@ejemplo.com # "Jonathan o las chicas"
|
||||
|
||||
# URL del frontend para CORS
|
||||
WEB_ORIGIN=http://localhost:5173
|
||||
|
||||
# Jobs — runner de notificaciones
|
||||
JOB_RUNNER_INTERVAL_MS=300000 # 5 minutos (300000ms)
|
||||
JOB_MAX_RETRIES=3
|
||||
25
apps/api/package.json
Normal file
25
apps/api/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@naturcalabacera/api",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch --env-file=.env src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@naturcalabacera/shared": "workspace:*",
|
||||
"@supabase/supabase-js": "^2.95.3",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.21.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.18",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^24.10.1",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "~5.9.3"
|
||||
}
|
||||
}
|
||||
40
apps/api/src/config/env.ts
Normal file
40
apps/api/src/config/env.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Configuración centralizada de variables de entorno.
|
||||
* Falla al arrancar si faltan variables críticas.
|
||||
*/
|
||||
|
||||
function required(name: string): string {
|
||||
const value = process.env[name];
|
||||
if (!value) throw new Error(`Variable de entorno requerida: ${name}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function optional(name: string, defaultValue: string): string {
|
||||
return process.env[name] ?? defaultValue;
|
||||
}
|
||||
|
||||
export const env = {
|
||||
// Servidor
|
||||
PORT: parseInt(optional('API_PORT', '3001'), 10),
|
||||
NODE_ENV: optional('NODE_ENV', 'development'),
|
||||
|
||||
// Supabase (self-hosted)
|
||||
SUPABASE_URL: required('SUPABASE_URL'),
|
||||
SUPABASE_SERVICE_ROLE_KEY: required('SUPABASE_SERVICE_ROLE_KEY'),
|
||||
|
||||
// Email — Resend
|
||||
RESEND_API_KEY: required('RESEND_API_KEY'),
|
||||
EMAIL_FROM: optional('EMAIL_FROM', 'Naturcalabacera <reservas@naturcalabacera.com>'),
|
||||
|
||||
// Destinatarios de notificaciones (configurables, sin hardcodear)
|
||||
NOTIFICATION_EMAIL_TENERIFFA: required('NOTIFICATION_EMAIL_TENERIFFA'),
|
||||
NOTIFICATION_EMAIL_NATUR: required('NOTIFICATION_EMAIL_NATUR'),
|
||||
NOTIFICATION_EMAIL_POOL_HEATING: required('NOTIFICATION_EMAIL_POOL_HEATING'),
|
||||
|
||||
// Jobs
|
||||
JOB_RUNNER_INTERVAL_MS: parseInt(optional('JOB_RUNNER_INTERVAL_MS', '300000'), 10), // 5 min
|
||||
JOB_MAX_RETRIES: parseInt(optional('JOB_MAX_RETRIES', '3'), 10),
|
||||
|
||||
// Origen del frontend para CORS
|
||||
WEB_ORIGIN: optional('WEB_ORIGIN', 'http://localhost:5173'),
|
||||
};
|
||||
252
apps/api/src/events/handler.ts
Normal file
252
apps/api/src/events/handler.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { sendEmail } from '../services/email.js';
|
||||
import { env } from '../config/env.js';
|
||||
import type { Reservation, NotificationEventType } from '@naturcalabacera/shared';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const TEMPLATES_DIR = join(__dirname, '../templates');
|
||||
|
||||
/**
|
||||
* Carga un template HTML y reemplaza las variables {{VARIABLE}}.
|
||||
*/
|
||||
function renderTemplate(templateName: string, vars: Record<string, string>): string {
|
||||
const filePath = join(TEMPLATES_DIR, `${templateName}.html`);
|
||||
let html = readFileSync(filePath, 'utf-8');
|
||||
for (const [key, value] of Object.entries(vars)) {
|
||||
html = html.replaceAll(`{{${key}}}`, value);
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
const date = new Date(dateStr + 'T12:00:00Z');
|
||||
return date.toLocaleDateString('es-ES', { day: '2-digit', month: 'long', year: 'numeric' });
|
||||
}
|
||||
|
||||
function formatDateShort(dateStr: string): string {
|
||||
const date = new Date(dateStr + 'T12:00:00Z');
|
||||
return date.toLocaleDateString('es-ES', { day: '2-digit', month: 'short', year: 'numeric' });
|
||||
}
|
||||
|
||||
function propertyLabel(property: string): string {
|
||||
return property === 'los_dragos' ? 'Los Dragos' : 'La Esquinita';
|
||||
}
|
||||
|
||||
function nightsCount(start: string, end: string): string {
|
||||
const d1 = new Date(start + 'T12:00:00Z');
|
||||
const d2 = new Date(end + 'T12:00:00Z');
|
||||
const nights = Math.round((d2.getTime() - d1.getTime()) / (1000 * 60 * 60 * 24));
|
||||
return `${nights} noche${nights === 1 ? '' : 's'}`;
|
||||
}
|
||||
|
||||
function formatFieldValue(field: string, value: unknown): string {
|
||||
if (value === null || value === undefined || value === '') return '—';
|
||||
if (field === 'start_date' || field === 'end_date') return formatDate(String(value));
|
||||
if (field === 'property') return propertyLabel(String(value));
|
||||
if (field === 'origin') return value === 'Teneriffa2000' ? 'Teneriffa' : 'Natur';
|
||||
if (typeof value === 'boolean') return value ? 'Sí' : 'No';
|
||||
return String(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Genera el bloque HTML con la tabla de cambios entre dos versiones de reserva.
|
||||
* Se incluye solo en emails de tipo "modificada".
|
||||
*/
|
||||
function buildChangesBlock(prev: Reservation, curr: Reservation): string {
|
||||
const FIELD_LABELS: Record<string, string> = {
|
||||
client_name: 'Cliente',
|
||||
start_date: 'Fecha de entrada',
|
||||
end_date: 'Fecha de salida',
|
||||
property: 'Propiedad',
|
||||
adults_count: 'Adultos',
|
||||
children_count: 'Niños',
|
||||
has_cleaning: 'Limpieza incluida',
|
||||
has_pool_heating: 'Calefacción de piscina',
|
||||
has_flies_products: 'Productos antiparasitarios',
|
||||
government_registration: 'Registro gubernamental',
|
||||
observations: 'Observaciones',
|
||||
is_event: 'Es evento',
|
||||
event_type: 'Tipo de evento',
|
||||
attendees_count: 'Nº asistentes',
|
||||
};
|
||||
|
||||
const changes: Array<{ label: string; from: string; to: string }> = [];
|
||||
|
||||
for (const [field, label] of Object.entries(FIELD_LABELS)) {
|
||||
const prevVal = (prev as Record<string, unknown>)[field];
|
||||
const currVal = (curr as Record<string, unknown>)[field];
|
||||
const prevStr = formatFieldValue(field, prevVal);
|
||||
const currStr = formatFieldValue(field, currVal);
|
||||
if (prevStr !== currStr) {
|
||||
changes.push({ label, from: prevStr, to: currStr });
|
||||
}
|
||||
}
|
||||
|
||||
if (changes.length === 0) {
|
||||
return `<p style="color:#6b7280; font-style:italic; margin:0; font-size:14px;">Sin cambios detectados en los datos principales.</p>`;
|
||||
}
|
||||
|
||||
let rows = '';
|
||||
for (const { label, from, to } of changes) {
|
||||
rows += `
|
||||
<tr>
|
||||
<td style="padding:10px 12px; font-weight:600; color:#374151; border-bottom:1px solid #e5e7eb; font-size:14px;">${label}</td>
|
||||
<td style="padding:10px 12px; color:#991b1b; text-decoration:line-through; border-bottom:1px solid #e5e7eb; font-size:14px;">${from}</td>
|
||||
<td style="padding:10px 12px; color:#065f46; font-weight:700; border-bottom:1px solid #e5e7eb; font-size:14px;">${to}</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div style="margin-top:24px; border-top:2px solid #f59e0b; padding-top:20px;">
|
||||
<h3 style="margin:0 0 14px; font-size:12px; text-transform:uppercase; letter-spacing:0.08em; color:#92400e; font-weight:700;">Cambios realizados</h3>
|
||||
<table style="width:100%; border-collapse:collapse;">
|
||||
<thead>
|
||||
<tr style="background:#fef3c7;">
|
||||
<th style="padding:8px 12px; text-align:left; font-size:11px; text-transform:uppercase; color:#92400e; font-weight:700; letter-spacing:0.05em;">Campo</th>
|
||||
<th style="padding:8px 12px; text-align:left; font-size:11px; text-transform:uppercase; color:#92400e; font-weight:700; letter-spacing:0.05em;">Antes</th>
|
||||
<th style="padding:8px 12px; text-align:left; font-size:11px; text-transform:uppercase; color:#92400e; font-weight:700; letter-spacing:0.05em;">Ahora</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Genera el bloque HTML de observaciones si existen.
|
||||
*/
|
||||
function buildObservationsBlock(observations?: string): string {
|
||||
if (!observations || observations.trim() === '') return '';
|
||||
return `
|
||||
<div style="margin-top:20px; padding:14px 16px; background:#f0fdf4; border-left:4px solid #10b981; border-radius:0 8px 8px 0;">
|
||||
<p style="margin:0 0 6px; font-size:11px; text-transform:uppercase; letter-spacing:0.05em; color:#065f46; font-weight:700;">Observaciones</p>
|
||||
<p style="margin:0; font-size:14px; color:#374151; line-height:1.5;">${observations}</p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Genera la lista de servicios adicionales de la reserva.
|
||||
*/
|
||||
function buildServicesText(r: Reservation): string {
|
||||
const services: string[] = [];
|
||||
if (r.has_cleaning) services.push('Limpieza');
|
||||
if (r.has_pool_heating) services.push('Calefacción piscina');
|
||||
if (r.has_flies_products) services.push('Antiparasitarios');
|
||||
if (r.is_event && r.event_type) {
|
||||
const label = r.event_type === 'Otro' ? `Evento: ${r.event_type_other ?? 'otro'}` : `Evento: ${r.event_type}`;
|
||||
services.push(label);
|
||||
}
|
||||
return services.length > 0 ? services.join(' · ') : '—';
|
||||
}
|
||||
|
||||
interface SendResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Envía el email correspondiente a un evento de notificación.
|
||||
* Para reservation.updated acepta previousReservation para mostrar el diff.
|
||||
*/
|
||||
export async function handleNotificationEvent(
|
||||
eventType: NotificationEventType,
|
||||
reservation: Reservation,
|
||||
previousReservation?: Reservation
|
||||
): Promise<SendResult> {
|
||||
const nights = nightsCount(reservation.start_date, reservation.end_date);
|
||||
const dateRange = `${formatDateShort(reservation.start_date)} – ${formatDateShort(reservation.end_date)}`;
|
||||
|
||||
const baseVars: Record<string, string> = {
|
||||
CLIENT_NAME: reservation.client_name,
|
||||
START_DATE: formatDate(reservation.start_date),
|
||||
END_DATE: formatDate(reservation.end_date),
|
||||
PROPERTY: propertyLabel(reservation.property),
|
||||
ORIGIN: reservation.origin === 'Teneriffa2000' ? 'Teneriffa' : 'Natur',
|
||||
ADULTS: String(reservation.adults_count),
|
||||
CHILDREN: String(reservation.children_count),
|
||||
TOTAL_PERSONS: String(reservation.adults_count + reservation.children_count),
|
||||
RESERVATION_ID: reservation.id,
|
||||
INVOICE_NUMBER: reservation.invoice_number ?? '—',
|
||||
NIGHTS: nights,
|
||||
DATE_RANGE: dateRange,
|
||||
SERVICES: buildServicesText(reservation),
|
||||
GOVERNMENT_REG: reservation.government_registration ?? '—',
|
||||
OBSERVATIONS_BLOCK: buildObservationsBlock(reservation.observations),
|
||||
CHANGES_BLOCK: '',
|
||||
CANCEL_ALERT: '',
|
||||
};
|
||||
|
||||
switch (eventType) {
|
||||
case 'reservation.created': {
|
||||
const isTeenriffa = reservation.origin === 'Teneriffa2000';
|
||||
const template = isTeenriffa ? 'teneriffa-crud' : 'natur-crud';
|
||||
const originLabel = isTeenriffa ? 'Teneriffa' : 'Natur';
|
||||
const vars = {
|
||||
...baseVars,
|
||||
ACTION: 'creada',
|
||||
ACTION_LABEL: 'Nueva Reserva',
|
||||
CHANGES_BLOCK: '',
|
||||
};
|
||||
const html = renderTemplate(template, vars);
|
||||
const subject = `[NUEVA RESERVA] ${originLabel} — ${reservation.client_name} | ${propertyLabel(reservation.property)} | ${dateRange}`;
|
||||
return sendEmail({ to: [env.NOTIFICATION_EMAIL_TENERIFFA, env.NOTIFICATION_EMAIL_NATUR], subject, html });
|
||||
}
|
||||
|
||||
case 'reservation.updated': {
|
||||
const isTeenriffa = reservation.origin === 'Teneriffa2000';
|
||||
const template = isTeenriffa ? 'teneriffa-crud' : 'natur-crud';
|
||||
const originLabel = isTeenriffa ? 'Teneriffa' : 'Natur';
|
||||
const changesBlock = previousReservation
|
||||
? buildChangesBlock(previousReservation, reservation)
|
||||
: `<p style="color:#6b7280; font-style:italic; margin:0; font-size:14px;">No se recibieron datos anteriores para comparar.</p>`;
|
||||
const vars = {
|
||||
...baseVars,
|
||||
ACTION: 'modificada',
|
||||
ACTION_LABEL: 'Reserva Modificada',
|
||||
CHANGES_BLOCK: changesBlock,
|
||||
};
|
||||
const html = renderTemplate(template, vars);
|
||||
const subject = `[MODIFICADA] ${originLabel} — ${reservation.client_name} | ${propertyLabel(reservation.property)} | ${dateRange}`;
|
||||
return sendEmail({ to: [env.NOTIFICATION_EMAIL_TENERIFFA, env.NOTIFICATION_EMAIL_NATUR], subject, html });
|
||||
}
|
||||
|
||||
case 'reservation.cancelled': {
|
||||
const isTeenriffa = reservation.origin === 'Teneriffa2000';
|
||||
const template = isTeenriffa ? 'teneriffa-crud' : 'natur-crud';
|
||||
const originLabel = isTeenriffa ? 'Teneriffa' : 'Natur';
|
||||
const vars = {
|
||||
...baseVars,
|
||||
ACTION: 'cancelada',
|
||||
ACTION_LABEL: 'Reserva Cancelada',
|
||||
CHANGES_BLOCK: '',
|
||||
CANCEL_ALERT: `<div class="cancel-alert"><p>Esta reserva ha sido cancelada y eliminada del sistema.</p></div>`,
|
||||
};
|
||||
const html = renderTemplate(template, vars);
|
||||
const subject = `[CANCELADA] ${originLabel} — ${reservation.client_name} | ${propertyLabel(reservation.property)} | ${dateRange}`;
|
||||
return sendEmail({ to: [env.NOTIFICATION_EMAIL_TENERIFFA, env.NOTIFICATION_EMAIL_NATUR], subject, html });
|
||||
}
|
||||
|
||||
case 'reservation.reminder_24h': {
|
||||
const html = renderTemplate('reminder-24h', baseVars);
|
||||
const subject = `Recordatorio: Check-in mañana — ${reservation.client_name} (Los Dragos)`;
|
||||
return sendEmail({ to: env.NOTIFICATION_EMAIL_TENERIFFA, subject, html });
|
||||
}
|
||||
|
||||
case 'reservation.invoice_second_notice': {
|
||||
const html = renderTemplate('invoice-10d', baseVars);
|
||||
const subject = `Segunda factura en 10 días — ${reservation.client_name} (${propertyLabel(reservation.property)})`;
|
||||
return sendEmail({ to: env.NOTIFICATION_EMAIL_NATUR, subject, html });
|
||||
}
|
||||
|
||||
case 'reservation.pool_heating_notice': {
|
||||
const html = renderTemplate('pool-heating-48h', baseVars);
|
||||
const subject = `Calefacción de piscina en 48h — ${reservation.client_name} (${propertyLabel(reservation.property)})`;
|
||||
return sendEmail({ to: env.NOTIFICATION_EMAIL_POOL_HEATING, subject, html });
|
||||
}
|
||||
|
||||
default:
|
||||
return { success: false, error: `Tipo de evento desconocido: ${eventType}` };
|
||||
}
|
||||
}
|
||||
67
apps/api/src/events/types.ts
Normal file
67
apps/api/src/events/types.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { NotificationEventType } from '@naturcalabacera/shared';
|
||||
import type { Reservation, ReservationOrigin, Property } from '@naturcalabacera/shared';
|
||||
|
||||
export type CrudOperation = 'created' | 'updated' | 'cancelled';
|
||||
|
||||
/**
|
||||
* Condiciones que determinan si se envía un evento de notificación.
|
||||
* Permiten evaluar de forma declarativa sin dispersar la lógica.
|
||||
*/
|
||||
export interface NotificationCondition {
|
||||
origin?: ReservationOrigin;
|
||||
property?: Property;
|
||||
hasPoolHeating?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Descriptor de cada tipo de evento:
|
||||
* - conditions: cuándo se dispara
|
||||
* - recipientEnvKey: qué env var contiene el destinatario
|
||||
* - templateName: nombre del template HTML
|
||||
* - offsetDays: días antes del check-in (para eventos programados; undefined = inmediato)
|
||||
*/
|
||||
export interface EventDescriptor {
|
||||
eventType: NotificationEventType;
|
||||
conditions: NotificationCondition;
|
||||
recipientEnvKey: 'NOTIFICATION_EMAIL_TENERIFFA' | 'NOTIFICATION_EMAIL_NATUR' | 'NOTIFICATION_EMAIL_POOL_HEATING';
|
||||
templateName: string;
|
||||
offsetDays?: number; // undefined = disparo inmediato (CRUD)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mapa declarativo de todos los eventos del sistema.
|
||||
* Añadir un nuevo tipo de notificación = añadir una entrada aquí.
|
||||
*/
|
||||
export const EVENT_DESCRIPTORS: EventDescriptor[] = [
|
||||
{
|
||||
eventType: 'reservation.reminder_24h',
|
||||
conditions: { origin: 'Teneriffa2000', property: 'los_dragos' },
|
||||
recipientEnvKey: 'NOTIFICATION_EMAIL_TENERIFFA',
|
||||
templateName: 'reminder-24h',
|
||||
offsetDays: 1,
|
||||
},
|
||||
{
|
||||
eventType: 'reservation.invoice_second_notice',
|
||||
conditions: { origin: 'Naturcalabacera' },
|
||||
recipientEnvKey: 'NOTIFICATION_EMAIL_NATUR',
|
||||
templateName: 'invoice-10d',
|
||||
offsetDays: 10,
|
||||
},
|
||||
{
|
||||
eventType: 'reservation.pool_heating_notice',
|
||||
conditions: { hasPoolHeating: true },
|
||||
recipientEnvKey: 'NOTIFICATION_EMAIL_POOL_HEATING',
|
||||
templateName: 'pool-heating-48h',
|
||||
offsetDays: 2,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Evalúa si una reserva cumple las condiciones de un descriptor de evento.
|
||||
*/
|
||||
export function matchesConditions(reservation: Reservation, conditions: NotificationCondition): boolean {
|
||||
if (conditions.origin && reservation.origin !== conditions.origin) return false;
|
||||
if (conditions.property && reservation.property !== conditions.property) return false;
|
||||
if (conditions.hasPoolHeating !== undefined && reservation.has_pool_heating !== conditions.hasPoolHeating) return false;
|
||||
return true;
|
||||
}
|
||||
38
apps/api/src/index.ts
Normal file
38
apps/api/src/index.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import { env } from './config/env.js';
|
||||
import { healthRouter } from './routes/health.js';
|
||||
import { notificationsRouter } from './routes/notifications.js';
|
||||
import { usersRouter } from './routes/users.js';
|
||||
import { runPendingJobs } from './jobs/runner.js';
|
||||
|
||||
const app = express();
|
||||
|
||||
// Middlewares
|
||||
const allowedOrigin = env.NODE_ENV === 'development'
|
||||
? (origin: string | undefined, cb: (e: Error | null, ok?: boolean) => void) => cb(null, true)
|
||||
: env.WEB_ORIGIN;
|
||||
app.use(cors({ origin: allowedOrigin, credentials: true }));
|
||||
app.use(express.json());
|
||||
|
||||
// Rutas
|
||||
app.use('/health', healthRouter);
|
||||
app.use('/api/notifications', notificationsRouter);
|
||||
app.use('/api/users', usersRouter);
|
||||
|
||||
// Arrancar servidor
|
||||
app.listen(env.PORT, () => {
|
||||
console.log(`[api] Servidor iniciado en puerto ${env.PORT} (${env.NODE_ENV})`);
|
||||
});
|
||||
|
||||
// Job runner — procesa notificaciones pendientes cada JOB_RUNNER_INTERVAL_MS
|
||||
// Solo una instancia debería ejecutarlo; el locking en BD evita duplicados si hay varias.
|
||||
console.log(`[api] Job runner iniciado (intervalo: ${env.JOB_RUNNER_INTERVAL_MS / 1000}s)`);
|
||||
|
||||
// Primera ejecución inmediata
|
||||
runPendingJobs().catch(err => console.error('[jobs] Error en primera ejecución:', err));
|
||||
|
||||
// Ejecuciones periódicas
|
||||
setInterval(() => {
|
||||
runPendingJobs().catch(err => console.error('[jobs] Error en runner:', err));
|
||||
}, env.JOB_RUNNER_INTERVAL_MS);
|
||||
168
apps/api/src/jobs/runner.ts
Normal file
168
apps/api/src/jobs/runner.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { supabaseAdmin } from '../lib/supabase.js';
|
||||
import { handleNotificationEvent } from '../events/handler.js';
|
||||
import { env } from '../config/env.js';
|
||||
import type { NotificationEventType, Reservation } from '@naturcalabacera/shared';
|
||||
|
||||
/**
|
||||
* Runner de jobs con garantías de idempotencia y robustez:
|
||||
*
|
||||
* - Selecciona eventos pendientes con FOR UPDATE SKIP LOCKED (via RPC)
|
||||
* para que múltiples instancias no procesen el mismo evento.
|
||||
* - Actualiza status a 'processing' antes de procesar.
|
||||
* - En éxito: status = 'sent'. En fallo: incrementa attempts, registra error.
|
||||
* - Reintentos máximos configurables (JOB_MAX_RETRIES).
|
||||
* - Todas las comparaciones de fechas en UTC.
|
||||
*/
|
||||
|
||||
interface NotificationRow {
|
||||
id: string;
|
||||
reservation_id: string;
|
||||
event_type: NotificationEventType;
|
||||
attempts: number;
|
||||
}
|
||||
|
||||
export async function runPendingJobs(): Promise<void> {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Selecciona hasta 10 eventos pendientes cuyo scheduled_for ya ha pasado
|
||||
// y que no hayan superado el máximo de reintentos
|
||||
const { data: events, error: fetchError } = await supabaseAdmin
|
||||
.from('notification_events')
|
||||
.select('id, reservation_id, event_type, attempts')
|
||||
.eq('status', 'pending')
|
||||
.lte('scheduled_for', now)
|
||||
.lt('attempts', env.JOB_MAX_RETRIES)
|
||||
.order('scheduled_for', { ascending: true })
|
||||
.limit(10);
|
||||
|
||||
if (fetchError) {
|
||||
console.error('[jobs] Error al obtener eventos pendientes:', fetchError.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!events || events.length === 0) return;
|
||||
|
||||
console.log(`[jobs] Procesando ${events.length} evento(s)...`);
|
||||
|
||||
for (const event of events as NotificationRow[]) {
|
||||
// Marcar como 'processing' para evitar que otra instancia lo tome
|
||||
const { error: lockError } = await supabaseAdmin
|
||||
.from('notification_events')
|
||||
.update({ status: 'processing', attempts: event.attempts + 1 })
|
||||
.eq('id', event.id)
|
||||
.eq('status', 'pending'); // Solo actualiza si sigue en pending (evita race)
|
||||
|
||||
if (lockError) {
|
||||
console.warn(`[jobs] Evento ${event.id} ya tomado por otra instancia`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Obtener la reserva completa
|
||||
const { data: reservation, error: resError } = await supabaseAdmin
|
||||
.from('reservations')
|
||||
.select('*')
|
||||
.eq('id', event.reservation_id)
|
||||
.single();
|
||||
|
||||
if (resError || !reservation) {
|
||||
await markFailed(event.id, `Reserva no encontrada: ${event.reservation_id}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Procesar el evento
|
||||
const result = await handleNotificationEvent(event.event_type, reservation as Reservation);
|
||||
|
||||
if (result.success) {
|
||||
await supabaseAdmin
|
||||
.from('notification_events')
|
||||
.update({ status: 'sent', sent_at: new Date().toISOString() })
|
||||
.eq('id', event.id);
|
||||
console.log(`[jobs] ✓ Evento ${event.event_type} para reserva ${event.reservation_id}`);
|
||||
} else {
|
||||
const isLastAttempt = event.attempts + 1 >= env.JOB_MAX_RETRIES;
|
||||
await supabaseAdmin
|
||||
.from('notification_events')
|
||||
.update({
|
||||
status: isLastAttempt ? 'failed' : 'pending',
|
||||
last_error: result.error ?? 'Error desconocido',
|
||||
})
|
||||
.eq('id', event.id);
|
||||
console.error(`[jobs] ✗ Evento ${event.event_type}: ${result.error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function markFailed(eventId: string, error: string): Promise<void> {
|
||||
await supabaseAdmin
|
||||
.from('notification_events')
|
||||
.update({ status: 'failed', last_error: error })
|
||||
.eq('id', eventId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserta eventos de notificación para una reserva según las condiciones del evento.
|
||||
* Usa ON CONFLICT DO NOTHING para idempotencia — si ya existe, no hace nada.
|
||||
*/
|
||||
export async function scheduleNotificationsForReservation(
|
||||
reservation: Reservation,
|
||||
operation: 'created' | 'updated' | 'cancelled'
|
||||
): Promise<void> {
|
||||
const eventsToInsert = [];
|
||||
const startDate = new Date(reservation.start_date + 'T12:00:00Z');
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// El email CRUD se envía directamente en la ruta, no se encola aquí.
|
||||
|
||||
// 2. Recordatorio 24h antes — solo Teneriffa + Los Dragos
|
||||
if (reservation.origin === 'Teneriffa2000' && reservation.property === 'los_dragos') {
|
||||
const scheduled = new Date(startDate);
|
||||
scheduled.setUTCHours(scheduled.getUTCHours() - 24);
|
||||
if (scheduled > new Date()) {
|
||||
eventsToInsert.push({
|
||||
reservation_id: reservation.id,
|
||||
event_type: 'reservation.reminder_24h',
|
||||
scheduled_for: scheduled.toISOString(),
|
||||
status: 'pending',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Segunda factura 10 días antes — solo Natur
|
||||
if (reservation.origin === 'Naturcalabacera') {
|
||||
const scheduled = new Date(startDate);
|
||||
scheduled.setUTCDate(scheduled.getUTCDate() - 10);
|
||||
if (scheduled > new Date()) {
|
||||
eventsToInsert.push({
|
||||
reservation_id: reservation.id,
|
||||
event_type: 'reservation.invoice_second_notice',
|
||||
scheduled_for: scheduled.toISOString(),
|
||||
status: 'pending',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Aviso calefacción piscina 48h antes
|
||||
if (reservation.has_pool_heating) {
|
||||
const scheduled = new Date(startDate);
|
||||
scheduled.setUTCHours(scheduled.getUTCHours() - 48);
|
||||
if (scheduled > new Date()) {
|
||||
eventsToInsert.push({
|
||||
reservation_id: reservation.id,
|
||||
event_type: 'reservation.pool_heating_notice',
|
||||
scheduled_for: scheduled.toISOString(),
|
||||
status: 'pending',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (eventsToInsert.length === 0) return;
|
||||
|
||||
// onConflict con la constraint UNIQUE(reservation_id, event_type, scheduled_for)
|
||||
const { error } = await supabaseAdmin
|
||||
.from('notification_events')
|
||||
.upsert(eventsToInsert, { onConflict: 'reservation_id,event_type,scheduled_for', ignoreDuplicates: true });
|
||||
|
||||
if (error) {
|
||||
console.error('[jobs] Error al insertar notification_events:', error.message);
|
||||
}
|
||||
}
|
||||
19
apps/api/src/lib/supabase.ts
Normal file
19
apps/api/src/lib/supabase.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import { env } from '../config/env.js';
|
||||
|
||||
/**
|
||||
* Cliente Supabase con service_role key.
|
||||
* Solo usar en el backend — nunca exponer al frontend.
|
||||
* Bypasea RLS: úsalo con precaución.
|
||||
*/
|
||||
export const supabaseAdmin = createClient(
|
||||
env.SUPABASE_URL,
|
||||
env.SUPABASE_SERVICE_ROLE_KEY,
|
||||
{
|
||||
db: { schema: 'natur_reservas' },
|
||||
auth: {
|
||||
autoRefreshToken: false,
|
||||
persistSession: false,
|
||||
},
|
||||
}
|
||||
);
|
||||
9
apps/api/src/routes/health.ts
Normal file
9
apps/api/src/routes/health.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Router } from 'express';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/', (_req, res) => {
|
||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
export { router as healthRouter };
|
||||
67
apps/api/src/routes/notifications.ts
Normal file
67
apps/api/src/routes/notifications.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Router } from 'express';
|
||||
import { scheduleNotificationsForReservation } from '../jobs/runner.js';
|
||||
import { handleNotificationEvent } from '../events/handler.js';
|
||||
import { supabaseAdmin } from '../lib/supabase.js';
|
||||
import type { Reservation } from '@naturcalabacera/shared';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* POST /api/notifications/reservation-event
|
||||
*
|
||||
* Llamado por el frontend tras cada operación CRUD exitosa.
|
||||
* - Envía el email CRUD inmediatamente (sin pasar por la cola)
|
||||
* - Encola los eventos futuros (recordatorios, facturas, etc.)
|
||||
*
|
||||
* Body: { reservation: Reservation, operation: 'created' | 'updated' | 'cancelled' }
|
||||
*/
|
||||
router.post('/reservation-event', async (req, res) => {
|
||||
const { reservation, operation, previousReservation } = req.body as {
|
||||
reservation: Reservation;
|
||||
operation: 'created' | 'updated' | 'cancelled';
|
||||
previousReservation?: Reservation;
|
||||
};
|
||||
|
||||
if (!reservation || !operation) {
|
||||
return res.status(400).json({ error: 'reservation y operation son requeridos' });
|
||||
}
|
||||
|
||||
if (!['created', 'updated', 'cancelled'].includes(operation)) {
|
||||
return res.status(400).json({ error: 'operation debe ser created, updated o cancelled' });
|
||||
}
|
||||
|
||||
try {
|
||||
// Enviar email CRUD al momento, sin esperar al job runner
|
||||
const eventType = `reservation.${operation}` as const;
|
||||
const now = new Date().toISOString();
|
||||
const emailResult = await handleNotificationEvent(eventType, reservation, previousReservation);
|
||||
|
||||
// Registrar el evento en notification_events (historial)
|
||||
await supabaseAdmin.from('notification_events').insert({
|
||||
reservation_id: reservation.id,
|
||||
event_type: eventType,
|
||||
scheduled_for: now,
|
||||
status: emailResult.success ? 'sent' : 'failed',
|
||||
sent_at: emailResult.success ? now : null,
|
||||
last_error: emailResult.success ? null : (emailResult.error ?? 'Error desconocido'),
|
||||
attempts: 1,
|
||||
});
|
||||
|
||||
if (!emailResult.success) {
|
||||
console.error(`[notifications] Email CRUD fallido: ${emailResult.error}`);
|
||||
} else {
|
||||
console.log(`[notifications] ✓ Email enviado para ${eventType} — ${reservation.client_name}`);
|
||||
}
|
||||
|
||||
// Encolar eventos futuros (recordatorios, avisos, etc.)
|
||||
await scheduleNotificationsForReservation(reservation, operation);
|
||||
|
||||
return res.json({ ok: true });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Error interno';
|
||||
console.error('[notifications] Error:', message);
|
||||
return res.status(500).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
export { router as notificationsRouter };
|
||||
154
apps/api/src/routes/users.ts
Normal file
154
apps/api/src/routes/users.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { Router, type Request, type Response, type NextFunction } from 'express';
|
||||
import { supabaseAdmin } from '../lib/supabase.js';
|
||||
import type { UserRole } from '@naturcalabacera/shared';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const VALID_ROLES: UserRole[] = ['admin', 'internal_staff', 'external_availability_viewer'];
|
||||
|
||||
/**
|
||||
* Middleware: extrae el JWT del header Authorization, valida la sesión con
|
||||
* Supabase y comprueba que el usuario es admin. Si pasa, deja el id del
|
||||
* caller en res.locals.callerId.
|
||||
*/
|
||||
async function requireAdmin(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
const auth = req.headers.authorization;
|
||||
if (!auth || !auth.startsWith('Bearer ')) {
|
||||
res.status(401).json({ error: 'Falta token de autenticación' });
|
||||
return;
|
||||
}
|
||||
const token = auth.slice('Bearer '.length);
|
||||
|
||||
const { data: userData, error: userError } = await supabaseAdmin.auth.getUser(token);
|
||||
if (userError || !userData.user) {
|
||||
res.status(401).json({ error: 'Token inválido' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { data: profile, error: profileError } = await supabaseAdmin
|
||||
.from('user_profiles')
|
||||
.select('role')
|
||||
.eq('id', userData.user.id)
|
||||
.single();
|
||||
|
||||
if (profileError || !profile) {
|
||||
res.status(403).json({ error: 'Perfil no encontrado' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (profile.role !== 'admin') {
|
||||
res.status(403).json({ error: 'Solo admins pueden gestionar usuarios' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.locals.callerId = userData.user.id;
|
||||
next();
|
||||
}
|
||||
|
||||
// GET /api/users — listar todos los perfiles
|
||||
router.get('/', requireAdmin, async (_req, res) => {
|
||||
const { data, error } = await supabaseAdmin
|
||||
.from('user_profiles')
|
||||
.select('id, email, role, display_name, created_at')
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) {
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
return res.json({ users: data ?? [] });
|
||||
});
|
||||
|
||||
// POST /api/users/invite — invitar un usuario nuevo
|
||||
router.post('/invite', requireAdmin, async (req, res) => {
|
||||
const { email, role, display_name } = req.body as {
|
||||
email?: string;
|
||||
role?: UserRole;
|
||||
display_name?: string;
|
||||
};
|
||||
|
||||
if (!email || !role) {
|
||||
return res.status(400).json({ error: 'email y role son requeridos' });
|
||||
}
|
||||
if (!VALID_ROLES.includes(role)) {
|
||||
return res.status(400).json({ error: 'Rol inválido' });
|
||||
}
|
||||
|
||||
// 1. Invitar al usuario por email (envía correo con magic link)
|
||||
const { data: invited, error: inviteError } = await supabaseAdmin.auth.admin.inviteUserByEmail(email);
|
||||
if (inviteError || !invited.user) {
|
||||
return res.status(400).json({ error: inviteError?.message ?? 'Error al invitar usuario' });
|
||||
}
|
||||
|
||||
// 2. Crear el perfil con el rol seleccionado
|
||||
const { data: profile, error: profileError } = await supabaseAdmin
|
||||
.from('user_profiles')
|
||||
.insert({
|
||||
id: invited.user.id,
|
||||
email,
|
||||
role,
|
||||
display_name: display_name ?? null,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (profileError) {
|
||||
// Best-effort cleanup: eliminar el usuario auth si el perfil falló
|
||||
await supabaseAdmin.auth.admin.deleteUser(invited.user.id).catch(() => {});
|
||||
return res.status(500).json({ error: profileError.message });
|
||||
}
|
||||
|
||||
return res.status(201).json({ user: profile });
|
||||
});
|
||||
|
||||
// PATCH /api/users/:id — actualizar rol o display_name
|
||||
router.patch('/:id', requireAdmin, async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { role, display_name } = req.body as {
|
||||
role?: UserRole;
|
||||
display_name?: string;
|
||||
};
|
||||
|
||||
if (role !== undefined && !VALID_ROLES.includes(role)) {
|
||||
return res.status(400).json({ error: 'Rol inválido' });
|
||||
}
|
||||
|
||||
const updates: Record<string, unknown> = {};
|
||||
if (role !== undefined) updates.role = role;
|
||||
if (display_name !== undefined) updates.display_name = display_name;
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
return res.status(400).json({ error: 'Nada que actualizar' });
|
||||
}
|
||||
|
||||
const { data, error } = await supabaseAdmin
|
||||
.from('user_profiles')
|
||||
.update(updates)
|
||||
.eq('id', id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
return res.json({ user: data });
|
||||
});
|
||||
|
||||
// DELETE /api/users/:id — eliminar usuario auth + perfil
|
||||
router.delete('/:id', requireAdmin, async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const callerId = res.locals.callerId as string;
|
||||
|
||||
if (id === callerId) {
|
||||
return res.status(400).json({ error: 'No puedes eliminar tu propia cuenta' });
|
||||
}
|
||||
|
||||
// ON DELETE CASCADE en user_profiles.id elimina el perfil al eliminar el usuario auth.
|
||||
const { error } = await supabaseAdmin.auth.admin.deleteUser(id);
|
||||
if (error) {
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
|
||||
return res.json({ ok: true });
|
||||
});
|
||||
|
||||
export { router as usersRouter };
|
||||
50
apps/api/src/services/email.ts
Normal file
50
apps/api/src/services/email.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { env } from '../config/env.js';
|
||||
|
||||
interface SendEmailOptions {
|
||||
to: string | string[];
|
||||
subject: string;
|
||||
html: string;
|
||||
replyTo?: string;
|
||||
}
|
||||
|
||||
interface ResendResponse {
|
||||
id?: string;
|
||||
error?: { message: string; name: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Envía un email usando la API de Resend.
|
||||
* Docs: https://resend.com/docs/api-reference/emails/send-email
|
||||
*/
|
||||
export async function sendEmail(options: SendEmailOptions): Promise<{ success: boolean; id?: string; error?: string }> {
|
||||
try {
|
||||
const res = await fetch('https://api.resend.com/emails', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${env.RESEND_API_KEY}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
from: env.EMAIL_FROM,
|
||||
to: Array.isArray(options.to) ? options.to : [options.to],
|
||||
subject: options.subject,
|
||||
html: options.html,
|
||||
...(options.replyTo && { reply_to: options.replyTo }),
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await res.json() as ResendResponse;
|
||||
|
||||
if (!res.ok || data.error) {
|
||||
const errorMsg = data.error?.message ?? `HTTP ${res.status}`;
|
||||
console.error('[email] Error al enviar:', errorMsg);
|
||||
return { success: false, error: errorMsg };
|
||||
}
|
||||
|
||||
return { success: true, id: data.id };
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : 'Error desconocido';
|
||||
console.error('[email] Error de red:', errorMsg);
|
||||
return { success: false, error: errorMsg };
|
||||
}
|
||||
}
|
||||
64
apps/api/src/templates/invoice-10d.html
Normal file
64
apps/api/src/templates/invoice-10d.html
Normal file
@@ -0,0 +1,64 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Segunda Factura en 10 días</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; background: #f4f4f4; margin: 0; padding: 20px; }
|
||||
.container { max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
|
||||
.header { background: linear-gradient(135deg, #f59e0b, #d97706); color: white; padding: 32px 24px; }
|
||||
.header h1 { margin: 0; font-size: 22px; }
|
||||
.header p { margin: 8px 0 0; opacity: 0.85; font-size: 14px; }
|
||||
.body { padding: 28px 24px; }
|
||||
.alert { background: #fffbeb; border-left: 4px solid #f59e0b; padding: 16px; border-radius: 8px; margin-bottom: 24px; }
|
||||
.alert strong { color: #92400e; }
|
||||
.field { margin-bottom: 16px; }
|
||||
.field label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: #6b7280; font-weight: 600; }
|
||||
.field value { display: block; font-size: 16px; font-weight: 600; color: #111; margin-top: 2px; }
|
||||
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
.footer { padding: 16px 24px; background: #f9fafb; border-top: 1px solid #e5e7eb; font-size: 12px; color: #9ca3af; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>📋 Segunda Factura — Natur</h1>
|
||||
<p>Aviso automático 10 días antes · Naturcalabacera</p>
|
||||
</div>
|
||||
<div class="body">
|
||||
<div class="alert">
|
||||
<strong>Aviso:</strong> La reserva de {{CLIENT_NAME}} comienza en 10 días. Pendiente segunda factura.
|
||||
</div>
|
||||
<div class="grid">
|
||||
<div class="field">
|
||||
<label>Cliente</label>
|
||||
<value>{{CLIENT_NAME}}</value>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Propiedad</label>
|
||||
<value>{{PROPERTY}}</value>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Nº Factura</label>
|
||||
<value>{{INVOICE_NUMBER}}</value>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Personas totales</label>
|
||||
<value>{{TOTAL_PERSONS}}</value>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Entrada</label>
|
||||
<value>{{START_DATE}}</value>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Salida</label>
|
||||
<value>{{END_DATE}}</value>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
ID: {{RESERVATION_ID}} · Naturcalabacera App Reservas
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
120
apps/api/src/templates/natur-crud.html
Normal file
120
apps/api/src/templates/natur-crud.html
Normal file
@@ -0,0 +1,120 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>{{ACTION_LABEL}} — Natur</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; background: #f4f4f4; margin: 0; padding: 20px; }
|
||||
.container { max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
|
||||
|
||||
/* Headers por acción */
|
||||
.header-creada { background: linear-gradient(135deg, #10b981, #065f46); }
|
||||
.header-modificada { background: linear-gradient(135deg, #f59e0b, #92400e); }
|
||||
.header-cancelada { background: linear-gradient(135deg, #ef4444, #7f1d1d); }
|
||||
|
||||
.header { color: white; padding: 28px 28px 24px; }
|
||||
.header .action-pill {
|
||||
display: inline-block; padding: 3px 12px; border-radius: 999px;
|
||||
font-size: 11px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.1em;
|
||||
background: rgba(255,255,255,0.25); margin-bottom: 12px;
|
||||
}
|
||||
.header h1 { margin: 0 0 4px; font-size: 24px; font-weight: 800; line-height: 1.2; }
|
||||
.header .subtitle { margin: 6px 0 0; opacity: 0.9; font-size: 14px; font-weight: 500; }
|
||||
.header .meta { margin: 10px 0 0; opacity: 0.75; font-size: 12px; }
|
||||
|
||||
/* Body */
|
||||
.body { padding: 28px; }
|
||||
.section-title {
|
||||
font-size: 11px; text-transform: uppercase; letter-spacing: 0.07em;
|
||||
color: #6b7280; font-weight: 700; margin: 0 0 14px;
|
||||
padding-bottom: 8px; border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-bottom: 20px; }
|
||||
.field label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: #9ca3af; font-weight: 600; display: block; }
|
||||
.field value { display: block; font-size: 15px; font-weight: 700; color: #111; margin-top: 3px; }
|
||||
.field-full { margin-bottom: 14px; }
|
||||
|
||||
/* Badge de origen */
|
||||
.origin-badge {
|
||||
display: inline-block; padding: 4px 12px; border-radius: 999px;
|
||||
font-size: 12px; font-weight: 700;
|
||||
background: #d1fae5; color: #065f46;
|
||||
}
|
||||
|
||||
/* Alerta cancelación */
|
||||
.cancel-alert {
|
||||
background: #fef2f2; border: 1px solid #fecaca; border-radius: 8px;
|
||||
padding: 14px 16px; margin-bottom: 20px;
|
||||
}
|
||||
.cancel-alert p { margin: 0; font-size: 14px; color: #7f1d1d; font-weight: 600; }
|
||||
|
||||
.footer { padding: 14px 28px; background: #f9fafb; border-top: 1px solid #e5e7eb; font-size: 11px; color: #9ca3af; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
|
||||
<!-- Header con color según acción -->
|
||||
<div class="header header-{{ACTION}}">
|
||||
<div class="action-pill">{{ACTION_LABEL}}</div>
|
||||
<h1>{{CLIENT_NAME}}</h1>
|
||||
<p class="subtitle">{{PROPERTY}} · {{NIGHTS}} · <span class="origin-badge" style="background:rgba(255,255,255,0.2); color:white;">Natur</span></p>
|
||||
<p class="meta">{{DATE_RANGE}}</p>
|
||||
</div>
|
||||
|
||||
<div class="body">
|
||||
|
||||
<!-- Alerta solo en cancelaciones -->
|
||||
{{CANCEL_ALERT}}
|
||||
|
||||
<!-- Datos principales -->
|
||||
<p class="section-title">Datos de la reserva</p>
|
||||
<div class="grid">
|
||||
<div class="field">
|
||||
<label>Propiedad</label>
|
||||
<value>{{PROPERTY}}</value>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Nº Factura</label>
|
||||
<value>{{INVOICE_NUMBER}}</value>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Entrada</label>
|
||||
<value>{{START_DATE}}</value>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Salida</label>
|
||||
<value>{{END_DATE}}</value>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Adultos</label>
|
||||
<value>{{ADULTS}}</value>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Niños</label>
|
||||
<value>{{CHILDREN}}</value>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Registro gov.</label>
|
||||
<value>{{GOVERNMENT_REG}}</value>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Servicios extra</label>
|
||||
<value>{{SERVICES}}</value>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Observaciones (solo si existen) -->
|
||||
{{OBSERVATIONS_BLOCK}}
|
||||
|
||||
<!-- Cambios (solo en modificaciones) -->
|
||||
{{CHANGES_BLOCK}}
|
||||
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
ID reserva: {{RESERVATION_ID}} · Naturcalabacera App Reservas
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
61
apps/api/src/templates/pool-heating-48h.html
Normal file
61
apps/api/src/templates/pool-heating-48h.html
Normal file
@@ -0,0 +1,61 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Aviso Calefacción de Piscina</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; background: #f4f4f4; margin: 0; padding: 20px; }
|
||||
.container { max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
|
||||
.header { background: linear-gradient(135deg, #ef4444, #dc2626); color: white; padding: 32px 24px; }
|
||||
.header h1 { margin: 0; font-size: 22px; }
|
||||
.header p { margin: 8px 0 0; opacity: 0.85; font-size: 14px; }
|
||||
.body { padding: 28px 24px; }
|
||||
.alert { background: #fef2f2; border-left: 4px solid #ef4444; padding: 16px; border-radius: 8px; margin-bottom: 24px; }
|
||||
.alert strong { color: #991b1b; }
|
||||
.field { margin-bottom: 16px; }
|
||||
.field label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: #6b7280; font-weight: 600; }
|
||||
.field value { display: block; font-size: 16px; font-weight: 600; color: #111; margin-top: 2px; }
|
||||
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
.footer { padding: 16px 24px; background: #f9fafb; border-top: 1px solid #e5e7eb; font-size: 12px; color: #9ca3af; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>🌡️ Calefacción de Piscina — 48h</h1>
|
||||
<p>Aviso automático · Naturcalabacera</p>
|
||||
</div>
|
||||
<div class="body">
|
||||
<div class="alert">
|
||||
<strong>Acción requerida:</strong> En 48 horas llega {{CLIENT_NAME}} a {{PROPERTY}} con calefacción de piscina activada.
|
||||
Por favor, encender la calefacción de piscina con suficiente antelación.
|
||||
</div>
|
||||
<div class="grid">
|
||||
<div class="field">
|
||||
<label>Cliente</label>
|
||||
<value>{{CLIENT_NAME}}</value>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Propiedad</label>
|
||||
<value>{{PROPERTY}}</value>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Entrada</label>
|
||||
<value>{{START_DATE}}</value>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Salida</label>
|
||||
<value>{{END_DATE}}</value>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Personas totales</label>
|
||||
<value>{{TOTAL_PERSONS}}</value>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
ID: {{RESERVATION_ID}} · Destinatario configurable via NOTIFICATION_EMAIL_POOL_HEATING
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
60
apps/api/src/templates/reminder-24h.html
Normal file
60
apps/api/src/templates/reminder-24h.html
Normal file
@@ -0,0 +1,60 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Recordatorio Check-in Mañana</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; background: #f4f4f4; margin: 0; padding: 20px; }
|
||||
.container { max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
|
||||
.header { background: linear-gradient(135deg, #10b981, #059669); color: white; padding: 32px 24px; }
|
||||
.header h1 { margin: 0; font-size: 22px; }
|
||||
.header p { margin: 8px 0 0; opacity: 0.85; font-size: 14px; }
|
||||
.body { padding: 28px 24px; }
|
||||
.alert { background: #ecfdf5; border-left: 4px solid #10b981; padding: 16px; border-radius: 8px; margin-bottom: 24px; }
|
||||
.alert strong { color: #065f46; }
|
||||
.field { margin-bottom: 16px; }
|
||||
.field label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: #6b7280; font-weight: 600; }
|
||||
.field value { display: block; font-size: 16px; font-weight: 600; color: #111; margin-top: 2px; }
|
||||
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
.footer { padding: 16px 24px; background: #f9fafb; border-top: 1px solid #e5e7eb; font-size: 12px; color: #9ca3af; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>⏰ Check-in Mañana — Los Dragos</h1>
|
||||
<p>Recordatorio automático 24h · Naturcalabacera</p>
|
||||
</div>
|
||||
<div class="body">
|
||||
<div class="alert">
|
||||
<strong>Recordatorio:</strong> Mañana llega {{CLIENT_NAME}} a Los Dragos.
|
||||
</div>
|
||||
<div class="grid">
|
||||
<div class="field">
|
||||
<label>Cliente</label>
|
||||
<value>{{CLIENT_NAME}}</value>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Nº Factura</label>
|
||||
<value>{{INVOICE_NUMBER}}</value>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Entrada</label>
|
||||
<value>{{START_DATE}}</value>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Salida</label>
|
||||
<value>{{END_DATE}}</value>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Personas totales</label>
|
||||
<value>{{TOTAL_PERSONS}}</value>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
ID: {{RESERVATION_ID}} · Naturcalabacera App Reservas
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
112
apps/api/src/templates/teneriffa-crud.html
Normal file
112
apps/api/src/templates/teneriffa-crud.html
Normal file
@@ -0,0 +1,112 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>{{ACTION_LABEL}} — Teneriffa</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; background: #f4f4f4; margin: 0; padding: 20px; }
|
||||
.container { max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
|
||||
|
||||
/* Headers por acción */
|
||||
.header-creada { background: linear-gradient(135deg, #3b82f6, #1d4ed8); }
|
||||
.header-modificada { background: linear-gradient(135deg, #f59e0b, #92400e); }
|
||||
.header-cancelada { background: linear-gradient(135deg, #ef4444, #7f1d1d); }
|
||||
|
||||
.header { color: white; padding: 28px 28px 24px; }
|
||||
.header .action-pill {
|
||||
display: inline-block; padding: 3px 12px; border-radius: 999px;
|
||||
font-size: 11px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.1em;
|
||||
background: rgba(255,255,255,0.25); margin-bottom: 12px;
|
||||
}
|
||||
.header h1 { margin: 0 0 4px; font-size: 24px; font-weight: 800; line-height: 1.2; }
|
||||
.header .subtitle { margin: 6px 0 0; opacity: 0.9; font-size: 14px; font-weight: 500; }
|
||||
.header .meta { margin: 10px 0 0; opacity: 0.75; font-size: 12px; }
|
||||
|
||||
/* Body */
|
||||
.body { padding: 28px; }
|
||||
.section-title {
|
||||
font-size: 11px; text-transform: uppercase; letter-spacing: 0.07em;
|
||||
color: #6b7280; font-weight: 700; margin: 0 0 14px;
|
||||
padding-bottom: 8px; border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-bottom: 20px; }
|
||||
.field label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: #9ca3af; font-weight: 600; display: block; }
|
||||
.field value { display: block; font-size: 15px; font-weight: 700; color: #111; margin-top: 3px; }
|
||||
|
||||
/* Alerta cancelación */
|
||||
.cancel-alert {
|
||||
background: #fef2f2; border: 1px solid #fecaca; border-radius: 8px;
|
||||
padding: 14px 16px; margin-bottom: 20px;
|
||||
}
|
||||
.cancel-alert p { margin: 0; font-size: 14px; color: #7f1d1d; font-weight: 600; }
|
||||
|
||||
.footer { padding: 14px 28px; background: #f9fafb; border-top: 1px solid #e5e7eb; font-size: 11px; color: #9ca3af; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
|
||||
<!-- Header con color según acción -->
|
||||
<div class="header header-{{ACTION}}">
|
||||
<div class="action-pill">{{ACTION_LABEL}}</div>
|
||||
<h1>{{CLIENT_NAME}}</h1>
|
||||
<p class="subtitle">{{PROPERTY}} · {{NIGHTS}} · Teneriffa</p>
|
||||
<p class="meta">{{DATE_RANGE}}</p>
|
||||
</div>
|
||||
|
||||
<div class="body">
|
||||
|
||||
<!-- Alerta solo en cancelaciones -->
|
||||
{{CANCEL_ALERT}}
|
||||
|
||||
<!-- Datos principales -->
|
||||
<p class="section-title">Datos de la reserva</p>
|
||||
<div class="grid">
|
||||
<div class="field">
|
||||
<label>Propiedad</label>
|
||||
<value>{{PROPERTY}}</value>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Nº Factura</label>
|
||||
<value>{{INVOICE_NUMBER}}</value>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Entrada</label>
|
||||
<value>{{START_DATE}}</value>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Salida</label>
|
||||
<value>{{END_DATE}}</value>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Adultos</label>
|
||||
<value>{{ADULTS}}</value>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Niños</label>
|
||||
<value>{{CHILDREN}}</value>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Registro gov.</label>
|
||||
<value>{{GOVERNMENT_REG}}</value>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Servicios extra</label>
|
||||
<value>{{SERVICES}}</value>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Observaciones (solo si existen) -->
|
||||
{{OBSERVATIONS_BLOCK}}
|
||||
|
||||
<!-- Cambios (solo en modificaciones) -->
|
||||
{{CHANGES_BLOCK}}
|
||||
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
ID reserva: {{RESERVATION_ID}} · Naturcalabacera App Reservas
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
19
apps/api/tsconfig.json
Normal file
19
apps/api/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"declaration": true,
|
||||
"paths": {
|
||||
"@naturcalabacera/shared": ["../../packages/shared/src/index.ts"],
|
||||
"@naturcalabacera/shared/*": ["../../packages/shared/src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
15
apps/web/.env.example
Normal file
15
apps/web/.env.example
Normal file
@@ -0,0 +1,15 @@
|
||||
# ─────────────────────────────────────────────
|
||||
# apps/web — Variables de entorno
|
||||
# Copia este archivo como .env y rellena los valores.
|
||||
# Los prefijos VITE_ son accesibles en el navegador.
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
# Supabase (self-hosted)
|
||||
VITE_SUPABASE_URL=https://tu-supabase.tudominio.com
|
||||
VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||
|
||||
# URL del backend API (apps/api)
|
||||
VITE_API_URL=http://localhost:3001
|
||||
|
||||
# Chatbot — poner true para mostrar el contenedor inferior
|
||||
VITE_CHATBOT_ENABLED=false
|
||||
23
apps/web/eslint.config.js
Normal file
23
apps/web/eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
13
apps/web/index.html
Normal file
13
apps/web/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>reservas-app</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
42
apps/web/package.json
Normal file
42
apps/web/package.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "@naturcalabacera/web",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@naturcalabacera/shared": "workspace:*",
|
||||
"@supabase/supabase-js": "^2.95.3",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"framer-motion": "^12.33.0",
|
||||
"lucide-react": "^0.563.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-hook-form": "^7.71.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"autoprefixer": "^10.4.24",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.46.4",
|
||||
"vite": "^7.2.4"
|
||||
}
|
||||
}
|
||||
6
apps/web/postcss.config.js
Normal file
6
apps/web/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
1
apps/web/public/vite.svg
Normal file
1
apps/web/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
42
apps/web/src/App.css
Normal file
42
apps/web/src/App.css
Normal file
@@ -0,0 +1,42 @@
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
275
apps/web/src/App.tsx
Normal file
275
apps/web/src/App.tsx
Normal file
@@ -0,0 +1,275 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useReservations } from './hooks/useReservations';
|
||||
import { usePropertyTheme } from './hooks/usePropertyTheme';
|
||||
import { Sidebar } from './components/Sidebar';
|
||||
import { MobileNavigation } from './components/MobileNavigation';
|
||||
import { CalendarGrid } from './components/CalendarGrid';
|
||||
import { ReservationModal } from './components/ReservationModal';
|
||||
import { SearchBar } from './components/SearchBar';
|
||||
import { SettingsPage } from './components/SettingsPage';
|
||||
import { YearlyCalendar } from './components/YearlyCalendar';
|
||||
import { UserManagement } from './components/UserManagement';
|
||||
import { LoginPage } from './components/LoginPage';
|
||||
import { ChatbotContainer } from './components/ChatbotContainer';
|
||||
import { useAuth } from './hooks/useAuth';
|
||||
import { PropertyProvider, useProperty } from './contexts/PropertyContext';
|
||||
import { UserRoleProvider, useUserRoleContext } from './contexts/UserRoleContext';
|
||||
import type { NewReservation, Reservation } from './types';
|
||||
import { format } from 'date-fns';
|
||||
import { Plus, Loader2 } from 'lucide-react';
|
||||
import { Toaster, toast } from 'sonner';
|
||||
|
||||
// Componente interno que ya tiene acceso al PropertyContext y UserRoleContext
|
||||
function AppContent() {
|
||||
const { property } = useProperty();
|
||||
const theme = usePropertyTheme();
|
||||
const { isViewer, isStaff, isAdmin } = useUserRoleContext();
|
||||
const {
|
||||
reservations,
|
||||
loading: reservationsLoading,
|
||||
createReservation,
|
||||
updateReservation,
|
||||
deleteReservation,
|
||||
refreshResolver,
|
||||
} = useReservations(property);
|
||||
|
||||
const [currentView, setCurrentView] = useState('calendar');
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [modalMode, setModalMode] = useState<'create' | 'edit'>('create');
|
||||
const [selectedReservation, setSelectedReservation] = useState<Partial<Reservation>>({});
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const filteredReservations = reservations.filter(res =>
|
||||
res.client_name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
const handleSelectReservation = (event: Reservation) => {
|
||||
setSelectedReservation(event);
|
||||
setModalMode('edit');
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSelectDay = (day: Date) => {
|
||||
setSelectedReservation({
|
||||
start_date: format(day, 'yyyy-MM-dd'),
|
||||
end_date: format(day, 'yyyy-MM-dd'),
|
||||
origin: 'Teneriffa2000',
|
||||
adults_count: 2,
|
||||
children_count: 0,
|
||||
});
|
||||
setModalMode('create');
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSelectRange = (start: Date, end: Date) => {
|
||||
setSelectedReservation({
|
||||
start_date: format(start, 'yyyy-MM-dd'),
|
||||
end_date: format(end, 'yyyy-MM-dd'),
|
||||
origin: 'Teneriffa2000',
|
||||
adults_count: 2,
|
||||
children_count: 0,
|
||||
});
|
||||
setModalMode('create');
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSave = async (data: NewReservation): Promise<Reservation | void> => {
|
||||
try {
|
||||
let savedReservation: Reservation;
|
||||
const previousSnapshot = modalMode === 'edit' ? { ...selectedReservation } as Reservation : undefined;
|
||||
if (modalMode === 'create') {
|
||||
savedReservation = await createReservation(data);
|
||||
} else {
|
||||
if (selectedReservation.id) {
|
||||
await updateReservation(selectedReservation.id, data);
|
||||
savedReservation = { ...selectedReservation, ...data } as Reservation;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
refreshResolver();
|
||||
toast.success('Reserva guardada correctamente');
|
||||
|
||||
// Notificar al API backend para programar notificaciones
|
||||
const operation = modalMode === 'create' ? 'created' : 'updated';
|
||||
void notifyApi(savedReservation, operation, previousSnapshot);
|
||||
return savedReservation;
|
||||
} catch (error) {
|
||||
console.error('Error saving:', error);
|
||||
toast.error('Error al guardar la reserva');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
const reservationToDelete = reservations.find(r => r.id === id);
|
||||
await deleteReservation(id);
|
||||
setModalOpen(false);
|
||||
refreshResolver();
|
||||
toast.success('Reserva eliminada correctamente');
|
||||
|
||||
if (reservationToDelete) {
|
||||
void notifyApi(reservationToDelete, 'cancelled');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting:', error);
|
||||
toast.error('Error al eliminar la reserva');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col md:flex-row h-screen overflow-hidden transition-all duration-700 ${theme.rootBg}`}>
|
||||
<Toaster position="top-right" theme="dark" richColors />
|
||||
<Sidebar currentView={currentView} onNavigate={setCurrentView} isViewer={isViewer} isAdmin={isAdmin} />
|
||||
|
||||
<div className="flex-1 flex flex-col overflow-hidden relative">
|
||||
<header className={`${theme.headerBg} backdrop-blur-xl border-b ${theme.headerBorder} px-4 md:px-10 py-4 md:py-6 shadow-2xl transition-all duration-700`}>
|
||||
{/* Relative container: center title absolutely, left/right content on sides */}
|
||||
<div className="relative flex items-center justify-center min-h-[3rem] md:min-h-[3.5rem]">
|
||||
{/* Left: status dot + tagline (desktop only) */}
|
||||
<div className="absolute left-0 hidden md:flex items-center gap-2">
|
||||
<span className={`inline-block w-2 h-2 rounded-full shadow-lg ${theme.dotColor} ${theme.dotShadow} animate-pulse flex-shrink-0`} />
|
||||
<span className={`text-xs font-medium ${theme.subtitleText}`}>
|
||||
Gestiona tus reservas y disponibilidad
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Center: property name — always truly centered */}
|
||||
<h1
|
||||
className={`text-3xl md:text-5xl font-black tracking-tight text-transparent bg-gradient-to-r ${theme.titleGradient} bg-clip-text ${theme.titleShadow} transition-all duration-700 select-none pointer-events-none`}
|
||||
>
|
||||
{theme.name}
|
||||
</h1>
|
||||
|
||||
{/* Right: Nueva Reserva button */}
|
||||
{isStaff && (
|
||||
<div className="absolute right-0">
|
||||
<button
|
||||
onClick={() => handleSelectDay(new Date())}
|
||||
className={`group px-4 md:px-7 py-2.5 md:py-3.5 bg-gradient-to-r ${theme.buttonBg} hover:opacity-90 text-white font-bold rounded-2xl transition-all duration-300 shadow-xl ${theme.buttonShadow} ${theme.buttonHoverShadow} hover:scale-105 border border-white/10 flex items-center gap-2`}
|
||||
>
|
||||
<Plus className="w-4 h-4 md:w-5 md:h-5 group-hover:rotate-90 transition-transform duration-300" />
|
||||
<span className="hidden md:inline text-sm">Nueva Reserva</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile subtitle below title */}
|
||||
<div className="flex md:hidden justify-center mt-1.5">
|
||||
<span className={`text-[11px] font-medium ${theme.subtitleText} flex items-center gap-1.5`}>
|
||||
<span className={`inline-block w-1.5 h-1.5 rounded-full ${theme.dotColor} animate-pulse`} />
|
||||
Gestiona tus reservas y disponibilidad
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className={`flex-1 overflow-auto p-4 md:p-10 pb-24 md:pb-10 ${theme.mainBg} transition-all duration-700`}>
|
||||
{currentView === 'calendar' && (
|
||||
<>
|
||||
<SearchBar searchTerm={searchTerm} onSearchChange={setSearchTerm} />
|
||||
<CalendarGrid
|
||||
reservations={filteredReservations}
|
||||
onSelectDay={handleSelectDay}
|
||||
onSelectRange={handleSelectRange}
|
||||
onSelectReservation={handleSelectReservation}
|
||||
isLoading={reservationsLoading}
|
||||
viewerMode={isViewer}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{currentView === 'settings' && <SettingsPage />}
|
||||
{currentView === 'yearly' && (
|
||||
<YearlyCalendar
|
||||
reservations={reservations}
|
||||
onSelectDay={isStaff ? handleSelectDay : undefined}
|
||||
onSelectRange={isStaff ? handleSelectRange : undefined}
|
||||
onSelectReservation={handleSelectReservation}
|
||||
viewerMode={isViewer}
|
||||
/>
|
||||
)}
|
||||
{currentView === 'users' && isAdmin && <UserManagement />}
|
||||
</main>
|
||||
|
||||
<MobileNavigation currentView={currentView} onNavigate={setCurrentView} isViewer={isViewer} isAdmin={isAdmin} />
|
||||
</div>
|
||||
|
||||
<ChatbotContainer />
|
||||
|
||||
{isStaff && (
|
||||
<ReservationModal
|
||||
isOpen={modalOpen}
|
||||
mode={modalMode}
|
||||
initialData={selectedReservation}
|
||||
existingReservations={reservations}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onSave={handleSave}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Llama al API backend para programar notificaciones (fire-and-forget)
|
||||
async function notifyApi(
|
||||
reservation: Reservation,
|
||||
operation: 'created' | 'updated' | 'cancelled',
|
||||
previousReservation?: Reservation
|
||||
) {
|
||||
const apiUrl = import.meta.env.VITE_API_URL;
|
||||
if (!apiUrl) return; // Si no hay API configurada, skip silencioso
|
||||
|
||||
try {
|
||||
await fetch(`${apiUrl}/api/notifications/reservation-event`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ reservation, operation, previousReservation }),
|
||||
});
|
||||
} catch {
|
||||
// No bloquear la UI si el API no está disponible
|
||||
console.warn('[notifications] API no disponible');
|
||||
}
|
||||
}
|
||||
|
||||
function App() {
|
||||
// Initialize theme from localStorage
|
||||
useEffect(() => {
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
if (savedTheme === 'dark' || (!savedTheme && systemPrefersDark)) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const { session, loading: authLoading } = useAuth();
|
||||
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-950 flex items-center justify-center">
|
||||
<Loader2 className="w-10 h-10 text-blue-500 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<>
|
||||
<Toaster position="top-right" theme="dark" richColors />
|
||||
<LoginPage />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PropertyProvider>
|
||||
<UserRoleProvider>
|
||||
<AppContent />
|
||||
</UserRoleProvider>
|
||||
</PropertyProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
1
apps/web/src/assets/react.svg
Normal file
1
apps/web/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
508
apps/web/src/components/CalendarGrid.tsx
Normal file
508
apps/web/src/components/CalendarGrid.tsx
Normal file
@@ -0,0 +1,508 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
format, startOfMonth, endOfMonth, startOfWeek, endOfWeek,
|
||||
eachDayOfInterval, isSameMonth, addMonths, subMonths,
|
||||
isSameDay, differenceInDays, parseISO, isWithinInterval
|
||||
} from 'date-fns';
|
||||
import { es } from 'date-fns/locale/es';
|
||||
import { ChevronLeft, ChevronRight, Users, Moon } from 'lucide-react';
|
||||
import type { Reservation } from '../types';
|
||||
import { useProperty } from '../contexts/PropertyContext';
|
||||
import { PROPERTY_CONFIG } from '@naturcalabacera/shared';
|
||||
import { ServiceIcons } from './ServiceIcons';
|
||||
|
||||
interface Props {
|
||||
reservations: Reservation[];
|
||||
onSelectDay: (day: Date) => void;
|
||||
onSelectReservation: (reservation: Reservation) => void;
|
||||
onSelectRange?: (start: Date, end: Date) => void;
|
||||
isLoading?: boolean;
|
||||
viewerMode?: boolean;
|
||||
}
|
||||
|
||||
export function CalendarGrid({
|
||||
reservations,
|
||||
onSelectDay,
|
||||
onSelectReservation,
|
||||
onSelectRange,
|
||||
isLoading: _isLoading = false,
|
||||
viewerMode = false,
|
||||
}: Props) {
|
||||
const { property } = useProperty();
|
||||
const propertyConfig = PROPERTY_CONFIG[property];
|
||||
const [currentDate, setCurrentDate] = useState(new Date());
|
||||
|
||||
// Drag-to-select state
|
||||
const [dragStart, setDragStart] = useState<Date | null>(null);
|
||||
const [dragEnd, setDragEnd] = useState<Date | null>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const gridBodyRef = useRef<HTMLDivElement>(null);
|
||||
// Tracks mouse movement to distinguish click vs drag
|
||||
const mouseMoved = useRef(false);
|
||||
// Prevents the click event that fires after mouseUp from triggering onSelectDay
|
||||
const dragJustFinished = useRef(false);
|
||||
|
||||
const monthStart = startOfMonth(currentDate);
|
||||
const monthEnd = endOfMonth(monthStart);
|
||||
const calendarStart = startOfWeek(monthStart, { weekStartsOn: 0 });
|
||||
const calendarEnd = endOfWeek(monthEnd, { weekStartsOn: 0 });
|
||||
|
||||
const calendarDays = eachDayOfInterval({ start: calendarStart, end: calendarEnd });
|
||||
|
||||
const weeks: Date[][] = [];
|
||||
for (let i = 0; i < calendarDays.length; i += 7) {
|
||||
weeks.push(calendarDays.slice(i, i + 7));
|
||||
}
|
||||
|
||||
const nextMonth = () => setCurrentDate(addMonths(currentDate, 1));
|
||||
const prevMonth = () => setCurrentDate(subMonths(currentDate, 1));
|
||||
|
||||
// Display occupancy: a day is occupied for rendering if within [start, end] inclusive
|
||||
const isDayOccupied = (day: Date): boolean => {
|
||||
return reservations.some(res => {
|
||||
const s = parseISO(res.start_date);
|
||||
const e = parseISO(res.end_date);
|
||||
return isWithinInterval(day, { start: s, end: e }) || isSameDay(day, s) || isSameDay(day, e);
|
||||
});
|
||||
};
|
||||
|
||||
// Get reservation for a specific day (for click-through on occupied cells)
|
||||
const getReservationForDay = (day: Date): Reservation | undefined => {
|
||||
return reservations.find(res => {
|
||||
const s = parseISO(res.start_date);
|
||||
const e = parseISO(res.end_date);
|
||||
return isWithinInterval(day, { start: s, end: e }) || isSameDay(day, s) || isSameDay(day, e);
|
||||
});
|
||||
};
|
||||
|
||||
// Calculate which calendar day is under a given coordinate
|
||||
const getDayFromCoords = (clientX: number, clientY: number): Date | null => {
|
||||
const el = gridBodyRef.current;
|
||||
if (!el) return null;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const x = clientX - rect.left;
|
||||
const y = clientY - rect.top;
|
||||
if (x < 0 || x >= rect.width || y < 0 || y >= rect.height) return null;
|
||||
const col = Math.min(Math.floor(x / (rect.width / 7)), 6);
|
||||
const row = Math.min(Math.floor(y / (rect.height / weeks.length)), weeks.length - 1);
|
||||
return weeks[row]?.[col] ?? null;
|
||||
};
|
||||
|
||||
// Is a day inside the current drag selection range?
|
||||
const isInDragSelection = (day: Date): boolean => {
|
||||
if (!dragStart || !dragEnd) return false;
|
||||
const start = dragStart <= dragEnd ? dragStart : dragEnd;
|
||||
const end = dragStart <= dragEnd ? dragEnd : dragStart;
|
||||
return day >= start && day <= end;
|
||||
};
|
||||
|
||||
// Confirm drag and open modal
|
||||
const confirmDrag = (start: Date, end: Date) => {
|
||||
const s = start <= end ? start : end;
|
||||
const e = start <= end ? end : start;
|
||||
if (isSameDay(s, e)) {
|
||||
onSelectDay(s);
|
||||
} else if (onSelectRange) {
|
||||
onSelectRange(s, e);
|
||||
} else {
|
||||
onSelectDay(s);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Grid body mouse events ---
|
||||
|
||||
const handleGridMouseDown = (ev: React.MouseEvent) => {
|
||||
if (viewerMode) return;
|
||||
const day = getDayFromCoords(ev.clientX, ev.clientY);
|
||||
if (!day) return;
|
||||
// Allow drag start on any day (free or occupied boundary)
|
||||
// Drag is only initiated on non-occupied days
|
||||
if (isDayOccupied(day)) return;
|
||||
ev.preventDefault();
|
||||
mouseMoved.current = false;
|
||||
setDragStart(day);
|
||||
setDragEnd(day);
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleGridMouseMove = (ev: React.MouseEvent) => {
|
||||
if (!isDragging || !dragStart) return;
|
||||
mouseMoved.current = true;
|
||||
const day = getDayFromCoords(ev.clientX, ev.clientY);
|
||||
if (day) setDragEnd(day);
|
||||
};
|
||||
|
||||
const handleGridMouseUp = (ev: React.MouseEvent) => {
|
||||
if (!isDragging || !dragStart || !dragEnd) return;
|
||||
const hasMoved = mouseMoved.current;
|
||||
const start = dragStart;
|
||||
const end = dragEnd;
|
||||
setIsDragging(false);
|
||||
setDragStart(null);
|
||||
setDragEnd(null);
|
||||
if (hasMoved && !isSameDay(start, end)) {
|
||||
dragJustFinished.current = true;
|
||||
confirmDrag(start, end);
|
||||
}
|
||||
// Single clicks are handled entirely by handleGridClick
|
||||
ev.stopPropagation();
|
||||
};
|
||||
|
||||
// Handle click on grid body (for free-day single clicks when drag is not involved)
|
||||
const handleGridClick = (ev: React.MouseEvent) => {
|
||||
if (viewerMode || isDragging) return;
|
||||
// Ignore the synthetic click that fires immediately after a drag ends
|
||||
if (dragJustFinished.current) {
|
||||
dragJustFinished.current = false;
|
||||
return;
|
||||
}
|
||||
const day = getDayFromCoords(ev.clientX, ev.clientY);
|
||||
if (!day) return;
|
||||
const res = getReservationForDay(day);
|
||||
if (res) {
|
||||
// Click on occupied day — open reservation
|
||||
onSelectReservation(res);
|
||||
} else {
|
||||
// Click on free day — open create modal
|
||||
onSelectDay(day);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Touch events (mobile drag) ---
|
||||
|
||||
const handleTouchStart = (ev: React.TouchEvent) => {
|
||||
if (viewerMode) return;
|
||||
const touch = ev.touches[0];
|
||||
const day = getDayFromCoords(touch.clientX, touch.clientY);
|
||||
if (!day || isDayOccupied(day)) return;
|
||||
mouseMoved.current = false;
|
||||
setDragStart(day);
|
||||
setDragEnd(day);
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleTouchMove = (ev: React.TouchEvent) => {
|
||||
if (!isDragging) return;
|
||||
mouseMoved.current = true;
|
||||
const touch = ev.touches[0];
|
||||
const day = getDayFromCoords(touch.clientX, touch.clientY);
|
||||
if (day) setDragEnd(day);
|
||||
};
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
if (!isDragging || !dragStart || !dragEnd) return;
|
||||
const hasMoved = mouseMoved.current;
|
||||
const start = dragStart;
|
||||
const end = dragEnd;
|
||||
setIsDragging(false);
|
||||
setDragStart(null);
|
||||
setDragEnd(null);
|
||||
if (hasMoved && !isSameDay(start, end)) {
|
||||
dragJustFinished.current = true;
|
||||
confirmDrag(start, end);
|
||||
} else {
|
||||
onSelectDay(start);
|
||||
}
|
||||
};
|
||||
|
||||
// Cancel drag if mouse leaves the window
|
||||
useEffect(() => {
|
||||
const cancel = () => {
|
||||
if (isDragging) {
|
||||
setIsDragging(false);
|
||||
setDragStart(null);
|
||||
setDragEnd(null);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mouseup', cancel);
|
||||
return () => document.removeEventListener('mouseup', cancel);
|
||||
}, [isDragging]);
|
||||
|
||||
// --- Reservation blocks (visual only — clicks handled at grid body level) ---
|
||||
const renderReservationBlocks = () => {
|
||||
const blocks: React.ReactElement[] = [];
|
||||
|
||||
reservations.forEach((res) => {
|
||||
const startDate = parseISO(res.start_date);
|
||||
const endDate = parseISO(res.end_date);
|
||||
|
||||
const startIndex = calendarDays.findIndex(day => isSameDay(day, startDate));
|
||||
if (startIndex === -1) return;
|
||||
|
||||
const endIndex = calendarDays.findIndex(day => isSameDay(day, endDate));
|
||||
if (endIndex === -1) return;
|
||||
|
||||
const totalDuration = differenceInDays(endDate, startDate) + 1;
|
||||
const nights = totalDuration - 1;
|
||||
|
||||
const isTeneriffa = res.origin === 'Teneriffa2000';
|
||||
const gradient = viewerMode
|
||||
? 'bg-stone-400/30 dark:bg-stone-500/30'
|
||||
: isTeneriffa
|
||||
? 'bg-blue-600/30 dark:bg-blue-500/30'
|
||||
: 'bg-yellow-500/30 dark:bg-yellow-400/30';
|
||||
|
||||
const borderClass = viewerMode
|
||||
? 'border-l-4 border-stone-400'
|
||||
: isTeneriffa
|
||||
? 'border-l-4 border-blue-500'
|
||||
: 'border-l-4 border-yellow-500';
|
||||
const textShadow = 'drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)]';
|
||||
|
||||
let currentDayIndex = startIndex;
|
||||
let blockIndex = 0;
|
||||
|
||||
while (currentDayIndex <= endIndex) {
|
||||
const weekIndex = Math.floor(currentDayIndex / 7);
|
||||
const dayOfWeek = currentDayIndex % 7;
|
||||
const daysUntilWeekEnd = 7 - dayOfWeek;
|
||||
const daysRemaining = endIndex - currentDayIndex + 1;
|
||||
const daysInThisWeek = Math.min(daysUntilWeekEnd, daysRemaining);
|
||||
const isFirstBlock = blockIndex === 0;
|
||||
|
||||
blocks.push(
|
||||
<div
|
||||
key={`${res.id}-week-${weekIndex}`}
|
||||
className={`
|
||||
absolute pointer-events-none
|
||||
${gradient} ${borderClass}
|
||||
z-10
|
||||
flex flex-col justify-end items-center md:items-start md:p-1.5
|
||||
`}
|
||||
style={{
|
||||
top: `calc(${weekIndex} * var(--cell-height))`,
|
||||
left: `${(dayOfWeek * 100 / 7)}%`,
|
||||
width: `${(daysInThisWeek * (100 / 7))}%`,
|
||||
height: 'var(--cell-height)',
|
||||
}}
|
||||
>
|
||||
{/* Desktop */}
|
||||
<div className="hidden md:block w-full">
|
||||
<div className={`text-xs font-black text-white truncate ${textShadow} px-1`}>
|
||||
{viewerMode ? 'Ocupado' : res.client_name}
|
||||
</div>
|
||||
{!viewerMode && isFirstBlock && (
|
||||
<div className="flex items-center gap-1.5 mt-0.5 px-1">
|
||||
<div className={`flex items-center gap-0.5 text-white ${textShadow}`}>
|
||||
<Moon className="w-2.5 h-2.5" />
|
||||
<span className="text-[9px] font-bold">{nights}n</span>
|
||||
</div>
|
||||
<div className={`flex items-center gap-0.5 text-white ${textShadow}`}>
|
||||
<Users className="w-2.5 h-2.5" />
|
||||
<span className="text-[9px] font-bold">{res.adults_count + res.children_count}p</span>
|
||||
</div>
|
||||
<ServiceIcons reservation={res} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile */}
|
||||
<div className="md:hidden w-full flex items-end justify-between pb-0.5 px-0.5">
|
||||
{daysInThisWeek > 1 && (
|
||||
<span className={`text-[8px] font-black text-white/90 truncate uppercase tracking-tight ${textShadow}`}>
|
||||
{viewerMode ? 'Ocupado' : res.client_name}
|
||||
</span>
|
||||
)}
|
||||
{!viewerMode && daysInThisWeek > 2 && isFirstBlock && (
|
||||
<ServiceIcons reservation={res} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
currentDayIndex += daysInThisWeek;
|
||||
blockIndex++;
|
||||
}
|
||||
});
|
||||
|
||||
return blocks;
|
||||
};
|
||||
|
||||
// Drag selection highlight blocks
|
||||
const renderDragSelection = () => {
|
||||
if (!isDragging || !dragStart || !dragEnd) return null;
|
||||
const start = dragStart <= dragEnd ? dragStart : dragEnd;
|
||||
const end = dragStart <= dragEnd ? dragEnd : dragStart;
|
||||
const startIndex = calendarDays.findIndex(d => d >= start);
|
||||
if (startIndex === -1) return null;
|
||||
const endIndex = calendarDays.findIndex(d => d >= end);
|
||||
const effectiveEnd = endIndex === -1 ? calendarDays.length - 1 : endIndex;
|
||||
|
||||
const selBlocks: React.ReactElement[] = [];
|
||||
let cur = startIndex;
|
||||
while (cur <= effectiveEnd) {
|
||||
const weekIndex = Math.floor(cur / 7);
|
||||
const dayOfWeek = cur % 7;
|
||||
const daysUntilWeekEnd = 7 - dayOfWeek;
|
||||
const daysRemaining = effectiveEnd - cur + 1;
|
||||
const span = Math.min(daysUntilWeekEnd, daysRemaining);
|
||||
selBlocks.push(
|
||||
<div
|
||||
key={`sel-${cur}`}
|
||||
className="absolute z-20 pointer-events-none bg-emerald-500/20 border-l-2 border-r-2 border-emerald-400/60 border-t border-b border-emerald-400/30"
|
||||
style={{
|
||||
top: `calc(${weekIndex} * var(--cell-height))`,
|
||||
left: `${(dayOfWeek * 100 / 7)}%`,
|
||||
width: `${(span * (100 / 7))}%`,
|
||||
height: 'var(--cell-height)',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
cur += span;
|
||||
}
|
||||
return selBlocks;
|
||||
};
|
||||
|
||||
// Days count label during drag
|
||||
const dragNights = dragStart && dragEnd
|
||||
? Math.abs(differenceInDays(dragEnd, dragStart))
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-emerald-950/10 rounded-3xl p-3 md:p-5 shadow-xl border border-stone-200 dark:border-emerald-900/30 backdrop-blur-xl transition-colors duration-500 flex flex-col">
|
||||
|
||||
<style>{`
|
||||
:root {
|
||||
--cell-height: 2.75rem;
|
||||
--header-height: 1.75rem;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
:root {
|
||||
--cell-height: 5rem;
|
||||
--header-height: 2.75rem;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-3 md:mb-4 shrink-0 overflow-visible">
|
||||
<div className="min-w-0 overflow-visible flex-1">
|
||||
<div className="flex items-baseline gap-3">
|
||||
<h2 className="text-xl md:text-2xl font-black text-transparent bg-gradient-to-r from-emerald-800 to-amber-600 dark:from-white dark:to-emerald-200 bg-clip-text capitalize pb-1 leading-tight">
|
||||
{format(currentDate, 'MMMM yyyy', { locale: es })}
|
||||
</h2>
|
||||
{/* Drag hint — shown only during active drag */}
|
||||
{isDragging && dragNights > 0 && (
|
||||
<span className="text-xs font-bold text-emerald-500 dark:text-emerald-400 animate-pulse">
|
||||
{dragNights} noche{dragNights !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<p className="text-xs text-stone-500 dark:text-emerald-400/60 font-medium">
|
||||
{viewerMode ? 'Vista de disponibilidad' : 'Clic para crear · Arrastra para rango'}
|
||||
</p>
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-bold bg-gradient-to-r ${propertyConfig.color.gradient} text-white`}>
|
||||
{propertyConfig.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0 ml-3">
|
||||
<button
|
||||
onClick={prevMonth}
|
||||
className="p-2 rounded-xl bg-stone-100 dark:bg-emerald-900/20 hover:bg-emerald-50 dark:hover:bg-emerald-900/40 text-stone-600 dark:text-emerald-100/80 transition-all duration-300 shadow-sm hover:shadow-md hover:scale-110 border border-stone-200 dark:border-emerald-500/20"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4 md:w-5 md:h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={nextMonth}
|
||||
className="p-2 rounded-xl bg-stone-100 dark:bg-emerald-900/20 hover:bg-emerald-50 dark:hover:bg-emerald-900/40 text-stone-600 dark:text-emerald-100/80 transition-all duration-300 shadow-sm hover:shadow-md hover:scale-110 border border-stone-200 dark:border-emerald-500/20"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4 md:w-5 md:h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid container */}
|
||||
<div className="bg-stone-50 dark:bg-black/20 backdrop-blur-xl rounded-2xl overflow-hidden border border-stone-200 dark:border-emerald-900/30 shadow-2xl shadow-stone-200/50 dark:shadow-black/20 relative">
|
||||
<div className="w-full flex flex-col">
|
||||
|
||||
{/* Days header */}
|
||||
<div className="grid grid-cols-7 bg-stone-100 dark:bg-emerald-950/30 border-b border-stone-200 dark:border-emerald-900/30 backdrop-blur-sm shrink-0">
|
||||
{['D', 'L', 'M', 'X', 'J', 'V', 'S'].map((day, i) => (
|
||||
<div key={i} className="text-center py-1.5 md:py-3 text-[9px] md:text-xs font-black text-stone-400 dark:text-emerald-500/60 uppercase tracking-widest">
|
||||
<span className="md:hidden">{day}</span>
|
||||
<span className="hidden md:inline">{['Dom', 'Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb'][i]}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Grid body — handles all pointer interactions via coordinates */}
|
||||
<div
|
||||
ref={gridBodyRef}
|
||||
className={`relative ${!viewerMode ? (isDragging ? 'cursor-crosshair' : 'cursor-pointer') : 'cursor-default'}`}
|
||||
onMouseDown={handleGridMouseDown}
|
||||
onMouseMove={handleGridMouseMove}
|
||||
onMouseUp={handleGridMouseUp}
|
||||
onClick={handleGridClick}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
// Prevent text selection during drag
|
||||
style={{ userSelect: isDragging ? 'none' : undefined }}
|
||||
>
|
||||
{/* Grid cells — purely visual, no pointer-events needed */}
|
||||
{weeks.map((week, weekIdx) => (
|
||||
<div key={weekIdx} className="grid grid-cols-7 border-b border-stone-200 dark:border-emerald-900/30 last:border-b-0">
|
||||
{week.map((day) => {
|
||||
const isCurrentMonth = isSameMonth(day, monthStart);
|
||||
const isOccupied = isDayOccupied(day);
|
||||
const inSel = isInDragSelection(day);
|
||||
const isToday = isSameDay(day, new Date());
|
||||
|
||||
return (
|
||||
<div
|
||||
key={day.toString()}
|
||||
className={`
|
||||
relative border-r border-stone-200 dark:border-emerald-900/30 last:border-r-0
|
||||
p-1 md:p-2
|
||||
${!isCurrentMonth ? 'bg-stone-100/50 dark:bg-emerald-950/30' : ''}
|
||||
${inSel && !isOccupied ? 'bg-emerald-50/60 dark:bg-emerald-900/10' : ''}
|
||||
${isToday && isCurrentMonth ? 'bg-blue-50/60 dark:bg-blue-500/5' : ''}
|
||||
`}
|
||||
style={{ height: 'var(--cell-height)' }}
|
||||
>
|
||||
<span className={`
|
||||
inline-flex items-center justify-center w-4 h-4 md:w-7 md:h-7 rounded-md text-[9px] md:text-xs font-bold
|
||||
transition-all duration-200 relative z-30
|
||||
${isToday && isCurrentMonth ? 'bg-blue-500 text-white ring-2 ring-blue-400/60 ring-offset-1 ring-offset-white dark:ring-offset-emerald-950 shadow-lg shadow-blue-500/30' : ''}
|
||||
${!isToday && isCurrentMonth
|
||||
? isOccupied
|
||||
? 'text-white drop-shadow-md'
|
||||
: inSel
|
||||
? 'text-emerald-700 dark:text-emerald-300'
|
||||
: 'text-stone-700 dark:text-emerald-100/80'
|
||||
: !isCurrentMonth ? 'text-stone-300 dark:text-emerald-900/40' : ''
|
||||
}
|
||||
`}>
|
||||
{format(day, 'd')}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Reservation color blocks */}
|
||||
{renderReservationBlocks()}
|
||||
|
||||
{/* Drag selection overlay */}
|
||||
{renderDragSelection()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="mt-3 flex flex-wrap items-center gap-3 md:gap-5 text-xs text-stone-500 dark:text-emerald-400/60 shrink-0">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-blue-50 dark:bg-blue-500/5 rounded-xl border border-blue-200 dark:border-blue-500/10">
|
||||
<div className="w-3 h-3 rounded-md bg-gradient-to-br from-blue-400 to-blue-600 shadow-sm shadow-blue-500/50 shrink-0" />
|
||||
<span className="font-semibold text-stone-700 dark:text-emerald-100/80">Teneriffa</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-amber-50 dark:bg-amber-500/5 rounded-xl border border-amber-200 dark:border-amber-500/10">
|
||||
<div className="w-3 h-3 rounded-md bg-gradient-to-br from-amber-400 to-amber-600 shadow-sm shadow-amber-500/50 shrink-0" />
|
||||
<span className="font-semibold text-stone-700 dark:text-emerald-100/80">Natur</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
222
apps/web/src/components/CalendarGrid.tsx.backup
Normal file
222
apps/web/src/components/CalendarGrid.tsx.backup
Normal file
@@ -0,0 +1,222 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
format, startOfMonth, endOfMonth, startOfWeek, endOfWeek,
|
||||
eachDayOfInterval, isSameMonth, addMonths, subMonths,
|
||||
isSameDay, differenceInDays, parseISO, isWithinInterval
|
||||
} from 'date-fns';
|
||||
import { es } from 'date-fns/locale/es';
|
||||
import { ChevronLeft, ChevronRight, Users, Moon, Ban } from 'lucide-react';
|
||||
import type { Reservation } from '../types';
|
||||
|
||||
interface Props {
|
||||
reservations: Reservation[];
|
||||
onSelectDay: (day: Date) => void;
|
||||
onSelectReservation: (reservation: Reservation) => void;
|
||||
}
|
||||
|
||||
export function CalendarGrid({ reservations, onSelectDay, onSelectReservation }: Props) {
|
||||
const [currentDate, setCurrentDate] = useState(new Date());
|
||||
|
||||
const monthStart = startOfMonth(currentDate);
|
||||
const monthEnd = endOfMonth(monthStart);
|
||||
const calendarStart = startOfWeek(monthStart, { weekStartsOn: 0 });
|
||||
const calendarEnd = endOfWeek(monthEnd, { weekStartsOn: 0 });
|
||||
|
||||
const calendarDays = eachDayOfInterval({ start: calendarStart, end: calendarEnd });
|
||||
|
||||
// Organizar en semanas
|
||||
const weeks: Date[][] = [];
|
||||
for (let i = 0; i < calendarDays.length; i += 7) {
|
||||
weeks.push(calendarDays.slice(i, i + 7));
|
||||
}
|
||||
|
||||
const nextMonth = () => setCurrentDate(addMonths(currentDate, 1));
|
||||
const prevMonth = () => setCurrentDate(subMonths(currentDate, 1));
|
||||
|
||||
// Función para verificar si un día está ocupado
|
||||
const isDayOccupied = (day: Date): boolean => {
|
||||
return reservations.some(res => {
|
||||
const startDate = parseISO(res.start_date);
|
||||
const endDate = parseISO(res.end_date);
|
||||
|
||||
// Un día está ocupado si está dentro del rango [start_date, end_date] (ambos inclusive)
|
||||
return isWithinInterval(day, { start: startDate, end: endDate }) ||
|
||||
isSameDay(day, startDate) ||
|
||||
isSameDay(day, endDate);
|
||||
});
|
||||
};
|
||||
|
||||
// Renderizar bloques de reserva
|
||||
const renderReservationBlocks = () => {
|
||||
return reservations.map((res) => {
|
||||
const startDate = parseISO(res.start_date);
|
||||
const endDate = parseISO(res.end_date);
|
||||
|
||||
const dayIndex = calendarDays.findIndex(day => isSameDay(day, startDate));
|
||||
if (dayIndex === -1) return null;
|
||||
|
||||
const weekIndex = Math.floor(dayIndex / 7);
|
||||
const dayOfWeek = dayIndex % 7;
|
||||
|
||||
const duration = differenceInDays(endDate, startDate) + 1;
|
||||
const nights = duration - 1;
|
||||
|
||||
const isTeneriffa = res.origin === 'Teneriffa2000';
|
||||
const gradient = isTeneriffa
|
||||
? 'from-blue-600/90 via-blue-500/90 to-blue-400/90'
|
||||
: 'from-yellow-600/90 via-yellow-500/90 to-yellow-400/90';
|
||||
|
||||
const borderColor = isTeneriffa ? 'border-blue-400' : 'border-yellow-400';
|
||||
const shadowColor = isTeneriffa ? 'shadow-blue-500/50' : 'shadow-yellow-500/50';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={res.id}
|
||||
onClick={() => onSelectReservation(res)}
|
||||
className={`
|
||||
absolute cursor-pointer group
|
||||
bg-gradient-to-r ${gradient} ${borderColor}
|
||||
border-l-4 rounded-2xl p-3
|
||||
hover:scale-105 transition-all duration-300
|
||||
shadow-2xl ${shadowColor}
|
||||
backdrop-blur-xl
|
||||
z-10
|
||||
`}
|
||||
style={{
|
||||
top: `${weekIndex * 100 + 50}px`,
|
||||
left: `${(dayOfWeek * 100 / 7) + 0.75}%`,
|
||||
width: `${Math.min(duration, 7 - dayOfWeek) * (100 / 7) - 1.5}%`,
|
||||
height: '60px'
|
||||
}}
|
||||
>
|
||||
<div className=\"relative z-10\">
|
||||
<div className=\"text-sm font-bold text-white truncate drop-shadow-lg\">{res.client_name}</div>
|
||||
<div className=\"flex items-center gap-3 mt-1\">
|
||||
< div className =\"flex items-center gap-1 text-white/90\">
|
||||
< Moon className =\"w-3 h-3\" />
|
||||
< span className =\"text-[11px] font-semibold\">{nights}n</span>
|
||||
</div >
|
||||
<div className=\"flex items-center gap-1 text-white/90\">
|
||||
< Users className =\"w-3 h-3\" />
|
||||
< span className =\"text-[11px] font-semibold\">{res.adults_count + res.children_count}p</span>
|
||||
</div >
|
||||
</div >
|
||||
</div >
|
||||
</div >
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className=\"flex-1 bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 rounded-3xl p-8 shadow-2xl border border-slate-700/50 backdrop-blur-xl\">
|
||||
{/* Header */ }
|
||||
<div className=\"flex items-center justify-between mb-8\">
|
||||
< div >
|
||||
<h2 className=\"text-4xl font-black text-transparent bg-gradient-to-r from-white to-slate-300 bg-clip-text capitalize\">
|
||||
{ format(currentDate, 'MMMM yyyy', { locale: es }) }
|
||||
</h2 >
|
||||
<p className=\"text-sm text-slate-400 mt-1 font-medium\">Vista mensual de reservas</p>
|
||||
</div >
|
||||
<div className=\"flex items-center gap-3\">
|
||||
< button
|
||||
onClick = { prevMonth }
|
||||
className =\"p-3 rounded-xl bg-gradient-to-br from-slate-700 to-slate-800 hover:from-slate-600 hover:to-slate-700 text-slate-200 transition-all duration-300 shadow-lg hover:shadow-xl hover:scale-110 border border-slate-600/50\"
|
||||
>
|
||||
<ChevronLeft className=\"w-5 h-5\" />
|
||||
</button >
|
||||
<button
|
||||
onClick={nextMonth}
|
||||
className=\"p-3 rounded-xl bg-gradient-to-br from-slate-700 to-slate-800 hover:from-slate-600 hover:to-slate-700 text-slate-200 transition-all duration-300 shadow-lg hover:shadow-xl hover:scale-110 border border-slate-600/50\"
|
||||
>
|
||||
<ChevronRight className=\"w-5 h-5\" />
|
||||
</button >
|
||||
</div >
|
||||
</div >
|
||||
|
||||
{/* Calendar */ }
|
||||
< div className =\"bg-slate-800/30 backdrop-blur-xl rounded-2xl overflow-hidden border border-slate-700/50 shadow-2xl\">
|
||||
{/* Days header */ }
|
||||
<div className=\"grid grid-cols-7 bg-gradient-to-r from-slate-800/80 to-slate-700/80 border-b border-slate-600/50 backdrop-blur\">
|
||||
{
|
||||
['Dom', 'Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb'].map((day) => (
|
||||
<div key={day} className=\"text-center py-4 text-sm font-black text-slate-300 uppercase tracking-widest\">
|
||||
{ day }
|
||||
</div >
|
||||
))
|
||||
}
|
||||
</div >
|
||||
|
||||
{/* Calendar grid */ }
|
||||
< div className =\"relative\">
|
||||
{
|
||||
weeks.map((week, weekIdx) => (
|
||||
<div key={weekIdx} className=\"grid grid-cols-7 border-b border-slate-700/30 last:border-b-0\">
|
||||
{
|
||||
week.map((day) => {
|
||||
const isCurrentMonth = isSameMonth(day, monthStart);
|
||||
const isOccupied = isDayOccupied(day);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={day.toString()}
|
||||
onClick={() => {
|
||||
if (!isOccupied) {
|
||||
onSelectDay(day);
|
||||
}
|
||||
}}
|
||||
className={`
|
||||
relative h-28 p-3 border-r border-slate-700/30 last:border-r-0
|
||||
transition-all duration-300 group
|
||||
${!isCurrentMonth ? 'bg-slate-900/50' : ''}
|
||||
${isOccupied
|
||||
? 'cursor-not-allowed bg-red-900/10'
|
||||
: 'cursor-pointer hover:bg-gradient-to-br hover:from-slate-700/50 hover:to-slate-600/30'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<span className={`
|
||||
inline-flex items-center justify-center w-8 h-8 rounded-lg text-sm font-bold
|
||||
transition-all duration-300
|
||||
${isCurrentMonth
|
||||
? isOccupied
|
||||
? 'text-red-400/50'
|
||||
: 'text-slate-100 group-hover:bg-white/10 group-hover:scale-110'
|
||||
: 'text-slate-600'
|
||||
}
|
||||
`}>
|
||||
{format(day, 'd')}
|
||||
</span>
|
||||
|
||||
{/* Indicador visual de día ocupado */}
|
||||
{isOccupied && isCurrentMonth && (
|
||||
<div className=\"absolute top-2 right-2\">
|
||||
<Ban className=\"w-4 h-4 text-red-400/30\" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div >
|
||||
);
|
||||
})
|
||||
}
|
||||
</div >
|
||||
))}
|
||||
|
||||
{/* Reservation blocks */ }
|
||||
{ renderReservationBlocks() }
|
||||
</div >
|
||||
</div >
|
||||
|
||||
{/* Legend */ }
|
||||
< div className =\"mt-6 flex items-center gap-8 text-sm text-slate-400\">
|
||||
< div className =\"flex items-center gap-3 px-4 py-2 bg-gradient-to-r from-blue-500/10 to-transparent rounded-xl border border-blue-500/20\">
|
||||
< div className =\"w-4 h-4 rounded-lg bg-gradient-to-br from-blue-500 to-blue-600 shadow-lg shadow-blue-500/50\"></div>
|
||||
< span className =\"font-semibold\">Teneriffa2000</span>
|
||||
</div >
|
||||
<div className=\"flex items-center gap-3 px-4 py-2 bg-gradient-to-r from-yellow-500/10 to-transparent rounded-xl border border-yellow-500/20\">
|
||||
< div className =\"w-4 h-4 rounded-lg bg-gradient-to-br from-yellow-500 to-yellow-600 shadow-lg shadow-yellow-500/50\"></div>
|
||||
< span className =\"font-semibold\">Naturcalabacera</span>
|
||||
</div >
|
||||
</div >
|
||||
</div >
|
||||
);
|
||||
}
|
||||
591
apps/web/src/components/ChatbotContainer.tsx
Normal file
591
apps/web/src/components/ChatbotContainer.tsx
Normal file
@@ -0,0 +1,591 @@
|
||||
/**
|
||||
* ChatbotContainer — asistente conversacional con datos reales de Supabase.
|
||||
*
|
||||
* Arquitectura lista para IA real: la función processMessage() actualmente
|
||||
* usa un motor de reglas. Para conectar un LLM, sustituye su cuerpo por
|
||||
* una llamada a la API (Claude, GPT, etc.) pasando los datos de reservas
|
||||
* como contexto del sistema.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { MessageCircle, X, Send, Home, RefreshCw } from 'lucide-react';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import type { Reservation, Property } from '../types';
|
||||
import { format, parseISO, isAfter, isBefore, differenceInDays } from 'date-fns';
|
||||
import { es } from 'date-fns/locale/es';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function fmtDate(d: string) {
|
||||
return format(parseISO(d), "d MMM yyyy", { locale: es });
|
||||
}
|
||||
|
||||
function fmtShort(d: string) {
|
||||
return format(parseISO(d), "d MMM", { locale: es });
|
||||
}
|
||||
|
||||
function propertyLabel(p: Property) {
|
||||
return p === 'los_dragos' ? 'Los Dragos' : 'La Esquinita';
|
||||
}
|
||||
|
||||
function nightsBetween(start: string, end: string) {
|
||||
return differenceInDays(parseISO(end), parseISO(start));
|
||||
}
|
||||
|
||||
// ─── Response engine ──────────────────────────────────────────────────────────
|
||||
|
||||
function guestDetail(r: Reservation): string {
|
||||
const nights = nightsBetween(r.start_date, r.end_date);
|
||||
const pax = r.adults_count + r.children_count;
|
||||
const lines: string[] = [
|
||||
`👤 **${r.client_name}**`,
|
||||
`📅 ${fmtDate(r.start_date)} → ${fmtDate(r.end_date)} (${nights} noche${nights !== 1 ? 's' : ''})`,
|
||||
`👥 ${pax} persona${pax !== 1 ? 's' : ''} (${r.adults_count} adultos, ${r.children_count} niños)`,
|
||||
`🏷️ Origen: ${r.origin}`,
|
||||
];
|
||||
if (r.government_registration) {
|
||||
lines.push(`🏛️ Reg. gubernamental: \`${r.government_registration}\``);
|
||||
} else {
|
||||
lines.push(`⚠️ Sin registro gubernamental`);
|
||||
}
|
||||
if (r.invoice_number) lines.push(`🧾 Factura: ${r.invoice_number}`);
|
||||
if (r.has_cleaning) lines.push(`🧹 Servicio de limpieza incluido`);
|
||||
if (r.has_pool_heating) lines.push(`♨️ Calefacción de piscina incluida`);
|
||||
if (r.has_flies_products) lines.push(`🦟 Productos anti-mosquitos incluidos`);
|
||||
if (r.is_event) {
|
||||
lines.push(`🎉 Evento: ${r.event_type ?? ''}${r.event_type_other ? ` (${r.event_type_other})` : ''}`);
|
||||
if (r.attendees_count) lines.push(` Asistentes: ${r.attendees_count}`);
|
||||
}
|
||||
if (r.pricing_snapshot) {
|
||||
const p = r.pricing_snapshot;
|
||||
lines.push(`💶 Total: ${p.total.toLocaleString('es-ES', { style: 'currency', currency: 'EUR' })}`);
|
||||
}
|
||||
if (r.observations) lines.push(`📝 Notas: ${r.observations}`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function statsInfo(reservations: Reservation[], property: Property): string {
|
||||
const now = new Date();
|
||||
const label = propertyLabel(property);
|
||||
const total = reservations.length;
|
||||
const active = reservations.filter(r =>
|
||||
!isAfter(parseISO(r.start_date), now) && !isBefore(parseISO(r.end_date), now)
|
||||
);
|
||||
const upcoming = reservations.filter(r => isAfter(parseISO(r.start_date), now));
|
||||
const past = reservations.filter(r => isBefore(parseISO(r.end_date), now));
|
||||
const teneriffa = reservations.filter(r => r.origin === 'Teneriffa2000');
|
||||
const natur = reservations.filter(r => r.origin === 'Naturcalabacera');
|
||||
const withReg = reservations.filter(r => r.government_registration);
|
||||
const withoutReg = reservations.filter(r => !r.government_registration);
|
||||
const totalNights = reservations.reduce((sum, r) => sum + nightsBetween(r.start_date, r.end_date), 0);
|
||||
|
||||
const lines = [
|
||||
`📊 **Resumen — ${label}**\n`,
|
||||
`• Total reservas: **${total}**`,
|
||||
`• Activas ahora: ${active.length}`,
|
||||
`• Próximas: ${upcoming.length}`,
|
||||
`• Pasadas: ${past.length}`,
|
||||
`• Noches totales reservadas: ${totalNights}`,
|
||||
``,
|
||||
`📋 **Por origen:**`,
|
||||
`• Teneriffa2000: ${teneriffa.length}`,
|
||||
`• Naturcalabacera: ${natur.length}`,
|
||||
``,
|
||||
`🏛️ **Registros gubernamentales:**`,
|
||||
`• Con registro: ${withReg.length}`,
|
||||
`• Sin registro: ${withoutReg.length}${withoutReg.length > 0 ? ' ⚠️' : ' ✅'}`,
|
||||
];
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function contractsInfo(reservations: Reservation[]): string {
|
||||
const now = new Date();
|
||||
const withReg = reservations.filter(r => r.government_registration);
|
||||
const pendingReg = reservations.filter(
|
||||
r => !r.government_registration && !isBefore(parseISO(r.end_date), now)
|
||||
);
|
||||
|
||||
const lines: string[] = ['🏛️ **Registros y contratos**\n'];
|
||||
|
||||
if (withReg.length > 0) {
|
||||
lines.push(`✅ **Con registro (${withReg.length}):**`);
|
||||
withReg.forEach(r => {
|
||||
lines.push(`• ${r.client_name} — \`${r.government_registration}\``);
|
||||
if (r.invoice_number) lines.push(` Factura: ${r.invoice_number}`);
|
||||
lines.push(` ${fmtShort(r.start_date)} → ${fmtShort(r.end_date)}`);
|
||||
});
|
||||
}
|
||||
|
||||
if (pendingReg.length > 0) {
|
||||
lines.push(`\n⚠️ **Sin registro (activas/futuras) — ${pendingReg.length}:**`);
|
||||
pendingReg.forEach(r => {
|
||||
lines.push(`• ${r.client_name} (${fmtShort(r.start_date)} → ${fmtShort(r.end_date)})`);
|
||||
});
|
||||
}
|
||||
|
||||
if (withReg.length === 0 && pendingReg.length === 0) {
|
||||
lines.push('No hay reservas con información de contratos disponible.');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function upcomingInfo(reservations: Reservation[]): string {
|
||||
const now = new Date();
|
||||
const upcoming = reservations
|
||||
.filter(r => isAfter(parseISO(r.start_date), now))
|
||||
.sort((a, b) => a.start_date.localeCompare(b.start_date))
|
||||
.slice(0, 6);
|
||||
|
||||
if (upcoming.length === 0) return 'No hay reservas futuras registradas.';
|
||||
|
||||
const lines = [`📅 **Próximas reservas (${upcoming.length}):**\n`];
|
||||
upcoming.forEach(r => {
|
||||
const daysTo = differenceInDays(parseISO(r.start_date), now);
|
||||
const nights = nightsBetween(r.start_date, r.end_date);
|
||||
lines.push(
|
||||
`**${r.client_name}** — en ${daysTo} día${daysTo !== 1 ? 's' : ''}\n` +
|
||||
` ${fmtShort(r.start_date)} → ${fmtShort(r.end_date)} · ${nights} noches · ${r.adults_count + r.children_count} pax\n` +
|
||||
(r.government_registration ? ` ✅ Reg: \`${r.government_registration}\`` : ` ⚠️ Sin registro`)
|
||||
);
|
||||
});
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function availabilityInfo(reservations: Reservation[]): string {
|
||||
const now = new Date();
|
||||
const future = reservations
|
||||
.filter(r => !isBefore(parseISO(r.end_date), now))
|
||||
.sort((a, b) => a.start_date.localeCompare(b.start_date));
|
||||
|
||||
if (future.length === 0) {
|
||||
return '✅ No hay reservas futuras. La propiedad está completamente disponible.';
|
||||
}
|
||||
|
||||
const lines = ['🗓️ **Disponibilidad próxima:**\n'];
|
||||
|
||||
// Gap before first reservation
|
||||
const firstStart = parseISO(future[0].start_date);
|
||||
const daysToFirst = differenceInDays(firstStart, now);
|
||||
if (daysToFirst > 0) {
|
||||
lines.push(`✅ Libre ahora → ${fmtShort(future[0].start_date)} (${daysToFirst} días)`);
|
||||
}
|
||||
|
||||
// Gaps between reservations
|
||||
for (let i = 0; i < future.length; i++) {
|
||||
lines.push(`🔴 Ocupado: ${fmtShort(future[i].start_date)} → ${fmtShort(future[i].end_date)} (${future[i].client_name})`);
|
||||
if (i < future.length - 1) {
|
||||
const gapStart = parseISO(future[i].end_date);
|
||||
const gapEnd = parseISO(future[i + 1].start_date);
|
||||
const gapDays = differenceInDays(gapEnd, gapStart);
|
||||
if (gapDays > 0) {
|
||||
lines.push(`✅ Libre: ${fmtShort(future[i].end_date)} → ${fmtShort(future[i + 1].start_date)} (${gapDays} días)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function allReservationsInfo(reservations: Reservation[]): string {
|
||||
if (reservations.length === 0) return 'No hay reservas registradas.';
|
||||
|
||||
const sorted = [...reservations].sort((a, b) => a.start_date.localeCompare(b.start_date));
|
||||
const now = new Date();
|
||||
const lines = [`📋 **Todas las reservas (${sorted.length}):**\n`];
|
||||
|
||||
sorted.forEach(r => {
|
||||
const isPast = isBefore(parseISO(r.end_date), now);
|
||||
const isActive = !isAfter(parseISO(r.start_date), now) && !isBefore(parseISO(r.end_date), now);
|
||||
const icon = isPast ? '⬜' : isActive ? '🟢' : '🔵';
|
||||
lines.push(
|
||||
`${icon} **${r.client_name}** — ${fmtShort(r.start_date)} → ${fmtShort(r.end_date)}` +
|
||||
(r.government_registration ? ` ✅` : ` ⚠️`)
|
||||
);
|
||||
});
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function processMessage(input: string, reservations: Reservation[], property: Property): string {
|
||||
const lower = input.toLowerCase().trim();
|
||||
const now = new Date();
|
||||
const label = propertyLabel(property);
|
||||
|
||||
// Greeting
|
||||
if (/^(hola|buenos|buenas|hey|hi|ey|qué tal|que tal|buen)/.test(lower)) {
|
||||
const total = reservations.length;
|
||||
const upcoming = reservations.filter(r => isAfter(parseISO(r.start_date), now)).length;
|
||||
const active = reservations.filter(r =>
|
||||
!isAfter(parseISO(r.start_date), now) && !isBefore(parseISO(r.end_date), now)
|
||||
).length;
|
||||
return [
|
||||
`¡Hola! Soy tu asistente para **${label}** 🏡`,
|
||||
``,
|
||||
`Estado actual:`,
|
||||
`• ${active > 0 ? `🟢 ${active} reserva${active !== 1 ? 's' : ''} activa${active !== 1 ? 's' : ''} ahora mismo` : '⬜ Sin reservas activas hoy'}`,
|
||||
`• 🔵 ${upcoming} próxima${upcoming !== 1 ? 's' : ''}`,
|
||||
`• 📋 ${total} reserva${total !== 1 ? 's' : ''} en total`,
|
||||
``,
|
||||
`¿Qué necesitas saber?`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// Stats / summary
|
||||
if (/estadist|resumen|total|cuántas|cuantas|dato|cifra|número|numeros|summary/.test(lower)) {
|
||||
return statsInfo(reservations, property);
|
||||
}
|
||||
|
||||
// Contracts / government registration
|
||||
if (/contrat|registro|viajero|govern|código|codigos|rvtca|factura|invoice|número de registro/.test(lower)) {
|
||||
return contractsInfo(reservations);
|
||||
}
|
||||
|
||||
// Upcoming
|
||||
if (/próxim|siguiente|futuras|upcoming|pronto|esta semana|este mes|entran|llegan/.test(lower)) {
|
||||
return upcomingInfo(reservations);
|
||||
}
|
||||
|
||||
// Availability
|
||||
if (/disponib|libre|ocup|vac|hueco|cuando|free|gap|abierto/.test(lower)) {
|
||||
return availabilityInfo(reservations);
|
||||
}
|
||||
|
||||
// List all
|
||||
if (/lista|todas|todos|ver todo|todas las|show all|all reserv/.test(lower)) {
|
||||
return allReservationsInfo(reservations);
|
||||
}
|
||||
|
||||
// Search by guest name — scan all words in input against client names
|
||||
const words = lower.split(/\s+/).filter(w => w.length > 2);
|
||||
for (const word of words) {
|
||||
const found = reservations.find(r =>
|
||||
r.client_name.toLowerCase().includes(word)
|
||||
);
|
||||
if (found) return guestDetail(found);
|
||||
}
|
||||
|
||||
// Help / default
|
||||
return [
|
||||
`Puedo ayudarte con información de **${label}**. Pregúntame sobre:`,
|
||||
``,
|
||||
`• 📋 **Reservas** — "lista todas", "reservas de mayo"`,
|
||||
`• 📅 **Próximas** — "próximas reservas"`,
|
||||
`• 🗓️ **Disponibilidad** — "¿cuándo está libre?"`,
|
||||
`• 🏛️ **Contratos** — "registros gubernamentales", "contratos"`,
|
||||
`• 👤 **Huésped** — escribe el nombre del cliente`,
|
||||
`• 📊 **Estadísticas** — "dame un resumen"`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// ─── Quick-reply suggestions ──────────────────────────────────────────────────
|
||||
|
||||
const QUICK_REPLIES = [
|
||||
{ label: 'Próximas reservas', text: 'próximas reservas' },
|
||||
{ label: 'Disponibilidad', text: '¿cuándo está libre?' },
|
||||
{ label: 'Contratos y registros', text: 'registros gubernamentales' },
|
||||
{ label: 'Resumen estadístico', text: 'dame un resumen' },
|
||||
{ label: 'Todas las reservas', text: 'lista todas las reservas' },
|
||||
];
|
||||
|
||||
// ─── Message renderer (markdown-lite) ────────────────────────────────────────
|
||||
|
||||
function RenderMessage({ content }: { content: string }) {
|
||||
const lines = content.split('\n');
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
{lines.map((line, i) => {
|
||||
// Bold: **text**
|
||||
const parts = line.split(/(\*\*[^*]+\*\*)/g).map((part, j) => {
|
||||
if (part.startsWith('**') && part.endsWith('**')) {
|
||||
return <strong key={j}>{part.slice(2, -2)}</strong>;
|
||||
}
|
||||
// Inline code: `text`
|
||||
return part.split(/(`[^`]+`)/g).map((seg, k) => {
|
||||
if (seg.startsWith('`') && seg.endsWith('`')) {
|
||||
return (
|
||||
<code key={k} className="bg-white/10 px-1 py-0.5 rounded text-xs font-mono">
|
||||
{seg.slice(1, -1)}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
return seg;
|
||||
});
|
||||
});
|
||||
if (line === '') return <div key={i} className="h-1.5" />;
|
||||
return <p key={i} className="leading-snug">{parts}</p>;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main component ───────────────────────────────────────────────────────────
|
||||
|
||||
export function ChatbotContainer() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [selectedProperty, setSelectedProperty] = useState<Property>('los_dragos');
|
||||
const [reservationsMap, setReservationsMap] = useState<Record<Property, Reservation[]>>({
|
||||
los_dragos: [],
|
||||
la_esquinita: [],
|
||||
});
|
||||
const [loadingData, setLoadingData] = useState(false);
|
||||
const [isTyping, setIsTyping] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Fetch all reservations for both properties
|
||||
const fetchAll = useCallback(async () => {
|
||||
setLoadingData(true);
|
||||
try {
|
||||
const { data } = await supabase
|
||||
.from('reservations')
|
||||
.select('*')
|
||||
.order('start_date', { ascending: true });
|
||||
if (data) {
|
||||
setReservationsMap({
|
||||
los_dragos: data.filter((r: Reservation) => r.property === 'los_dragos'),
|
||||
la_esquinita: data.filter((r: Reservation) => r.property === 'la_esquinita'),
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setLoadingData(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && messages.length === 0) {
|
||||
fetchAll();
|
||||
// Initial greeting
|
||||
const greeting: Message = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
content: `¡Hola! 👋 Soy tu asistente de reservas.\n\nSelecciona una propiedad arriba y pregúntame lo que necesites: disponibilidad, contratos, huéspedes, estadísticas…`,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
setMessages([greeting]);
|
||||
}
|
||||
}, [isOpen, messages.length, fetchAll]);
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages, isTyping]);
|
||||
|
||||
const handleSend = useCallback(async (text?: string) => {
|
||||
const userText = (text ?? input).trim();
|
||||
if (!userText) return;
|
||||
|
||||
const userMsg: Message = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'user',
|
||||
content: userText,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
setMessages(prev => [...prev, userMsg]);
|
||||
setInput('');
|
||||
setIsTyping(true);
|
||||
|
||||
// Simulate processing delay for UX
|
||||
await new Promise(resolve => setTimeout(resolve, 400));
|
||||
|
||||
const reservations = reservationsMap[selectedProperty];
|
||||
const response = processMessage(userText, reservations, selectedProperty);
|
||||
|
||||
const botMsg: Message = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
content: response,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
setIsTyping(false);
|
||||
setMessages(prev => [...prev, botMsg]);
|
||||
}, [input, reservationsMap, selectedProperty]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePropertySwitch = (p: Property) => {
|
||||
setSelectedProperty(p);
|
||||
const switchMsg: Message = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
content: `Cambiado a **${propertyLabel(p)}**. ¿Qué necesitas saber sobre esta propiedad?`,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
setMessages(prev => [...prev, switchMsg]);
|
||||
};
|
||||
|
||||
const isDragos = selectedProperty === 'los_dragos';
|
||||
const accentGradient = isDragos
|
||||
? 'from-emerald-600 to-teal-600'
|
||||
: 'from-amber-600 to-orange-600';
|
||||
const accentBorder = isDragos ? 'border-emerald-500/30' : 'border-amber-500/30';
|
||||
const accentShadow = isDragos ? 'shadow-emerald-500/20' : 'shadow-amber-500/20';
|
||||
const accentBg = isDragos ? 'bg-emerald-600/20' : 'bg-amber-600/20';
|
||||
const accentText = isDragos ? 'text-emerald-300' : 'text-amber-300';
|
||||
const activePropBg = isDragos
|
||||
? 'bg-gradient-to-r from-emerald-600 to-teal-600 text-white shadow-lg shadow-emerald-500/30'
|
||||
: 'bg-gradient-to-r from-amber-600 to-orange-600 text-white shadow-lg shadow-amber-500/30';
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Floating button */}
|
||||
<button
|
||||
onClick={() => setIsOpen(o => !o)}
|
||||
className={`fixed bottom-20 right-4 md:bottom-6 md:right-6 z-50 w-14 h-14 bg-gradient-to-br ${accentGradient} rounded-full shadow-xl ${accentShadow} flex items-center justify-center text-white transition-all duration-300 hover:scale-110 border border-white/10`}
|
||||
aria-label="Abrir asistente"
|
||||
>
|
||||
{isOpen ? <X className="w-6 h-6" /> : <MessageCircle className="w-6 h-6" />}
|
||||
</button>
|
||||
|
||||
{/* Chat panel */}
|
||||
{isOpen && (
|
||||
<div
|
||||
className={`fixed bottom-36 right-4 md:bottom-24 md:right-6 z-50 w-[calc(100vw-2rem)] md:w-96 h-[520px] md:h-[580px] bg-slate-900 rounded-3xl shadow-2xl ${accentShadow} border ${accentBorder} flex flex-col overflow-hidden transition-all duration-300`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className={`bg-gradient-to-r ${accentGradient} px-5 py-4 flex items-center justify-between flex-shrink-0`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-white/20 rounded-xl flex items-center justify-center">
|
||||
<MessageCircle className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-bold text-white text-sm">Asistente de Reservas</p>
|
||||
<p className="text-white/70 text-xs">{propertyLabel(selectedProperty)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={fetchAll}
|
||||
disabled={loadingData}
|
||||
className="p-1.5 rounded-lg bg-white/10 hover:bg-white/20 transition-colors text-white"
|
||||
title="Actualizar datos"
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${loadingData ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="p-1.5 rounded-lg bg-white/10 hover:bg-white/20 transition-colors text-white"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Property selector */}
|
||||
<div className="flex gap-2 px-4 py-3 bg-slate-800/80 border-b border-white/5 flex-shrink-0">
|
||||
{(['los_dragos', 'la_esquinita'] as Property[]).map(p => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => handlePropertySwitch(p)}
|
||||
className={`flex-1 flex items-center justify-center gap-1.5 py-2 px-3 rounded-xl text-xs font-semibold transition-all duration-200 ${
|
||||
selectedProperty === p
|
||||
? (p === 'los_dragos'
|
||||
? 'bg-gradient-to-r from-emerald-600 to-teal-600 text-white shadow-lg shadow-emerald-500/30'
|
||||
: 'bg-gradient-to-r from-amber-600 to-orange-600 text-white shadow-lg shadow-amber-500/30')
|
||||
: 'bg-white/5 text-slate-400 hover:bg-white/10 hover:text-slate-200'
|
||||
}`}
|
||||
>
|
||||
<Home className="w-3 h-3" />
|
||||
{p === 'los_dragos' ? 'Los Dragos' : 'La Esquinita'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-4">
|
||||
{messages.map(msg => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
{msg.role === 'assistant' && (
|
||||
<div className={`w-7 h-7 rounded-xl bg-gradient-to-br ${accentGradient} flex-shrink-0 flex items-center justify-center mr-2 mt-0.5`}>
|
||||
<MessageCircle className="w-3.5 h-3.5 text-white" />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`max-w-[78%] px-4 py-3 rounded-2xl text-xs leading-relaxed ${
|
||||
msg.role === 'user'
|
||||
? `${accentBg} ${accentText} border ${accentBorder} rounded-tr-sm`
|
||||
: 'bg-slate-800 text-slate-100 rounded-tl-sm border border-white/5'
|
||||
}`}
|
||||
>
|
||||
<RenderMessage content={msg.content} />
|
||||
<p className="text-[10px] opacity-40 mt-1.5 text-right">
|
||||
{format(msg.timestamp, 'HH:mm')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Typing indicator */}
|
||||
{isTyping && (
|
||||
<div className="flex justify-start">
|
||||
<div className={`w-7 h-7 rounded-xl bg-gradient-to-br ${accentGradient} flex-shrink-0 flex items-center justify-center mr-2`}>
|
||||
<MessageCircle className="w-3.5 h-3.5 text-white" />
|
||||
</div>
|
||||
<div className="bg-slate-800 border border-white/5 px-4 py-3 rounded-2xl rounded-tl-sm flex items-center gap-1">
|
||||
<span className="w-1.5 h-1.5 bg-slate-400 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
|
||||
<span className="w-1.5 h-1.5 bg-slate-400 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
|
||||
<span className="w-1.5 h-1.5 bg-slate-400 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Quick replies */}
|
||||
{messages.length <= 2 && (
|
||||
<div className="px-4 pb-2 flex gap-1.5 flex-wrap flex-shrink-0">
|
||||
{QUICK_REPLIES.map(qr => (
|
||||
<button
|
||||
key={qr.text}
|
||||
onClick={() => handleSend(qr.text)}
|
||||
className={`px-3 py-1.5 rounded-xl text-[11px] font-medium bg-slate-800 text-slate-300 border border-white/5 hover:border-white/20 hover:text-white transition-all duration-150`}
|
||||
>
|
||||
{qr.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input */}
|
||||
<div className="px-4 pb-4 pt-2 flex gap-2 flex-shrink-0 border-t border-white/5">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Escribe tu pregunta…"
|
||||
className="flex-1 bg-slate-800 border border-white/10 rounded-xl px-4 py-2.5 text-sm text-white placeholder-slate-500 outline-none focus:border-white/25 transition-colors"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleSend()}
|
||||
disabled={!input.trim() || isTyping}
|
||||
className={`w-10 h-10 flex items-center justify-center rounded-xl bg-gradient-to-br ${accentGradient} text-white disabled:opacity-40 hover:opacity-90 transition-all duration-200 flex-shrink-0`}
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
234
apps/web/src/components/ContractUpload.tsx
Normal file
234
apps/web/src/components/ContractUpload.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Upload, Trash2, FileText, AlertCircle, Loader2, X, Maximize2 } from 'lucide-react';
|
||||
import { useFileUpload, type UploadedContract } from '../hooks/useFileUpload';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
interface Props {
|
||||
reservationId: string;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
const isImage = (mimeType: string) => mimeType.startsWith('image/');
|
||||
|
||||
export function ContractUpload({ reservationId }: Props) {
|
||||
const { uploading, error, uploadFile, fetchContracts, getSignedUrl, deleteContract } = useFileUpload(reservationId);
|
||||
const [contracts, setContracts] = useState<UploadedContract[]>([]);
|
||||
const [thumbnails, setThumbnails] = useState<Record<string, string>>({});
|
||||
const [lightboxUrl, setLightboxUrl] = useState<string | null>(null);
|
||||
const [loadingOpen, setLoadingOpen] = useState<string | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchContracts().then(list => {
|
||||
setContracts(list);
|
||||
// Pre-fetch signed URLs for image thumbnails
|
||||
list.filter(c => isImage(c.mime_type)).forEach(async c => {
|
||||
const url = await getSignedUrl(c.file_path);
|
||||
if (url) setThumbnails(prev => ({ ...prev, [c.id]: url }));
|
||||
});
|
||||
});
|
||||
}, [fetchContracts, getSignedUrl]);
|
||||
|
||||
const handleFiles = async (files: FileList | null) => {
|
||||
if (!files || files.length === 0) return;
|
||||
for (const file of Array.from(files)) {
|
||||
const result = await uploadFile(file);
|
||||
if (result) {
|
||||
setContracts(prev => [result, ...prev]);
|
||||
// Pre-fetch thumbnail for image
|
||||
if (isImage(result.mime_type)) {
|
||||
const url = await getSignedUrl(result.file_path);
|
||||
if (url) setThumbnails(prev => ({ ...prev, [result.id]: url }));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpen = async (contract: UploadedContract) => {
|
||||
if (isImage(contract.mime_type) && thumbnails[contract.id]) {
|
||||
setLightboxUrl(thumbnails[contract.id]);
|
||||
return;
|
||||
}
|
||||
setLoadingOpen(contract.id);
|
||||
const url = await getSignedUrl(contract.file_path);
|
||||
setLoadingOpen(null);
|
||||
if (url) {
|
||||
if (isImage(contract.mime_type)) {
|
||||
setLightboxUrl(url);
|
||||
} else {
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (contract: UploadedContract) => {
|
||||
setDeletingId(contract.id);
|
||||
const ok = await deleteContract(contract.id, contract.file_path);
|
||||
setDeletingId(null);
|
||||
if (ok) {
|
||||
setContracts(prev => prev.filter(c => c.id !== contract.id));
|
||||
setThumbnails(prev => { const n = { ...prev }; delete n[contract.id]; return n; });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
{/* Drop zone */}
|
||||
<div
|
||||
onDragOver={e => { e.preventDefault(); setDragging(true); }}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={e => { e.preventDefault(); setDragging(false); handleFiles(e.dataTransfer.files); }}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
className={`
|
||||
border-2 border-dashed rounded-xl p-4 cursor-pointer transition-all text-center
|
||||
${dragging
|
||||
? 'border-emerald-500 bg-emerald-900/20'
|
||||
: 'border-slate-600 bg-slate-800/50 hover:border-slate-500 hover:bg-slate-800'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept=".pdf,.jpg,.jpeg,.png"
|
||||
multiple
|
||||
className="sr-only"
|
||||
onChange={e => handleFiles(e.target.files)}
|
||||
/>
|
||||
{uploading ? (
|
||||
<div className="flex flex-col items-center gap-2 text-slate-400">
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
<span className="text-xs font-medium">Subiendo...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-1.5 text-slate-400">
|
||||
<Upload className="w-5 h-5" />
|
||||
<span className="text-xs font-medium text-slate-300">
|
||||
Arrastra archivos o pulsa para seleccionar
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-500">PDF, JPEG, PNG · máx. 10 MB</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 px-4 py-3 bg-red-950/40 border border-red-800 rounded-xl text-sm text-red-400">
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File list */}
|
||||
{contracts.length > 0 && (
|
||||
<ul className="space-y-2">
|
||||
{contracts.map(contract => (
|
||||
<li key={contract.id} className="rounded-xl overflow-hidden border border-slate-700 bg-slate-800">
|
||||
{/* Image thumbnail */}
|
||||
{isImage(contract.mime_type) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleOpen(contract)}
|
||||
className="relative w-full block group"
|
||||
>
|
||||
{thumbnails[contract.id] ? (
|
||||
<img
|
||||
src={thumbnails[contract.id]}
|
||||
alt={contract.filename}
|
||||
className="w-full h-32 object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-32 bg-slate-700 flex items-center justify-center">
|
||||
<Loader2 className="w-5 h-5 text-slate-500 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors flex items-center justify-center">
|
||||
<Maximize2 className="w-6 h-6 text-white opacity-0 group-hover:opacity-100 transition-opacity drop-shadow-lg" />
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* File info row */}
|
||||
<div className="flex items-center gap-3 px-3 py-2.5">
|
||||
{!isImage(contract.mime_type) && (
|
||||
<div className="w-8 h-8 flex items-center justify-center bg-red-900/40 rounded-lg flex-shrink-0">
|
||||
<FileText className="w-4 h-4 text-red-400" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-slate-200 truncate">{contract.filename}</p>
|
||||
<p className="text-xs text-slate-500">{formatBytes(contract.size_bytes)}</p>
|
||||
</div>
|
||||
{!isImage(contract.mime_type) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleOpen(contract)}
|
||||
disabled={loadingOpen === contract.id}
|
||||
className="p-2 text-slate-500 hover:text-slate-200 transition-colors"
|
||||
title="Ver archivo"
|
||||
>
|
||||
{loadingOpen === contract.id
|
||||
? <Loader2 className="w-4 h-4 animate-spin" />
|
||||
: <span className="text-xs font-medium text-slate-400 hover:text-emerald-400">Abrir</span>
|
||||
}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(contract)}
|
||||
disabled={deletingId === contract.id}
|
||||
className="p-2 text-slate-500 hover:text-red-400 transition-colors"
|
||||
title="Eliminar"
|
||||
>
|
||||
{deletingId === contract.id
|
||||
? <Loader2 className="w-4 h-4 animate-spin" />
|
||||
: <Trash2 className="w-4 h-4" />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Lightbox */}
|
||||
<AnimatePresence>
|
||||
{lightboxUrl && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={() => setLightboxUrl(null)}
|
||||
className="fixed inset-0 bg-black/92 z-[100] flex items-center justify-center p-4"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLightboxUrl(null)}
|
||||
className="absolute top-5 right-5 p-2.5 bg-slate-800/80 rounded-full hover:bg-slate-700 transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5 text-white" />
|
||||
</button>
|
||||
<motion.img
|
||||
initial={{ scale: 0.92, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.92, opacity: 0 }}
|
||||
src={lightboxUrl}
|
||||
alt="Contrato"
|
||||
className="max-w-full max-h-full object-contain rounded-2xl shadow-2xl"
|
||||
onClick={e => e.stopPropagation()}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
}
|
||||
155
apps/web/src/components/CustomMobileCalendar.tsx
Normal file
155
apps/web/src/components/CustomMobileCalendar.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
format, startOfMonth, endOfMonth, startOfWeek, endOfWeek,
|
||||
eachDayOfInterval, isSameMonth, isSameDay, addMonths, subMonths,
|
||||
isWithinInterval, parseISO, isBefore
|
||||
} from 'date-fns';
|
||||
import { es } from 'date-fns/locale/es';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import type { Reservation } from '../types';
|
||||
|
||||
interface Props {
|
||||
reservations: Reservation[];
|
||||
onSelectRange: (start: Date, end: Date) => void;
|
||||
onSelectReservation: (reservation: Reservation) => void;
|
||||
}
|
||||
|
||||
export function CustomMobileCalendar({ reservations, onSelectRange, onSelectReservation }: Props) {
|
||||
const [currentDate, setCurrentDate] = useState(new Date());
|
||||
const [selectionStart, setSelectionStart] = useState<Date | null>(null);
|
||||
|
||||
const monthStart = startOfMonth(currentDate);
|
||||
const monthEnd = endOfMonth(monthStart);
|
||||
const startDate = startOfWeek(monthStart, { locale: es });
|
||||
const endDate = endOfWeek(monthEnd, { locale: es });
|
||||
|
||||
const days = eachDayOfInterval({ start: startDate, end: endDate });
|
||||
|
||||
const nextMonth = () => setCurrentDate(addMonths(currentDate, 1));
|
||||
const prevMonth = () => setCurrentDate(subMonths(currentDate, 1));
|
||||
|
||||
const getReservationForDay = (day: Date) => {
|
||||
return reservations.find(res =>
|
||||
isWithinInterval(day, { start: parseISO(res.start_date), end: parseISO(res.end_date) })
|
||||
);
|
||||
};
|
||||
|
||||
const rangeHasOverlap = (start: Date, end: Date) => {
|
||||
const rangeDays = eachDayOfInterval({ start, end });
|
||||
return rangeDays.some(day => getReservationForDay(day));
|
||||
};
|
||||
|
||||
const handleDayClick = (day: Date) => {
|
||||
const existingRes = getReservationForDay(day);
|
||||
|
||||
if (existingRes) {
|
||||
onSelectReservation(existingRes);
|
||||
setSelectionStart(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectionStart) {
|
||||
setSelectionStart(day);
|
||||
return;
|
||||
}
|
||||
|
||||
let start = selectionStart;
|
||||
let end = day;
|
||||
|
||||
if (isBefore(day, selectionStart)) {
|
||||
start = day;
|
||||
end = selectionStart;
|
||||
}
|
||||
|
||||
if (rangeHasOverlap(start, end)) {
|
||||
toast.error("No puedes seleccionar un rango que incluya días ya reservados.");
|
||||
setSelectionStart(null);
|
||||
return;
|
||||
}
|
||||
|
||||
onSelectRange(start, end);
|
||||
setSelectionStart(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-3xl overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 bg-white border-b border-gray-100">
|
||||
<h2 className="text-lg font-bold text-gray-900 capitalize">
|
||||
{format(currentDate, 'MMMM yyyy', { locale: es })}
|
||||
</h2>
|
||||
<div className="flex space-x-1">
|
||||
<button onClick={prevMonth} className="p-2 rounded-lg hover:bg-gray-50">
|
||||
<ChevronLeft size={20} className="text-gray-600" />
|
||||
</button>
|
||||
<button onClick={nextMonth} className="p-2 rounded-lg hover:bg-gray-50">
|
||||
<ChevronRight size={20} className="text-gray-600" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Week Days */}
|
||||
<div className="grid grid-cols-7 px-3 py-2 bg-gray-50">
|
||||
{['Dom', 'Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb'].map(day => (
|
||||
<div key={day} className="text-center text-[11px] font-semibold text-gray-500 uppercase tracking-wide py-1">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Days Grid - NUEVO DISEÑO TIPO AIRBNB */}
|
||||
<div className="grid grid-cols-7 gap-0 p-3 bg-white">
|
||||
{days.map((day) => {
|
||||
const dayRes = getReservationForDay(day);
|
||||
const isSelected = selectionStart && isSameDay(day, selectionStart);
|
||||
const isCurrentMonth = isSameMonth(day, monthStart);
|
||||
|
||||
// Palette de colores según referencia
|
||||
let bgColor = 'bg-white';
|
||||
let numberBgColor = '';
|
||||
let numberTextColor = 'text-gray-400';
|
||||
|
||||
if (dayRes) {
|
||||
if (dayRes.origin === 'Teneriffa2000') {
|
||||
bgColor = 'bg-blue-100'; // Fondo azul pastel
|
||||
numberBgColor = 'bg-blue-500'; // Círculo azul intenso
|
||||
numberTextColor = 'text-white';
|
||||
} else {
|
||||
bgColor = 'bg-yellow-100'; // Fondo amarillo pastel
|
||||
numberBgColor = 'bg-yellow-500'; // Círculo amarillo intenso
|
||||
numberTextColor = 'text-white';
|
||||
}
|
||||
} else if (isSelected) {
|
||||
bgColor = 'bg-gray-800';
|
||||
numberBgColor = 'bg-white';
|
||||
numberTextColor = 'text-gray-800';
|
||||
} else if (isCurrentMonth) {
|
||||
numberTextColor = 'text-gray-900';
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={day.toString()}
|
||||
onClick={() => handleDayClick(day)}
|
||||
className={`
|
||||
relative h-12 flex items-center justify-center cursor-pointer
|
||||
transition-all duration-150
|
||||
${bgColor}
|
||||
`}
|
||||
>
|
||||
<div className={`
|
||||
w-8 h-8 rounded-full flex items-center justify-center
|
||||
text-sm font-semibold
|
||||
${numberBgColor}
|
||||
${numberTextColor}
|
||||
`}>
|
||||
{format(day, 'd')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
390
apps/web/src/components/DocumentUpload.tsx
Normal file
390
apps/web/src/components/DocumentUpload.tsx
Normal file
@@ -0,0 +1,390 @@
|
||||
import { useEffect, useImperativeHandle, useRef, useState, forwardRef } from 'react';
|
||||
import { Upload, Trash2, FileText, AlertCircle, Loader2, X, Maximize2 } from 'lucide-react';
|
||||
import {
|
||||
useFileUpload,
|
||||
validateFile,
|
||||
uploadDocumentFile,
|
||||
type UploadedContract,
|
||||
type DocumentType,
|
||||
} from '../hooks/useFileUpload';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
/**
|
||||
* Imperativo que expone el componente al padre. Cuando el modal se monta en
|
||||
* modo "create" no hay reservation_id todavía, así que los archivos quedan en
|
||||
* `pendingFiles`. Tras crear la reserva, el padre llama a `flushPending(id)`
|
||||
* para subir todos los pendientes a la nueva reserva.
|
||||
*/
|
||||
export interface DocumentUploadHandle {
|
||||
hasPending: () => boolean;
|
||||
flushPending: (reservationId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** ID de la reserva. Si es undefined, los archivos se acumulan localmente. */
|
||||
reservationId?: string;
|
||||
documentType: DocumentType;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
const isImage = (mimeType: string) => mimeType.startsWith('image/');
|
||||
|
||||
interface PendingFile {
|
||||
id: string; // local id (uuid-ish)
|
||||
file: File;
|
||||
previewUrl: string | null; // para imágenes locales
|
||||
}
|
||||
|
||||
export const DocumentUpload = forwardRef<DocumentUploadHandle, Props>(function DocumentUpload(
|
||||
{ reservationId, documentType },
|
||||
ref,
|
||||
) {
|
||||
const safeId = reservationId ?? '__pending__';
|
||||
const { uploading, error: uploadError, uploadFile, fetchContracts, getSignedUrl, deleteContract } =
|
||||
useFileUpload(safeId, documentType);
|
||||
const [contracts, setContracts] = useState<UploadedContract[]>([]);
|
||||
const [thumbnails, setThumbnails] = useState<Record<string, string>>({});
|
||||
const [lightboxUrl, setLightboxUrl] = useState<string | null>(null);
|
||||
const [loadingOpen, setLoadingOpen] = useState<string | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [pending, setPending] = useState<PendingFile[]>([]);
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
|
||||
const error = localError ?? uploadError;
|
||||
|
||||
// Carga lista de archivos remotos solo si hay reservationId
|
||||
useEffect(() => {
|
||||
if (!reservationId) {
|
||||
setContracts([]);
|
||||
return;
|
||||
}
|
||||
fetchContracts().then(list => {
|
||||
setContracts(list);
|
||||
list.filter(c => isImage(c.mime_type)).forEach(async c => {
|
||||
const url = await getSignedUrl(c.file_path);
|
||||
if (url) setThumbnails(prev => ({ ...prev, [c.id]: url }));
|
||||
});
|
||||
});
|
||||
}, [reservationId, fetchContracts, getSignedUrl]);
|
||||
|
||||
// Limpia las URLs de objeto al desmontar
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
pending.forEach(p => p.previewUrl && URL.revokeObjectURL(p.previewUrl));
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
hasPending: () => pending.length > 0,
|
||||
flushPending: async (newReservationId: string) => {
|
||||
for (const p of pending) {
|
||||
try {
|
||||
await uploadDocumentFile(p.file, newReservationId, documentType);
|
||||
} catch (err) {
|
||||
console.error('Error subiendo archivo pendiente:', err);
|
||||
}
|
||||
}
|
||||
pending.forEach(p => p.previewUrl && URL.revokeObjectURL(p.previewUrl));
|
||||
setPending([]);
|
||||
},
|
||||
}), [pending, documentType]);
|
||||
|
||||
const handleFiles = async (files: FileList | null) => {
|
||||
if (!files || files.length === 0) return;
|
||||
setLocalError(null);
|
||||
|
||||
if (!reservationId) {
|
||||
// Modo diferido: acumular localmente
|
||||
const newPending: PendingFile[] = [];
|
||||
for (const file of Array.from(files)) {
|
||||
const validationError = validateFile(file);
|
||||
if (validationError) {
|
||||
setLocalError(validationError);
|
||||
continue;
|
||||
}
|
||||
newPending.push({
|
||||
id: `pending-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
file,
|
||||
previewUrl: isImage(file.type) ? URL.createObjectURL(file) : null,
|
||||
});
|
||||
}
|
||||
if (newPending.length > 0) {
|
||||
setPending(prev => [...newPending, ...prev]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Modo directo: subir al servidor
|
||||
for (const file of Array.from(files)) {
|
||||
const result = await uploadFile(file);
|
||||
if (result) {
|
||||
setContracts(prev => [result, ...prev]);
|
||||
if (isImage(result.mime_type)) {
|
||||
const url = await getSignedUrl(result.file_path);
|
||||
if (url) setThumbnails(prev => ({ ...prev, [result.id]: url }));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpen = async (contract: UploadedContract) => {
|
||||
if (isImage(contract.mime_type) && thumbnails[contract.id]) {
|
||||
setLightboxUrl(thumbnails[contract.id]);
|
||||
return;
|
||||
}
|
||||
setLoadingOpen(contract.id);
|
||||
const url = await getSignedUrl(contract.file_path);
|
||||
setLoadingOpen(null);
|
||||
if (url) {
|
||||
if (isImage(contract.mime_type)) {
|
||||
setLightboxUrl(url);
|
||||
} else {
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (contract: UploadedContract) => {
|
||||
setDeletingId(contract.id);
|
||||
const ok = await deleteContract(contract.id, contract.file_path);
|
||||
setDeletingId(null);
|
||||
if (ok) {
|
||||
setContracts(prev => prev.filter(c => c.id !== contract.id));
|
||||
setThumbnails(prev => { const n = { ...prev }; delete n[contract.id]; return n; });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeletePending = (id: string) => {
|
||||
setPending(prev => {
|
||||
const target = prev.find(p => p.id === id);
|
||||
if (target?.previewUrl) URL.revokeObjectURL(target.previewUrl);
|
||||
return prev.filter(p => p.id !== id);
|
||||
});
|
||||
};
|
||||
|
||||
const handleOpenPending = (p: PendingFile) => {
|
||||
if (p.previewUrl) {
|
||||
setLightboxUrl(p.previewUrl);
|
||||
} else {
|
||||
// Para PDFs locales, abre en nueva pestaña con object URL
|
||||
const url = URL.createObjectURL(p.file);
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
// No revocamos inmediatamente: el navegador lo necesita mientras esté abierto
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
{/* Drop zone */}
|
||||
<div
|
||||
onDragOver={e => { e.preventDefault(); setDragging(true); }}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={e => { e.preventDefault(); setDragging(false); handleFiles(e.dataTransfer.files); }}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
className={`
|
||||
border-2 border-dashed rounded-xl p-4 cursor-pointer transition-all text-center
|
||||
${dragging
|
||||
? 'border-emerald-500 bg-emerald-900/20'
|
||||
: 'border-slate-600 bg-slate-800/50 hover:border-slate-500 hover:bg-slate-800'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept=".pdf,.jpg,.jpeg,.png"
|
||||
multiple
|
||||
className="sr-only"
|
||||
onChange={e => handleFiles(e.target.files)}
|
||||
/>
|
||||
{uploading ? (
|
||||
<div className="flex flex-col items-center gap-2 text-slate-400">
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
<span className="text-xs font-medium">Subiendo...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-1.5 text-slate-400">
|
||||
<Upload className="w-5 h-5" />
|
||||
<span className="text-xs font-medium text-slate-300">
|
||||
Arrastra archivos o pulsa para seleccionar
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-500">PDF, JPEG, PNG · máx. 10 MB</span>
|
||||
{!reservationId && (pending.length > 0) && (
|
||||
<span className="text-[10px] text-amber-400 font-medium mt-0.5">
|
||||
Se subirán al guardar la reserva
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 px-4 py-3 bg-red-950/40 border border-red-800 rounded-xl text-sm text-red-400">
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pending files (modo diferido) */}
|
||||
{pending.length > 0 && (
|
||||
<ul className="space-y-2">
|
||||
{pending.map(p => (
|
||||
<li key={p.id} className="rounded-xl overflow-hidden border border-amber-700/50 bg-amber-950/20">
|
||||
{isImage(p.file.type) && p.previewUrl && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleOpenPending(p)}
|
||||
className="relative w-full block group"
|
||||
>
|
||||
<img src={p.previewUrl} alt={p.file.name} className="w-full h-32 object-cover" />
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors flex items-center justify-center">
|
||||
<Maximize2 className="w-6 h-6 text-white opacity-0 group-hover:opacity-100 transition-opacity drop-shadow-lg" />
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-center gap-3 px-3 py-2.5">
|
||||
{!isImage(p.file.type) && (
|
||||
<div className="w-8 h-8 flex items-center justify-center bg-amber-900/40 rounded-lg flex-shrink-0">
|
||||
<FileText className="w-4 h-4 text-amber-400" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-slate-200 truncate">{p.file.name}</p>
|
||||
<p className="text-xs text-amber-400">{formatBytes(p.file.size)} · pendiente</p>
|
||||
</div>
|
||||
{!isImage(p.file.type) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleOpenPending(p)}
|
||||
className="p-2 text-slate-500 hover:text-slate-200 transition-colors"
|
||||
title="Ver archivo"
|
||||
>
|
||||
<span className="text-xs font-medium text-slate-400 hover:text-emerald-400">Abrir</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDeletePending(p.id)}
|
||||
className="p-2 text-slate-500 hover:text-red-400 transition-colors"
|
||||
title="Quitar"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* Uploaded files */}
|
||||
{contracts.length > 0 && (
|
||||
<ul className="space-y-2">
|
||||
{contracts.map(contract => (
|
||||
<li key={contract.id} className="rounded-xl overflow-hidden border border-slate-700 bg-slate-800">
|
||||
{isImage(contract.mime_type) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleOpen(contract)}
|
||||
className="relative w-full block group"
|
||||
>
|
||||
{thumbnails[contract.id] ? (
|
||||
<img
|
||||
src={thumbnails[contract.id]}
|
||||
alt={contract.filename}
|
||||
className="w-full h-32 object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-32 bg-slate-700 flex items-center justify-center">
|
||||
<Loader2 className="w-5 h-5 text-slate-500 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors flex items-center justify-center">
|
||||
<Maximize2 className="w-6 h-6 text-white opacity-0 group-hover:opacity-100 transition-opacity drop-shadow-lg" />
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-center gap-3 px-3 py-2.5">
|
||||
{!isImage(contract.mime_type) && (
|
||||
<div className="w-8 h-8 flex items-center justify-center bg-red-900/40 rounded-lg flex-shrink-0">
|
||||
<FileText className="w-4 h-4 text-red-400" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-slate-200 truncate">{contract.filename}</p>
|
||||
<p className="text-xs text-slate-500">{formatBytes(contract.size_bytes)}</p>
|
||||
</div>
|
||||
{!isImage(contract.mime_type) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleOpen(contract)}
|
||||
disabled={loadingOpen === contract.id}
|
||||
className="p-2 text-slate-500 hover:text-slate-200 transition-colors"
|
||||
title="Ver archivo"
|
||||
>
|
||||
{loadingOpen === contract.id
|
||||
? <Loader2 className="w-4 h-4 animate-spin" />
|
||||
: <span className="text-xs font-medium text-slate-400 hover:text-emerald-400">Abrir</span>
|
||||
}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(contract)}
|
||||
disabled={deletingId === contract.id}
|
||||
className="p-2 text-slate-500 hover:text-red-400 transition-colors"
|
||||
title="Eliminar"
|
||||
>
|
||||
{deletingId === contract.id
|
||||
? <Loader2 className="w-4 h-4 animate-spin" />
|
||||
: <Trash2 className="w-4 h-4" />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Lightbox */}
|
||||
<AnimatePresence>
|
||||
{lightboxUrl && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={() => setLightboxUrl(null)}
|
||||
className="fixed inset-0 bg-black/92 z-[100] flex items-center justify-center p-4"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLightboxUrl(null)}
|
||||
className="absolute top-5 right-5 p-2.5 bg-slate-800/80 rounded-full hover:bg-slate-700 transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5 text-white" />
|
||||
</button>
|
||||
<motion.img
|
||||
initial={{ scale: 0.92, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.92, opacity: 0 }}
|
||||
src={lightboxUrl}
|
||||
alt="Documento"
|
||||
className="max-w-full max-h-full object-contain rounded-2xl shadow-2xl"
|
||||
onClick={e => e.stopPropagation()}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
});
|
||||
108
apps/web/src/components/LoginPage.tsx
Normal file
108
apps/web/src/components/LoginPage.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import React, { useState } from 'react';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { Calendar, Loader2, Lock, Mail, ArrowRight } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export function LoginPage() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const { error } = await supabase.auth.signInWithPassword({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error('Error al iniciar sesión: ' + error.message);
|
||||
} else {
|
||||
toast.success('¡Bienvenido de nuevo!');
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('Ocurrió un error inesperado');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-stone-50 dark:bg-black flex items-center justify-center p-4 transition-colors duration-500">
|
||||
<div className="w-full max-w-md bg-white dark:bg-emerald-950/10 backdrop-blur-xl border border-stone-200 dark:border-emerald-900/30 rounded-3xl shadow-2xl shadow-stone-200/50 dark:shadow-emerald-900/10 overflow-hidden">
|
||||
|
||||
{/* Header */}
|
||||
<div className="p-8 text-center border-b border-stone-100 dark:border-emerald-900/30 bg-gradient-to-b from-stone-50/50 to-transparent dark:from-emerald-900/5">
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-emerald-500 to-emerald-700 rounded-2xl mx-auto flex items-center justify-center shadow-lg shadow-emerald-500/30 mb-6 transform rotate-3 hover:rotate-6 transition-transform duration-300">
|
||||
<Calendar className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-black text-stone-900 dark:text-white mb-2 tracking-tight">
|
||||
Naturcalabacera
|
||||
</h1>
|
||||
<p className="text-stone-500 dark:text-emerald-400/60 font-medium">
|
||||
Sistema de Gestión de Reservas
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleLogin} className="p-8 space-y-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-stone-400 dark:text-emerald-500/60 uppercase tracking-wider ml-1">Email Corporativo</label>
|
||||
<div className="relative group">
|
||||
<Mail className="absolute left-4 top-3.5 w-5 h-5 text-stone-400 dark:text-emerald-600/60 group-focus-within:text-emerald-500 transition-colors" />
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full bg-stone-50 dark:bg-black/40 border border-stone-200 dark:border-emerald-900/30 rounded-xl py-3 pl-12 pr-4 text-stone-900 dark:text-white placeholder:text-stone-400 dark:placeholder:text-emerald-800/40 focus:outline-none focus:ring-2 focus:ring-emerald-500/50 focus:border-emerald-500/50 transition-all font-medium"
|
||||
placeholder="nombre@empresa.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-stone-400 dark:text-emerald-500/60 uppercase tracking-wider ml-1">Contraseña</label>
|
||||
<div className="relative group">
|
||||
<Lock className="absolute left-4 top-3.5 w-5 h-5 text-stone-400 dark:text-emerald-600/60 group-focus-within:text-emerald-500 transition-colors" />
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full bg-stone-50 dark:bg-black/40 border border-stone-200 dark:border-emerald-900/30 rounded-xl py-3 pl-12 pr-4 text-stone-900 dark:text-white placeholder:text-stone-400 dark:placeholder:text-emerald-800/40 focus:outline-none focus:ring-2 focus:ring-emerald-500/50 focus:border-emerald-500/50 transition-all font-medium"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-gradient-to-r from-emerald-600 to-emerald-500 hover:from-emerald-500 hover:to-emerald-400 text-white font-bold py-4 rounded-xl shadow-xl shadow-emerald-500/20 hover:shadow-emerald-500/40 hover:scale-[1.02] active:scale-[0.98] transition-all duration-300 flex items-center justify-center gap-2 group disabled:opacity-70 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
Iniciar Sesión
|
||||
<ArrowRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-6 bg-stone-50/50 dark:bg-emerald-950/20 border-t border-stone-100 dark:border-emerald-900/30 text-center">
|
||||
<p className="text-xs text-stone-400 dark:text-emerald-600/40 font-medium">
|
||||
Protegido por autenticación segura
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
89
apps/web/src/components/MobileNavigation.tsx
Normal file
89
apps/web/src/components/MobileNavigation.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { Calendar, Settings, CalendarDays, Home, Users } from 'lucide-react';
|
||||
import { useProperty } from '../contexts/PropertyContext';
|
||||
import { PROPERTY_CONFIG, PROPERTIES } from '@naturcalabacera/shared';
|
||||
import type { Property } from '@naturcalabacera/shared';
|
||||
|
||||
interface Props {
|
||||
currentView: string;
|
||||
onNavigate: (view: string) => void;
|
||||
isViewer?: boolean;
|
||||
isAdmin?: boolean;
|
||||
}
|
||||
|
||||
export function MobileNavigation({ currentView, onNavigate, isViewer = false, isAdmin = false }: Props) {
|
||||
const { property, setProperty } = useProperty();
|
||||
const allMenuItems = [
|
||||
{ id: 'calendar', label: 'Mensual', icon: Calendar, requires: 'all' as const },
|
||||
{ id: 'yearly', label: 'Anual', icon: CalendarDays, requires: 'all' as const },
|
||||
{ id: 'users', label: 'Usuarios', icon: Users, requires: 'admin' as const },
|
||||
{ id: 'settings', label: 'Ajustes', icon: Settings, requires: 'staff' as const },
|
||||
];
|
||||
const menuItems = allMenuItems.filter(item => {
|
||||
if (item.requires === 'all') return true;
|
||||
if (item.requires === 'admin') return isAdmin;
|
||||
if (item.requires === 'staff') return !isViewer;
|
||||
return false;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="md:hidden fixed bottom-0 left-0 right-0 bg-white dark:bg-emerald-950/90 backdrop-blur-xl border-t border-stone-200 dark:border-emerald-900/30 z-50">
|
||||
{/* Selector de propiedad en mobile — fila superior compacta (solo staff) */}
|
||||
{!isViewer && (
|
||||
<div className="flex border-b border-stone-100 dark:border-emerald-900/30 px-2 pt-1.5 pb-1 gap-1">
|
||||
{PROPERTIES.map((p: Property) => {
|
||||
const config = PROPERTY_CONFIG[p];
|
||||
const isActive = property === p;
|
||||
return (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setProperty(p)}
|
||||
className={`
|
||||
flex-1 flex items-center justify-center gap-1.5 py-1 rounded-lg text-xs font-bold transition-all duration-200
|
||||
${isActive
|
||||
? `bg-gradient-to-r ${config.color.gradient} text-white`
|
||||
: 'text-stone-400 dark:text-stone-500 hover:text-stone-600'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Home className="w-3 h-3" />
|
||||
<span>{config.shortLabel}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Navegación principal */}
|
||||
<div className="flex justify-around items-center p-2 pb-safe">
|
||||
{menuItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = currentView === item.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => onNavigate(item.id)}
|
||||
className={`
|
||||
flex flex-col items-center justify-center p-2 rounded-xl transition-all duration-300
|
||||
${isActive
|
||||
? 'text-emerald-600 dark:text-emerald-400'
|
||||
: 'text-stone-400 dark:text-emerald-600/40 hover:text-stone-600 dark:hover:text-emerald-400/80'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className={`
|
||||
p-2 rounded-xl mb-1 transition-all duration-300
|
||||
${isActive ? 'bg-emerald-100 dark:bg-emerald-900/40' : 'bg-transparent'}
|
||||
`}>
|
||||
<Icon className={`w-6 h-6 ${isActive ? 'scale-110' : ''}`} />
|
||||
</div>
|
||||
<span className={`text-[10px] font-bold ${isActive ? 'opacity-100' : 'opacity-70'}`}>
|
||||
{item.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
87
apps/web/src/components/PricingSection.tsx
Normal file
87
apps/web/src/components/PricingSection.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { calculateNaturPrice, formatPrice, DEFAULT_IGIC_RATE } from '@naturcalabacera/shared';
|
||||
import { differenceInDays, parseISO } from 'date-fns';
|
||||
import { Calculator } from 'lucide-react';
|
||||
import type { Property } from '../types';
|
||||
|
||||
interface Props {
|
||||
property: Property;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
adults: number;
|
||||
children: number;
|
||||
igicRate?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Muestra el desglose de precios para reservas Naturcalabacera vacacional.
|
||||
* El cálculo es en tiempo real — no requiere guardar para ver los precios.
|
||||
* Al guardar, el llamador debe congelar el snapshot en pricing_snapshot.
|
||||
*/
|
||||
export function PricingSection({ property, startDate, endDate, adults, children, igicRate = DEFAULT_IGIC_RATE }: Props) {
|
||||
const nights = startDate && endDate
|
||||
? Math.max(0, differenceInDays(parseISO(endDate), parseISO(startDate)))
|
||||
: 0;
|
||||
const totalPersons = (Number(adults) || 0) + (Number(children) || 0);
|
||||
|
||||
if (nights <= 0 || totalPersons <= 0) {
|
||||
return (
|
||||
<div className="bg-slate-800/50 border border-slate-700 rounded-2xl p-4 text-center">
|
||||
<Calculator className="w-5 h-5 text-slate-500 mx-auto mb-1.5" />
|
||||
<p className="text-xs text-slate-500 font-medium">
|
||||
Introduce fechas y personas para ver el cálculo de precio
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const year = startDate ? parseISO(startDate).getFullYear() : new Date().getFullYear();
|
||||
const pricing = calculateNaturPrice({ property, nights, totalPersons, igicRate, year });
|
||||
|
||||
return (
|
||||
<div className="bg-slate-800/50 border border-slate-700 rounded-2xl p-4 space-y-3">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<Calculator className="w-4 h-4 text-emerald-400" />
|
||||
<span className="text-sm font-bold text-emerald-300">Precio Natur</span>
|
||||
<span className="ml-auto text-[10px] bg-slate-700 text-slate-400 px-2 py-0.5 rounded-full font-bold uppercase tracking-wide">
|
||||
{nights}n · {totalPersons}p
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between text-slate-300">
|
||||
<span>Canon base ({nights} noches)</span>
|
||||
<span className="font-semibold">{formatPrice(pricing.basePrice)}</span>
|
||||
</div>
|
||||
|
||||
{pricing.extraPersons > 0 && (
|
||||
<div className="flex justify-between text-slate-300">
|
||||
<span>
|
||||
+{pricing.extraPersons} persona{pricing.extraPersons !== 1 ? 's' : ''} extra
|
||||
<span className="text-xs text-slate-500 ml-1">(sobre {pricing.includedPersons} incluidas)</span>
|
||||
</span>
|
||||
<span className="font-semibold text-yellow-400">{formatPrice(pricing.extraPersonsFee)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t border-slate-700 pt-2 flex justify-between text-slate-300">
|
||||
<span>Subtotal</span>
|
||||
<span className="font-semibold">{formatPrice(pricing.subtotal)}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between text-slate-400">
|
||||
<span>IGIC ({(igicRate * 100).toFixed(0)}%)</span>
|
||||
<span className="font-semibold">{formatPrice(pricing.igicAmount)}</span>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-slate-600 pt-2 flex justify-between">
|
||||
<span className="font-bold text-white text-base">Total</span>
|
||||
<span className="font-black text-white text-base">{formatPrice(pricing.total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] text-slate-500 mt-1">
|
||||
* Precio calculado al vuelo. Se congela al guardar la reserva.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
apps/web/src/components/PropertySelector.tsx
Normal file
43
apps/web/src/components/PropertySelector.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { useProperty } from '../contexts/PropertyContext';
|
||||
import { PROPERTY_CONFIG, PROPERTIES } from '@naturcalabacera/shared';
|
||||
import type { Property } from '@naturcalabacera/shared';
|
||||
|
||||
export function PropertySelector() {
|
||||
const { property, setProperty } = useProperty();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-stone-400 dark:text-emerald-500/50 px-1">
|
||||
Propiedad
|
||||
</span>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{PROPERTIES.map((p: Property) => {
|
||||
const config = PROPERTY_CONFIG[p];
|
||||
const isActive = property === p;
|
||||
return (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setProperty(p)}
|
||||
className={`
|
||||
w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-left
|
||||
transition-all duration-200 border
|
||||
${isActive
|
||||
? `bg-gradient-to-r ${config.color.gradient} text-white border-transparent shadow-lg`
|
||||
: 'bg-stone-100 dark:bg-white/5 text-stone-600 dark:text-stone-300 border-stone-200 dark:border-white/10 hover:bg-stone-200 dark:hover:bg-white/10'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${isActive ? 'bg-white/80' : `bg-gradient-to-br ${config.color.gradient}`}`} />
|
||||
<span className="text-sm font-semibold">{config.label}</span>
|
||||
{isActive && (
|
||||
<span className="ml-auto text-[10px] bg-white/20 px-2 py-0.5 rounded-full font-bold">
|
||||
Activa
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
628
apps/web/src/components/ReservationModal.tsx
Normal file
628
apps/web/src/components/ReservationModal.tsx
Normal file
@@ -0,0 +1,628 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import type { NewReservation, Reservation, Property } from '../types';
|
||||
import { X, Check, Trash2, AlertCircle, ChevronDown, Zap, Paperclip, Receipt } from 'lucide-react';
|
||||
import { differenceInDays, parseISO } from 'date-fns';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { PROPERTY_CONFIG, getExtraPersonRate } from '@naturcalabacera/shared';
|
||||
import { useProperty } from '../contexts/PropertyContext';
|
||||
import { PricingSection } from './PricingSection';
|
||||
import { DocumentUpload, type DocumentUploadHandle } from './DocumentUpload';
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
mode: 'create' | 'edit';
|
||||
initialData?: Partial<Reservation>;
|
||||
existingReservations?: Reservation[];
|
||||
onClose: () => void;
|
||||
onSave: (data: NewReservation) => Promise<Reservation | void>;
|
||||
onDelete?: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const EVENT_TYPES = ['Boda', 'Comunión', 'Cumpleaños', 'Evento privado', 'Corporativo', 'Otro'] as const;
|
||||
|
||||
export function ReservationModal({
|
||||
isOpen,
|
||||
mode,
|
||||
initialData,
|
||||
existingReservations = [],
|
||||
onClose,
|
||||
onSave,
|
||||
onDelete,
|
||||
}: Props) {
|
||||
const { property } = useProperty();
|
||||
const propertyConfig = PROPERTY_CONFIG[property];
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
watch,
|
||||
reset,
|
||||
setValue,
|
||||
setError,
|
||||
clearErrors,
|
||||
formState: { errors },
|
||||
} = useForm<NewReservation>();
|
||||
|
||||
// Event toggle — local state (not a form field, controls section visibility)
|
||||
const [isEvent, setIsEvent] = useState(false);
|
||||
|
||||
// Override manual de la tarifa por persona extra (€/pax/noche).
|
||||
// null = usar tarifa automática por año.
|
||||
const [extraRateOverride, setExtraRateOverride] = useState<number | null>(null);
|
||||
|
||||
// Refs a los DocumentUpload para hacer flush de archivos pendientes tras crear la reserva.
|
||||
const contractUploadRef = useRef<DocumentUploadHandle>(null);
|
||||
const invoiceUploadRef = useRef<DocumentUploadHandle>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
reset({
|
||||
origin: 'Teneriffa2000',
|
||||
adults_count: 2,
|
||||
children_count: 0,
|
||||
government_registration: '',
|
||||
has_cleaning: false,
|
||||
has_pool_heating: false,
|
||||
has_flies_products: false,
|
||||
observations: '',
|
||||
is_event: false,
|
||||
event_type: '',
|
||||
event_type_other: '',
|
||||
attendees_count: 0,
|
||||
...initialData,
|
||||
});
|
||||
setIsEvent(initialData?.is_event ?? false);
|
||||
const snapshotOverride = initialData?.pricing_snapshot?.extraPersonRateOverride;
|
||||
setExtraRateOverride(snapshotOverride ?? null);
|
||||
clearErrors();
|
||||
}
|
||||
}, [isOpen, initialData, reset, clearErrors]);
|
||||
|
||||
const startDate = watch('start_date');
|
||||
const endDate = watch('end_date');
|
||||
const adults = watch('adults_count');
|
||||
const children = watch('children_count');
|
||||
const origin = watch('origin');
|
||||
const eventType = watch('event_type');
|
||||
|
||||
const totalDays = startDate && endDate
|
||||
? Math.max(0, differenceInDays(parseISO(endDate), parseISO(startDate)))
|
||||
: 0;
|
||||
const totalPeople = (Number(adults) || 0) + (Number(children) || 0);
|
||||
|
||||
// Auto-correct: if start_date moves ahead of end_date, set end_date = start_date
|
||||
useEffect(() => {
|
||||
if (startDate && endDate && parseISO(endDate) < parseISO(startDate)) {
|
||||
setValue('end_date', startDate);
|
||||
clearErrors('end_date');
|
||||
}
|
||||
}, [startDate, endDate, setValue, clearErrors]);
|
||||
|
||||
// Event pricing: uses totalPeople (adults + children) as attendee count.
|
||||
// La tarifa por persona extra depende del año de start_date (12€ en 2026, 14€ desde 2027)
|
||||
// y puede sobrescribirse manualmente con extraRateOverride.
|
||||
const reservationYear = startDate ? parseISO(startDate).getFullYear() : new Date().getFullYear();
|
||||
const autoExtraRate = getExtraPersonRate(property, reservationYear);
|
||||
const effectiveExtraRate = extraRateOverride ?? autoExtraRate;
|
||||
const eventPricing = (() => {
|
||||
const cfg = PROPERTY_CONFIG[property];
|
||||
const extra = Math.max(0, totalPeople - cfg.pricing.includedPersons);
|
||||
const subtotal = cfg.pricing.baseRatePerNight + extra * effectiveExtraRate;
|
||||
return { extra, subtotal, cfg, extraRate: effectiveExtraRate, isOverride: extraRateOverride !== null };
|
||||
})();
|
||||
|
||||
/**
|
||||
* Overlap check — intervalos semi-abiertos [start, end).
|
||||
* El día de salida de una reserva existente SÍ permite entrada ese mismo día:
|
||||
* si res=[19, 20] y new=[20, 21], no hay solapamiento (check-out y check-in mismo día).
|
||||
* Fórmula: overlap iff newStart < resEnd AND newEnd > resStart
|
||||
*/
|
||||
const checkOverlap = (start: string, end: string, currentProperty: Property): boolean => {
|
||||
const newStart = parseISO(start);
|
||||
const newEnd = parseISO(end);
|
||||
const toCheck = existingReservations.filter(r => {
|
||||
if (mode === 'edit' && r.id === initialData?.id) return false;
|
||||
return r.property === currentProperty;
|
||||
});
|
||||
return toCheck.some(res => {
|
||||
const resStart = parseISO(res.start_date);
|
||||
const resEnd = parseISO(res.end_date);
|
||||
return newStart < resEnd && newEnd > resStart;
|
||||
});
|
||||
};
|
||||
|
||||
const onSubmit = async (data: NewReservation) => {
|
||||
if (!data.start_date || !data.end_date) {
|
||||
setError('start_date', { type: 'manual', message: 'Las fechas son obligatorias' });
|
||||
return;
|
||||
}
|
||||
if (parseISO(data.end_date) < parseISO(data.start_date)) {
|
||||
setError('end_date', { type: 'manual', message: 'La fecha de salida no puede ser anterior a la de entrada' });
|
||||
return;
|
||||
}
|
||||
if (checkOverlap(data.start_date, data.end_date, property)) {
|
||||
setError('start_date', {
|
||||
type: 'manual',
|
||||
message: `Las fechas seleccionadas se superponen con otra reserva en ${propertyConfig.label}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Build a clean payload: only include event fields when actually used,
|
||||
// to avoid sending unknown columns to Supabase if the migration hasn't run.
|
||||
const saveData: NewReservation = {
|
||||
origin: data.origin,
|
||||
client_name: data.client_name,
|
||||
start_date: data.start_date,
|
||||
end_date: data.end_date,
|
||||
adults_count: Number(data.adults_count) || 0,
|
||||
children_count: Number(data.children_count) || 0,
|
||||
has_cleaning: Boolean(data.has_cleaning),
|
||||
has_pool_heating: Boolean(data.has_pool_heating),
|
||||
has_flies_products: Boolean(data.has_flies_products),
|
||||
government_registration: data.government_registration || undefined,
|
||||
observations: data.observations || undefined,
|
||||
igic_rate: data.igic_rate,
|
||||
pricing_snapshot: data.pricing_snapshot,
|
||||
property,
|
||||
};
|
||||
|
||||
if (isEvent) {
|
||||
saveData.is_event = true;
|
||||
if (data.event_type) saveData.event_type = data.event_type;
|
||||
if (data.event_type === 'Otro' && data.event_type_other) saveData.event_type_other = data.event_type_other;
|
||||
if (totalPeople > 0) saveData.attendees_count = totalPeople;
|
||||
|
||||
// Congelar el cálculo del canon en pricing_snapshot
|
||||
const cfg = PROPERTY_CONFIG[property].pricing;
|
||||
const igicRate = data.igic_rate ?? 0.07;
|
||||
const basePrice = cfg.baseRatePerNight;
|
||||
const extraPersonsFee = eventPricing.extra * effectiveExtraRate;
|
||||
const subtotal = basePrice + extraPersonsFee;
|
||||
const igicAmount = Math.round(subtotal * igicRate * 100) / 100;
|
||||
const total = Math.round((subtotal + igicAmount) * 100) / 100;
|
||||
saveData.pricing_snapshot = {
|
||||
basePrice,
|
||||
extraPersonsFee,
|
||||
subtotal,
|
||||
igicAmount,
|
||||
total,
|
||||
calculatedAt: new Date().toISOString(),
|
||||
extraPersonRate: effectiveExtraRate,
|
||||
...(extraRateOverride !== null ? { extraPersonRateOverride: extraRateOverride } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const result = await onSave(saveData);
|
||||
if (!result) {
|
||||
// onSave abortó (error o falta de id en edit). No cerramos el modal.
|
||||
return;
|
||||
}
|
||||
// Si creamos una reserva nueva, sube los documentos pendientes ahora que tenemos id
|
||||
const newId = typeof result === 'object' && 'id' in result ? (result as Reservation).id : undefined;
|
||||
if (newId) {
|
||||
try {
|
||||
await Promise.all([
|
||||
contractUploadRef.current?.flushPending(newId),
|
||||
invoiceUploadRef.current?.flushPending(newId),
|
||||
]);
|
||||
} catch (err) {
|
||||
console.error('Error subiendo documentos pendientes:', err);
|
||||
}
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={onClose}
|
||||
className="fixed inset-0 bg-black/60 backdrop-blur-md z-40"
|
||||
/>
|
||||
|
||||
{/* Sheet — always dark */}
|
||||
<motion.div
|
||||
initial={{ y: '100%' }}
|
||||
animate={{ y: 0 }}
|
||||
exit={{ y: '100%' }}
|
||||
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
||||
className="fixed bottom-0 left-0 right-0 bg-slate-900 z-50 rounded-t-[2.5rem] shadow-[0_-10px_60px_rgba(0,0,0,0.5)] h-[92vh] overflow-y-auto w-full max-w-md mx-auto"
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<div className="sticky top-0 bg-slate-900 pt-4 pb-2 z-10 flex justify-center">
|
||||
<div className="w-16 h-1.5 bg-slate-700 rounded-full" />
|
||||
</div>
|
||||
|
||||
<div className="px-6 pb-8">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-start mb-6">
|
||||
<div>
|
||||
<h2 className="text-3xl font-extrabold text-white tracking-tight">
|
||||
{mode === 'create' ? 'Nueva Estancia' : 'Editar Estancia'}
|
||||
</h2>
|
||||
<p className="text-base text-slate-400 mt-1 font-medium">
|
||||
{totalDays} noches · {totalPeople} personas
|
||||
</p>
|
||||
<div className={`inline-flex items-center gap-1.5 mt-2 px-3 py-1 rounded-full text-xs font-bold bg-gradient-to-r ${propertyConfig.color.gradient} text-white`}>
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-white/70" />
|
||||
{propertyConfig.label}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-3 bg-slate-800 rounded-full hover:bg-slate-700 transition-colors"
|
||||
>
|
||||
<X size={22} className="text-slate-300" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
|
||||
|
||||
{/* Validation error */}
|
||||
{(errors.start_date || errors.end_date) && (
|
||||
<div className="bg-red-950/40 border border-red-800 rounded-2xl p-4 flex items-start gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-red-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-bold text-red-300">Error de validación</p>
|
||||
<p className="text-sm text-red-400 mt-0.5">
|
||||
{errors.start_date?.message || errors.end_date?.message}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 1. Empresa / Origen */}
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-slate-400 mb-2 ml-1 uppercase tracking-wider">Empresa</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<label className="cursor-pointer">
|
||||
<input type="radio" value="Teneriffa2000" {...register('origin')} className="peer sr-only" />
|
||||
<div className="p-4 rounded-2xl bg-slate-800 border-2 border-slate-700 peer-checked:border-blue-500 peer-checked:bg-blue-900/30 hover:bg-slate-700 transition-all flex flex-col items-center gap-1.5">
|
||||
<div className="w-3 h-3 rounded-full bg-blue-500" />
|
||||
<span className="font-bold text-slate-300 peer-checked:text-blue-300 text-sm">Teneriffa</span>
|
||||
</div>
|
||||
</label>
|
||||
<label className="cursor-pointer">
|
||||
<input type="radio" value="Naturcalabacera" {...register('origin')} className="peer sr-only" />
|
||||
<div className="p-4 rounded-2xl bg-slate-800 border-2 border-slate-700 peer-checked:border-yellow-500 peer-checked:bg-yellow-900/30 hover:bg-slate-700 transition-all flex flex-col items-center gap-1.5">
|
||||
<div className="w-3 h-3 rounded-full bg-yellow-500" />
|
||||
<span className="font-bold text-slate-300 peer-checked:text-yellow-300 text-sm">Natur</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2. Modo Evento (solo Naturcalabacera) */}
|
||||
<AnimatePresence>
|
||||
{origin === 'Naturcalabacera' && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="bg-slate-800/60 border border-slate-700 rounded-2xl p-4 space-y-4">
|
||||
|
||||
{/* Toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="w-4 h-4 text-yellow-400" />
|
||||
<span className="font-semibold text-slate-200 text-sm">Es un evento</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsEvent(v => !v)}
|
||||
className={`w-11 h-6 rounded-full transition-all duration-300 relative flex-shrink-0 focus:outline-none focus:ring-2 focus:ring-yellow-500/50 ${isEvent ? 'bg-yellow-500' : 'bg-slate-600'}`}
|
||||
aria-pressed={isEvent}
|
||||
>
|
||||
<span className={`absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform duration-300 ${isEvent ? 'translate-x-5' : 'translate-x-0'}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Event details */}
|
||||
<AnimatePresence>
|
||||
{isEvent && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="overflow-hidden space-y-4"
|
||||
>
|
||||
{/* Tipo de evento */}
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-slate-400 mb-1.5 uppercase tracking-wider">Tipo de evento</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
{...register('event_type')}
|
||||
className="w-full px-4 py-3 bg-slate-700 border border-slate-600 rounded-xl text-white font-medium focus:outline-none focus:ring-2 focus:ring-yellow-500/50 focus:border-yellow-500/50 appearance-none cursor-pointer"
|
||||
>
|
||||
<option value="" className="bg-slate-800">Selecciona tipo...</option>
|
||||
{EVENT_TYPES.map(t => (
|
||||
<option key={t} value={t} className="bg-slate-800">{t}</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400 pointer-events-none" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Descripción si es Otro */}
|
||||
{eventType === 'Otro' && (
|
||||
<input
|
||||
{...register('event_type_other')}
|
||||
placeholder="Describe el tipo de evento..."
|
||||
className="w-full px-4 py-3 bg-slate-700 border border-slate-600 rounded-xl text-white placeholder-slate-500 font-medium focus:outline-none focus:ring-2 focus:ring-yellow-500/50"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Canon — usa el conteo de huéspedes (adultos + niños) */}
|
||||
{totalPeople > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.97 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="bg-slate-900/70 border border-emerald-800/40 rounded-xl p-4 space-y-2.5"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<div className="w-1.5 h-4 bg-gradient-to-b from-emerald-400 to-emerald-600 rounded-full" />
|
||||
<p className="text-xs font-black text-emerald-400 uppercase tracking-wider">Cálculo de Canon</p>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-slate-400">Propiedad</span>
|
||||
<span className="text-slate-200 font-semibold">{eventPricing.cfg.label}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-slate-400">Canon base / noche</span>
|
||||
<span className="text-slate-200 font-semibold">{eventPricing.cfg.pricing.baseRatePerNight.toLocaleString('es-ES')} €</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-slate-400">Asistentes ({totalPeople} pax)</span>
|
||||
<span className="text-slate-200 font-semibold">incluye hasta {eventPricing.cfg.pricing.includedPersons}</span>
|
||||
</div>
|
||||
{eventPricing.extra > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-slate-400">{eventPricing.extra} pax extra × {eventPricing.extraRate} €</span>
|
||||
<span className="text-yellow-400 font-semibold">+{(eventPricing.extra * eventPricing.extraRate).toLocaleString('es-ES')} €</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Editor de tarifa por pax extra */}
|
||||
<div className="border-t border-slate-700 pt-2.5 space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-[10px] text-slate-400 uppercase tracking-wider font-bold flex-1">
|
||||
Tarifa pax extra (€/pax/noche)
|
||||
</label>
|
||||
{eventPricing.isOverride && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExtraRateOverride(null)}
|
||||
className="text-[10px] text-slate-400 hover:text-emerald-400 underline underline-offset-2"
|
||||
>
|
||||
Restablecer ({autoExtraRate} €)
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.5"
|
||||
value={effectiveExtraRate}
|
||||
onChange={e => {
|
||||
const v = e.target.value;
|
||||
if (v === '') {
|
||||
setExtraRateOverride(null);
|
||||
return;
|
||||
}
|
||||
const num = Number(v);
|
||||
if (Number.isFinite(num) && num >= 0) {
|
||||
setExtraRateOverride(num === autoExtraRate ? null : num);
|
||||
}
|
||||
}}
|
||||
className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm font-medium focus:outline-none focus:ring-2 focus:ring-yellow-500/40 focus:border-yellow-500/40"
|
||||
/>
|
||||
<p className="text-[10px] text-slate-500">
|
||||
{eventPricing.isOverride
|
||||
? `Tarifa personalizada (auto ${reservationYear}: ${autoExtraRate} €)`
|
||||
: `Tarifa automática para ${reservationYear}`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="border-t border-slate-700 pt-2.5 flex justify-between">
|
||||
<span className="text-slate-300 font-bold text-sm">Subtotal</span>
|
||||
<span className="text-white font-black text-lg">{eventPricing.subtotal.toLocaleString('es-ES')} €</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-500 text-right">* IGIC no incluido</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 3. Cliente */}
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-slate-400 mb-2 ml-1 uppercase tracking-wider">Cliente</label>
|
||||
<input
|
||||
{...register('client_name', { required: true })}
|
||||
placeholder="Nombre completo"
|
||||
className="w-full px-5 py-4 bg-slate-800 border border-slate-700 rounded-2xl text-white text-base font-medium focus:outline-none focus:ring-2 focus:ring-emerald-500/50 focus:border-emerald-500/50 placeholder-slate-500 transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 4. Fechas */}
|
||||
<div className={`flex gap-4 p-4 rounded-2xl border-2 transition-all ${errors.start_date || errors.end_date
|
||||
? 'bg-red-950/30 border-red-800'
|
||||
: 'bg-slate-800 border-slate-700'
|
||||
}`}>
|
||||
<div className="flex-1 min-w-0">
|
||||
<label className="text-[10px] text-slate-400 uppercase font-bold tracking-wider">Entrada</label>
|
||||
<input
|
||||
type="date"
|
||||
{...register('start_date', { required: true })}
|
||||
className="bg-transparent w-full font-bold text-white mt-1 focus:outline-none [color-scheme:dark] text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-px bg-slate-600 self-stretch" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<label className="text-[10px] text-slate-400 uppercase font-bold tracking-wider">Salida</label>
|
||||
<input
|
||||
type="date"
|
||||
{...register('end_date', { required: true })}
|
||||
min={startDate || undefined}
|
||||
className="bg-transparent w-full font-bold text-white mt-1 focus:outline-none [color-scheme:dark] text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 5. Huéspedes */}
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-slate-400 mb-2 ml-1 uppercase tracking-wider">Huéspedes</label>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1 flex flex-col items-center p-4 bg-slate-800 border border-slate-700 rounded-2xl">
|
||||
<span className="text-xs font-medium text-slate-400 mb-2">Adultos</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
{...register('adults_count')}
|
||||
className="w-full text-center text-2xl font-bold bg-transparent text-white border-none p-0 focus:outline-none focus:ring-0"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col items-center p-4 bg-slate-800 border border-slate-700 rounded-2xl">
|
||||
<span className="text-xs font-medium text-slate-400 mb-2">Niños</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
{...register('children_count')}
|
||||
className="w-full text-center text-2xl font-bold bg-transparent text-white border-none p-0 focus:outline-none focus:ring-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 6. Extras */}
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-slate-400 mb-2 ml-1 uppercase tracking-wider">Extras</label>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
{ id: 'has_cleaning' as const, label: 'Limpieza Final' },
|
||||
{ id: 'has_pool_heating' as const, label: 'Calefacción Piscina' },
|
||||
{ id: 'has_flies_products' as const, label: 'Productos Moscas' },
|
||||
].map(extra => (
|
||||
<label key={extra.id} className="flex items-center justify-between p-4 bg-slate-800 border border-slate-700 rounded-2xl cursor-pointer hover:border-slate-600 transition-colors">
|
||||
<span className="font-medium text-slate-200 text-sm">{extra.label}</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
{...register(extra.id)}
|
||||
className="w-5 h-5 rounded-md text-emerald-500 bg-slate-700 border-slate-600 focus:ring-emerald-500/50 focus:ring-2"
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 7. Precios Natur (solo vacacional — no evento) */}
|
||||
{origin === 'Naturcalabacera' && !isEvent && (
|
||||
<PricingSection
|
||||
property={property}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
adults={Number(adults) || 0}
|
||||
children={Number(children) || 0}
|
||||
igicRate={initialData?.igic_rate ?? 0.07}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 8. Registro Gubernamental */}
|
||||
<div className="bg-slate-800/50 p-4 rounded-2xl border border-slate-700">
|
||||
<label className="text-sm font-bold text-slate-300 flex items-center mb-2">
|
||||
Registro Gubernamental
|
||||
<span className="ml-auto text-[10px] bg-slate-700 text-slate-400 px-2 py-1 rounded-full uppercase tracking-wide font-bold">Opcional</span>
|
||||
</label>
|
||||
<input
|
||||
{...register('government_registration')}
|
||||
placeholder="Identificador oficial..."
|
||||
className="block w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl focus:outline-none focus:ring-2 focus:ring-emerald-500/40 text-slate-100 placeholder-slate-500 font-medium"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 9. Observaciones */}
|
||||
<div className="bg-slate-800/50 p-4 rounded-2xl border border-slate-700">
|
||||
<label className="text-sm font-bold text-slate-300 flex items-center mb-2">
|
||||
Observaciones
|
||||
<span className="ml-auto text-[10px] bg-slate-700 text-slate-400 px-2 py-1 rounded-full uppercase tracking-wide font-bold">Opcional</span>
|
||||
</label>
|
||||
<textarea
|
||||
{...register('observations')}
|
||||
placeholder="Notas internas, peticiones especiales..."
|
||||
rows={3}
|
||||
className="block w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl focus:outline-none focus:ring-2 focus:ring-emerald-500/40 text-slate-100 placeholder-slate-500 font-medium resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 10. Contrato */}
|
||||
<div className="bg-slate-800/50 p-4 rounded-2xl border border-slate-700">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Paperclip className="w-4 h-4 text-slate-400" />
|
||||
<span className="text-sm font-bold text-slate-300">Contrato</span>
|
||||
<span className="ml-auto text-[10px] bg-slate-700 text-slate-400 px-2 py-1 rounded-full uppercase tracking-wide font-bold">Opcional</span>
|
||||
</div>
|
||||
<DocumentUpload
|
||||
ref={contractUploadRef}
|
||||
reservationId={initialData?.id}
|
||||
documentType="contract"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 11. Factura */}
|
||||
<div className="bg-slate-800/50 p-4 rounded-2xl border border-slate-700">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Receipt className="w-4 h-4 text-slate-400" />
|
||||
<span className="text-sm font-bold text-slate-300">Factura</span>
|
||||
<span className="ml-auto text-[10px] bg-slate-700 text-slate-400 px-2 py-1 rounded-full uppercase tracking-wide font-bold">Opcional</span>
|
||||
</div>
|
||||
<DocumentUpload
|
||||
ref={invoiceUploadRef}
|
||||
reservationId={initialData?.id}
|
||||
documentType="invoice"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="h-2" />
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3 pb-6">
|
||||
{mode === 'edit' && onDelete && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(initialData?.id as string)}
|
||||
className="p-4 bg-slate-800 border border-red-900/40 text-red-400 rounded-2xl hover:bg-red-950/40 hover:border-red-900/60 transition-colors"
|
||||
>
|
||||
<Trash2 size={22} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!!errors.start_date}
|
||||
className="flex-1 py-4 bg-emerald-600 hover:bg-emerald-500 text-white font-bold text-base rounded-2xl shadow-xl shadow-emerald-900/30 transform transition-all active:scale-95 flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none"
|
||||
>
|
||||
<Check size={22} />
|
||||
{mode === 'create' ? 'Crear Reserva' : 'Guardar Cambios'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
30
apps/web/src/components/SearchBar.tsx
Normal file
30
apps/web/src/components/SearchBar.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Search } from 'lucide-react';
|
||||
|
||||
interface SearchBarProps {
|
||||
searchTerm: string;
|
||||
onSearchChange: (term: string) => void;
|
||||
}
|
||||
|
||||
export function SearchBar({
|
||||
searchTerm,
|
||||
onSearchChange,
|
||||
}: SearchBarProps) {
|
||||
return (
|
||||
<div className="flex flex-col md:flex-row gap-4 mb-6">
|
||||
<div className="relative flex-1">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Search className="h-5 w-5 text-slate-400" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
className="block w-full pl-10 pr-3 py-2.5 border border-slate-700 rounded-xl leading-5 bg-slate-800/50 text-slate-200 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 sm:text-sm transition-all shadow-lg backdrop-blur-sm"
|
||||
placeholder="Buscar por nombre de huésped..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
46
apps/web/src/components/ServiceIcons.tsx
Normal file
46
apps/web/src/components/ServiceIcons.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Sparkles, Thermometer, Bug } from 'lucide-react';
|
||||
import type { Reservation } from '../types';
|
||||
|
||||
interface Props {
|
||||
reservation: Reservation;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Muestra iconos compactos para los extras activos de una reserva.
|
||||
* Solo renderiza los iconos de servicios que están activados.
|
||||
* Diseñado para encajar en los bloques del calendario (pequeños, con text-shadow).
|
||||
*/
|
||||
export function ServiceIcons({ reservation, className = '' }: Props) {
|
||||
const activeIcons: { icon: React.ElementType; title: string; key: string }[] = [];
|
||||
|
||||
if (reservation.has_cleaning) {
|
||||
activeIcons.push({ icon: Sparkles, title: 'Limpieza final', key: 'cleaning' });
|
||||
}
|
||||
if (reservation.has_pool_heating) {
|
||||
activeIcons.push({ icon: Thermometer, title: 'Calefacción piscina', key: 'pool' });
|
||||
}
|
||||
if (reservation.has_flies_products) {
|
||||
activeIcons.push({ icon: Bug, title: 'Productos moscas', key: 'flies' });
|
||||
}
|
||||
|
||||
if (activeIcons.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-0.5 ${className}`}
|
||||
role="group"
|
||||
aria-label="Servicios incluidos"
|
||||
>
|
||||
{activeIcons.map(({ icon: Icon, title, key }) => (
|
||||
<span
|
||||
key={key}
|
||||
title={title}
|
||||
className="drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)]"
|
||||
>
|
||||
<Icon className="w-3 h-3 text-white" aria-label={title} />
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user