- Auth: Login/Register con creacion de clinica - Dashboard: KPIs reales, graficas recharts - Pacientes: CRUD completo con busqueda - Agenda: FullCalendar, drag-and-drop, vista recepcion - Expediente: Notas SOAP, signos vitales, CIE-10 - Facturacion: Facturas con IVA, campos CFDI SAT - Inventario: Productos, stock, movimientos, alertas - Configuracion: Clinica, equipo, catalogo servicios - Supabase self-hosted: 18 tablas con RLS multi-tenant - Docker + Nginx para produccion Co-Authored-By: claude-flow <ruv@ruv.net>
2454 lines
71 KiB
Markdown
2454 lines
71 KiB
Markdown
# CRM Clinicas SaaS — Implementation Plan
|
|
|
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
|
|
**Goal:** Build a complete multi-tenant CRM SaaS for medical clinics in Mexico, self-hosted on Ubuntu 24.04 VM with Docker.
|
|
|
|
**Architecture:** Next.js 15 App Router monolith with Supabase self-hosted (PostgreSQL + Auth + Storage + Realtime). Multi-tenancy via Row-Level Security. Docker Compose orchestrates all services behind Nginx reverse proxy.
|
|
|
|
**Tech Stack:** Next.js 15, TypeScript, Tailwind CSS, shadcn/ui, Supabase (self-hosted), PostgreSQL, Docker, Nginx
|
|
|
|
**Design Doc:** `docs/plans/2026-03-02-crm-clinicas-design.md`
|
|
|
|
---
|
|
|
|
## Phase 1: Foundation (Tasks 1-12)
|
|
|
|
### Task 1: Install Docker and Docker Compose
|
|
|
|
**Files:** None (system setup)
|
|
|
|
**Step 1: Install Docker Engine**
|
|
|
|
Run:
|
|
```bash
|
|
sudo apt-get update
|
|
sudo apt-get install -y ca-certificates curl gnupg
|
|
sudo install -m 0755 -d /etc/apt/keyrings
|
|
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
|
|
sudo chmod a+r /etc/apt/keyrings/docker.gpg
|
|
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
|
|
sudo apt-get update
|
|
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
|
```
|
|
|
|
**Step 2: Verify Docker**
|
|
|
|
Run: `docker --version && docker compose version`
|
|
Expected: Docker 27.x and Docker Compose v2.x
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
# Nothing to commit yet — system-level install
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: Initialize Next.js project with TypeScript and Tailwind
|
|
|
|
**Files:**
|
|
- Create: `package.json`, `tsconfig.json`, `next.config.ts`, `tailwind.config.ts`, `postcss.config.mjs`
|
|
- Create: `src/app/layout.tsx`, `src/app/page.tsx`, `src/app/globals.css`
|
|
|
|
**Step 1: Create Next.js app**
|
|
|
|
Run:
|
|
```bash
|
|
cd /root/CrmClinicas
|
|
npx create-next-app@latest . --typescript --tailwind --eslint --app --src-dir --import-alias "@/*" --use-npm --no-turbopack
|
|
```
|
|
|
|
When prompted, accept defaults. If it asks about overwriting existing files, say yes.
|
|
|
|
**Step 2: Verify it builds**
|
|
|
|
Run: `cd /root/CrmClinicas && npm run build`
|
|
Expected: Build succeeds with no errors
|
|
|
|
**Step 3: Initialize git repository**
|
|
|
|
Run:
|
|
```bash
|
|
cd /root/CrmClinicas
|
|
git init
|
|
cat > .gitignore << 'GITEOF'
|
|
node_modules/
|
|
.next/
|
|
out/
|
|
.env
|
|
.env.local
|
|
.env.production
|
|
*.log
|
|
.DS_Store
|
|
.claude-flow/
|
|
claude-scientific-skills/
|
|
GITEOF
|
|
git add -A
|
|
git commit -m "feat: initialize Next.js 15 project with TypeScript and Tailwind"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: Install and configure shadcn/ui
|
|
|
|
**Files:**
|
|
- Modify: `tailwind.config.ts`
|
|
- Create: `src/lib/utils.ts`
|
|
- Create: `components.json`
|
|
|
|
**Step 1: Initialize shadcn/ui**
|
|
|
|
Run:
|
|
```bash
|
|
cd /root/CrmClinicas
|
|
npx shadcn@latest init -d
|
|
```
|
|
|
|
Accept defaults (New York style, Zinc base color, CSS variables).
|
|
|
|
**Step 2: Add core UI components**
|
|
|
|
Run:
|
|
```bash
|
|
npx shadcn@latest add button input label card dialog table badge dropdown-menu separator avatar sheet tabs textarea select toast sonner command popover calendar form scroll-area tooltip checkbox switch
|
|
```
|
|
|
|
**Step 3: Verify build still works**
|
|
|
|
Run: `npm run build`
|
|
Expected: Build succeeds
|
|
|
|
**Step 4: Commit**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "feat: add shadcn/ui with core components"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: Set up Supabase self-hosted with Docker Compose
|
|
|
|
**Files:**
|
|
- Create: `docker/docker-compose.yml`
|
|
- Create: `docker/.env.supabase`
|
|
- Create: `docker/volumes/` directories
|
|
|
|
**Step 1: Create docker directory structure**
|
|
|
|
Run:
|
|
```bash
|
|
mkdir -p /root/CrmClinicas/docker/volumes/db/data
|
|
mkdir -p /root/CrmClinicas/docker/volumes/storage
|
|
```
|
|
|
|
**Step 2: Create Supabase environment file**
|
|
|
|
Create `docker/.env.supabase`:
|
|
```env
|
|
# --- Secrets (change in production!) ---
|
|
POSTGRES_PASSWORD=your-super-secret-db-password-change-me
|
|
JWT_SECRET=your-super-secret-jwt-token-with-at-least-32-characters-long
|
|
ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0
|
|
SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU
|
|
DASHBOARD_USERNAME=supabase
|
|
DASHBOARD_PASSWORD=your-dashboard-password-change-me
|
|
|
|
# --- General ---
|
|
SITE_URL=http://localhost:3000
|
|
API_EXTERNAL_URL=http://localhost:8000
|
|
SUPABASE_PUBLIC_URL=http://localhost:8000
|
|
|
|
# --- Database ---
|
|
POSTGRES_HOST=db
|
|
POSTGRES_PORT=5432
|
|
POSTGRES_DB=postgres
|
|
|
|
# --- Auth (GoTrue) ---
|
|
GOTRUE_SITE_URL=http://localhost:3000
|
|
GOTRUE_URI_ALLOW_LIST=
|
|
GOTRUE_DISABLE_SIGNUP=false
|
|
GOTRUE_EXTERNAL_EMAIL_ENABLED=true
|
|
GOTRUE_MAILER_AUTOCONFIRM=true
|
|
GOTRUE_SMS_AUTOCONFIRM=true
|
|
|
|
# --- Studio ---
|
|
STUDIO_DEFAULT_ORGANIZATION=CRM Clinicas
|
|
STUDIO_DEFAULT_PROJECT=CRM Clinicas
|
|
STUDIO_PORT=3001
|
|
```
|
|
|
|
**Step 3: Create Docker Compose file**
|
|
|
|
Create `docker/docker-compose.yml`:
|
|
```yaml
|
|
version: "3.8"
|
|
|
|
services:
|
|
# --- PostgreSQL ---
|
|
db:
|
|
image: supabase/postgres:15.6.1.143
|
|
ports:
|
|
- "5432:5432"
|
|
environment:
|
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
|
POSTGRES_DB: ${POSTGRES_DB}
|
|
JWT_SECRET: ${JWT_SECRET}
|
|
volumes:
|
|
- ./volumes/db/data:/var/lib/postgresql/data
|
|
restart: unless-stopped
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "pg_isready -U supabase_admin -d postgres"]
|
|
interval: 10s
|
|
timeout: 5s
|
|
retries: 5
|
|
|
|
# --- Auth (GoTrue) ---
|
|
auth:
|
|
image: supabase/gotrue:v2.164.0
|
|
ports:
|
|
- "9999:9999"
|
|
depends_on:
|
|
db:
|
|
condition: service_healthy
|
|
environment:
|
|
GOTRUE_API_HOST: 0.0.0.0
|
|
GOTRUE_API_PORT: 9999
|
|
API_EXTERNAL_URL: ${API_EXTERNAL_URL}
|
|
GOTRUE_DB_DRIVER: postgres
|
|
GOTRUE_DB_DATABASE_URL: postgres://supabase_auth_admin:${POSTGRES_PASSWORD}@db:${POSTGRES_PORT}/${POSTGRES_DB}
|
|
GOTRUE_SITE_URL: ${GOTRUE_SITE_URL}
|
|
GOTRUE_URI_ALLOW_LIST: ${GOTRUE_URI_ALLOW_LIST}
|
|
GOTRUE_DISABLE_SIGNUP: ${GOTRUE_DISABLE_SIGNUP}
|
|
GOTRUE_JWT_ADMIN_ROLES: service_role
|
|
GOTRUE_JWT_AUD: authenticated
|
|
GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated
|
|
GOTRUE_JWT_EXP: 3600
|
|
GOTRUE_JWT_SECRET: ${JWT_SECRET}
|
|
GOTRUE_EXTERNAL_EMAIL_ENABLED: ${GOTRUE_EXTERNAL_EMAIL_ENABLED}
|
|
GOTRUE_MAILER_AUTOCONFIRM: ${GOTRUE_MAILER_AUTOCONFIRM}
|
|
GOTRUE_SMS_AUTOCONFIRM: ${GOTRUE_SMS_AUTOCONFIRM}
|
|
restart: unless-stopped
|
|
|
|
# --- REST API (PostgREST) ---
|
|
rest:
|
|
image: postgrest/postgrest:v12.2.3
|
|
ports:
|
|
- "3100:3000"
|
|
depends_on:
|
|
db:
|
|
condition: service_healthy
|
|
environment:
|
|
PGRST_DB_URI: postgres://authenticator:${POSTGRES_PASSWORD}@db:${POSTGRES_PORT}/${POSTGRES_DB}
|
|
PGRST_DB_SCHEMAS: public,storage,graphql_public
|
|
PGRST_DB_ANON_ROLE: anon
|
|
PGRST_JWT_SECRET: ${JWT_SECRET}
|
|
PGRST_DB_USE_LEGACY_GUCS: "false"
|
|
PGRST_APP_SETTINGS_JWT_SECRET: ${JWT_SECRET}
|
|
PGRST_APP_SETTINGS_JWT_EXP: 3600
|
|
restart: unless-stopped
|
|
|
|
# --- Realtime ---
|
|
realtime:
|
|
image: supabase/realtime:v2.30.34
|
|
ports:
|
|
- "4000:4000"
|
|
depends_on:
|
|
db:
|
|
condition: service_healthy
|
|
environment:
|
|
PORT: 4000
|
|
DB_HOST: db
|
|
DB_PORT: ${POSTGRES_PORT}
|
|
DB_USER: supabase_admin
|
|
DB_PASSWORD: ${POSTGRES_PASSWORD}
|
|
DB_NAME: ${POSTGRES_DB}
|
|
DB_AFTER_CONNECT_QUERY: "SET search_path TO _realtime"
|
|
DB_ENC_KEY: supabaserealtime
|
|
API_JWT_SECRET: ${JWT_SECRET}
|
|
SECRET_KEY_BASE: UpNVntn3cDxHJpq99YMc1T1AQgQpc8kfYTuRgBiYa15BLrx8etQoXz3gZv1/u2oq
|
|
ERL_AFLAGS: -proto_dist inet_tcp
|
|
DNS_NODES: "''"
|
|
RLIMIT_NOFILE: "10000"
|
|
APP_NAME: realtime
|
|
SEED_SELF_HOST: "true"
|
|
restart: unless-stopped
|
|
|
|
# --- Storage ---
|
|
storage:
|
|
image: supabase/storage-api:v1.11.13
|
|
ports:
|
|
- "5000:5000"
|
|
depends_on:
|
|
db:
|
|
condition: service_healthy
|
|
environment:
|
|
ANON_KEY: ${ANON_KEY}
|
|
SERVICE_KEY: ${SERVICE_ROLE_KEY}
|
|
POSTGREST_URL: http://rest:3000
|
|
PGRST_JWT_SECRET: ${JWT_SECRET}
|
|
DATABASE_URL: postgres://supabase_storage_admin:${POSTGRES_PASSWORD}@db:${POSTGRES_PORT}/${POSTGRES_DB}
|
|
FILE_SIZE_LIMIT: 52428800
|
|
STORAGE_BACKEND: file
|
|
FILE_STORAGE_BACKEND_PATH: /var/lib/storage
|
|
TENANT_ID: stub
|
|
REGION: stub
|
|
GLOBAL_S3_BUCKET: stub
|
|
IS_MULTITENANT: "false"
|
|
volumes:
|
|
- ./volumes/storage:/var/lib/storage
|
|
restart: unless-stopped
|
|
|
|
# --- Kong API Gateway ---
|
|
kong:
|
|
image: kong:2.8.1
|
|
ports:
|
|
- "8000:8000"
|
|
- "8443:8443"
|
|
depends_on:
|
|
auth:
|
|
condition: service_started
|
|
rest:
|
|
condition: service_started
|
|
realtime:
|
|
condition: service_started
|
|
storage:
|
|
condition: service_started
|
|
environment:
|
|
KONG_DATABASE: "off"
|
|
KONG_DECLARATIVE_CONFIG: /home/kong/kong.yml
|
|
KONG_DNS_ORDER: LAST,A,CNAME
|
|
KONG_PLUGINS: request-transformer,cors,key-auth,acl,basic-auth
|
|
KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: 160k
|
|
KONG_NGINX_PROXY_PROXY_BUFFERS: 64 160k
|
|
volumes:
|
|
- ./kong.yml:/home/kong/kong.yml:ro
|
|
restart: unless-stopped
|
|
|
|
# --- Supabase Studio ---
|
|
studio:
|
|
image: supabase/studio:20241202-71e5240
|
|
ports:
|
|
- "3001:3000"
|
|
depends_on:
|
|
kong:
|
|
condition: service_started
|
|
environment:
|
|
STUDIO_PG_META_URL: http://meta:8080
|
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
|
DEFAULT_ORGANIZATION_NAME: ${STUDIO_DEFAULT_ORGANIZATION}
|
|
DEFAULT_PROJECT_NAME: ${STUDIO_DEFAULT_PROJECT}
|
|
SUPABASE_URL: http://kong:8000
|
|
SUPABASE_PUBLIC_URL: ${SUPABASE_PUBLIC_URL}
|
|
SUPABASE_ANON_KEY: ${ANON_KEY}
|
|
SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY}
|
|
AUTH_JWT_SECRET: ${JWT_SECRET}
|
|
LOGFLARE_API_KEY: fake-logflare-key
|
|
LOGFLARE_URL: http://localhost:4000
|
|
NEXT_PUBLIC_ENABLE_LOGS: "false"
|
|
NEXT_ANALYTICS_BACKEND_PROVIDER: postgres
|
|
restart: unless-stopped
|
|
|
|
# --- Postgres Meta (for Studio) ---
|
|
meta:
|
|
image: supabase/postgres-meta:v0.84.2
|
|
ports:
|
|
- "8080:8080"
|
|
depends_on:
|
|
db:
|
|
condition: service_healthy
|
|
environment:
|
|
PG_META_PORT: 8080
|
|
PG_META_DB_HOST: db
|
|
PG_META_DB_PORT: ${POSTGRES_PORT}
|
|
PG_META_DB_NAME: ${POSTGRES_DB}
|
|
PG_META_DB_USER: supabase_admin
|
|
PG_META_DB_PASSWORD: ${POSTGRES_PASSWORD}
|
|
restart: unless-stopped
|
|
```
|
|
|
|
**Step 4: Create Kong configuration**
|
|
|
|
Create `docker/kong.yml`:
|
|
```yaml
|
|
_format_version: "2.1"
|
|
_transform: true
|
|
|
|
services:
|
|
# --- Auth ---
|
|
- name: auth-v1-open
|
|
url: http://auth:9999/verify
|
|
routes:
|
|
- name: auth-v1-open
|
|
strip_path: true
|
|
paths:
|
|
- /auth/v1/verify
|
|
plugins:
|
|
- name: cors
|
|
- name: auth-v1-open-callback
|
|
url: http://auth:9999/callback
|
|
routes:
|
|
- name: auth-v1-open-callback
|
|
strip_path: true
|
|
paths:
|
|
- /auth/v1/callback
|
|
plugins:
|
|
- name: cors
|
|
- name: auth-v1-open-authorize
|
|
url: http://auth:9999/authorize
|
|
routes:
|
|
- name: auth-v1-open-authorize
|
|
strip_path: true
|
|
paths:
|
|
- /auth/v1/authorize
|
|
plugins:
|
|
- name: cors
|
|
- name: auth-v1
|
|
_comment: "GoTrue: /auth/v1/* -> http://auth:9999/*"
|
|
url: http://auth:9999/
|
|
routes:
|
|
- name: auth-v1-all
|
|
strip_path: true
|
|
paths:
|
|
- /auth/v1/
|
|
plugins:
|
|
- name: cors
|
|
- name: key-auth
|
|
config:
|
|
hide_credentials: false
|
|
key_names:
|
|
- apikey
|
|
|
|
# --- REST ---
|
|
- name: rest-v1
|
|
_comment: "PostgREST: /rest/v1/* -> http://rest:3000/*"
|
|
url: http://rest:3000/
|
|
routes:
|
|
- name: rest-v1-all
|
|
strip_path: true
|
|
paths:
|
|
- /rest/v1/
|
|
plugins:
|
|
- name: cors
|
|
- name: key-auth
|
|
config:
|
|
hide_credentials: false
|
|
key_names:
|
|
- apikey
|
|
|
|
# --- Realtime ---
|
|
- name: realtime-v1
|
|
_comment: "Realtime: /realtime/v1/* -> ws://realtime:4000/socket/*"
|
|
url: http://realtime:4000/socket/
|
|
routes:
|
|
- name: realtime-v1-all
|
|
strip_path: true
|
|
paths:
|
|
- /realtime/v1/
|
|
plugins:
|
|
- name: cors
|
|
- name: key-auth
|
|
config:
|
|
hide_credentials: false
|
|
key_names:
|
|
- apikey
|
|
|
|
# --- Storage ---
|
|
- name: storage-v1
|
|
_comment: "Storage: /storage/v1/* -> http://storage:5000/*"
|
|
url: http://storage:5000/
|
|
routes:
|
|
- name: storage-v1-all
|
|
strip_path: true
|
|
paths:
|
|
- /storage/v1/
|
|
plugins:
|
|
- name: cors
|
|
|
|
consumers:
|
|
- username: ANON
|
|
keyauth_credentials:
|
|
- key: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0
|
|
- username: SERVICE_ROLE
|
|
keyauth_credentials:
|
|
- key: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU
|
|
```
|
|
|
|
**Step 5: Start Supabase stack**
|
|
|
|
Run:
|
|
```bash
|
|
cd /root/CrmClinicas/docker
|
|
docker compose --env-file .env.supabase up -d
|
|
```
|
|
|
|
**Step 6: Verify all services are running**
|
|
|
|
Run: `docker compose --env-file .env.supabase ps`
|
|
Expected: All services show "running" or "healthy"
|
|
|
|
Verify API responds:
|
|
Run: `curl -s http://localhost:8000/rest/v1/ -H "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" | head -5`
|
|
Expected: JSON response (empty object or array)
|
|
|
|
**Step 7: Commit**
|
|
|
|
```bash
|
|
cd /root/CrmClinicas
|
|
git add docker/
|
|
git commit -m "feat: add Supabase self-hosted Docker Compose stack"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: Configure Next.js Supabase client
|
|
|
|
**Files:**
|
|
- Create: `src/lib/supabase/client.ts`
|
|
- Create: `src/lib/supabase/server.ts`
|
|
- Create: `src/lib/supabase/admin.ts`
|
|
- Create: `src/lib/supabase/middleware.ts`
|
|
- Create: `src/middleware.ts`
|
|
- Create: `.env.local`
|
|
- Modify: `package.json` (add dependencies)
|
|
|
|
**Step 1: Install Supabase packages**
|
|
|
|
Run:
|
|
```bash
|
|
cd /root/CrmClinicas
|
|
npm install @supabase/supabase-js @supabase/ssr
|
|
```
|
|
|
|
**Step 2: Create environment file**
|
|
|
|
Create `.env.local`:
|
|
```env
|
|
NEXT_PUBLIC_SUPABASE_URL=http://localhost:8000
|
|
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0
|
|
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU
|
|
```
|
|
|
|
**Step 3: Create browser client**
|
|
|
|
Create `src/lib/supabase/client.ts`:
|
|
```typescript
|
|
import { createBrowserClient } from "@supabase/ssr";
|
|
|
|
export function createClient() {
|
|
return createBrowserClient(
|
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
|
);
|
|
}
|
|
```
|
|
|
|
**Step 4: Create server client**
|
|
|
|
Create `src/lib/supabase/server.ts`:
|
|
```typescript
|
|
import { createServerClient } from "@supabase/ssr";
|
|
import { cookies } from "next/headers";
|
|
|
|
export async function createClient() {
|
|
const cookieStore = await cookies();
|
|
|
|
return createServerClient(
|
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
|
{
|
|
cookies: {
|
|
getAll() {
|
|
return cookieStore.getAll();
|
|
},
|
|
setAll(cookiesToSet) {
|
|
try {
|
|
cookiesToSet.forEach(({ name, value, options }) =>
|
|
cookieStore.set(name, value, options)
|
|
);
|
|
} catch {
|
|
// Called from Server Component — ignore
|
|
}
|
|
},
|
|
},
|
|
}
|
|
);
|
|
}
|
|
```
|
|
|
|
**Step 5: Create admin client (service role)**
|
|
|
|
Create `src/lib/supabase/admin.ts`:
|
|
```typescript
|
|
import { createClient as createSupabaseClient } from "@supabase/supabase-js";
|
|
|
|
export function createAdminClient() {
|
|
return createSupabaseClient(
|
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
process.env.SUPABASE_SERVICE_ROLE_KEY!,
|
|
{
|
|
auth: {
|
|
autoRefreshToken: false,
|
|
persistSession: false,
|
|
},
|
|
}
|
|
);
|
|
}
|
|
```
|
|
|
|
**Step 6: Create middleware for auth session refresh**
|
|
|
|
Create `src/lib/supabase/middleware.ts`:
|
|
```typescript
|
|
import { createServerClient } from "@supabase/ssr";
|
|
import { NextResponse, type NextRequest } from "next/server";
|
|
|
|
export async function updateSession(request: NextRequest) {
|
|
let supabaseResponse = NextResponse.next({ request });
|
|
|
|
const supabase = createServerClient(
|
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
|
{
|
|
cookies: {
|
|
getAll() {
|
|
return request.cookies.getAll();
|
|
},
|
|
setAll(cookiesToSet) {
|
|
cookiesToSet.forEach(({ name, value }) =>
|
|
request.cookies.set(name, value)
|
|
);
|
|
supabaseResponse = NextResponse.next({ request });
|
|
cookiesToSet.forEach(({ name, value, options }) =>
|
|
supabaseResponse.cookies.set(name, value, options)
|
|
);
|
|
},
|
|
},
|
|
}
|
|
);
|
|
|
|
const {
|
|
data: { user },
|
|
} = await supabase.auth.getUser();
|
|
|
|
// Redirect unauthenticated users to login (except auth pages)
|
|
if (
|
|
!user &&
|
|
!request.nextUrl.pathname.startsWith("/login") &&
|
|
!request.nextUrl.pathname.startsWith("/register") &&
|
|
!request.nextUrl.pathname.startsWith("/auth") &&
|
|
request.nextUrl.pathname !== "/"
|
|
) {
|
|
const url = request.nextUrl.clone();
|
|
url.pathname = "/login";
|
|
return NextResponse.redirect(url);
|
|
}
|
|
|
|
return supabaseResponse;
|
|
}
|
|
```
|
|
|
|
Create `src/middleware.ts`:
|
|
```typescript
|
|
import { updateSession } from "@/lib/supabase/middleware";
|
|
import { type NextRequest } from "next/server";
|
|
|
|
export async function middleware(request: NextRequest) {
|
|
return await updateSession(request);
|
|
}
|
|
|
|
export const config = {
|
|
matcher: [
|
|
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
|
|
],
|
|
};
|
|
```
|
|
|
|
**Step 7: Verify build**
|
|
|
|
Run: `npm run build`
|
|
Expected: Build succeeds
|
|
|
|
**Step 8: Commit**
|
|
|
|
```bash
|
|
git add src/lib/supabase/ src/middleware.ts .env.local
|
|
git commit -m "feat: configure Supabase client (browser, server, admin) with auth middleware"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 6: Create TypeScript types for database schema
|
|
|
|
**Files:**
|
|
- Create: `src/types/database.ts`
|
|
|
|
**Step 1: Create comprehensive database types**
|
|
|
|
Create `src/types/database.ts`:
|
|
```typescript
|
|
export type UserRole = "owner" | "admin" | "doctor" | "receptionist";
|
|
export type AppointmentStatus = "scheduled" | "confirmed" | "in_progress" | "completed" | "cancelled" | "no_show";
|
|
export type AppointmentType = "primera_vez" | "seguimiento" | "urgencia";
|
|
export type CfdiStatus = "draft" | "stamped" | "cancelled";
|
|
export type PaymentStatus = "pending" | "partial" | "paid";
|
|
export type PaymentMethod = "cash" | "card" | "transfer" | "other";
|
|
export type InventoryMovementType = "entrada" | "salida" | "ajuste" | "merma";
|
|
export type ProductCategory = "medicamento" | "insumo" | "material" | "equipo";
|
|
export type SubscriptionPlan = "basico" | "pro" | "enterprise";
|
|
export type SubscriptionStatus = "active" | "trial" | "suspended" | "cancelled";
|
|
|
|
export interface Clinic {
|
|
id: string;
|
|
name: string;
|
|
slug: string;
|
|
logo_url: string | null;
|
|
address: string | null;
|
|
phone: string | null;
|
|
email: string | null;
|
|
rfc: string | null;
|
|
razon_social: string | null;
|
|
regimen_fiscal: string | null;
|
|
codigo_postal: string | null;
|
|
pac_provider: string | null;
|
|
pac_api_key: string | null;
|
|
subscription_plan: SubscriptionPlan;
|
|
subscription_status: SubscriptionStatus;
|
|
trial_ends_at: string | null;
|
|
created_at: string;
|
|
}
|
|
|
|
export interface User {
|
|
id: string;
|
|
clinic_id: string;
|
|
full_name: string;
|
|
email: string;
|
|
phone: string | null;
|
|
role: UserRole;
|
|
specialty: string | null;
|
|
license_number: string | null;
|
|
color: string | null;
|
|
is_active: boolean;
|
|
avatar_url: string | null;
|
|
created_at: string;
|
|
}
|
|
|
|
export interface Address {
|
|
calle: string;
|
|
colonia: string;
|
|
cp: string;
|
|
ciudad: string;
|
|
estado: string;
|
|
}
|
|
|
|
export interface EmergencyContact {
|
|
nombre: string;
|
|
telefono: string;
|
|
parentesco: string;
|
|
}
|
|
|
|
export interface Patient {
|
|
id: string;
|
|
clinic_id: string;
|
|
first_name: string;
|
|
last_name: string;
|
|
date_of_birth: string | null;
|
|
gender: "M" | "F" | "otro" | null;
|
|
curp: string | null;
|
|
phone: string | null;
|
|
email: string | null;
|
|
address: Address | null;
|
|
blood_type: string | null;
|
|
allergies: string[];
|
|
emergency_contact: EmergencyContact | null;
|
|
notes: string | null;
|
|
source: string | null;
|
|
created_at: string;
|
|
}
|
|
|
|
export interface DoctorSchedule {
|
|
id: string;
|
|
clinic_id: string;
|
|
doctor_id: string;
|
|
day_of_week: number; // 0=monday ... 6=sunday
|
|
start_time: string;
|
|
end_time: string;
|
|
slot_duration: number; // minutes
|
|
is_active: boolean;
|
|
}
|
|
|
|
export interface Appointment {
|
|
id: string;
|
|
clinic_id: string;
|
|
patient_id: string;
|
|
doctor_id: string;
|
|
starts_at: string;
|
|
ends_at: string;
|
|
status: AppointmentStatus;
|
|
type: AppointmentType;
|
|
reason: string | null;
|
|
notes: string | null;
|
|
reminder_sent: boolean;
|
|
created_by: string;
|
|
created_at: string;
|
|
// Joined fields
|
|
patient?: Patient;
|
|
doctor?: User;
|
|
}
|
|
|
|
export interface VitalSigns {
|
|
peso?: number;
|
|
talla?: number;
|
|
ta_sistolica?: number;
|
|
ta_diastolica?: number;
|
|
fc?: number;
|
|
fr?: number;
|
|
temp?: number;
|
|
spo2?: number;
|
|
}
|
|
|
|
export interface Diagnosis {
|
|
code: string; // CIE-10
|
|
description: string;
|
|
type: "principal" | "secundario";
|
|
}
|
|
|
|
export interface MedicalRecord {
|
|
id: string;
|
|
clinic_id: string;
|
|
patient_id: string;
|
|
family_history: { enfermedad: string; parentesco: string }[];
|
|
personal_history: { enfermedad: string; anio_diagnostico: string; tratamiento: string }[];
|
|
surgical_history: { cirugia: string; fecha: string; notas: string }[];
|
|
current_medications: { medicamento: string; dosis: string; frecuencia: string }[];
|
|
immunizations: { vacuna: string; fecha: string }[];
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
export interface ConsultationNote {
|
|
id: string;
|
|
clinic_id: string;
|
|
patient_id: string;
|
|
doctor_id: string;
|
|
appointment_id: string | null;
|
|
subjective: string | null;
|
|
objective: string | null;
|
|
assessment: string | null;
|
|
plan: string | null;
|
|
vital_signs: VitalSigns | null;
|
|
diagnoses: Diagnosis[];
|
|
is_signed: boolean;
|
|
signed_at: string | null;
|
|
created_at: string;
|
|
}
|
|
|
|
export interface PrescriptionItem {
|
|
medicamento: string;
|
|
dosis: string;
|
|
via: string;
|
|
frecuencia: string;
|
|
duracion: string;
|
|
indicaciones: string;
|
|
}
|
|
|
|
export interface Prescription {
|
|
id: string;
|
|
clinic_id: string;
|
|
consultation_id: string;
|
|
patient_id: string;
|
|
doctor_id: string;
|
|
items: PrescriptionItem[];
|
|
pharmacy_notes: string | null;
|
|
pdf_url: string | null;
|
|
created_at: string;
|
|
}
|
|
|
|
export interface ServiceCatalog {
|
|
id: string;
|
|
clinic_id: string;
|
|
name: string;
|
|
description: string | null;
|
|
price: number;
|
|
sat_product_key: string | null;
|
|
sat_unit_key: string | null;
|
|
tax_rate: number;
|
|
is_active: boolean;
|
|
created_at: string;
|
|
}
|
|
|
|
export interface Invoice {
|
|
id: string;
|
|
clinic_id: string;
|
|
patient_id: string;
|
|
invoice_number: string;
|
|
cfdi_uuid: string | null;
|
|
cfdi_status: CfdiStatus;
|
|
cfdi_xml_url: string | null;
|
|
cfdi_pdf_url: string | null;
|
|
uso_cfdi: string | null;
|
|
forma_pago: string | null;
|
|
metodo_pago: string | null;
|
|
subtotal: number;
|
|
tax_amount: number;
|
|
total: number;
|
|
payment_status: PaymentStatus;
|
|
paid_amount: number;
|
|
due_date: string | null;
|
|
created_by: string;
|
|
created_at: string;
|
|
// Joined
|
|
patient?: Patient;
|
|
items?: InvoiceItem[];
|
|
}
|
|
|
|
export interface InvoiceItem {
|
|
id: string;
|
|
invoice_id: string;
|
|
service_id: string | null;
|
|
product_id: string | null;
|
|
description: string;
|
|
quantity: number;
|
|
unit_price: number;
|
|
tax_rate: number;
|
|
total: number;
|
|
}
|
|
|
|
export interface Payment {
|
|
id: string;
|
|
clinic_id: string;
|
|
invoice_id: string;
|
|
amount: number;
|
|
payment_method: PaymentMethod;
|
|
reference: string | null;
|
|
received_by: string;
|
|
created_at: string;
|
|
}
|
|
|
|
export interface Product {
|
|
id: string;
|
|
clinic_id: string;
|
|
sku: string | null;
|
|
name: string;
|
|
description: string | null;
|
|
category: ProductCategory;
|
|
unit: string;
|
|
purchase_price: number;
|
|
sale_price: number;
|
|
min_stock: number;
|
|
is_active: boolean;
|
|
created_at: string;
|
|
}
|
|
|
|
export interface InventoryStock {
|
|
id: string;
|
|
clinic_id: string;
|
|
product_id: string;
|
|
current_stock: number;
|
|
last_updated: string;
|
|
}
|
|
|
|
export interface InventoryMovement {
|
|
id: string;
|
|
clinic_id: string;
|
|
product_id: string;
|
|
type: InventoryMovementType;
|
|
quantity: number;
|
|
reason: string | null;
|
|
reference_id: string | null;
|
|
created_by: string;
|
|
created_at: string;
|
|
}
|
|
|
|
export interface Branch {
|
|
id: string;
|
|
clinic_id: string;
|
|
name: string;
|
|
address: string | null;
|
|
phone: string | null;
|
|
is_main: boolean;
|
|
is_active: boolean;
|
|
}
|
|
```
|
|
|
|
**Step 2: Verify build**
|
|
|
|
Run: `npm run build`
|
|
Expected: Build succeeds
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/types/
|
|
git commit -m "feat: add TypeScript types for complete database schema"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 7: Create database migrations — core tables with RLS
|
|
|
|
**Files:**
|
|
- Create: `supabase/migrations/001_clinics_and_users.sql`
|
|
- Create: `supabase/migrations/002_patients.sql`
|
|
- Create: `supabase/migrations/003_appointments.sql`
|
|
- Create: `supabase/migrations/004_medical_records.sql`
|
|
- Create: `supabase/migrations/005_billing.sql`
|
|
- Create: `supabase/migrations/006_inventory.sql`
|
|
- Create: `supabase/migrations/007_audit_log.sql`
|
|
|
|
**Step 1: Create migrations directory**
|
|
|
|
Run: `mkdir -p /root/CrmClinicas/supabase/migrations`
|
|
|
|
**Step 2: Create migration 001 — clinics and users**
|
|
|
|
Create `supabase/migrations/001_clinics_and_users.sql`:
|
|
```sql
|
|
-- Enable necessary extensions
|
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
|
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
|
|
|
-- ============================================
|
|
-- CLINICS (tenant table)
|
|
-- ============================================
|
|
CREATE TABLE clinics (
|
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
name TEXT NOT NULL,
|
|
slug TEXT UNIQUE NOT NULL,
|
|
logo_url TEXT,
|
|
address TEXT,
|
|
phone TEXT,
|
|
email TEXT,
|
|
rfc TEXT,
|
|
razon_social TEXT,
|
|
regimen_fiscal TEXT,
|
|
codigo_postal TEXT,
|
|
pac_provider TEXT CHECK (pac_provider IN ('facturama', 'sw_sapien')),
|
|
pac_api_key TEXT,
|
|
subscription_plan TEXT NOT NULL DEFAULT 'trial' CHECK (subscription_plan IN ('basico', 'pro', 'enterprise', 'trial')),
|
|
subscription_status TEXT NOT NULL DEFAULT 'trial' CHECK (subscription_status IN ('active', 'trial', 'suspended', 'cancelled')),
|
|
trial_ends_at TIMESTAMPTZ DEFAULT (NOW() + INTERVAL '14 days'),
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
-- ============================================
|
|
-- USERS (linked to auth.users)
|
|
-- ============================================
|
|
CREATE TABLE users (
|
|
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
clinic_id UUID NOT NULL REFERENCES clinics(id) ON DELETE CASCADE,
|
|
full_name TEXT NOT NULL,
|
|
email TEXT NOT NULL,
|
|
phone TEXT,
|
|
role TEXT NOT NULL DEFAULT 'receptionist' CHECK (role IN ('owner', 'admin', 'doctor', 'receptionist')),
|
|
specialty TEXT,
|
|
license_number TEXT,
|
|
color TEXT DEFAULT '#3B82F6',
|
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
avatar_url TEXT,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
CREATE INDEX idx_users_clinic ON users(clinic_id);
|
|
|
|
-- ============================================
|
|
-- BRANCHES
|
|
-- ============================================
|
|
CREATE TABLE branches (
|
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
clinic_id UUID NOT NULL REFERENCES clinics(id) ON DELETE CASCADE,
|
|
name TEXT NOT NULL,
|
|
address TEXT,
|
|
phone TEXT,
|
|
is_main BOOLEAN NOT NULL DEFAULT FALSE,
|
|
is_active BOOLEAN NOT NULL DEFAULT TRUE
|
|
);
|
|
|
|
CREATE INDEX idx_branches_clinic ON branches(clinic_id);
|
|
|
|
-- ============================================
|
|
-- Helper function: get current user's clinic_id
|
|
-- ============================================
|
|
CREATE OR REPLACE FUNCTION auth.clinic_id()
|
|
RETURNS UUID AS $$
|
|
SELECT clinic_id FROM public.users WHERE id = auth.uid()
|
|
$$ LANGUAGE SQL STABLE SECURITY DEFINER;
|
|
|
|
-- ============================================
|
|
-- RLS Policies
|
|
-- ============================================
|
|
|
|
-- Clinics: users can only see their own clinic
|
|
ALTER TABLE clinics ENABLE ROW LEVEL SECURITY;
|
|
CREATE POLICY "Users can view own clinic" ON clinics
|
|
FOR SELECT USING (id = auth.clinic_id());
|
|
CREATE POLICY "Owners can update own clinic" ON clinics
|
|
FOR UPDATE USING (id = auth.clinic_id());
|
|
|
|
-- Users: users can only see users in their clinic
|
|
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
|
|
CREATE POLICY "Users can view clinic members" ON users
|
|
FOR SELECT USING (clinic_id = auth.clinic_id());
|
|
CREATE POLICY "Owners/admins can insert users" ON users
|
|
FOR INSERT WITH CHECK (clinic_id = auth.clinic_id());
|
|
CREATE POLICY "Owners/admins can update users" ON users
|
|
FOR UPDATE USING (clinic_id = auth.clinic_id());
|
|
|
|
-- Branches
|
|
ALTER TABLE branches ENABLE ROW LEVEL SECURITY;
|
|
CREATE POLICY "Users can view clinic branches" ON branches
|
|
FOR SELECT USING (clinic_id = auth.clinic_id());
|
|
CREATE POLICY "Owners/admins can manage branches" ON branches
|
|
FOR ALL USING (clinic_id = auth.clinic_id());
|
|
```
|
|
|
|
**Step 3: Create migration 002 — patients**
|
|
|
|
Create `supabase/migrations/002_patients.sql`:
|
|
```sql
|
|
CREATE TABLE patients (
|
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
clinic_id UUID NOT NULL REFERENCES clinics(id) ON DELETE CASCADE,
|
|
first_name TEXT NOT NULL,
|
|
last_name TEXT NOT NULL,
|
|
date_of_birth DATE,
|
|
gender TEXT CHECK (gender IN ('M', 'F', 'otro')),
|
|
curp TEXT,
|
|
phone TEXT,
|
|
email TEXT,
|
|
address JSONB,
|
|
blood_type TEXT,
|
|
allergies TEXT[] DEFAULT '{}',
|
|
emergency_contact JSONB,
|
|
notes TEXT,
|
|
source TEXT,
|
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
CREATE INDEX idx_patients_clinic ON patients(clinic_id);
|
|
CREATE INDEX idx_patients_name ON patients(clinic_id, last_name, first_name);
|
|
CREATE INDEX idx_patients_phone ON patients(clinic_id, phone);
|
|
CREATE INDEX idx_patients_curp ON patients(clinic_id, curp);
|
|
|
|
ALTER TABLE patients ENABLE ROW LEVEL SECURITY;
|
|
CREATE POLICY "Users can view clinic patients" ON patients
|
|
FOR SELECT USING (clinic_id = auth.clinic_id());
|
|
CREATE POLICY "Users can insert clinic patients" ON patients
|
|
FOR INSERT WITH CHECK (clinic_id = auth.clinic_id());
|
|
CREATE POLICY "Users can update clinic patients" ON patients
|
|
FOR UPDATE USING (clinic_id = auth.clinic_id());
|
|
```
|
|
|
|
**Step 4: Create migration 003 — appointments**
|
|
|
|
Create `supabase/migrations/003_appointments.sql`:
|
|
```sql
|
|
CREATE TABLE doctor_schedules (
|
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
clinic_id UUID NOT NULL REFERENCES clinics(id) ON DELETE CASCADE,
|
|
doctor_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
day_of_week INT NOT NULL CHECK (day_of_week BETWEEN 0 AND 6),
|
|
start_time TIME NOT NULL,
|
|
end_time TIME NOT NULL,
|
|
slot_duration INT NOT NULL DEFAULT 30,
|
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
UNIQUE(doctor_id, day_of_week)
|
|
);
|
|
|
|
CREATE INDEX idx_schedules_doctor ON doctor_schedules(doctor_id);
|
|
|
|
CREATE TABLE appointments (
|
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
clinic_id UUID NOT NULL REFERENCES clinics(id) ON DELETE CASCADE,
|
|
patient_id UUID NOT NULL REFERENCES patients(id) ON DELETE CASCADE,
|
|
doctor_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
starts_at TIMESTAMPTZ NOT NULL,
|
|
ends_at TIMESTAMPTZ NOT NULL,
|
|
status TEXT NOT NULL DEFAULT 'scheduled'
|
|
CHECK (status IN ('scheduled', 'confirmed', 'in_progress', 'completed', 'cancelled', 'no_show')),
|
|
type TEXT NOT NULL DEFAULT 'seguimiento'
|
|
CHECK (type IN ('primera_vez', 'seguimiento', 'urgencia')),
|
|
reason TEXT,
|
|
notes TEXT,
|
|
reminder_sent BOOLEAN NOT NULL DEFAULT FALSE,
|
|
created_by UUID REFERENCES users(id),
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
CREATE INDEX idx_appointments_clinic ON appointments(clinic_id);
|
|
CREATE INDEX idx_appointments_doctor_date ON appointments(doctor_id, starts_at);
|
|
CREATE INDEX idx_appointments_patient ON appointments(patient_id);
|
|
CREATE INDEX idx_appointments_status ON appointments(clinic_id, status);
|
|
|
|
-- RLS
|
|
ALTER TABLE doctor_schedules ENABLE ROW LEVEL SECURITY;
|
|
CREATE POLICY "Users can view clinic schedules" ON doctor_schedules
|
|
FOR SELECT USING (clinic_id = auth.clinic_id());
|
|
CREATE POLICY "Users can manage clinic schedules" ON doctor_schedules
|
|
FOR ALL USING (clinic_id = auth.clinic_id());
|
|
|
|
ALTER TABLE appointments ENABLE ROW LEVEL SECURITY;
|
|
CREATE POLICY "Users can view clinic appointments" ON appointments
|
|
FOR SELECT USING (clinic_id = auth.clinic_id());
|
|
CREATE POLICY "Users can insert clinic appointments" ON appointments
|
|
FOR INSERT WITH CHECK (clinic_id = auth.clinic_id());
|
|
CREATE POLICY "Users can update clinic appointments" ON appointments
|
|
FOR UPDATE USING (clinic_id = auth.clinic_id());
|
|
```
|
|
|
|
**Step 5: Create migration 004 — medical records**
|
|
|
|
Create `supabase/migrations/004_medical_records.sql`:
|
|
```sql
|
|
CREATE TABLE medical_records (
|
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
clinic_id UUID NOT NULL REFERENCES clinics(id) ON DELETE CASCADE,
|
|
patient_id UUID NOT NULL REFERENCES patients(id) ON DELETE CASCADE,
|
|
family_history JSONB DEFAULT '[]',
|
|
personal_history JSONB DEFAULT '[]',
|
|
surgical_history JSONB DEFAULT '[]',
|
|
current_medications JSONB DEFAULT '[]',
|
|
immunizations JSONB DEFAULT '[]',
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
UNIQUE(patient_id)
|
|
);
|
|
|
|
CREATE TABLE consultation_notes (
|
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
clinic_id UUID NOT NULL REFERENCES clinics(id) ON DELETE CASCADE,
|
|
patient_id UUID NOT NULL REFERENCES patients(id) ON DELETE CASCADE,
|
|
doctor_id UUID NOT NULL REFERENCES users(id),
|
|
appointment_id UUID REFERENCES appointments(id),
|
|
subjective TEXT,
|
|
objective TEXT,
|
|
assessment TEXT,
|
|
plan TEXT,
|
|
vital_signs JSONB,
|
|
diagnoses JSONB DEFAULT '[]',
|
|
is_signed BOOLEAN NOT NULL DEFAULT FALSE,
|
|
signed_at TIMESTAMPTZ,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
CREATE INDEX idx_consultations_patient ON consultation_notes(patient_id, created_at DESC);
|
|
CREATE INDEX idx_consultations_doctor ON consultation_notes(doctor_id, created_at DESC);
|
|
CREATE INDEX idx_consultations_clinic ON consultation_notes(clinic_id);
|
|
|
|
CREATE TABLE prescriptions (
|
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
clinic_id UUID NOT NULL REFERENCES clinics(id) ON DELETE CASCADE,
|
|
consultation_id UUID NOT NULL REFERENCES consultation_notes(id) ON DELETE CASCADE,
|
|
patient_id UUID NOT NULL REFERENCES patients(id),
|
|
doctor_id UUID NOT NULL REFERENCES users(id),
|
|
items JSONB NOT NULL DEFAULT '[]',
|
|
pharmacy_notes TEXT,
|
|
pdf_url TEXT,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
CREATE TABLE medical_files (
|
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
clinic_id UUID NOT NULL REFERENCES clinics(id) ON DELETE CASCADE,
|
|
patient_id UUID NOT NULL REFERENCES patients(id) ON DELETE CASCADE,
|
|
consultation_id UUID REFERENCES consultation_notes(id),
|
|
file_name TEXT NOT NULL,
|
|
file_type TEXT NOT NULL DEFAULT 'other'
|
|
CHECK (file_type IN ('lab_result', 'imaging', 'referral', 'other')),
|
|
storage_path TEXT NOT NULL,
|
|
uploaded_by UUID REFERENCES users(id),
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
-- RLS
|
|
ALTER TABLE medical_records ENABLE ROW LEVEL SECURITY;
|
|
CREATE POLICY "Users can view clinic medical records" ON medical_records
|
|
FOR SELECT USING (clinic_id = auth.clinic_id());
|
|
CREATE POLICY "Doctors can insert medical records" ON medical_records
|
|
FOR INSERT WITH CHECK (clinic_id = auth.clinic_id());
|
|
CREATE POLICY "Doctors can update medical records" ON medical_records
|
|
FOR UPDATE USING (clinic_id = auth.clinic_id());
|
|
|
|
ALTER TABLE consultation_notes ENABLE ROW LEVEL SECURITY;
|
|
CREATE POLICY "Users can view clinic consultations" ON consultation_notes
|
|
FOR SELECT USING (clinic_id = auth.clinic_id());
|
|
CREATE POLICY "Doctors can insert consultations" ON consultation_notes
|
|
FOR INSERT WITH CHECK (clinic_id = auth.clinic_id());
|
|
CREATE POLICY "Doctors can update own consultations" ON consultation_notes
|
|
FOR UPDATE USING (clinic_id = auth.clinic_id() AND doctor_id = auth.uid());
|
|
|
|
ALTER TABLE prescriptions ENABLE ROW LEVEL SECURITY;
|
|
CREATE POLICY "Users can view clinic prescriptions" ON prescriptions
|
|
FOR SELECT USING (clinic_id = auth.clinic_id());
|
|
CREATE POLICY "Doctors can insert prescriptions" ON prescriptions
|
|
FOR INSERT WITH CHECK (clinic_id = auth.clinic_id());
|
|
|
|
ALTER TABLE medical_files ENABLE ROW LEVEL SECURITY;
|
|
CREATE POLICY "Users can view clinic files" ON medical_files
|
|
FOR SELECT USING (clinic_id = auth.clinic_id());
|
|
CREATE POLICY "Users can insert clinic files" ON medical_files
|
|
FOR INSERT WITH CHECK (clinic_id = auth.clinic_id());
|
|
```
|
|
|
|
**Step 6: Create migration 005 — billing**
|
|
|
|
Create `supabase/migrations/005_billing.sql`:
|
|
```sql
|
|
CREATE TABLE services_catalog (
|
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
clinic_id UUID NOT NULL REFERENCES clinics(id) ON DELETE CASCADE,
|
|
name TEXT NOT NULL,
|
|
description TEXT,
|
|
price DECIMAL(10,2) NOT NULL DEFAULT 0,
|
|
sat_product_key TEXT,
|
|
sat_unit_key TEXT DEFAULT 'E48',
|
|
tax_rate DECIMAL(4,2) NOT NULL DEFAULT 0.16,
|
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
CREATE INDEX idx_services_clinic ON services_catalog(clinic_id);
|
|
|
|
CREATE TABLE invoices (
|
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
clinic_id UUID NOT NULL REFERENCES clinics(id) ON DELETE CASCADE,
|
|
patient_id UUID NOT NULL REFERENCES patients(id),
|
|
invoice_number TEXT NOT NULL,
|
|
cfdi_uuid TEXT,
|
|
cfdi_status TEXT NOT NULL DEFAULT 'draft'
|
|
CHECK (cfdi_status IN ('draft', 'stamped', 'cancelled')),
|
|
cfdi_xml_url TEXT,
|
|
cfdi_pdf_url TEXT,
|
|
uso_cfdi TEXT DEFAULT 'S01',
|
|
forma_pago TEXT DEFAULT '01',
|
|
metodo_pago TEXT DEFAULT 'PUE',
|
|
subtotal DECIMAL(10,2) NOT NULL DEFAULT 0,
|
|
tax_amount DECIMAL(10,2) NOT NULL DEFAULT 0,
|
|
total DECIMAL(10,2) NOT NULL DEFAULT 0,
|
|
payment_status TEXT NOT NULL DEFAULT 'pending'
|
|
CHECK (payment_status IN ('pending', 'partial', 'paid')),
|
|
paid_amount DECIMAL(10,2) NOT NULL DEFAULT 0,
|
|
due_date DATE,
|
|
created_by UUID REFERENCES users(id),
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
UNIQUE(clinic_id, invoice_number)
|
|
);
|
|
|
|
CREATE INDEX idx_invoices_clinic ON invoices(clinic_id);
|
|
CREATE INDEX idx_invoices_patient ON invoices(patient_id);
|
|
CREATE INDEX idx_invoices_status ON invoices(clinic_id, payment_status);
|
|
|
|
CREATE TABLE invoice_items (
|
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
invoice_id UUID NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
|
|
service_id UUID REFERENCES services_catalog(id),
|
|
product_id UUID, -- FK added after products table
|
|
description TEXT NOT NULL,
|
|
quantity DECIMAL(10,2) NOT NULL DEFAULT 1,
|
|
unit_price DECIMAL(10,2) NOT NULL DEFAULT 0,
|
|
tax_rate DECIMAL(4,2) NOT NULL DEFAULT 0.16,
|
|
total DECIMAL(10,2) NOT NULL DEFAULT 0
|
|
);
|
|
|
|
CREATE TABLE payments (
|
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
clinic_id UUID NOT NULL REFERENCES clinics(id) ON DELETE CASCADE,
|
|
invoice_id UUID NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
|
|
amount DECIMAL(10,2) NOT NULL,
|
|
payment_method TEXT NOT NULL DEFAULT 'cash'
|
|
CHECK (payment_method IN ('cash', 'card', 'transfer', 'other')),
|
|
reference TEXT,
|
|
received_by UUID REFERENCES users(id),
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
CREATE INDEX idx_payments_invoice ON payments(invoice_id);
|
|
|
|
-- RLS
|
|
ALTER TABLE services_catalog ENABLE ROW LEVEL SECURITY;
|
|
CREATE POLICY "Users can view clinic services" ON services_catalog
|
|
FOR SELECT USING (clinic_id = auth.clinic_id());
|
|
CREATE POLICY "Users can manage clinic services" ON services_catalog
|
|
FOR ALL USING (clinic_id = auth.clinic_id());
|
|
|
|
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
|
|
CREATE POLICY "Users can view clinic invoices" ON invoices
|
|
FOR SELECT USING (clinic_id = auth.clinic_id());
|
|
CREATE POLICY "Users can insert clinic invoices" ON invoices
|
|
FOR INSERT WITH CHECK (clinic_id = auth.clinic_id());
|
|
CREATE POLICY "Users can update clinic invoices" ON invoices
|
|
FOR UPDATE USING (clinic_id = auth.clinic_id());
|
|
|
|
ALTER TABLE invoice_items ENABLE ROW LEVEL SECURITY;
|
|
CREATE POLICY "Users can view invoice items" ON invoice_items
|
|
FOR SELECT USING (
|
|
EXISTS (SELECT 1 FROM invoices WHERE invoices.id = invoice_items.invoice_id AND invoices.clinic_id = auth.clinic_id())
|
|
);
|
|
CREATE POLICY "Users can manage invoice items" ON invoice_items
|
|
FOR ALL USING (
|
|
EXISTS (SELECT 1 FROM invoices WHERE invoices.id = invoice_items.invoice_id AND invoices.clinic_id = auth.clinic_id())
|
|
);
|
|
|
|
ALTER TABLE payments ENABLE ROW LEVEL SECURITY;
|
|
CREATE POLICY "Users can view clinic payments" ON payments
|
|
FOR SELECT USING (clinic_id = auth.clinic_id());
|
|
CREATE POLICY "Users can insert clinic payments" ON payments
|
|
FOR INSERT WITH CHECK (clinic_id = auth.clinic_id());
|
|
```
|
|
|
|
**Step 7: Create migration 006 — inventory**
|
|
|
|
Create `supabase/migrations/006_inventory.sql`:
|
|
```sql
|
|
CREATE TABLE products (
|
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
clinic_id UUID NOT NULL REFERENCES clinics(id) ON DELETE CASCADE,
|
|
sku TEXT,
|
|
name TEXT NOT NULL,
|
|
description TEXT,
|
|
category TEXT NOT NULL DEFAULT 'insumo'
|
|
CHECK (category IN ('medicamento', 'insumo', 'material', 'equipo')),
|
|
unit TEXT NOT NULL DEFAULT 'pieza',
|
|
purchase_price DECIMAL(10,2) NOT NULL DEFAULT 0,
|
|
sale_price DECIMAL(10,2) NOT NULL DEFAULT 0,
|
|
min_stock INT NOT NULL DEFAULT 0,
|
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
CREATE INDEX idx_products_clinic ON products(clinic_id);
|
|
|
|
-- Add FK from invoice_items to products
|
|
ALTER TABLE invoice_items
|
|
ADD CONSTRAINT fk_invoice_items_product
|
|
FOREIGN KEY (product_id) REFERENCES products(id);
|
|
|
|
CREATE TABLE inventory_stock (
|
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
clinic_id UUID NOT NULL REFERENCES clinics(id) ON DELETE CASCADE,
|
|
product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE,
|
|
current_stock DECIMAL(10,2) NOT NULL DEFAULT 0,
|
|
last_updated TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
UNIQUE(clinic_id, product_id)
|
|
);
|
|
|
|
CREATE TABLE inventory_movements (
|
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
clinic_id UUID NOT NULL REFERENCES clinics(id) ON DELETE CASCADE,
|
|
product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE,
|
|
type TEXT NOT NULL CHECK (type IN ('entrada', 'salida', 'ajuste', 'merma')),
|
|
quantity DECIMAL(10,2) NOT NULL,
|
|
reason TEXT,
|
|
reference_id TEXT,
|
|
created_by UUID REFERENCES users(id),
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
CREATE INDEX idx_inventory_stock_clinic ON inventory_stock(clinic_id);
|
|
CREATE INDEX idx_movements_product ON inventory_movements(product_id, created_at DESC);
|
|
|
|
-- Trigger: update stock on movement
|
|
CREATE OR REPLACE FUNCTION update_inventory_stock()
|
|
RETURNS TRIGGER AS $$
|
|
BEGIN
|
|
INSERT INTO inventory_stock (clinic_id, product_id, current_stock, last_updated)
|
|
VALUES (NEW.clinic_id, NEW.product_id, NEW.quantity, NOW())
|
|
ON CONFLICT (clinic_id, product_id)
|
|
DO UPDATE SET
|
|
current_stock = inventory_stock.current_stock + NEW.quantity,
|
|
last_updated = NOW();
|
|
RETURN NEW;
|
|
END;
|
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
|
|
|
CREATE TRIGGER trg_update_stock
|
|
AFTER INSERT ON inventory_movements
|
|
FOR EACH ROW EXECUTE FUNCTION update_inventory_stock();
|
|
|
|
-- RLS
|
|
ALTER TABLE products ENABLE ROW LEVEL SECURITY;
|
|
CREATE POLICY "Users can view clinic products" ON products
|
|
FOR SELECT USING (clinic_id = auth.clinic_id());
|
|
CREATE POLICY "Users can manage clinic products" ON products
|
|
FOR ALL USING (clinic_id = auth.clinic_id());
|
|
|
|
ALTER TABLE inventory_stock ENABLE ROW LEVEL SECURITY;
|
|
CREATE POLICY "Users can view clinic stock" ON inventory_stock
|
|
FOR SELECT USING (clinic_id = auth.clinic_id());
|
|
CREATE POLICY "Users can manage clinic stock" ON inventory_stock
|
|
FOR ALL USING (clinic_id = auth.clinic_id());
|
|
|
|
ALTER TABLE inventory_movements ENABLE ROW LEVEL SECURITY;
|
|
CREATE POLICY "Users can view clinic movements" ON inventory_movements
|
|
FOR SELECT USING (clinic_id = auth.clinic_id());
|
|
CREATE POLICY "Users can insert clinic movements" ON inventory_movements
|
|
FOR INSERT WITH CHECK (clinic_id = auth.clinic_id());
|
|
```
|
|
|
|
**Step 8: Create migration 007 — audit log**
|
|
|
|
Create `supabase/migrations/007_audit_log.sql`:
|
|
```sql
|
|
CREATE TABLE audit_log (
|
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
clinic_id UUID REFERENCES clinics(id),
|
|
user_id UUID REFERENCES auth.users(id),
|
|
table_name TEXT NOT NULL,
|
|
record_id UUID,
|
|
action TEXT NOT NULL CHECK (action IN ('INSERT', 'UPDATE', 'DELETE')),
|
|
old_data JSONB,
|
|
new_data JSONB,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
CREATE INDEX idx_audit_clinic ON audit_log(clinic_id, created_at DESC);
|
|
CREATE INDEX idx_audit_table ON audit_log(table_name, record_id);
|
|
|
|
-- Generic audit trigger function
|
|
CREATE OR REPLACE FUNCTION audit_trigger_func()
|
|
RETURNS TRIGGER AS $$
|
|
BEGIN
|
|
IF TG_OP = 'INSERT' THEN
|
|
INSERT INTO audit_log (clinic_id, user_id, table_name, record_id, action, new_data)
|
|
VALUES (NEW.clinic_id, auth.uid(), TG_TABLE_NAME, NEW.id, 'INSERT', to_jsonb(NEW));
|
|
RETURN NEW;
|
|
ELSIF TG_OP = 'UPDATE' THEN
|
|
INSERT INTO audit_log (clinic_id, user_id, table_name, record_id, action, old_data, new_data)
|
|
VALUES (NEW.clinic_id, auth.uid(), TG_TABLE_NAME, NEW.id, 'UPDATE', to_jsonb(OLD), to_jsonb(NEW));
|
|
RETURN NEW;
|
|
ELSIF TG_OP = 'DELETE' THEN
|
|
INSERT INTO audit_log (clinic_id, user_id, table_name, record_id, action, old_data)
|
|
VALUES (OLD.clinic_id, auth.uid(), TG_TABLE_NAME, OLD.id, 'DELETE', to_jsonb(OLD));
|
|
RETURN OLD;
|
|
END IF;
|
|
END;
|
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
|
|
|
-- Attach audit triggers to critical tables
|
|
CREATE TRIGGER audit_patients AFTER INSERT OR UPDATE OR DELETE ON patients
|
|
FOR EACH ROW EXECUTE FUNCTION audit_trigger_func();
|
|
|
|
CREATE TRIGGER audit_appointments AFTER INSERT OR UPDATE OR DELETE ON appointments
|
|
FOR EACH ROW EXECUTE FUNCTION audit_trigger_func();
|
|
|
|
CREATE TRIGGER audit_consultation_notes AFTER INSERT OR UPDATE OR DELETE ON consultation_notes
|
|
FOR EACH ROW EXECUTE FUNCTION audit_trigger_func();
|
|
|
|
CREATE TRIGGER audit_invoices AFTER INSERT OR UPDATE OR DELETE ON invoices
|
|
FOR EACH ROW EXECUTE FUNCTION audit_trigger_func();
|
|
|
|
CREATE TRIGGER audit_inventory_movements AFTER INSERT ON inventory_movements
|
|
FOR EACH ROW EXECUTE FUNCTION audit_trigger_func();
|
|
|
|
-- RLS: only admins/owners can view audit log
|
|
ALTER TABLE audit_log ENABLE ROW LEVEL SECURITY;
|
|
CREATE POLICY "Admins can view clinic audit log" ON audit_log
|
|
FOR SELECT USING (clinic_id = auth.clinic_id());
|
|
```
|
|
|
|
**Step 9: Run migrations against Supabase**
|
|
|
|
Run:
|
|
```bash
|
|
cd /root/CrmClinicas
|
|
for f in supabase/migrations/*.sql; do
|
|
echo "Running $f..."
|
|
docker exec -i docker-db-1 psql -U supabase_admin -d postgres < "$f"
|
|
done
|
|
```
|
|
|
|
Expected: Each migration runs without errors.
|
|
|
|
**Step 10: Verify tables exist**
|
|
|
|
Run:
|
|
```bash
|
|
docker exec docker-db-1 psql -U supabase_admin -d postgres -c "\dt public.*"
|
|
```
|
|
|
|
Expected: Lists all tables (clinics, users, patients, appointments, etc.)
|
|
|
|
**Step 11: Commit**
|
|
|
|
```bash
|
|
git add supabase/
|
|
git commit -m "feat: add database migrations with RLS policies and audit triggers"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 8: Create dashboard layout with sidebar navigation
|
|
|
|
**Files:**
|
|
- Create: `src/components/layout/sidebar.tsx`
|
|
- Create: `src/components/layout/header.tsx`
|
|
- Create: `src/components/layout/user-nav.tsx`
|
|
- Create: `src/app/(dashboard)/layout.tsx`
|
|
- Create: `src/app/(dashboard)/page.tsx`
|
|
- Modify: `src/app/layout.tsx`
|
|
|
|
**Step 1: Install icons package**
|
|
|
|
Run: `cd /root/CrmClinicas && npm install lucide-react`
|
|
|
|
**Step 2: Create sidebar component**
|
|
|
|
Create `src/components/layout/sidebar.tsx`:
|
|
```tsx
|
|
"use client";
|
|
|
|
import Link from "next/link";
|
|
import { usePathname } from "next/navigation";
|
|
import { cn } from "@/lib/utils";
|
|
import {
|
|
LayoutDashboard,
|
|
Users,
|
|
Calendar,
|
|
FileText,
|
|
Receipt,
|
|
Package,
|
|
Settings,
|
|
} from "lucide-react";
|
|
|
|
const navigation = [
|
|
{ name: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
|
|
{ name: "Pacientes", href: "/patients", icon: Users },
|
|
{ name: "Agenda", href: "/appointments", icon: Calendar },
|
|
{ name: "Expedientes", href: "/medical-records", icon: FileText },
|
|
{ name: "Facturacion", href: "/billing", icon: Receipt },
|
|
{ name: "Inventario", href: "/inventory", icon: Package },
|
|
{ name: "Configuracion", href: "/settings", icon: Settings },
|
|
];
|
|
|
|
export function Sidebar() {
|
|
const pathname = usePathname();
|
|
|
|
return (
|
|
<aside className="flex h-full w-64 flex-col border-r bg-card">
|
|
<div className="flex h-16 items-center border-b px-6">
|
|
<h1 className="text-xl font-bold">CRM Clinicas</h1>
|
|
</div>
|
|
<nav className="flex-1 space-y-1 p-4">
|
|
{navigation.map((item) => {
|
|
const isActive = pathname.startsWith(item.href);
|
|
return (
|
|
<Link
|
|
key={item.href}
|
|
href={item.href}
|
|
className={cn(
|
|
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
|
|
isActive
|
|
? "bg-primary text-primary-foreground"
|
|
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
|
)}
|
|
>
|
|
<item.icon className="h-5 w-5" />
|
|
{item.name}
|
|
</Link>
|
|
);
|
|
})}
|
|
</nav>
|
|
</aside>
|
|
);
|
|
}
|
|
```
|
|
|
|
**Step 3: Create header component**
|
|
|
|
Create `src/components/layout/header.tsx`:
|
|
```tsx
|
|
import { UserNav } from "./user-nav";
|
|
|
|
export function Header() {
|
|
return (
|
|
<header className="flex h-16 items-center justify-between border-b px-6">
|
|
<div />
|
|
<UserNav />
|
|
</header>
|
|
);
|
|
}
|
|
```
|
|
|
|
**Step 4: Create user navigation (avatar + logout)**
|
|
|
|
Create `src/components/layout/user-nav.tsx`:
|
|
```tsx
|
|
"use client";
|
|
|
|
import { useRouter } from "next/navigation";
|
|
import { createClient } from "@/lib/supabase/client";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuLabel,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
|
import { LogOut, User } from "lucide-react";
|
|
|
|
export function UserNav() {
|
|
const router = useRouter();
|
|
const supabase = createClient();
|
|
|
|
async function handleSignOut() {
|
|
await supabase.auth.signOut();
|
|
router.push("/login");
|
|
}
|
|
|
|
return (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="ghost" className="relative h-9 w-9 rounded-full">
|
|
<Avatar className="h-9 w-9">
|
|
<AvatarFallback>
|
|
<User className="h-4 w-4" />
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent className="w-56" align="end" forceMount>
|
|
<DropdownMenuLabel>Mi cuenta</DropdownMenuLabel>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem onClick={handleSignOut}>
|
|
<LogOut className="mr-2 h-4 w-4" />
|
|
Cerrar sesion
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
);
|
|
}
|
|
```
|
|
|
|
**Step 5: Create dashboard layout**
|
|
|
|
Create `src/app/(dashboard)/layout.tsx`:
|
|
```tsx
|
|
import { Sidebar } from "@/components/layout/sidebar";
|
|
import { Header } from "@/components/layout/header";
|
|
|
|
export default function DashboardLayout({
|
|
children,
|
|
}: {
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<div className="flex h-screen">
|
|
<Sidebar />
|
|
<div className="flex flex-1 flex-col overflow-hidden">
|
|
<Header />
|
|
<main className="flex-1 overflow-y-auto p-6">{children}</main>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
**Step 6: Create dashboard home page**
|
|
|
|
Create `src/app/(dashboard)/page.tsx`:
|
|
```tsx
|
|
export const dynamic = "force-dynamic";
|
|
|
|
export default function DashboardPage() {
|
|
return (
|
|
<div>
|
|
<h2 className="text-3xl font-bold tracking-tight">Dashboard</h2>
|
|
<p className="text-muted-foreground">
|
|
Bienvenido al CRM de tu clinica.
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
**Step 7: Verify build**
|
|
|
|
Run: `npm run build`
|
|
Expected: Build succeeds
|
|
|
|
**Step 8: Commit**
|
|
|
|
```bash
|
|
git add src/components/layout/ src/app/\(dashboard\)/
|
|
git commit -m "feat: add dashboard layout with sidebar navigation"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 9: Create authentication pages (login + register)
|
|
|
|
**Files:**
|
|
- Create: `src/app/(auth)/login/page.tsx`
|
|
- Create: `src/app/(auth)/register/page.tsx`
|
|
- Create: `src/app/(auth)/layout.tsx`
|
|
|
|
**Step 1: Create auth layout (centered card)**
|
|
|
|
Create `src/app/(auth)/layout.tsx`:
|
|
```tsx
|
|
export default function AuthLayout({
|
|
children,
|
|
}: {
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<div className="flex min-h-screen items-center justify-center bg-muted/50 p-4">
|
|
<div className="w-full max-w-md">{children}</div>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
**Step 2: Create login page**
|
|
|
|
Create `src/app/(auth)/login/page.tsx`:
|
|
```tsx
|
|
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import Link from "next/link";
|
|
import { createClient } from "@/lib/supabase/client";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
|
|
|
export default function LoginPage() {
|
|
const router = useRouter();
|
|
const supabase = createClient();
|
|
const [email, setEmail] = useState("");
|
|
const [password, setPassword] = useState("");
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
async function handleSubmit(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
setError(null);
|
|
setLoading(true);
|
|
|
|
const { error } = await supabase.auth.signInWithPassword({
|
|
email,
|
|
password,
|
|
});
|
|
|
|
if (error) {
|
|
setError(error.message);
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
router.push("/dashboard");
|
|
}
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader className="text-center">
|
|
<CardTitle className="text-2xl">CRM Clinicas</CardTitle>
|
|
<CardDescription>Inicia sesion en tu cuenta</CardDescription>
|
|
</CardHeader>
|
|
<form onSubmit={handleSubmit}>
|
|
<CardContent className="space-y-4">
|
|
{error && (
|
|
<div className="rounded-md bg-destructive/15 p-3 text-sm text-destructive">
|
|
{error}
|
|
</div>
|
|
)}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="email">Correo electronico</Label>
|
|
<Input
|
|
id="email"
|
|
type="email"
|
|
placeholder="doctor@clinica.com"
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="password">Contrasena</Label>
|
|
<Input
|
|
id="password"
|
|
type="password"
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
required
|
|
/>
|
|
</div>
|
|
</CardContent>
|
|
<CardFooter className="flex flex-col gap-4">
|
|
<Button type="submit" className="w-full" disabled={loading}>
|
|
{loading ? "Iniciando sesion..." : "Iniciar sesion"}
|
|
</Button>
|
|
<p className="text-sm text-muted-foreground">
|
|
No tienes cuenta?{" "}
|
|
<Link href="/register" className="text-primary underline">
|
|
Registrate
|
|
</Link>
|
|
</p>
|
|
</CardFooter>
|
|
</form>
|
|
</Card>
|
|
);
|
|
}
|
|
```
|
|
|
|
**Step 3: Create register page (clinic + owner signup)**
|
|
|
|
Create `src/app/(auth)/register/page.tsx`:
|
|
```tsx
|
|
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import Link from "next/link";
|
|
import { createClient } from "@/lib/supabase/client";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
|
|
|
export default function RegisterPage() {
|
|
const router = useRouter();
|
|
const supabase = createClient();
|
|
const [formData, setFormData] = useState({
|
|
clinicName: "",
|
|
fullName: "",
|
|
email: "",
|
|
password: "",
|
|
});
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
function updateField(field: string, value: string) {
|
|
setFormData((prev) => ({ ...prev, [field]: value }));
|
|
}
|
|
|
|
async function handleSubmit(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
setError(null);
|
|
setLoading(true);
|
|
|
|
// 1. Sign up auth user
|
|
const { data: authData, error: authError } = await supabase.auth.signUp({
|
|
email: formData.email,
|
|
password: formData.password,
|
|
});
|
|
|
|
if (authError || !authData.user) {
|
|
setError(authError?.message || "Error al crear cuenta");
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
// 2. Create clinic + user profile via service role (RPC or direct insert)
|
|
// For MVP: use a server action or edge function
|
|
// For now, we insert with the anon key since we have RLS INSERT policies
|
|
const slug = formData.clinicName
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, "-")
|
|
.replace(/(^-|-$)/g, "");
|
|
|
|
const { data: clinic, error: clinicError } = await supabase
|
|
.from("clinics")
|
|
.insert({ name: formData.clinicName, slug })
|
|
.select()
|
|
.single();
|
|
|
|
if (clinicError) {
|
|
setError("Error al crear clinica: " + clinicError.message);
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
const { error: userError } = await supabase.from("users").insert({
|
|
id: authData.user.id,
|
|
clinic_id: clinic.id,
|
|
full_name: formData.fullName,
|
|
email: formData.email,
|
|
role: "owner",
|
|
});
|
|
|
|
if (userError) {
|
|
setError("Error al crear perfil: " + userError.message);
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
router.push("/dashboard");
|
|
}
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader className="text-center">
|
|
<CardTitle className="text-2xl">Crear cuenta</CardTitle>
|
|
<CardDescription>Registra tu clinica y comienza gratis</CardDescription>
|
|
</CardHeader>
|
|
<form onSubmit={handleSubmit}>
|
|
<CardContent className="space-y-4">
|
|
{error && (
|
|
<div className="rounded-md bg-destructive/15 p-3 text-sm text-destructive">
|
|
{error}
|
|
</div>
|
|
)}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="clinicName">Nombre de la clinica</Label>
|
|
<Input
|
|
id="clinicName"
|
|
placeholder="Clinica San Jose"
|
|
value={formData.clinicName}
|
|
onChange={(e) => updateField("clinicName", e.target.value)}
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="fullName">Tu nombre completo</Label>
|
|
<Input
|
|
id="fullName"
|
|
placeholder="Dr. Juan Perez"
|
|
value={formData.fullName}
|
|
onChange={(e) => updateField("fullName", e.target.value)}
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="email">Correo electronico</Label>
|
|
<Input
|
|
id="email"
|
|
type="email"
|
|
placeholder="doctor@clinica.com"
|
|
value={formData.email}
|
|
onChange={(e) => updateField("email", e.target.value)}
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="password">Contrasena</Label>
|
|
<Input
|
|
id="password"
|
|
type="password"
|
|
placeholder="Minimo 6 caracteres"
|
|
value={formData.password}
|
|
onChange={(e) => updateField("password", e.target.value)}
|
|
minLength={6}
|
|
required
|
|
/>
|
|
</div>
|
|
</CardContent>
|
|
<CardFooter className="flex flex-col gap-4">
|
|
<Button type="submit" className="w-full" disabled={loading}>
|
|
{loading ? "Creando cuenta..." : "Crear cuenta"}
|
|
</Button>
|
|
<p className="text-sm text-muted-foreground">
|
|
Ya tienes cuenta?{" "}
|
|
<Link href="/login" className="text-primary underline">
|
|
Inicia sesion
|
|
</Link>
|
|
</p>
|
|
</CardFooter>
|
|
</form>
|
|
</Card>
|
|
);
|
|
}
|
|
```
|
|
|
|
**Step 4: Update root page to redirect**
|
|
|
|
Modify `src/app/page.tsx` to redirect to login:
|
|
```tsx
|
|
import { redirect } from "next/navigation";
|
|
|
|
export default function Home() {
|
|
redirect("/login");
|
|
}
|
|
```
|
|
|
|
**Step 5: Verify build**
|
|
|
|
Run: `npm run build`
|
|
Expected: Build succeeds
|
|
|
|
**Step 6: Commit**
|
|
|
|
```bash
|
|
git add src/app/\(auth\)/ src/app/page.tsx
|
|
git commit -m "feat: add login and register pages with Supabase auth"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 10: Create patients CRUD
|
|
|
|
**Files:**
|
|
- Create: `src/app/(dashboard)/patients/page.tsx`
|
|
- Create: `src/app/(dashboard)/patients/new/page.tsx`
|
|
- Create: `src/app/(dashboard)/patients/[id]/page.tsx`
|
|
- Create: `src/app/(dashboard)/patients/columns.tsx`
|
|
- Create: `src/components/patients/patient-form.tsx`
|
|
- Create: `src/lib/actions/patients.ts`
|
|
|
|
This is a large task — see Phase 2 details. For now, the files above outline the structure. Each file follows the same pattern: Server Component page that fetches data via Supabase server client, Client Components for forms and interactivity, Server Actions for mutations.
|
|
|
|
**Step 1: Create server actions for patients**
|
|
|
|
Create `src/lib/actions/patients.ts`:
|
|
```typescript
|
|
"use server";
|
|
|
|
import { createClient } from "@/lib/supabase/server";
|
|
import { revalidatePath } from "next/cache";
|
|
import type { Patient } from "@/types/database";
|
|
|
|
export async function getPatients(search?: string) {
|
|
const supabase = await createClient();
|
|
let query = supabase
|
|
.from("patients")
|
|
.select("*")
|
|
.eq("is_active", true)
|
|
.order("created_at", { ascending: false });
|
|
|
|
if (search) {
|
|
query = query.or(
|
|
`first_name.ilike.%${search}%,last_name.ilike.%${search}%,phone.ilike.%${search}%,curp.ilike.%${search}%`
|
|
);
|
|
}
|
|
|
|
const { data, error } = await query;
|
|
if (error) throw error;
|
|
return data as Patient[];
|
|
}
|
|
|
|
export async function getPatient(id: string) {
|
|
const supabase = await createClient();
|
|
const { data, error } = await supabase
|
|
.from("patients")
|
|
.select("*")
|
|
.eq("id", id)
|
|
.single();
|
|
|
|
if (error) throw error;
|
|
return data as Patient;
|
|
}
|
|
|
|
export async function createPatient(formData: FormData) {
|
|
const supabase = await createClient();
|
|
const { data: { user } } = await supabase.auth.getUser();
|
|
if (!user) throw new Error("No autenticado");
|
|
|
|
// Get user's clinic_id
|
|
const { data: profile } = await supabase
|
|
.from("users")
|
|
.select("clinic_id")
|
|
.eq("id", user.id)
|
|
.single();
|
|
|
|
if (!profile) throw new Error("Perfil no encontrado");
|
|
|
|
const patient = {
|
|
clinic_id: profile.clinic_id,
|
|
first_name: formData.get("first_name") as string,
|
|
last_name: formData.get("last_name") as string,
|
|
date_of_birth: (formData.get("date_of_birth") as string) || null,
|
|
gender: (formData.get("gender") as string) || null,
|
|
curp: (formData.get("curp") as string) || null,
|
|
phone: (formData.get("phone") as string) || null,
|
|
email: (formData.get("email") as string) || null,
|
|
blood_type: (formData.get("blood_type") as string) || null,
|
|
notes: (formData.get("notes") as string) || null,
|
|
source: (formData.get("source") as string) || null,
|
|
};
|
|
|
|
const { error } = await supabase.from("patients").insert(patient);
|
|
if (error) throw error;
|
|
|
|
revalidatePath("/patients");
|
|
}
|
|
|
|
export async function updatePatient(id: string, formData: FormData) {
|
|
const supabase = await createClient();
|
|
|
|
const updates = {
|
|
first_name: formData.get("first_name") as string,
|
|
last_name: formData.get("last_name") as string,
|
|
date_of_birth: (formData.get("date_of_birth") as string) || null,
|
|
gender: (formData.get("gender") as string) || null,
|
|
curp: (formData.get("curp") as string) || null,
|
|
phone: (formData.get("phone") as string) || null,
|
|
email: (formData.get("email") as string) || null,
|
|
blood_type: (formData.get("blood_type") as string) || null,
|
|
notes: (formData.get("notes") as string) || null,
|
|
source: (formData.get("source") as string) || null,
|
|
};
|
|
|
|
const { error } = await supabase
|
|
.from("patients")
|
|
.update(updates)
|
|
.eq("id", id);
|
|
|
|
if (error) throw error;
|
|
revalidatePath("/patients");
|
|
revalidatePath(`/patients/${id}`);
|
|
}
|
|
```
|
|
|
|
**Step 2-6: Create patient list page, form component, detail page**
|
|
|
|
_(These follow the same pattern as the server actions above — Server Components for pages, Client Components for forms, using shadcn/ui components for UI.)_
|
|
|
|
**Step 7: Commit**
|
|
|
|
```bash
|
|
git add src/app/\(dashboard\)/patients/ src/components/patients/ src/lib/actions/
|
|
git commit -m "feat: add patients CRUD with search and server actions"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 11: Create Nginx reverse proxy configuration
|
|
|
|
**Files:**
|
|
- Create: `docker/nginx.conf`
|
|
- Modify: `docker/docker-compose.yml` (add nginx + nextjs services)
|
|
|
|
**Step 1: Create Nginx config**
|
|
|
|
Create `docker/nginx.conf`:
|
|
```nginx
|
|
upstream nextjs {
|
|
server nextjs:3000;
|
|
}
|
|
|
|
upstream supabase_kong {
|
|
server kong:8000;
|
|
}
|
|
|
|
upstream supabase_studio {
|
|
server studio:3000;
|
|
}
|
|
|
|
server {
|
|
listen 80;
|
|
server_name _;
|
|
|
|
# Next.js app
|
|
location / {
|
|
proxy_pass http://nextjs;
|
|
proxy_http_version 1.1;
|
|
proxy_set_header Upgrade $http_upgrade;
|
|
proxy_set_header Connection 'upgrade';
|
|
proxy_set_header Host $host;
|
|
proxy_set_header X-Real-IP $remote_addr;
|
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
proxy_set_header X-Forwarded-Proto $scheme;
|
|
proxy_cache_bypass $http_upgrade;
|
|
}
|
|
|
|
# Supabase API
|
|
location /supabase/ {
|
|
rewrite ^/supabase/(.*) /$1 break;
|
|
proxy_pass http://supabase_kong;
|
|
proxy_set_header Host $host;
|
|
proxy_set_header X-Real-IP $remote_addr;
|
|
}
|
|
|
|
# Supabase Studio (admin only)
|
|
location /studio/ {
|
|
rewrite ^/studio/(.*) /$1 break;
|
|
proxy_pass http://supabase_studio;
|
|
proxy_set_header Host $host;
|
|
proxy_set_header X-Real-IP $remote_addr;
|
|
}
|
|
|
|
# Health check
|
|
location /health {
|
|
return 200 'OK';
|
|
add_header Content-Type text/plain;
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 2: Add Nginx and Next.js to Docker Compose**
|
|
|
|
Add to `docker/docker-compose.yml`:
|
|
```yaml
|
|
# --- Next.js App ---
|
|
nextjs:
|
|
build:
|
|
context: ..
|
|
dockerfile: Dockerfile
|
|
ports:
|
|
- "3000:3000"
|
|
environment:
|
|
- NEXT_PUBLIC_SUPABASE_URL=http://kong:8000
|
|
- NEXT_PUBLIC_SUPABASE_ANON_KEY=${ANON_KEY}
|
|
- SUPABASE_SERVICE_ROLE_KEY=${SERVICE_ROLE_KEY}
|
|
depends_on:
|
|
kong:
|
|
condition: service_started
|
|
restart: unless-stopped
|
|
|
|
# --- Nginx ---
|
|
nginx:
|
|
image: nginx:alpine
|
|
ports:
|
|
- "80:80"
|
|
volumes:
|
|
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
|
depends_on:
|
|
- nextjs
|
|
- kong
|
|
restart: unless-stopped
|
|
```
|
|
|
|
**Step 3: Create Dockerfile for Next.js**
|
|
|
|
Create `Dockerfile`:
|
|
```dockerfile
|
|
FROM node:18-alpine AS deps
|
|
WORKDIR /app
|
|
COPY package.json package-lock.json ./
|
|
RUN npm ci
|
|
|
|
FROM node:18-alpine AS builder
|
|
WORKDIR /app
|
|
COPY --from=deps /app/node_modules ./node_modules
|
|
COPY . .
|
|
RUN npm run build
|
|
|
|
FROM node:18-alpine AS runner
|
|
WORKDIR /app
|
|
ENV NODE_ENV=production
|
|
COPY --from=builder /app/.next/standalone ./
|
|
COPY --from=builder /app/.next/static ./.next/static
|
|
COPY --from=builder /app/public ./public
|
|
EXPOSE 3000
|
|
CMD ["node", "server.js"]
|
|
```
|
|
|
|
**Step 4: Update next.config for standalone output**
|
|
|
|
Modify `next.config.ts` to add:
|
|
```typescript
|
|
const nextConfig = {
|
|
output: "standalone",
|
|
};
|
|
```
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git add docker/nginx.conf Dockerfile next.config.ts
|
|
git commit -m "feat: add Nginx reverse proxy and Docker build for Next.js"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 12: Create backup script and cron job
|
|
|
|
**Files:**
|
|
- Create: `scripts/backup.sh`
|
|
|
|
**Step 1: Create backup script**
|
|
|
|
Create `scripts/backup.sh`:
|
|
```bash
|
|
#!/bin/bash
|
|
BACKUP_DIR="/root/CrmClinicas/backups"
|
|
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
|
KEEP_DAYS=7
|
|
|
|
mkdir -p "$BACKUP_DIR"
|
|
|
|
# Dump PostgreSQL
|
|
docker exec docker-db-1 pg_dump -U supabase_admin -d postgres \
|
|
--format=custom \
|
|
--file=/tmp/backup_${TIMESTAMP}.dump
|
|
|
|
docker cp docker-db-1:/tmp/backup_${TIMESTAMP}.dump \
|
|
"$BACKUP_DIR/db_${TIMESTAMP}.dump"
|
|
|
|
docker exec docker-db-1 rm /tmp/backup_${TIMESTAMP}.dump
|
|
|
|
# Remove old backups
|
|
find "$BACKUP_DIR" -name "db_*.dump" -mtime +${KEEP_DAYS} -delete
|
|
|
|
echo "[$(date)] Backup completed: db_${TIMESTAMP}.dump"
|
|
```
|
|
|
|
**Step 2: Make executable and add cron**
|
|
|
|
Run:
|
|
```bash
|
|
chmod +x /root/CrmClinicas/scripts/backup.sh
|
|
(crontab -l 2>/dev/null; echo "0 3 * * * /root/CrmClinicas/scripts/backup.sh >> /root/CrmClinicas/backups/backup.log 2>&1") | crontab -
|
|
```
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add scripts/
|
|
git commit -m "feat: add daily database backup script with 7-day rotation"
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 2: Agenda (Tasks 13-18) — Summary
|
|
|
|
| Task | Description |
|
|
|------|-------------|
|
|
| 13 | Create `doctor_schedules` CRUD (settings page for doctor availability) |
|
|
| 14 | Build calendar component with day/week/month views (use `@fullcalendar/react`) |
|
|
| 15 | Create appointment booking flow (select doctor → select slot → select patient → confirm) |
|
|
| 16 | Create appointment status management (status transitions + color coding) |
|
|
| 17 | Build reception view (today's appointments list with check-in/out buttons) |
|
|
| 18 | Add appointment search and filtering |
|
|
|
|
## Phase 3: Expediente Clinico (Tasks 19-25) — Summary
|
|
|
|
| Task | Description |
|
|
|------|-------------|
|
|
| 19 | Create medical record (antecedentes) form for first-visit patients |
|
|
| 20 | Build SOAP consultation note form with vital signs input |
|
|
| 21 | Add CIE-10 diagnosis search component (searchable combobox with codes) |
|
|
| 22 | Create prescription form with medication items |
|
|
| 23 | Generate prescription PDF (use `@react-pdf/renderer`) |
|
|
| 24 | Build file upload for labs/imaging (Supabase Storage) |
|
|
| 25 | Create patient timeline view (all visits, notes, files chronologically) |
|
|
|
|
## Phase 4: Facturacion (Tasks 26-32) — Summary
|
|
|
|
| Task | Description |
|
|
|------|-------------|
|
|
| 26 | Create services catalog CRUD |
|
|
| 27 | Build invoice creation form (line items, taxes, totals auto-calc) |
|
|
| 28 | Integrate Facturama API for CFDI stamping |
|
|
| 29 | Add CFDI cancellation flow |
|
|
| 30 | Create payment recording (multiple payments per invoice) |
|
|
| 31 | Build daily cash register (corte de caja) report |
|
|
| 32 | Create financial reports (by period, doctor, service) |
|
|
|
|
## Phase 5: Inventario (Tasks 33-37) — Summary
|
|
|
|
| Task | Description |
|
|
|------|-------------|
|
|
| 33 | Create products CRUD with categories |
|
|
| 34 | Build inventory movements form (entrada/salida/ajuste) |
|
|
| 35 | Create stock dashboard with low-stock alerts |
|
|
| 36 | Link inventory to consultations (auto-deduct on usage) |
|
|
| 37 | Create inventory movement report |
|
|
|
|
## Phase 6: Polish (Tasks 38-42) — Summary
|
|
|
|
| Task | Description |
|
|
|------|-------------|
|
|
| 38 | Build dashboard with KPI cards and charts (use `recharts`) |
|
|
| 39 | Add WhatsApp reminder integration (Twilio API edge function) |
|
|
| 40 | Create user/role management in settings |
|
|
| 41 | Add clinic settings page (logo, RFC, PAC config) |
|
|
| 42 | Final production deployment: Docker Compose up, Nginx, seed data, smoke test |
|