Compare commits
6 Commits
e24bc743e2
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 597d47f824 | |||
| 44dbf6b93c | |||
| 387d2ba2ab | |||
| 3ec79e1923 | |||
| f46ba8cadc | |||
| e34d56e36a |
@@ -66,6 +66,7 @@ FILE_SIZE_LIMIT=52428800
|
||||
N8N_ENCRYPTION_KEY=mc-cars-n8n-encryption-key-change-me
|
||||
N8N_USER_EMAIL=admin@mccars.local
|
||||
N8N_USER_PASSWORD=McCars-N8n-Admin1
|
||||
N8N_WEBHOOK_URL=http://localhost:55521/webhook/manual-email-send
|
||||
N8N_POSTGRES_CREDENTIAL_ID=AWozEaiOSymMj7JF
|
||||
N8N_POSTGRES_CREDENTIAL_NAME=Postgres account
|
||||
N8N_SMTP_CREDENTIAL_ID=nRMemi1sz2C0N4Vu
|
||||
|
||||
@@ -22,3 +22,4 @@ docker-compose.override.yml
|
||||
frontend/config.js
|
||||
|
||||
.playwright-mcp
|
||||
node_modules/
|
||||
@@ -0,0 +1,137 @@
|
||||
# Production Deployment - n8n Webhook Routing
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Ensure your production environment has:
|
||||
- `docker-compose.yml` and `docker-compose.local.yml` updated with new n8n webhook routing
|
||||
- `supabase/kong.yml` updated with n8n webhook service
|
||||
- `frontend/admin.js` updated with new sendOrderEmailDirect function
|
||||
- Production domain configured (e.g., `your-domain.com`)
|
||||
|
||||
## Deployment Steps
|
||||
|
||||
### 1. Update Production Config
|
||||
|
||||
Edit `frontend/config.js` and replace `localhost:55521` with your production domain:
|
||||
|
||||
```javascript
|
||||
window.MCCARS_CONFIG={
|
||||
SUPABASE_URL:"https://your-domain.com",
|
||||
SUPABASE_ANON_KEY:"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||
N8N_WEBHOOK_URL:"https://your-domain.com/webhook/manual-email-send"
|
||||
};
|
||||
```
|
||||
|
||||
Replace:
|
||||
- `your-domain.com` with your actual production domain
|
||||
- Keep the same ANON_KEY value
|
||||
|
||||
### 2. Optional: Configure WEBHOOK_DOMAIN
|
||||
|
||||
If you want n8n to know its public webhook URL (for n8n UI display), set environment variable:
|
||||
|
||||
```bash
|
||||
export WEBHOOK_DOMAIN=https://your-domain.com
|
||||
```
|
||||
|
||||
This tells n8n that webhooks are accessible at `https://your-domain.com/webhook/*` from the internet.
|
||||
|
||||
### 3. Deploy Updated Files
|
||||
|
||||
Push these files to production:
|
||||
- `supabase/kong.yml` (updated with n8n webhook service)
|
||||
- `docker-compose.yml` (updated WEBHOOK_URL variable syntax)
|
||||
- `frontend/config.js` (updated with production domain)
|
||||
- `frontend/admin.js` (updated sendOrderEmailDirect function)
|
||||
|
||||
### 4. Restart Stack on Production Server
|
||||
|
||||
```bash
|
||||
# On production host
|
||||
cd /mnt/user/appdata/mc-cars # or your deployment path
|
||||
|
||||
# Pull latest code
|
||||
git pull origin dev # or your deployment branch
|
||||
|
||||
# Restart with new config
|
||||
docker-compose down
|
||||
docker-compose up -d --build
|
||||
|
||||
# Verify services are healthy
|
||||
docker-compose ps
|
||||
```
|
||||
|
||||
### 5. Verify Webhook Routing
|
||||
|
||||
Test webhook from production domain:
|
||||
|
||||
```bash
|
||||
curl 'https://your-domain.com/webhook/manual-email-send' \
|
||||
-H 'Content-Type: application/x-www-form-urlencoded' \
|
||||
-d 'sales_order_id=YOUR_ORDER_ID'
|
||||
```
|
||||
|
||||
Expected response: 200 OK with n8n workflow result
|
||||
|
||||
## Network Setup
|
||||
|
||||
Kong must be accessible from the internet:
|
||||
- **Port 55521** exposed via reverse proxy (nginx/Apache) or firewall rule
|
||||
- Domain DNS points to production server
|
||||
- SSL certificate configured (recommended to use Kong's 8443 port with cert)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Failed to fetch" on send email button
|
||||
|
||||
1. Check Kong is routing webhook:
|
||||
```bash
|
||||
docker-compose exec kong curl -v http://n8n:5678/webhook/manual-email-send
|
||||
```
|
||||
|
||||
2. Verify Kong config loaded:
|
||||
```bash
|
||||
docker-compose logs kong | grep "n8n-webhooks"
|
||||
```
|
||||
|
||||
3. Check n8n workflow is active:
|
||||
```bash
|
||||
docker-compose logs n8n | grep "webhook"
|
||||
```
|
||||
|
||||
### CORS errors
|
||||
|
||||
Ensure Kong's CORS plugin is enabled for `/webhook/` routes (should be in kong.yml):
|
||||
|
||||
```yaml
|
||||
plugins:
|
||||
- name: cors
|
||||
```
|
||||
|
||||
### Webhook not triggering from browser
|
||||
|
||||
Verify in browser DevTools:
|
||||
1. Network tab shows POST to `/webhook/manual-email-send`
|
||||
2. Response status is 200 (not 404 or 500)
|
||||
3. Check n8n logs for workflow execution
|
||||
|
||||
## Rollback
|
||||
|
||||
If issues occur:
|
||||
|
||||
```bash
|
||||
# Rollback config.js to localhost for debugging
|
||||
git checkout frontend/config.js
|
||||
docker-compose up -d
|
||||
|
||||
# Then fix and redeploy
|
||||
```
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
- [ ] Kong routing `/webhook/*` to n8n ✓
|
||||
- [ ] Frontend config.js has production domain ✓
|
||||
- [ ] Admin portal can reach Kong on correct port ✓
|
||||
- [ ] Webhook accepts POST requests ✓
|
||||
- [ ] n8n workflow triggers and sends email ✓
|
||||
- [ ] Email appears in order record ✓
|
||||
@@ -0,0 +1,152 @@
|
||||
# n8n Webhook Routing Configuration
|
||||
|
||||
## Overview
|
||||
|
||||
n8n is intentionally kept internal to the Docker network and **not exposed to the internet**. To allow the browser to trigger n8n workflows via webhooks, Kong (the API gateway) proxies webhook requests to the internal n8n service.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Browser Kong (Port 55521) n8n (Port 5678, internal)
|
||||
| | |
|
||||
| POST /webhook/* | |
|
||||
|----------------------> | (no strip_path) |
|
||||
| | POST /webhook/* |
|
||||
| |--------------------------> |
|
||||
| | Webhook triggers |
|
||||
| | workflow |
|
||||
|<----- Response --------|<---------------------------|
|
||||
```
|
||||
|
||||
## Configuration Changes
|
||||
|
||||
### 1. Kong Configuration (`supabase/kong.yml`)
|
||||
|
||||
Added a new service to route webhook traffic to internal n8n:
|
||||
|
||||
```yaml
|
||||
- name: n8n-webhooks
|
||||
url: http://n8n:5678/
|
||||
routes:
|
||||
- name: n8n-webhooks-all
|
||||
strip_path: false
|
||||
paths:
|
||||
- /webhook/
|
||||
plugins:
|
||||
- name: cors
|
||||
```
|
||||
|
||||
- `strip_path: false` ensures the full `/webhook/...` path is forwarded to n8n
|
||||
- CORS plugin allows browser cross-origin requests (all origins for internal workflow triggers)
|
||||
|
||||
### 2. Docker Compose (`docker-compose.yml`)
|
||||
|
||||
**Kong service:**
|
||||
- Added `n8n` to the `depends_on` list (waits for n8n to start before Kong)
|
||||
|
||||
**n8n service:**
|
||||
- Updated `WEBHOOK_URL` environment variable to use `${WEBHOOK_DOMAIN:http://localhost:55590}/`
|
||||
- This allows production deployments to override the default localhost URL
|
||||
|
||||
### 3. Frontend Configuration (`frontend/config.js`)
|
||||
|
||||
Updated the webhook URL configuration:
|
||||
|
||||
```javascript
|
||||
N8N_WEBHOOK_URL: "/webhook/manual-email-send"
|
||||
```
|
||||
|
||||
This is a **same-origin request** path that works for both:
|
||||
- **Local:** `http://localhost:55521/webhook/manual-email-send` (Kong on port 55521)
|
||||
- **Production:** `https://your-domain.com/webhook/manual-email-send`
|
||||
|
||||
### 4. Admin UI (`frontend/admin.js`)
|
||||
|
||||
Updated `sendOrderEmailDirect()` function to use the configured webhook URL directly:
|
||||
|
||||
```javascript
|
||||
const n8nUrl = window.MCCARS_CONFIG?.N8N_WEBHOOK_URL || "/webhook/manual-email-send";
|
||||
```
|
||||
|
||||
## Deployment Steps
|
||||
|
||||
### For Production Deployment:
|
||||
|
||||
1. **Update Kong configuration** by deploying the modified `supabase/kong.yml`
|
||||
- Kong will automatically reload the config and start proxying `/webhook/*` requests
|
||||
|
||||
2. **Set environment variables** (in your `.env` file):
|
||||
```bash
|
||||
# Optional: Override n8n webhook domain (defaults to localhost)
|
||||
WEBHOOK_DOMAIN=https://your-domain.com
|
||||
```
|
||||
|
||||
If not set, n8n will use the default `http://localhost:55590/` (only works internally)
|
||||
|
||||
3. **Deploy the updated code**:
|
||||
- `frontend/config.js` with the new webhook URL
|
||||
- `frontend/admin.js` with the updated sendOrderEmailDirect function
|
||||
- `docker-compose.yml` with Kong n8n dependency
|
||||
- `supabase/kong.yml` with the new n8n service
|
||||
|
||||
4. **Restart the stack**:
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### For Local Development:
|
||||
|
||||
No special configuration needed:
|
||||
- Kong is already on port 55521
|
||||
- Browser requests to `/webhook/manual-email-send` will be proxied to internal n8n
|
||||
- Works the same as production (same-origin requests)
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Browser action**: User clicks "Email senden" button in order dialog
|
||||
2. **Browser request**: JavaScript POSTs to `/webhook/manual-email-send` (same origin)
|
||||
3. **Kong routing**: Kong receives request, forwards to `http://n8n:5678/webhook/manual-email-send`
|
||||
4. **n8n webhook**: n8n webhook listener triggers the manual-email-send workflow
|
||||
5. **Workflow execution**: n8n fetches order data, builds email, sends via SMTP
|
||||
6. **Response**: Workflow returns success/error response to browser
|
||||
|
||||
## Security
|
||||
|
||||
- **Network isolation**: n8n remains internal, not exposed to internet
|
||||
- **No authentication required**: Webhook path is open (can be restricted later if needed)
|
||||
- **CORS enabled**: Allows browser requests to Kong
|
||||
- **Kong isolation**: Kong is the only service exposed; internal services hidden behind it
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Failed to fetch" error in browser
|
||||
|
||||
1. Check Kong is routing properly:
|
||||
```bash
|
||||
# Test from inside docker network
|
||||
docker-compose exec kong curl -v http://n8n:5678/webhook/manual-email-send
|
||||
```
|
||||
|
||||
2. Verify Kong config loaded:
|
||||
```bash
|
||||
docker-compose logs kong | grep "n8n-webhooks"
|
||||
```
|
||||
|
||||
3. Check n8n is running:
|
||||
```bash
|
||||
docker-compose logs n8n
|
||||
```
|
||||
|
||||
### n8n workflow not triggering
|
||||
|
||||
1. Verify webhook path in n8n workflow (should be exactly `/webhook/manual-email-send`)
|
||||
2. Check n8n logs for webhook errors:
|
||||
```bash
|
||||
docker-compose logs n8n | grep webhook
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- Kong configuration format: https://docs.konghq.com/deck/latest/
|
||||
- n8n webhooks: https://docs.n8n.io/nodes/n8n-nodes-base.Webhook/
|
||||
- Docker networking: https://docs.docker.com/engine/reference/commandline/network_connect/
|
||||
@@ -121,31 +121,51 @@ The admin is seeded with `must_change_password = true` in `raw_user_meta_data`.
|
||||
- RPCs: `calculate_price(uuid, date, date)` (public pricing), `create_lead(...)` (server-side submission), `qualify_lead(uuid, text)`, `disqualify_lead(uuid, text)`, `reopen_lead(uuid)` — transactional, `SECURITY INVOKER`, `authenticated` only (except calculate_price and create_lead which are anon-accessible).
|
||||
- Realtime: `supabase_realtime` publication broadcasts inserts/updates on leads, customers, vehicles.
|
||||
|
||||
## Environment: two variables per deployment
|
||||
## Environment: three variables per deployment
|
||||
|
||||
Only two lines in `.env` need changing between environments:
|
||||
Three variables in `.env` need changing between environments:
|
||||
|
||||
| Variable | Local dev | Production |
|
||||
|---|---|---|
|
||||
| `SITE_URL` | `http://localhost:55580` | `https://your.domain.com` |
|
||||
| `SUPABASE_PUBLIC_URL` | `http://localhost:55521` | `https://your.domain.com` |
|
||||
| `N8N_WEBHOOK_URL` | `http://localhost:55521/webhook/manual-email-send` | `https://your.domain.com/webhook/manual-email-send` |
|
||||
|
||||
All other GoTrue URLs (`API_EXTERNAL_URL`, `GOTRUE_SITE_URL`, `GOTRUE_URI_ALLOW_LIST`) are derived automatically in `docker-compose.yml`.
|
||||
|
||||
On the NAS (example):
|
||||
### Quick setup with deploy-setup.sh
|
||||
|
||||
Use the included deployment script to update all environment variables at once:
|
||||
|
||||
```bash
|
||||
sed -i 's|SITE_URL=.*|SITE_URL=https://your.domain.com|' .env
|
||||
sed -i 's|SUPABASE_PUBLIC_URL=.*|SUPABASE_PUBLIC_URL=https://your.domain.com|' .env
|
||||
docker compose up -d --force-recreate web
|
||||
./deploy-setup.sh https://www.mc-cars.at
|
||||
```
|
||||
|
||||
For mc-cars.at:
|
||||
This updates `.env` and outputs the configuration. Then restart:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
### Manual setup (legacy sed method)
|
||||
|
||||
```bash
|
||||
sed -i 's|SITE_URL=.*|SITE_URL=https://www.mc-cars.at|' .env
|
||||
sed -i 's|SUPABASE_PUBLIC_URL=.*|SUPABASE_PUBLIC_URL=https://www.mc-cars.at|' .env
|
||||
docker compose up -d --force-recreate web
|
||||
sed -i 's|N8N_WEBHOOK_URL=.*|N8N_WEBHOOK_URL=https://www.mc-cars.at/webhook/manual-email-send|' .env
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
### How n8n webhooks work
|
||||
|
||||
- n8n runs internally (not exposed to the internet)
|
||||
- Kong API gateway proxies `/webhook/*` traffic to internal n8n
|
||||
- Browser requests to `https://your.domain.com/webhook/manual-email-send` route through Kong → n8n
|
||||
- Frontend config is generated at container startup from `N8N_WEBHOOK_URL` environment variable
|
||||
|
||||
See [N8N_WEBHOOK_ROUTING.md](N8N_WEBHOOK_ROUTING.md) for full architecture details.
|
||||
|
||||
## Deployment & portability
|
||||
|
||||
Runtime state under `/mnt/user/appdata/mc-cars/data/`:
|
||||
|
||||
Executable
+31
@@ -0,0 +1,31 @@
|
||||
#!/bin/bash
|
||||
# MC Cars Deployment Configuration Setup
|
||||
# Usage: ./deploy-setup.sh https://www.mc-cars.at
|
||||
|
||||
set -e
|
||||
|
||||
if [ $# -eq 0 ]; then
|
||||
echo "Usage: ./deploy-setup.sh <domain>"
|
||||
echo "Example: ./deploy-setup.sh https://www.mc-cars.at"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
DOMAIN="$1"
|
||||
|
||||
echo "🚀 Configuring MC Cars for: $DOMAIN"
|
||||
|
||||
# Update environment variables
|
||||
sed -i "s|SITE_URL=.*|SITE_URL=$DOMAIN|" .env
|
||||
sed -i "s|SUPABASE_PUBLIC_URL=.*|SUPABASE_PUBLIC_URL=$DOMAIN|" .env
|
||||
sed -i "s|N8N_WEBHOOK_URL=.*|N8N_WEBHOOK_URL=$DOMAIN/webhook/manual-email-send|" .env
|
||||
|
||||
echo "✅ Updated .env:"
|
||||
echo " SITE_URL=$DOMAIN"
|
||||
echo " SUPABASE_PUBLIC_URL=$DOMAIN"
|
||||
echo " N8N_WEBHOOK_URL=$DOMAIN/webhook/manual-email-send"
|
||||
|
||||
echo ""
|
||||
echo "📋 Next steps:"
|
||||
echo " 1. Verify .env looks correct: grep -E 'SITE_URL|SUPABASE_PUBLIC_URL|N8N_WEBHOOK_URL' .env"
|
||||
echo " 2. Restart services: docker-compose down && docker-compose up -d --build"
|
||||
echo " 3. Test webhook: curl '$DOMAIN/webhook/manual-email-send' -d 'sales_order_id=test'"
|
||||
@@ -25,6 +25,11 @@ services:
|
||||
- ./supabase/migrations/08-backend-pricing-and-security.sql:/sql/08-backend-pricing-and-security.sql:ro
|
||||
- ./supabase/migrations/09-site-settings.sql:/sql/09-site-settings.sql:ro
|
||||
- ./supabase/migrations/10-mietvertrag-workflow.sql:/sql/10-mietvertrag-workflow.sql:ro
|
||||
- ./supabase/migrations/11-consolidate-km-rental.sql:/sql/11-consolidate-km-rental.sql:ro
|
||||
- ./supabase/migrations/12-email-sent-and-more.sql:/sql/12-email-sent-and-more.sql:ro
|
||||
- ./supabase/migrations/13-rental-type-daily-and-email-guard.sql:/sql/13-rental-type-daily-and-email-guard.sql:ro
|
||||
- ./supabase/migrations/14-email-requested-trigger.sql:/sql/14-email-requested-trigger.sql:ro
|
||||
- ./supabase/migrations/15-individuell-vat-subtotal-fix.sql:/sql/15-individuell-vat-subtotal-fix.sql:ro
|
||||
|
||||
kong:
|
||||
volumes:
|
||||
|
||||
+11
-1
@@ -218,6 +218,11 @@ services:
|
||||
- /mnt/user/appdata/mc-cars/supabase/migrations/08-backend-pricing-and-security.sql:/sql/08-backend-pricing-and-security.sql:ro
|
||||
- /mnt/user/appdata/mc-cars/supabase/migrations/09-site-settings.sql:/sql/09-site-settings.sql:ro
|
||||
- /mnt/user/appdata/mc-cars/supabase/migrations/10-mietvertrag-workflow.sql:/sql/10-mietvertrag-workflow.sql:ro
|
||||
- /mnt/user/appdata/mc-cars/supabase/migrations/11-consolidate-km-rental.sql:/sql/11-consolidate-km-rental.sql:ro
|
||||
- /mnt/user/appdata/mc-cars/supabase/migrations/12-email-sent-and-more.sql:/sql/12-email-sent-and-more.sql:ro
|
||||
- /mnt/user/appdata/mc-cars/supabase/migrations/13-rental-type-daily-and-email-guard.sql:/sql/13-rental-type-daily-and-email-guard.sql:ro
|
||||
- /mnt/user/appdata/mc-cars/supabase/migrations/14-email-requested-trigger.sql:/sql/14-email-requested-trigger.sql:ro
|
||||
- /mnt/user/appdata/mc-cars/supabase/migrations/15-individuell-vat-subtotal-fix.sql:/sql/15-individuell-vat-subtotal-fix.sql:ro
|
||||
entrypoint: ["sh","-c"]
|
||||
command:
|
||||
- |
|
||||
@@ -244,6 +249,11 @@ services:
|
||||
psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/08-backend-pricing-and-security.sql
|
||||
psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/09-site-settings.sql
|
||||
psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/10-mietvertrag-workflow.sql
|
||||
psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/11-consolidate-km-rental.sql
|
||||
psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/12-email-sent-and-more.sql
|
||||
psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/13-rental-type-daily-and-email-guard.sql
|
||||
psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/14-email-requested-trigger.sql
|
||||
psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/15-individuell-vat-subtotal-fix.sql
|
||||
echo "post-init done."
|
||||
restart: "no"
|
||||
networks: [mccars]
|
||||
@@ -387,7 +397,7 @@ services:
|
||||
N8N_HOST: 0.0.0.0
|
||||
N8N_PORT: 5678
|
||||
N8N_PROTOCOL: http
|
||||
WEBHOOK_URL: http://localhost:55590/
|
||||
WEBHOOK_URL: ${WEBHOOK_DOMAIN:-http://localhost:55590}/
|
||||
N8N_ENCRYPTION_KEY: ${N8N_ENCRYPTION_KEY}
|
||||
N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS: "false"
|
||||
N8N_SECURE_COOKIE: "false"
|
||||
|
||||
@@ -3,7 +3,8 @@ set -eu
|
||||
cat > /usr/share/nginx/html/config.js <<EOF
|
||||
window.MCCARS_CONFIG = {
|
||||
SUPABASE_URL: "${SUPABASE_URL:-http://localhost:8000}",
|
||||
SUPABASE_ANON_KEY: "${SUPABASE_ANON_KEY:-}"
|
||||
SUPABASE_ANON_KEY: "${SUPABASE_ANON_KEY:-}",
|
||||
N8N_WEBHOOK_URL: "${N8N_WEBHOOK_URL:-http://localhost:55590}"
|
||||
};
|
||||
EOF
|
||||
exec nginx -g "daemon off;"
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
# (anon key only — safe for the browser).
|
||||
RUN rm -f /usr/share/nginx/html/Dockerfile /usr/share/nginx/html/nginx.conf
|
||||
|
||||
RUN printf '#!/bin/sh\nset -eu\ncat > /usr/share/nginx/html/config.js <<EOF\nwindow.MCCARS_CONFIG = {\n SUPABASE_URL: "${SUPABASE_URL:-http://localhost:8000}",\n SUPABASE_ANON_KEY: "${SUPABASE_ANON_KEY:-}"\n};\nEOF\nexec nginx -g "daemon off;"\n' > /docker-entrypoint.d/99-config.sh \
|
||||
RUN printf '#!/bin/sh\nset -eu\ncat > /usr/share/nginx/html/config.js <<EOF\nwindow.MCCARS_CONFIG = {\n SUPABASE_URL: "${SUPABASE_URL:-http://localhost:8000}",\n SUPABASE_ANON_KEY: "${SUPABASE_ANON_KEY:-}",\n N8N_WEBHOOK_URL: "${N8N_WEBHOOK_URL:-http://localhost:55521/webhook/manual-email-send}"\n};\nEOF\nexec nginx -g "daemon off;"\n' > /docker-entrypoint.d/99-config.sh \
|
||||
&& chmod +x /docker-entrypoint.d/99-config.sh
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
+8
-4
@@ -101,6 +101,7 @@
|
||||
<th data-i18n="adminNameEmail">Name / E-Mail</th>
|
||||
<th data-i18n="adminVehicleTab">Fahrzeug</th>
|
||||
<th data-i18n="adminPeriod">Zeitraum</th>
|
||||
<th data-i18n="adminRentalType">Miettyp</th>
|
||||
<th data-i18n="adminTotalPrice">Gesamtbetrag</th>
|
||||
<th data-i18n="adminStatus">Status</th>
|
||||
<th></th>
|
||||
@@ -146,10 +147,12 @@
|
||||
<th data-i18n="adminNameEmail">Name / E-Mail</th>
|
||||
<th data-i18n="adminVehicleTab">Fahrzeug</th>
|
||||
<th data-i18n="adminPeriod">Zeitraum</th>
|
||||
<th data-i18n="adminRentalType">Miettyp</th>
|
||||
<th data-i18n="adminTotalPrice">Gesamtbetrag</th>
|
||||
<th>Kaution</th>
|
||||
<th>Miete</th>
|
||||
<th data-i18n="adminStatus">Status</th>
|
||||
<th data-i18n="adminEmailSent">Email</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -192,13 +195,13 @@
|
||||
</div>
|
||||
|
||||
<div class="row3">
|
||||
<label><span>Max. km/Tag</span><input type="number" name="max_daily_km" min="0" value="150" /></label>
|
||||
<label><span>Inkl. km/Tag</span><input type="number" name="included_km_per_day" min="0" value="150" /></label>
|
||||
<label><span data-i18n="adminPricePerKm">Preis extra km (€)</span><input type="number" name="price_per_km_eur" step="0.01" min="0" value="1.50" /></label>
|
||||
<label><span data-i18n="adminKaution">Kaution (€)</span><input type="number" name="kaution_eur" min="1" value="5000" required /></label>
|
||||
<label><span data-i18n="adminMaxKmWeekend">Max. km/Wochenendtag</span><input type="number" name="max_km_weekend" min="0" placeholder="wie km/Tag" /></label>
|
||||
</div>
|
||||
|
||||
<div class="row2">
|
||||
<label><span data-i18n="adminSort">Reihenfolge</span><input type="number" name="sort_order" value="100" /></label>
|
||||
<label><span data-i18n="adminSortOrder">Ordnung</span><input type="number" name="sort_order" value="100" /></label>
|
||||
<label><span data-i18n="adminLocation">Standort</span><input name="location" value="Steiermark (TBD)" /></label>
|
||||
</div>
|
||||
|
||||
@@ -278,6 +281,7 @@
|
||||
<h3 id="orderDialogTitle" style="margin:0;">Bestellung</h3>
|
||||
<button class="dialog-close" id="orderDialogClose" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="dialog-tabs" id="orderDialogTabs" role="tablist"></div>
|
||||
<div class="dialog-body" id="orderDialogBody"></div>
|
||||
<div class="dialog-footer" id="orderDialogFooter"></div>
|
||||
</dialog>
|
||||
@@ -304,6 +308,6 @@
|
||||
<div class="dialog-footer" id="customerDialogFooter"></div>
|
||||
</dialog>
|
||||
|
||||
<script type="module" src="admin.js"></script>
|
||||
<script type="module" src="admin.js?v=3"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+285
-16
@@ -70,6 +70,55 @@ const state = {
|
||||
forcedRotation: false,
|
||||
};
|
||||
|
||||
function notify(message, duration = 3000) {
|
||||
if (typeof window.showToast === "function") {
|
||||
window.showToast(message, duration);
|
||||
return;
|
||||
}
|
||||
showAdminPopup(message, duration);
|
||||
}
|
||||
|
||||
function showAdminPopup(message, duration = 3000) {
|
||||
const host = document.querySelector("dialog[open]") || document.body;
|
||||
let popup = host.querySelector("[data-admin-notify-popup]");
|
||||
if (!popup) {
|
||||
popup = document.createElement("div");
|
||||
popup.setAttribute("data-admin-notify-popup", "1");
|
||||
popup.setAttribute("role", "status");
|
||||
popup.setAttribute("aria-live", "polite");
|
||||
popup.style.position = host.tagName === "DIALOG" ? "absolute" : "fixed";
|
||||
popup.style.left = "50%";
|
||||
popup.style.top = "50%";
|
||||
popup.style.transform = "translate(-50%, -50%) scale(0.96)";
|
||||
popup.style.minWidth = "320px";
|
||||
popup.style.maxWidth = "min(92vw, 560px)";
|
||||
popup.style.padding = "1rem 1.2rem";
|
||||
popup.style.borderRadius = "12px";
|
||||
popup.style.border = "1px solid var(--line)";
|
||||
popup.style.background = "var(--bg-card)";
|
||||
popup.style.color = "var(--text)";
|
||||
popup.style.boxShadow = "0 16px 40px rgba(0,0,0,0.35)";
|
||||
popup.style.textAlign = "center";
|
||||
popup.style.fontSize = "1rem";
|
||||
popup.style.fontWeight = "600";
|
||||
popup.style.opacity = "0";
|
||||
popup.style.zIndex = "3000";
|
||||
popup.style.pointerEvents = "none";
|
||||
popup.style.transition = "opacity 0.18s ease, transform 0.18s ease";
|
||||
host.appendChild(popup);
|
||||
}
|
||||
|
||||
popup.textContent = message;
|
||||
popup.style.opacity = "1";
|
||||
popup.style.transform = "translate(-50%, -50%) scale(1)";
|
||||
|
||||
clearTimeout(popup._hideTimer);
|
||||
popup._hideTimer = setTimeout(() => {
|
||||
popup.style.opacity = "0";
|
||||
popup.style.transform = "translate(-50%, -50%) scale(0.96)";
|
||||
}, duration);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// AUTH FLOW
|
||||
// =========================================================================
|
||||
@@ -262,9 +311,9 @@ function loadForEdit(id) {
|
||||
vehicleForm.seats.value = v.seats;
|
||||
vehicleForm.daily_price_eur.value = v.daily_price_eur;
|
||||
vehicleForm.weekend_price_eur.value = v.weekend_price_eur || 0;
|
||||
vehicleForm.max_daily_km.value = v.max_daily_km || 150;
|
||||
vehicleForm.included_km_per_day.value = v.included_km_per_day || 150;
|
||||
vehicleForm.kaution_eur.value = v.kaution_eur || 5000;
|
||||
vehicleForm.max_km_weekend.value = v.max_km_weekend || '';
|
||||
vehicleForm.price_per_km_eur.value = v.price_per_km_eur || 1.50;
|
||||
vehicleForm.sort_order.value = v.sort_order;
|
||||
vehicleForm.location.value = v.location;
|
||||
vehicleForm.description_de.value = v.description_de;
|
||||
@@ -283,10 +332,10 @@ resetBtn.addEventListener("click", () => {
|
||||
vehicleForm.sort_order.value = 100;
|
||||
vehicleForm.location.value = "Steiermark (TBD)";
|
||||
vehicleForm.seats.value = 2;
|
||||
vehicleForm.max_daily_km.value = 150;
|
||||
vehicleForm.included_km_per_day.value = 150;
|
||||
vehicleForm.weekend_price_eur.value = 0;
|
||||
vehicleForm.kaution_eur.value = 5000;
|
||||
vehicleForm.max_km_weekend.value = '';
|
||||
vehicleForm.price_per_km_eur.value = 1.50;
|
||||
state.currentPhotoPath = null;
|
||||
updatePreview("");
|
||||
formTitle.textContent = "Neues Fahrzeug";
|
||||
@@ -309,9 +358,9 @@ vehicleForm.addEventListener("submit", async (e) => {
|
||||
seats: +fd.get("seats") || 2,
|
||||
daily_price_eur: +fd.get("daily_price_eur") || 0,
|
||||
weekend_price_eur: +fd.get("weekend_price_eur") || 0,
|
||||
max_daily_km: +fd.get("max_daily_km") || 150,
|
||||
included_km_per_day: +fd.get("included_km_per_day") || 150,
|
||||
kaution_eur: +fd.get("kaution_eur") || 5000,
|
||||
max_km_weekend: fd.get("max_km_weekend") ? +fd.get("max_km_weekend") : null,
|
||||
price_per_km_eur: parseFloat(fd.get("price_per_km_eur")) || 1.50,
|
||||
sort_order: +fd.get("sort_order") || 100,
|
||||
location: fd.get("location") || "Steiermark (TBD)",
|
||||
description_de: fd.get("description_de") || "",
|
||||
@@ -396,6 +445,7 @@ function renderLeads() {
|
||||
leadsEmpty.style.display = rows.length ? "none" : "block";
|
||||
leadsTableBody.innerHTML = "";
|
||||
for (const l of rows) {
|
||||
const rental = rentalTypeMeta(l.rental_type);
|
||||
const total = l.total_eur || 0;
|
||||
const totalStr = total > 0 ? "€ " + total.toLocaleString("de-DE") : "—";
|
||||
const tr = document.createElement("tr");
|
||||
@@ -404,6 +454,7 @@ function renderLeads() {
|
||||
<td><strong>${esc(l.name)}</strong><br /><span class="muted">${esc(l.email)}${l.phone ? " · " + esc(l.phone) : ""}</span></td>
|
||||
<td>${esc(l.vehicle_label || "—")}</td>
|
||||
<td>${esc(l.date_from || "—")} → ${esc(l.date_to || "—")}</td>
|
||||
<td style="white-space:nowrap;"><span class="pill pill-${esc(rental.type)}">${esc(rental.label)}</span></td>
|
||||
<td style="font-weight:600;color:var(--accent-strong);">${totalStr}</td>
|
||||
<td><span class="pill pill-${esc(l.status)}">${esc(l.status)}</span></td>
|
||||
<td style="white-space:nowrap;">
|
||||
@@ -552,6 +603,7 @@ async function renderLeadTab(tab, l) {
|
||||
});
|
||||
leadDialogBody.appendChild(saveNoteBtn);
|
||||
} else if (tab === "pricing") {
|
||||
const rental = rentalTypeMeta(l.rental_type);
|
||||
const daily = l.daily_subtotal || 0;
|
||||
const weekend = l.weekend_subtotal || 0;
|
||||
const sub = l.subtotal_eur || 0;
|
||||
@@ -567,8 +619,9 @@ async function renderLeadTab(tab, l) {
|
||||
<div class="price-row muted"><span>${lang === "de" ? t("adminVatLabel") : t("adminVatLabelEn")}</span><span>€ ${vat.toLocaleString("de-DE")}</span></div>
|
||||
<div class="price-row total"><span>${lang === "de" ? t("adminTotalLabel") : t("adminTotalLabelEn")}</span><span>€ ${total.toLocaleString("de-DE")}</span></div>
|
||||
<div class="price-row muted" style="margin-top:0.8rem;"><span>${lang === "de" ? t("adminDepositLabel") : t("adminDepositLabelEn")}</span><span>€ ${deposit.toLocaleString("de-DE")}</span></div>
|
||||
<div class="price-row muted"><span>${lang === "de" ? t("adminIncludedKmLabel") : t("adminIncludedKmLabelEn")}</span><span>${((l.weekday_count || 0) * (state.vehicleMap.get(l.vehicle_id)?.max_daily_km || 150) + (l.weekend_day_count || 0) * (state.vehicleMap.get(l.vehicle_id)?.max_km_weekend || state.vehicleMap.get(l.vehicle_id)?.max_daily_km || 150))} km</span></div>
|
||||
<div class="price-row muted"><span>${lang === "de" ? t("adminIncludedKmLabel") : t("adminIncludedKmLabelEn")}</span><span>${(l.total_days || 0) * (state.vehicleMap.get(l.vehicle_id)?.included_km_per_day || 150)} km</span></div>
|
||||
<div class="price-row muted"><span>${lang === "de" ? t("adminTotalDaysLabel") : t("adminTotalDaysLabelEn")}</span><span>${l.total_days || 0}</span></div>
|
||||
<div class="price-row muted"><span>${lang === "de" ? t("adminRentalType") : t("Rental type")}</span><span><span class="pill pill-${esc(rental.type)}">${esc(rental.label)}</span></span></div>
|
||||
</div>`;
|
||||
} else if (tab === "documents") {
|
||||
const docs = await loadLeadAttachments(l.id);
|
||||
@@ -685,6 +738,7 @@ function renderOrders() {
|
||||
ordersEmpty.style.display = state.salesOrders.length ? "none" : "block";
|
||||
ordersTableBody.innerHTML = "";
|
||||
for (const o of state.salesOrders) {
|
||||
const rental = rentalTypeMeta(o.rental_type);
|
||||
const total = o.total_eur || 0;
|
||||
const totalStr = total > 0 ? "€ " + total.toLocaleString("de-DE") : "—";
|
||||
const cust = state.customers.find(c => c.id === o.customer_id);
|
||||
@@ -694,10 +748,12 @@ function renderOrders() {
|
||||
<td>${cust ? `<strong>${esc(cust.name)}</strong><br><span class="muted">${esc(cust.email)}</span>` : `<span class="muted">${esc(o.customer_id?.slice(0, 8) || "—")}</span>`}</td>
|
||||
<td>${esc(o.vehicle_label || "—")}</td>
|
||||
<td>${esc(o.date_from || "—")} → ${esc(o.date_to || "—")}</td>
|
||||
<td style="white-space:nowrap;"><span class="pill pill-${esc(rental.type)}">${esc(rental.label)}</span></td>
|
||||
<td style="font-weight:600;color:var(--accent-strong);">${totalStr}</td>
|
||||
<td><span class="pill pill-${o.kaution_paid ? "active" : "new"}">${o.kaution_paid ? "✓" : "—"}</span></td>
|
||||
<td><span class="pill pill-${o.rental_paid ? "active" : "new"}">${o.rental_paid ? "✓" : "—"}</span></td>
|
||||
<td><span class="pill pill-${o.rental_complete ? "qualified" : "new"}">${o.rental_complete ? t("adminCompleteDone") : t("adminCompletePending")}</span></td>
|
||||
<td style="white-space:nowrap;"><span class="pill pill-${o.email_sent === 1 ? 'active' : o.email_sent === 2 ? 'disqualified' : 'new'}">${o.email_sent === 0 ? '—' : o.email_sent === 1 ? '✓' : '✗'}</span></td>
|
||||
<td style="white-space:nowrap;"><button class="btn small ghost" data-open-order="${o.id}">${t("adminDetails")}</button></td>`;
|
||||
ordersTableBody.appendChild(tr);
|
||||
}
|
||||
@@ -708,20 +764,98 @@ function renderOrders() {
|
||||
// ----- ORDER DETAIL DIALOG -----
|
||||
const orderDialog = document.querySelector("#orderDialog");
|
||||
const orderDialogTitle = document.querySelector("#orderDialogTitle");
|
||||
const orderDialogTabs = document.querySelector("#orderDialogTabs");
|
||||
const orderDialogBody = document.querySelector("#orderDialogBody");
|
||||
const orderDialogFooter = document.querySelector("#orderDialogFooter");
|
||||
const orderDialogClose = document.querySelector("#orderDialogClose");
|
||||
|
||||
async function sendOrderEmailDirect(orderId) {
|
||||
const sendBtn = orderDialogBody.querySelector("[data-manual-email-send]");
|
||||
if (sendBtn) sendBtn.disabled = true;
|
||||
|
||||
const n8nUrl = window.MCCARS_CONFIG?.N8N_WEBHOOK_URL || "http://localhost:55521/webhook/manual-email-send";
|
||||
|
||||
try {
|
||||
// Use urlencoded payload to avoid browser preflight/CORS issues with JSON headers.
|
||||
const payload = new URLSearchParams({ sales_order_id: orderId });
|
||||
const res = await fetch(n8nUrl, {
|
||||
method: "POST",
|
||||
body: payload,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
notify(t("emailSentToast"), 5000);
|
||||
} catch (err) {
|
||||
console.error("Webhook error:", err);
|
||||
alert(`Email senden fehlgeschlagen: ${err?.message || err}`);
|
||||
}
|
||||
}
|
||||
|
||||
orderDialogBody.addEventListener("click", async (e) => {
|
||||
const sendBtn = e.target.closest("[data-manual-email-send]");
|
||||
if (!sendBtn) return;
|
||||
|
||||
e.preventDefault();
|
||||
const orderId = sendBtn.dataset.orderId;
|
||||
if (!orderId) return;
|
||||
|
||||
await sendOrderEmailDirect(orderId);
|
||||
await loadSalesOrders();
|
||||
const fresh = state.salesOrders.find(x => x.id === orderId);
|
||||
if (fresh) await renderOrderTab("general", fresh, orderId);
|
||||
});
|
||||
|
||||
const orderTabOrder = ["general", "pricing"];
|
||||
const orderTabLabels = {
|
||||
general: () => getLang() === "de" ? t("adminTabGeneral") : t("adminTabGeneralEn"),
|
||||
pricing: () => getLang() === "de" ? t("adminTabPricing") : t("adminTabPricingEn"),
|
||||
};
|
||||
|
||||
async function openOrder(id) {
|
||||
const o = state.salesOrders.find(x => x.id === id);
|
||||
if (!o) return;
|
||||
const cust = state.customers.find(c => c.id === o.customer_id);
|
||||
|
||||
orderDialogTitle.textContent = `${o.order_number || o.id.slice(0, 8)} · ${cust?.name || "—"}`;
|
||||
|
||||
// Build tabs
|
||||
orderDialogTabs.innerHTML = orderTabOrder.map((tab, i) =>
|
||||
`<button class="order-tab${i === 0 ? " active" : ""}" data-order-tab="${tab}">${orderTabLabels[tab]()}</button>`
|
||||
).join("");
|
||||
|
||||
// Render first tab
|
||||
await renderOrderTab("general", o, id);
|
||||
|
||||
orderDialog.showModal();
|
||||
|
||||
// Tab switching
|
||||
orderDialogTabs.querySelectorAll(".order-tab").forEach(btn => {
|
||||
btn.addEventListener("click", () => {
|
||||
orderDialogTabs.querySelectorAll(".order-tab").forEach(b => b.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
renderOrderTab(btn.dataset.orderTab, o, id);
|
||||
});
|
||||
});
|
||||
|
||||
orderDialogClose.addEventListener("click", () => orderDialog.close(), { once: true });
|
||||
}
|
||||
|
||||
async function renderOrderTab(tab, o, id) {
|
||||
const rental = rentalTypeMeta(o.rental_type);
|
||||
const isIndividuell = rental.type === "individuell";
|
||||
const lang = getLang();
|
||||
const cust = state.customers.find(c => c.id === o.customer_id);
|
||||
const total = o.total_eur || 0;
|
||||
const deposit = o.deposit_eur || 0;
|
||||
const emailSent = o.email_sent || 0;
|
||||
const emailSentText = emailSent === 1 ? '✓' : emailSent === 2 ? '✗' : '—';
|
||||
const emailSentPillClass = emailSent === 1 ? 'active' : emailSent === 2 ? 'disqualified' : 'new';
|
||||
const isEmailLocked = emailSent === 1;
|
||||
|
||||
orderDialogTitle.textContent = `${o.order_number || o.id.slice(0, 8)} · ${cust?.name || "—"}`;
|
||||
|
||||
if (tab === "general") {
|
||||
// Load attachments for this order
|
||||
const { data: attachments } = await supabase
|
||||
.from("sales_order_attachments")
|
||||
@@ -735,8 +869,8 @@ async function openOrder(id) {
|
||||
<dt>${lang === "de" ? "Kunde" : "Customer"}</dt><dd>${cust ? `<a href="#" class="link-lead" data-goto-cust="${cust.id}">${esc(cust.name)} (${esc(cust.email)})</a>` : esc(o.customer_id?.slice(0, 8) || "—")}</dd>
|
||||
<dt>${lang === "de" ? "Fahrzeug" : "Vehicle"}</dt><dd>${esc(o.vehicle_label || "—")}</dd>
|
||||
<dt>${lang === "de" ? "Zeitraum" : "Period"}</dt><dd>${esc(o.date_from || "—")} → ${esc(o.date_to || "—")}</dd>
|
||||
<dt>${t("adminTotalLabel")}</dt><dd style="font-weight:600;">€ ${total.toLocaleString("de-DE")}</dd>
|
||||
<dt>${t("adminDepositLabel")}</dt><dd>€ ${deposit.toLocaleString("de-DE")}</dd>
|
||||
<dt>${lang === "de" ? t("adminRentalType") : t("Rental type")}</dt><dd><span class="pill pill-${esc(rental.type)}">${esc(rental.label)}</span></dd>
|
||||
<dt>${t("adminEmailSent")}</dt><dd><span class="pill pill-${emailSentPillClass}">${emailSentText}</span></dd>
|
||||
</dl>
|
||||
<div style="display:flex;gap:0.4rem;flex-wrap:wrap;margin:1rem 0;">
|
||||
<button class="btn small ${o.kaution_paid ? "ghost" : ""}" data-so-toggle="kaution" data-so-id="${o.id}">${o.kaution_paid ? t("adminKautionPaid") : t("adminKautionPending")}</button>
|
||||
@@ -748,7 +882,8 @@ async function openOrder(id) {
|
||||
<div style="margin-top:0.8rem;">
|
||||
<label class="muted" style="display:block;margin-bottom:0.3rem;">${t("adminPrivateNotes")}</label>
|
||||
<textarea id="orderNote" rows="4" style="width:100%;resize:vertical;">${esc(o.private_notes || "")}</textarea>
|
||||
<div style="display:flex;justify-content:flex-end;margin-top:0.4rem;">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:0.4rem;">
|
||||
<div>${emailSent !== 1 ? `<button class="btn small" type="button" data-manual-email-send data-order-id="${o.id}" style="background-color:var(--accent-strong);color:#fff;">${t("sendEmailButton")}</button>` : ''}</div>
|
||||
<button class="btn small" id="orderNoteSave">${t("adminSaveNotes")}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
@@ -774,22 +909,114 @@ async function openOrder(id) {
|
||||
orderDialogBody.querySelectorAll("[data-so-toggle]").forEach(btn => {
|
||||
btn.addEventListener("click", async () => {
|
||||
await toggleSalesOrderState(btn.dataset.soId, btn.dataset.soToggle);
|
||||
await openOrder(id); // re-render
|
||||
await loadSalesOrders();
|
||||
const fresh = state.salesOrders.find(x => x.id === id);
|
||||
if (fresh) await renderOrderTab("general", fresh, id);
|
||||
});
|
||||
});
|
||||
|
||||
// Dirty form tracking
|
||||
let noteIsDirty = false;
|
||||
const orderNoteEl = document.querySelector("#orderNote");
|
||||
const originalNoteValue = o.private_notes || "";
|
||||
const saveBtn = document.querySelector("#orderNoteSave");
|
||||
if (orderNoteEl && saveBtn) {
|
||||
saveBtn.classList.add("ghost");
|
||||
orderNoteEl.addEventListener("input", () => {
|
||||
noteIsDirty = orderNoteEl.value !== originalNoteValue;
|
||||
if (noteIsDirty) {
|
||||
saveBtn.classList.remove("ghost");
|
||||
saveBtn.style.backgroundColor = "var(--accent-strong)";
|
||||
saveBtn.style.color = "#fff";
|
||||
saveBtn.textContent = t("adminSaveNotes") + " (unsaved)";
|
||||
} else {
|
||||
saveBtn.classList.add("ghost");
|
||||
saveBtn.style.backgroundColor = "";
|
||||
saveBtn.style.color = "";
|
||||
saveBtn.textContent = t("adminSaveNotes");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Save notes
|
||||
document.querySelector("#orderNoteSave")?.addEventListener("click", async () => {
|
||||
const ok = await saveSalesOrderPrivateNotes(o.id, document.querySelector("#orderNote").value);
|
||||
if (ok) {
|
||||
noteIsDirty = false;
|
||||
document.querySelector("#orderNoteSave").textContent = "✓";
|
||||
setTimeout(() => { document.querySelector("#orderNoteSave").textContent = t("adminSaveNotes"); }, 1500);
|
||||
}
|
||||
});
|
||||
|
||||
} else if (tab === "pricing") {
|
||||
let daily = o.daily_subtotal || 0;
|
||||
let weekend = o.weekend_subtotal || 0;
|
||||
let sub = o.subtotal_eur || 0;
|
||||
let vat = o.vat_eur || 0;
|
||||
const days = o.total_days || 0;
|
||||
const inclVatLabel = lang === "de" ? t("adminInclVat") : t("adminInclVatEn");
|
||||
|
||||
// For individuell: derive pricing from total (which includes VAT)
|
||||
if (isIndividuell && total > 0) {
|
||||
sub = Math.round(total / 1.2 * 100) / 100;
|
||||
vat = Math.round((total - sub) * 100) / 100;
|
||||
daily = sub; // all days counted as weekdays
|
||||
weekend = 0;
|
||||
}
|
||||
|
||||
const weekdayCount = isIndividuell ? days : (o.weekday_count || 0);
|
||||
const weekendCount = isIndividuell ? 0 : (o.weekend_day_count || 0);
|
||||
const perDay = daily && weekdayCount ? Math.round(daily / weekdayCount) : 0;
|
||||
const perWeekend = weekend && weekendCount ? Math.round(weekend / weekendCount) : 0;
|
||||
|
||||
orderDialogBody.innerHTML = `
|
||||
<div class="pricing-card">
|
||||
<div class="price-row"><span>${lang === "de" ? t("adminWeekdays") : t("adminWeekdaysEn")} (${weekdayCount} × € ${perDay || "—"})</span><span>€ ${daily.toLocaleString("de-DE")}</span></div>
|
||||
<div class="price-row"><span>${lang === "de" ? t("adminWeekendRateLabel") : t("adminWeekendRateLabelEn")} (${weekendCount} × € ${perWeekend || "—"})</span><span>€ ${weekend.toLocaleString("de-DE")}</span></div>
|
||||
<div class="price-row divider"><span>${lang === "de" ? t("adminSubtotalLabel") : t("adminSubtotalLabelEn")}</span><span>€ ${sub.toLocaleString("de-DE")}</span></div>
|
||||
<div class="price-row muted"><span>${lang === "de" ? t("adminVatLabel") : t("adminVatLabelEn")}</span><span>€ ${vat.toLocaleString("de-DE")}</span></div>
|
||||
<div class="price-row total"><span>${lang === "de" ? t("adminTotalLabel") : t("adminTotalLabelEn")}</span>
|
||||
<span>${isIndividuell && !isEmailLocked
|
||||
? `<input type="number" id="orderTotalInput" step="1" min="0" value="${total}" style="font-weight:600;width:120px;" />`
|
||||
: '€ ' + total.toLocaleString("de-DE")
|
||||
}</span>
|
||||
</div>
|
||||
${!isEmailLocked ? `<div class="price-row muted" id="inclVatHint"><span></span><span style="font-size:0.78rem;">${inclVatLabel}</span></div>` : ''}
|
||||
<div class="price-row muted" style="margin-top:0.8rem;"><span>${lang === "de" ? t("adminDepositLabel") : t("adminDepositLabelEn")}</span>
|
||||
<span>${isEmailLocked || !isIndividuell
|
||||
? '€ ' + deposit.toLocaleString("de-DE")
|
||||
: `<input type="number" id="orderDepositInput" step="1" min="0" value="${deposit}" style="width:120px;" />`
|
||||
}</span>
|
||||
</div>
|
||||
<div class="price-row muted"><span>${lang === "de" ? t("adminIncludedKmLabel") : t("adminIncludedKmLabelEn")}</span><span>${days * (state.vehicleMap.get(o.vehicle_id)?.included_km_per_day || 150)} km</span></div>
|
||||
<div class="price-row muted"><span>${lang === "de" ? t("adminTotalDaysLabel") : t("adminTotalDaysLabelEn")}</span><span>${days}</span></div>
|
||||
<div class="price-row muted"><span>${lang === "de" ? t("adminRentalType") : t("Rental type")}</span><span><span class="pill pill-${esc(rental.type)}">${esc(rental.label)}</span></span></div>
|
||||
</div>
|
||||
${isIndividuell && !isEmailLocked ? `<div style="display:flex;justify-content:flex-end;margin-top:0.8rem;"><button class="btn small" id="orderPricingSave">${t("adminSave")}</button></div>` : ''}`;
|
||||
|
||||
// Single save for both total + deposit
|
||||
document.querySelector("#orderPricingSave")?.addEventListener("click", async () => {
|
||||
const btn = document.querySelector("#orderPricingSave");
|
||||
btn.disabled = true;
|
||||
const totalInput = document.querySelector("#orderTotalInput");
|
||||
const depositInput = document.querySelector("#orderDepositInput");
|
||||
const errors = [];
|
||||
if (totalInput) {
|
||||
const { error } = await supabase.rpc("sales_order_set_total", { p_so_id: o.id, p_total_eur: +totalInput.value });
|
||||
if (error) errors.push(error.message);
|
||||
}
|
||||
if (depositInput) {
|
||||
const { error } = await supabase.rpc("sales_order_set_deposit", { p_so_id: o.id, p_deposit_eur: +depositInput.value });
|
||||
if (error) errors.push(error.message);
|
||||
}
|
||||
if (errors.length) { alert(errors.join("\n")); btn.disabled = false; return; }
|
||||
await loadSalesOrders();
|
||||
const fresh = state.salesOrders.find(x => x.id === id);
|
||||
if (fresh) await renderOrderTab("pricing", fresh, id);
|
||||
});
|
||||
}
|
||||
|
||||
orderDialogFooter.innerHTML = "";
|
||||
orderDialog.showModal();
|
||||
orderDialogClose.addEventListener("click", () => orderDialog.close(), { once: true });
|
||||
}
|
||||
|
||||
async function toggleSalesOrderState(orderId, action) {
|
||||
@@ -932,6 +1159,7 @@ async function renderCustomerTab(tab, c) {
|
||||
let html = "";
|
||||
if (orders.length) {
|
||||
for (const o of orders) {
|
||||
const rental = rentalTypeMeta(o.rental_type);
|
||||
const total = o.total_eur || 0;
|
||||
html += `
|
||||
<div class="pricing-card" style="margin-bottom:0.9rem;">
|
||||
@@ -941,6 +1169,7 @@ async function renderCustomerTab(tab, c) {
|
||||
</div>
|
||||
<div class="price-row"><span>${lang === "de" ? "Fahrzeug" : "Vehicle"}</span><span>${esc(o.vehicle_label || "—")}</span></div>
|
||||
<div class="price-row"><span>${lang === "de" ? "Zeitraum" : "Period"}</span><span>${esc(o.date_from || "—")} → ${esc(o.date_to || "—")}</span></div>
|
||||
<div class="price-row"><span>${lang === "de" ? "Miettyp" : "Rental type"}</span><span><span class="pill pill-${esc(rental.type)}">${esc(rental.label)}</span></span></div>
|
||||
<div style="display:flex;gap:0.4rem;flex-wrap:wrap;margin-top:0.7rem;">
|
||||
<button class="btn small ${o.kaution_paid ? "ghost" : ""}" data-so-toggle="kaution" data-so-id="${o.id}">${o.kaution_paid ? t("adminKautionPaid") : t("adminKautionPending")}</button>
|
||||
<button class="btn small ${o.rental_paid ? "ghost" : ""}" data-so-toggle="rental" data-so-id="${o.id}">${o.rental_paid ? t("adminRentalPaid") : t("adminRentalPending")}</button>
|
||||
@@ -974,8 +1203,32 @@ async function renderCustomerTab(tab, c) {
|
||||
const noteEl = customerDialogBody.querySelector(`[data-so-note="${btn.dataset.soSaveNote}"]`);
|
||||
const ok = await saveSalesOrderPrivateNotes(btn.dataset.soSaveNote, noteEl?.value || "");
|
||||
if (ok) {
|
||||
btn.classList.remove("ghost");
|
||||
btn.style.backgroundColor = "";
|
||||
btn.style.color = "";
|
||||
btn.textContent = "✓";
|
||||
setTimeout(() => { btn.textContent = t("adminSaveNotes"); }, 1500);
|
||||
setTimeout(() => {
|
||||
btn.textContent = t("adminSaveNotes");
|
||||
}, 1500);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
customerDialogBody.querySelectorAll("[data-so-note]").forEach((noteEl) => {
|
||||
const btn = customerDialogBody.querySelector(`[data-so-save-note="${noteEl.dataset.soNote}"]`);
|
||||
const originalValue = noteEl.value;
|
||||
noteEl.addEventListener("input", () => {
|
||||
const isDirty = noteEl.value !== originalValue;
|
||||
if (isDirty) {
|
||||
btn.classList.remove("ghost");
|
||||
btn.style.backgroundColor = "var(--accent-strong)";
|
||||
btn.style.color = "#fff";
|
||||
btn.textContent = "Speichern (unsaved)";
|
||||
} else {
|
||||
btn.classList.add("ghost");
|
||||
btn.style.backgroundColor = "";
|
||||
btn.style.color = "";
|
||||
btn.textContent = t("adminSaveNotes");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1074,6 +1327,22 @@ function attachRealtime() {
|
||||
// =========================================================================
|
||||
function esc(s) { return String(s ?? "").replace(/[&<>"']/g, c => ({ "&":"&","<":"<",">":">",'"':""","'":"'" })[c]); }
|
||||
function attr(s) { return esc(s); }
|
||||
function normalizeRentalType(rawType) {
|
||||
const value = String(rawType ?? "").trim().toLowerCase();
|
||||
if (!value) return "weekend";
|
||||
if (value === "individual" || value === "custom") return "individuell";
|
||||
if (value === "day" || value === "daily" || value === "1 tag" || value === "1_tag" || value === "single_day") return "single_day";
|
||||
if (value === "weekend") return "weekend";
|
||||
return value;
|
||||
}
|
||||
function rentalTypeMeta(rawType) {
|
||||
const type = normalizeRentalType(rawType);
|
||||
const lang = getLang();
|
||||
if (type === "single_day") return { type, label: lang === "de" ? "1 Tag" : "1 Day" };
|
||||
if (type === "individuell") return { type, label: lang === "de" ? "individuell" : "individual" };
|
||||
if (type === "weekend") return { type, label: "weekend" };
|
||||
return { type: "weekend", label: type };
|
||||
}
|
||||
function fmtDate(iso) {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
|
||||
+22
-6
@@ -1,5 +1,5 @@
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.4";
|
||||
import { translations, REVIEWS, getLang, setLang, t, applyI18n } from "./i18n.js";
|
||||
import { translations, REVIEWS, getLang, setLang, t, applyI18n } from "./i18n.js?v=3";
|
||||
|
||||
const SUPA_URL = window.MCCARS_CONFIG?.SUPABASE_URL ?? "";
|
||||
const SUPA_KEY = window.MCCARS_CONFIG?.SUPABASE_ANON_KEY || "";
|
||||
@@ -215,7 +215,7 @@ function openDetails(id) {
|
||||
<div class="spec-row" style="margin:1rem 0;">
|
||||
<div><strong>${v.seats}</strong><span>${t("seats")}</span></div>
|
||||
<div><strong>€ ${v.weekend_price_eur || v.daily_price_eur}</strong><span>${t("bpfWeekendRate")}</span></div>
|
||||
<div><strong>${v.max_daily_km || 150}</strong><span>${t("bpfMaxKm")}</span></div>
|
||||
<div><strong>${v.included_km_per_day || 150}</strong><span>${t("bpfInclKmPerDay")}</span></div>
|
||||
</div>
|
||||
<div class="spec-row" style="margin:1rem 0;grid-template-columns:1fr;">
|
||||
<div><strong>€ ${(v.kaution_eur || 5000).toLocaleString("de-DE")}</strong><span>${t("bpfDeposit")}</span></div>
|
||||
@@ -398,11 +398,25 @@ async function updateSidebar() {
|
||||
const vat = price.vat_eur;
|
||||
const total = price.total_eur;
|
||||
const deposit = price.deposit_eur;
|
||||
const kmPerWeekday = price.max_daily_km;
|
||||
const kmPerWeekendDay = price.max_km_weekend;
|
||||
const includedKm = (weekdays * kmPerWeekday) + (weekendDays * kmPerWeekendDay);
|
||||
const includedKmPerDay = price.included_km_per_day || 150;
|
||||
const includedKm = totalDays * includedKmPerDay;
|
||||
const photoUrl = optimizedVehiclePhotoUrl(v.photo_url);
|
||||
|
||||
if (totalDays > 2) {
|
||||
// Individuell mode: show info banner instead of pricing
|
||||
bpfSidebarPlaceholder.style.display = "none";
|
||||
bpfSidebarContent.style.display = "block";
|
||||
bpfSidebarContent.innerHTML = `
|
||||
<h4>${t("bpfPriceOverview")}</h4>
|
||||
<div class="bpf-info-banner">
|
||||
<p><strong>${t("bpfIndividuellTitle")}</strong></p>
|
||||
<p>${t("bpfIndividuellDesc")}</p>
|
||||
</div>
|
||||
<div class="bpf-car-preview" style="background-image:url('${escapeAttr(photoUrl)}');"></div>
|
||||
<p class="bpf-car-name">${escapeHtml(v.brand)} ${escapeHtml(v.model)}</p>
|
||||
<p class="bpf-car-specs">${v.power_hp} ${t("hp")} • ${v.top_speed_kmh} ${t("kmh")} • ${escapeHtml(v.acceleration)}</p>
|
||||
`;
|
||||
} else {
|
||||
bpfSidebarPlaceholder.style.display = "none";
|
||||
bpfSidebarContent.style.display = "block";
|
||||
bpfSidebarContent.innerHTML = `
|
||||
@@ -415,13 +429,15 @@ async function updateSidebar() {
|
||||
<div class="bpf-price-row total"><span>${t("bpfTotal")}</span><span>€ ${total.toLocaleString("de-DE")}</span></div>
|
||||
<div class="bpf-price-row muted" style="margin-top:0.8rem;"><span>${t("bpfDeposit")}</span><span>€ ${deposit.toLocaleString("de-DE")}</span></div>
|
||||
<div class="bpf-price-row muted"><span>${t("bpfIncludedKm")}</span><span>${includedKm} km</span></div>
|
||||
<div class="bpf-price-row muted"><span>${t("bpfExtraKm")}</span><span>€ 1,50${t("bpfPerKm")}</span></div>
|
||||
<div class="bpf-price-row muted"><span>${t("bpfExtraKm")}</span><span>€ ${(price.price_per_km_eur || 1.50).toFixed(2).replace('.', ',')}${t("bpfPerKm")}</span></div>
|
||||
<div class="bpf-car-preview" style="background-image:url('${escapeAttr(photoUrl)}');"></div>
|
||||
<p class="bpf-car-name">${escapeHtml(v.brand)} ${escapeHtml(v.model)}</p>
|
||||
<p class="bpf-car-specs">${v.power_hp} ${t("hp")} • ${v.top_speed_kmh} ${t("kmh")} • ${escapeHtml(v.acceleration)}</p>
|
||||
`;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
bpfCar.addEventListener("change", updateSidebar);
|
||||
bpfFrom.addEventListener("change", updateSidebar);
|
||||
bpfTo.addEventListener("change", updateSidebar);
|
||||
|
||||
+30
-2
@@ -89,6 +89,7 @@ export const translations = {
|
||||
bpfWeekendRate: "Wochenendmiete",
|
||||
bpfWeekendDef: "Wochenende: Samstag 9:00 – Sonntag 20:00",
|
||||
bpfMaxKm: "Max. km/Tag",
|
||||
bpfInclKmPerDay: "Inkl. km/Tag",
|
||||
bpfExtraKm: "Extra km",
|
||||
bpfPriceOverview: "Preisübersicht",
|
||||
bpfSelectForPrice: "Wähle Fahrzeug und Datum für eine Preisübersicht",
|
||||
@@ -176,7 +177,7 @@ export const translations = {
|
||||
adminVehicleTab: "Fahrzeug",
|
||||
adminPeriod: "Zeitraum",
|
||||
adminKaution: "Kaution (€)",
|
||||
adminMaxKmWeekend: "Max. km/Wochenendtag",
|
||||
adminMaxKmWeekend: "Inkl. km/Wochenende",
|
||||
adminTotalPrice: "Gesamtbetrag",
|
||||
adminLifetimeValueCol: "Gesamtwert",
|
||||
adminTabGeneral: "Allgemein",
|
||||
@@ -222,6 +223,8 @@ export const translations = {
|
||||
adminVatLabelEn: "VAT (20%)",
|
||||
adminTotalLabel: "Gesamtbetrag",
|
||||
adminTotalLabelEn: "Total",
|
||||
adminInclVat: "inkl. MwSt.",
|
||||
adminInclVatEn: "incl. VAT",
|
||||
adminDepositLabel: "Kaution",
|
||||
adminDepositLabelEn: "Deposit",
|
||||
adminIncludedKmLabel: "Inkl. km",
|
||||
@@ -234,6 +237,17 @@ export const translations = {
|
||||
adminNoteEn: "Note",
|
||||
adminSave: "Speichern",
|
||||
adminSaveEn: "Save",
|
||||
adminPricePerKm: "Preis extra km (€)",
|
||||
adminRentalType: "Miettyp",
|
||||
rentalTypeWeekend: "Wochenende",
|
||||
rentalTypeIndividuell: "Individuell",
|
||||
adminSortOrder: "Ordnung",
|
||||
adminEmailSent: "E-Mail gesendet",
|
||||
sendEmailButton: "E-Mail senden",
|
||||
emailSentToast: "E-Mail wird erstellt und in Kürze gesendet...",
|
||||
emailAlreadySent: "Bereits gesendet",
|
||||
bpfIndividuellTitle: "Individuelle Mietdauer",
|
||||
bpfIndividuellDesc: "Bei Mietdauer über 2 Tagen erstellen wir ein persönliches Angebot. Wir prüfen Verfügbarkeit und melden uns in Kürze per E-Mail bei Ihnen.",
|
||||
},
|
||||
en: {
|
||||
navCars: "Fleet",
|
||||
@@ -324,6 +338,7 @@ export const translations = {
|
||||
bpfWeekendRate: "Weekend rate",
|
||||
bpfWeekendDef: "Weekend: Saturday 9 AM – Sunday 8 PM",
|
||||
bpfMaxKm: "Max. km/day",
|
||||
bpfInclKmPerDay: "Included km/day",
|
||||
bpfExtraKm: "Extra km",
|
||||
bpfPriceOverview: "Price overview",
|
||||
bpfSelectForPrice: "Select vehicle and date for a price overview",
|
||||
@@ -411,7 +426,7 @@ export const translations = {
|
||||
adminVehicleTab: "Vehicle",
|
||||
adminPeriod: "Period",
|
||||
adminKaution: "Deposit (€)",
|
||||
adminMaxKmWeekend: "Max. km/weekend day",
|
||||
adminMaxKmWeekend: "Included km/weekend",
|
||||
adminTotalPrice: "Total",
|
||||
adminLifetimeValueCol: "Lifetime",
|
||||
adminTabGeneral: "General",
|
||||
@@ -457,6 +472,8 @@ export const translations = {
|
||||
adminVatLabelEn: "MwSt. (20%)",
|
||||
adminTotalLabel: "Total",
|
||||
adminTotalLabelEn: "Gesamtbetrag",
|
||||
adminInclVat: "incl. VAT",
|
||||
adminInclVatEn: "inkl. MwSt.",
|
||||
adminDepositLabel: "Deposit",
|
||||
adminDepositLabelEn: "Kaution",
|
||||
adminIncludedKmLabel: "Included km",
|
||||
@@ -469,6 +486,17 @@ export const translations = {
|
||||
adminNoteEn: "Notiz",
|
||||
adminSave: "Save",
|
||||
adminSaveEn: "Speichern",
|
||||
adminPricePerKm: "Extra km price (€)",
|
||||
adminRentalType: "Rental type",
|
||||
rentalTypeWeekend: "Weekend",
|
||||
rentalTypeIndividuell: "Custom",
|
||||
adminSortOrder: "Order",
|
||||
adminEmailSent: "Email sent",
|
||||
sendEmailButton: "Send Email",
|
||||
emailSentToast: "Email is being prepared and will be sent shortly...",
|
||||
emailAlreadySent: "Already sent",
|
||||
bpfIndividuellTitle: "Custom Rental Duration",
|
||||
bpfIndividuellDesc: "For rental periods over 2 days, we'll create a personalized quote. We'll check availability and get back to you via email shortly.",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
+1
-34
@@ -113,7 +113,6 @@
|
||||
|
||||
<nav class="main-nav" aria-label="Hauptnavigation">
|
||||
<a href="#fahrzeuge" data-i18n="navCars">Fahrzeuge</a>
|
||||
<a href="#warum" data-i18n="navWhy">Warum wir</a>
|
||||
<a href="#stimmen" data-i18n="navReviews">Stimmen</a>
|
||||
<a href="#buchen" data-i18n="navBook">Buchen</a>
|
||||
<a class="btn small" href="#buchen" data-i18n="bookNow">Jetzt buchen</a>
|
||||
@@ -179,37 +178,6 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Why -->
|
||||
<section id="warum" style="background:var(--bg-elev);">
|
||||
<a href="/impressum" data-i18n="imprint">Impressum</a>
|
||||
<a href="/agb" data-i18n="terms">AGB</a>
|
||||
<a href="/mietbedingungen" data-i18n="rentalTerms">Mietbedingungen</a>
|
||||
<a href="/datenschutz" data-i18n="privacy">Datenschutz</a>
|
||||
<p class="eyebrow" data-i18n="whyEyebrow">Warum MC Cars</p>
|
||||
<h2 data-i18n="whyTitle">Keine Kompromisse zwischen Sicherheit und Fahrspaß.</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="why-grid">
|
||||
<article class="why-card">
|
||||
<div class="icon">🛡</div>
|
||||
<h3 data-i18n="whyInsurance">Versicherungsschutz</h3>
|
||||
<p data-i18n="whyInsuranceText">Vollkasko mit klarem Selbstbehalt.</p>
|
||||
</article>
|
||||
<article class="why-card">
|
||||
<div class="icon">★</div>
|
||||
<h3 data-i18n="whyFleet">Premium Flotte</h3>
|
||||
<p data-i18n="whyFleetText">Handverlesene Performance-Modelle.</p>
|
||||
</article>
|
||||
<article class="why-card">
|
||||
<div class="icon">€</div>
|
||||
<h3 data-i18n="whyDeposit">Faire Kaution</h3>
|
||||
<p data-i18n="whyDepositText">Kein Überziehen. Transparente, faire Kaution ohne unnötige Belastung.</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Toast Notification -->
|
||||
<div id="toast" class="toast" role="status" aria-live="polite" aria-atomic="true"></div>
|
||||
<!-- Reviews -->
|
||||
@@ -409,7 +377,6 @@
|
||||
<div>
|
||||
<h4 data-i18n="footerNav">Navigation</h4>
|
||||
<a href="#fahrzeuge" data-i18n="navCars">Fahrzeuge</a>
|
||||
<a href="#warum" data-i18n="navWhy">Warum wir</a>
|
||||
<a href="#buchen" data-i18n="navBook">Buchen</a>
|
||||
</div>
|
||||
|
||||
@@ -447,6 +414,6 @@
|
||||
<div id="toast" class="toast" role="status" aria-live="polite" aria-atomic="true"></div>
|
||||
|
||||
<script src="config.js"></script>
|
||||
<script type="module" src="app.js"></script>
|
||||
<script type="module" src="app.js?v=3"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+2
-3
@@ -20,10 +20,9 @@ server {
|
||||
add_header Cache-Control "public";
|
||||
try_files $uri =404;
|
||||
}
|
||||
# CSS/JS: 1-week cache; config.js is excluded by its exact-match rule above.
|
||||
# CSS/JS: no cache to prevent stale content during development
|
||||
location ~* \.(?:css|js)$ {
|
||||
expires 7d;
|
||||
add_header Cache-Control "public, max-age=604800";
|
||||
add_header Cache-Control "no-store";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
|
||||
+14
-4
@@ -904,7 +904,7 @@ dialog::backdrop { background: rgba(0,0,0,0.6); }
|
||||
|
||||
/* ---------------- Admin ---------------- */
|
||||
.admin-page {
|
||||
max-width: 1100px;
|
||||
max-width: 1280px;
|
||||
margin: 2rem auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
@@ -960,6 +960,7 @@ table.admin-table th, table.admin-table td {
|
||||
text-align: left;
|
||||
padding: 0.75rem 0.6rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
vertical-align: top;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
table.admin-table th { color: var(--muted); font-weight: 500; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.08em; padding-bottom: 0.5rem; }
|
||||
@@ -969,6 +970,9 @@ table.admin-table tbody tr:hover {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
/* Admin table actions column: prevent button wrap */
|
||||
table.admin-table td:last-child { white-space: nowrap; }
|
||||
|
||||
.link-lead { text-decoration: none; cursor: pointer; }
|
||||
.link-lead:hover code { color: var(--accent-strong); text-decoration: underline; }
|
||||
|
||||
@@ -1098,6 +1102,9 @@ input:checked + .toggle-slider:before {
|
||||
.pill-disqualified { background: rgba(180, 90, 90, 0.15); color: #d48a8a; border: 1px solid rgba(180, 90, 90, 0.3); }
|
||||
.pill-active { background: rgba(90, 180, 120, 0.15); color: #6ecf96; border: 1px solid rgba(90, 180, 120, 0.3); }
|
||||
.pill-inactive { background: rgba(160, 160, 160, 0.12); color: var(--muted); border: 1px solid transparent; }
|
||||
.pill-single_day { background: rgba(74, 144, 226, 0.16); color: #8abfff; border: 1px solid rgba(74, 144, 226, 0.35); }
|
||||
.pill-weekend { background: rgba(200, 150, 80, 0.15); color: #e4b676; border: 1px solid rgba(200, 150, 80, 0.3); }
|
||||
.pill-individuell { background: rgba(204, 116, 58, 0.16); color: #ffb487; border: 1px solid rgba(204, 116, 58, 0.38); }
|
||||
|
||||
.muted { color: var(--muted); }
|
||||
|
||||
@@ -1107,7 +1114,8 @@ input:checked + .toggle-slider:before {
|
||||
|
||||
/* Dialog */
|
||||
dialog#leadDialog,
|
||||
dialog#customerDialog {
|
||||
dialog#customerDialog,
|
||||
dialog#orderDialog {
|
||||
border: 1px solid var(--line); border-radius: var(--radius);
|
||||
background: var(--bg-card); color: var(--text);
|
||||
padding: 0; max-width: 640px; width: 94%;
|
||||
@@ -1115,11 +1123,13 @@ dialog#customerDialog {
|
||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
}
|
||||
dialog#leadDialog[open],
|
||||
dialog#customerDialog[open] {
|
||||
dialog#customerDialog[open],
|
||||
dialog#orderDialog[open] {
|
||||
animation: fadeInScale 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
dialog#leadDialog::backdrop,
|
||||
dialog#customerDialog::backdrop {
|
||||
dialog#customerDialog::backdrop,
|
||||
dialog#orderDialog::backdrop {
|
||||
background: rgba(0,0,0,0.7);
|
||||
backdrop-filter: blur(4px);
|
||||
animation: fadeIn 0.3s ease forwards;
|
||||
|
||||
@@ -3,6 +3,8 @@ set -eu
|
||||
|
||||
WORKFLOW_TEMPLATE="${N8N_WORKFLOW_TEMPLATE:-/opt/mc-cars/workflows/01-qualification-payment-email.json}"
|
||||
WORKFLOW_RENDERED="/tmp/01-qualification-payment-email.rendered.json"
|
||||
WORKFLOW03_TEMPLATE="/opt/mc-cars/workflows/03-manual-email-send.json"
|
||||
WORKFLOW03_RENDERED="/tmp/03-manual-email-send.rendered.json"
|
||||
CREDENTIALS_FILE="/tmp/mc-cars-credentials.json"
|
||||
|
||||
required_var() {
|
||||
@@ -74,10 +76,28 @@ sed \
|
||||
echo "[n8n-bootstrap] Importing credentials"
|
||||
n8n import:credentials --input="$CREDENTIALS_FILE"
|
||||
|
||||
echo "[n8n-bootstrap] Importing workflow"
|
||||
echo "[n8n-bootstrap] Importing workflow 01"
|
||||
n8n import:workflow --input="$WORKFLOW_RENDERED"
|
||||
|
||||
echo "[n8n-bootstrap] Activating workflow ${N8N_PAYMENT_WORKFLOW_ID}"
|
||||
n8n update:workflow --id="${N8N_PAYMENT_WORKFLOW_ID}" --active=true
|
||||
# Process and import workflow 03 - Manual Email Send
|
||||
if [ -f "$WORKFLOW03_TEMPLATE" ]; then
|
||||
sed \
|
||||
-e "s/__POSTGRES_CREDENTIAL_ID__/${POSTGRES_ID_ESCAPED}/g" \
|
||||
-e "s/__SMTP_CREDENTIAL_ID__/${SMTP_ID_ESCAPED}/g" \
|
||||
-e "s|__PAYPAL_KAUTION_LINK__|${KAUTION_LINK_ESCAPED}|g" \
|
||||
-e "s|__PAYPAL_MIETE_LINK__|${MIETE_LINK_ESCAPED}|g" \
|
||||
"$WORKFLOW03_TEMPLATE" > "$WORKFLOW03_RENDERED"
|
||||
|
||||
echo "[n8n-bootstrap] Importing workflow 03 (Manual Email Send)"
|
||||
n8n import:workflow --input="$WORKFLOW03_RENDERED"
|
||||
fi
|
||||
|
||||
# Publish all imported workflows so they appear in the UI
|
||||
echo "[n8n-bootstrap] Publishing all workflows"
|
||||
WF_IDS=$(n8n list:workflow 2>/dev/null | cut -d'|' -f1 || true)
|
||||
for wfid in $WF_IDS; do
|
||||
echo "[n8n-bootstrap] Publishing workflow $wfid"
|
||||
n8n publish:workflow --id="$wfid" 2>/dev/null || true
|
||||
done
|
||||
|
||||
echo "[n8n-bootstrap] Bootstrap complete"
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -120,3 +120,16 @@ services:
|
||||
hide_groups_header: true
|
||||
allow:
|
||||
- admin
|
||||
|
||||
########################################
|
||||
# n8n Webhooks (internal workflow triggers)
|
||||
########################################
|
||||
- name: n8n-webhooks
|
||||
url: http://n8n:5678/
|
||||
routes:
|
||||
- name: n8n-webhooks-all
|
||||
strip_path: false
|
||||
paths:
|
||||
- /webhook/
|
||||
plugins:
|
||||
- name: cors
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
-- 11-consolidate-km-rental.sql
|
||||
-- Consolidate km/rental model: new included_km_per_day, rental_type,
|
||||
-- rewrite calculate_price / create_lead / qualify_lead / notify_lead_qualified,
|
||||
-- add sales_order_set_total RPC.
|
||||
-- Idempotent.
|
||||
|
||||
-- =============================================================================
|
||||
-- A. Vehicles table changes
|
||||
-- =============================================================================
|
||||
|
||||
alter table public.vehicles add column if not exists included_km_per_day integer not null default 150;
|
||||
|
||||
update public.vehicles set included_km_per_day = coalesce(max_daily_km, 150) where included_km_per_day = 150;
|
||||
|
||||
update public.vehicles set included_km_per_day = 200 where brand = 'Ferrari' and model = '296 GTB';
|
||||
|
||||
alter table public.vehicles add column if not exists price_per_km_eur numeric(10,2) not null default 1.50;
|
||||
|
||||
alter table public.vehicles drop column if exists max_daily_km;
|
||||
alter table public.vehicles drop column if exists max_km_weekend;
|
||||
|
||||
-- =============================================================================
|
||||
-- B. Leads table changes
|
||||
-- =============================================================================
|
||||
|
||||
alter table public.leads add column if not exists rental_type text not null default 'weekend' check (rental_type in ('weekend','individuell'));
|
||||
update public.leads set rental_type = 'weekend' where rental_type is null;
|
||||
create index if not exists leads_rental_type_idx on public.leads (rental_type);
|
||||
|
||||
-- =============================================================================
|
||||
-- C. Sales orders table changes
|
||||
-- =============================================================================
|
||||
|
||||
alter table public.sales_orders add column if not exists rental_type text not null default 'weekend' check (rental_type in ('weekend','individuell'));
|
||||
update public.sales_orders set rental_type = 'weekend' where rental_type is null;
|
||||
create index if not exists sales_orders_rental_type_idx on public.sales_orders (rental_type);
|
||||
|
||||
-- =============================================================================
|
||||
-- D. Rewrite calculate_price() RPC
|
||||
-- =============================================================================
|
||||
|
||||
drop function if exists public.calculate_price(uuid, date, date);
|
||||
|
||||
create or replace function public.calculate_price(
|
||||
p_vehicle_id uuid,
|
||||
p_date_from date,
|
||||
p_date_to date
|
||||
)
|
||||
returns jsonb
|
||||
language plpgsql
|
||||
stable
|
||||
security definer
|
||||
as $$
|
||||
declare
|
||||
v_vehicle record;
|
||||
v_total_days integer;
|
||||
v_weekend_days integer;
|
||||
v_weekdays integer;
|
||||
v_daily_subtotal integer;
|
||||
v_weekend_subtotal integer;
|
||||
v_subtotal_eur integer;
|
||||
v_vat_eur integer;
|
||||
v_total_eur integer;
|
||||
v_deposit_eur integer;
|
||||
v_included_km_per_day integer;
|
||||
v_price_per_km numeric(10,2);
|
||||
v_total_included_km integer;
|
||||
v_extra_km integer;
|
||||
v_extra_km_eur numeric(10,2);
|
||||
v_cur date;
|
||||
v_dow integer;
|
||||
begin
|
||||
if p_vehicle_id is null or p_date_from is null or p_date_to is null then
|
||||
raise exception 'vehicle_id, date_from and date_to are required';
|
||||
end if;
|
||||
if p_date_to <= p_date_from then
|
||||
raise exception 'date_to must be after date_from';
|
||||
end if;
|
||||
|
||||
select daily_price_eur, weekend_price_eur, kaution_eur, included_km_per_day, price_per_km_eur
|
||||
into v_vehicle
|
||||
from public.vehicles
|
||||
where id = p_vehicle_id;
|
||||
|
||||
if not found then
|
||||
raise exception 'Vehicle not found';
|
||||
end if;
|
||||
|
||||
v_total_days := (p_date_to - p_date_from);
|
||||
v_weekend_days := 0;
|
||||
v_cur := p_date_from;
|
||||
while v_cur < p_date_to loop
|
||||
v_dow := extract(isodow from v_cur); -- 6=Sat, 7=Sun
|
||||
if v_dow in (6, 7) then
|
||||
v_weekend_days := v_weekend_days + 1;
|
||||
end if;
|
||||
v_cur := v_cur + 1;
|
||||
end loop;
|
||||
v_weekdays := v_total_days - v_weekend_days;
|
||||
|
||||
v_daily_subtotal := v_weekdays * v_vehicle.daily_price_eur;
|
||||
v_weekend_subtotal := v_weekend_days * (case when v_vehicle.weekend_price_eur > 0 then v_vehicle.weekend_price_eur else v_vehicle.daily_price_eur end);
|
||||
v_subtotal_eur := v_daily_subtotal + v_weekend_subtotal;
|
||||
v_vat_eur := round(v_subtotal_eur * 0.20);
|
||||
v_total_eur := v_subtotal_eur + v_vat_eur;
|
||||
v_deposit_eur := coalesce(nullif(v_vehicle.kaution_eur, 0), 5000);
|
||||
|
||||
v_included_km_per_day := coalesce(v_vehicle.included_km_per_day, 150);
|
||||
v_total_included_km := v_total_days * v_included_km_per_day;
|
||||
v_price_per_km := coalesce(v_vehicle.price_per_km_eur, 1.50);
|
||||
v_extra_km := greatest(0, 0); -- extra km is determined by caller (frontend) based on expected usage
|
||||
v_extra_km_eur := v_extra_km * v_price_per_km;
|
||||
|
||||
return jsonb_build_object(
|
||||
'total_days', v_total_days,
|
||||
'weekday_count', v_weekdays,
|
||||
'weekend_day_count', v_weekend_days,
|
||||
'daily_subtotal', v_daily_subtotal,
|
||||
'weekend_subtotal', v_weekend_subtotal,
|
||||
'subtotal_eur', v_subtotal_eur,
|
||||
'vat_eur', v_vat_eur,
|
||||
'total_eur', v_total_eur,
|
||||
'deposit_eur', v_deposit_eur,
|
||||
'daily_price_eur', v_vehicle.daily_price_eur,
|
||||
'weekend_price_eur', (case when v_vehicle.weekend_price_eur > 0 then v_vehicle.weekend_price_eur else v_vehicle.daily_price_eur end),
|
||||
'included_km_per_day', v_included_km_per_day,
|
||||
'total_included_km', v_total_included_km,
|
||||
'price_per_km_eur', v_price_per_km,
|
||||
'extra_km', v_extra_km,
|
||||
'extra_km_eur', v_extra_km_eur
|
||||
);
|
||||
end;
|
||||
$$;
|
||||
|
||||
grant execute on function public.calculate_price(uuid, date, date) to anon, authenticated, service_role;
|
||||
|
||||
-- =============================================================================
|
||||
-- E. Rewrite create_lead() RPC
|
||||
-- =============================================================================
|
||||
|
||||
drop function if exists public.create_lead(
|
||||
text, text, text, uuid, text, date, date, text, text
|
||||
);
|
||||
drop function if exists public.create_lead(
|
||||
text, text, text, uuid, text, date, date, text, text,
|
||||
integer, integer, integer, integer, integer, integer, integer, integer, integer,
|
||||
text, text
|
||||
);
|
||||
|
||||
create or replace function public.create_lead(
|
||||
p_name text,
|
||||
p_email text,
|
||||
p_phone text default '',
|
||||
p_vehicle_id uuid default null,
|
||||
p_vehicle_label text default '',
|
||||
p_date_from date default null,
|
||||
p_date_to date default null,
|
||||
p_message text default '',
|
||||
p_source text default 'website',
|
||||
p_ip_address text default '',
|
||||
p_ip_country text default ''
|
||||
)
|
||||
returns uuid
|
||||
language plpgsql
|
||||
security definer
|
||||
as $$
|
||||
declare
|
||||
v_lead_id uuid;
|
||||
v_vehicle record;
|
||||
v_total_days integer := 0;
|
||||
v_weekend_days integer := 0;
|
||||
v_weekdays integer := 0;
|
||||
v_daily_subtotal integer := 0;
|
||||
v_weekend_subtotal integer := 0;
|
||||
v_subtotal_eur integer := 0;
|
||||
v_vat_eur integer := 0;
|
||||
v_total_eur integer := 0;
|
||||
v_deposit_eur integer := 0;
|
||||
v_rental_type text := 'weekend';
|
||||
v_cur date;
|
||||
v_dow integer;
|
||||
begin
|
||||
if p_vehicle_id is not null and p_date_from is not null and p_date_to is not null and p_date_to > p_date_from then
|
||||
select daily_price_eur, weekend_price_eur, kaution_eur
|
||||
into v_vehicle
|
||||
from public.vehicles
|
||||
where id = p_vehicle_id;
|
||||
|
||||
if found then
|
||||
v_total_days := (p_date_to - p_date_from);
|
||||
|
||||
-- Auto-detect rental type: 2 days or less = weekend, more = individuell
|
||||
v_rental_type := 'weekend';
|
||||
if v_total_days > 2 then
|
||||
v_rental_type := 'individuell';
|
||||
end if;
|
||||
|
||||
-- For individuell, set all pricing to 0
|
||||
if v_rental_type = 'individuell' then
|
||||
v_daily_subtotal := 0;
|
||||
v_weekend_subtotal := 0;
|
||||
v_subtotal_eur := 0;
|
||||
v_vat_eur := 0;
|
||||
v_total_eur := 0;
|
||||
v_deposit_eur := 0;
|
||||
else
|
||||
v_cur := p_date_from;
|
||||
while v_cur < p_date_to loop
|
||||
v_dow := extract(isodow from v_cur);
|
||||
if v_dow in (6, 7) then
|
||||
v_weekend_days := v_weekend_days + 1;
|
||||
end if;
|
||||
v_cur := v_cur + 1;
|
||||
end loop;
|
||||
v_weekdays := v_total_days - v_weekend_days;
|
||||
|
||||
v_daily_subtotal := v_weekdays * v_vehicle.daily_price_eur;
|
||||
v_weekend_subtotal := v_weekend_days * (case when v_vehicle.weekend_price_eur > 0 then v_vehicle.weekend_price_eur else v_vehicle.daily_price_eur end);
|
||||
v_subtotal_eur := v_daily_subtotal + v_weekend_subtotal;
|
||||
v_vat_eur := round(v_subtotal_eur * 0.20);
|
||||
v_total_eur := v_subtotal_eur + v_vat_eur;
|
||||
v_deposit_eur := coalesce(nullif(v_vehicle.kaution_eur, 0), 5000);
|
||||
end if;
|
||||
end if;
|
||||
end if;
|
||||
|
||||
insert into public.leads (
|
||||
name, email, phone, vehicle_id, vehicle_label, date_from, date_to,
|
||||
message, source,
|
||||
daily_subtotal, weekend_subtotal, subtotal_eur, vat_eur, total_eur, deposit_eur,
|
||||
total_days, weekday_count, weekend_day_count, ip_address, ip_country,
|
||||
rental_type
|
||||
) values (
|
||||
p_name, p_email, p_phone, p_vehicle_id, p_vehicle_label, p_date_from, p_date_to,
|
||||
p_message, p_source,
|
||||
v_daily_subtotal, v_weekend_subtotal, v_subtotal_eur, v_vat_eur, v_total_eur, v_deposit_eur,
|
||||
v_total_days, v_weekdays, v_weekend_days, p_ip_address, p_ip_country,
|
||||
v_rental_type
|
||||
)
|
||||
returning id into v_lead_id;
|
||||
return v_lead_id;
|
||||
end;
|
||||
$$;
|
||||
|
||||
grant execute on function public.create_lead(
|
||||
text, text, text, uuid, text, date, date, text, text, text, text
|
||||
) to anon, authenticated, service_role;
|
||||
|
||||
-- =============================================================================
|
||||
-- F. Rewrite qualify_lead() RPC
|
||||
-- =============================================================================
|
||||
|
||||
create or replace function public.qualify_lead(p_lead_id uuid, p_notes text default '')
|
||||
returns public.customers
|
||||
language plpgsql
|
||||
security invoker
|
||||
as $$
|
||||
declare
|
||||
v_lead public.leads;
|
||||
v_customer public.customers;
|
||||
v_sales_order public.sales_orders;
|
||||
v_user uuid := auth.uid();
|
||||
v_order_num text;
|
||||
v_year integer;
|
||||
v_count integer;
|
||||
begin
|
||||
select * into v_lead from public.leads where id = p_lead_id for update;
|
||||
if not found then
|
||||
raise exception 'lead % not found', p_lead_id;
|
||||
end if;
|
||||
|
||||
if v_lead.status = 'qualified' then
|
||||
select * into v_customer from public.customers where lower(email) = lower(v_lead.email) limit 1;
|
||||
return v_customer;
|
||||
end if;
|
||||
|
||||
update public.leads
|
||||
set status = 'qualified',
|
||||
is_active = false,
|
||||
qualified_at = now(),
|
||||
qualified_by = v_user,
|
||||
admin_notes = coalesce(nullif(p_notes, ''), admin_notes)
|
||||
where id = v_lead.id;
|
||||
|
||||
insert into public.customers (lead_id, name, email, phone, notes, created_by)
|
||||
values (v_lead.id, v_lead.name, v_lead.email, v_lead.phone, coalesce(p_notes,''), v_user)
|
||||
on conflict ((lower(email))) do update
|
||||
set name = excluded.name,
|
||||
phone = excluded.phone,
|
||||
notes = case when excluded.notes <> '' then excluded.notes else public.customers.notes end,
|
||||
updated_at = now()
|
||||
returning * into v_customer;
|
||||
|
||||
v_year := extract(year from now())::integer;
|
||||
select coalesce(count(*), 0) + 1 into v_count
|
||||
from public.sales_orders
|
||||
where extract(year from created_at)::integer = v_year;
|
||||
v_order_num := 'SO-' || v_year || '-' || lpad(v_count::text, 4, '0');
|
||||
|
||||
insert into public.sales_orders (
|
||||
customer_id, lead_id, order_number, private_notes,
|
||||
daily_subtotal, weekend_subtotal, subtotal_eur, vat_eur, total_eur, deposit_eur,
|
||||
total_days, weekday_count, weekend_day_count,
|
||||
date_from, date_to, vehicle_label, rental_type
|
||||
) values (
|
||||
v_customer.id, v_lead.id, v_order_num, coalesce(v_lead.admin_notes, ''),
|
||||
coalesce(v_lead.daily_subtotal, 0), coalesce(v_lead.weekend_subtotal, 0),
|
||||
coalesce(v_lead.subtotal_eur, 0), coalesce(v_lead.vat_eur, 0),
|
||||
coalesce(v_lead.total_eur, 0), coalesce(v_lead.deposit_eur, 0),
|
||||
coalesce(v_lead.total_days, 0), coalesce(v_lead.weekday_count, 0),
|
||||
coalesce(v_lead.weekend_day_count, 0),
|
||||
v_lead.date_from, v_lead.date_to, v_lead.vehicle_label, v_lead.rental_type
|
||||
) returning * into v_sales_order;
|
||||
|
||||
insert into public.customer_attachments (customer_id, lead_id, sales_order_id, bucket, file_path, file_name, mime_type, kind, created_at)
|
||||
select v_customer.id, la.lead_id, v_sales_order.id, la.bucket, la.file_path, la.file_name, la.mime_type, la.kind, la.created_at
|
||||
from public.lead_attachments la
|
||||
where la.lead_id = v_lead.id
|
||||
and not exists (
|
||||
select 1 from public.customer_attachments ca
|
||||
where ca.customer_id = v_customer.id
|
||||
and ca.file_path = la.file_path
|
||||
);
|
||||
|
||||
insert into public.sales_order_attachments (sales_order_id, bucket, file_path, file_name, mime_type, kind, created_at)
|
||||
select v_sales_order.id, la.bucket, la.file_path, la.file_name, la.mime_type, la.kind, la.created_at
|
||||
from public.lead_attachments la
|
||||
where la.lead_id = v_lead.id;
|
||||
|
||||
return v_customer;
|
||||
end;
|
||||
$$;
|
||||
|
||||
-- =============================================================================
|
||||
-- G. Rewrite notify_lead_qualified() trigger function
|
||||
-- =============================================================================
|
||||
|
||||
create or replace function public.notify_lead_qualified()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
security definer
|
||||
as $$
|
||||
begin
|
||||
-- Skip notification for 'individuell' rental type
|
||||
if NEW.rental_type = 'individuell' then
|
||||
return NEW;
|
||||
end if;
|
||||
|
||||
perform pg_notify('lead_qualified', json_build_object(
|
||||
'sales_order_id', NEW.id,
|
||||
'customer_id', NEW.customer_id,
|
||||
'lead_id', NEW.lead_id,
|
||||
'order_number', NEW.order_number,
|
||||
'total_eur', NEW.total_eur,
|
||||
'deposit_eur', NEW.deposit_eur,
|
||||
'date_from', NEW.date_from,
|
||||
'date_to', NEW.date_to,
|
||||
'vehicle_label', NEW.vehicle_label,
|
||||
'rental_type', NEW.rental_type
|
||||
)::text);
|
||||
return NEW;
|
||||
end;
|
||||
$$;
|
||||
|
||||
-- =============================================================================
|
||||
-- H. New RPC: sales_order_set_total
|
||||
-- =============================================================================
|
||||
|
||||
create or replace function public.sales_order_set_total(p_so_id uuid, p_total_eur integer)
|
||||
returns void
|
||||
language plpgsql
|
||||
security invoker
|
||||
as $$
|
||||
declare
|
||||
v_so public.sales_orders;
|
||||
begin
|
||||
select * into v_so from public.sales_orders where id = p_so_id for update;
|
||||
if not found then
|
||||
raise exception 'sales order % not found', p_so_id;
|
||||
end if;
|
||||
if v_so.rental_type != 'individuell' then
|
||||
raise exception 'can only set total for individuell orders';
|
||||
end if;
|
||||
update public.sales_orders
|
||||
set total_eur = p_total_eur, updated_at = now()
|
||||
where id = p_so_id;
|
||||
end;
|
||||
$$;
|
||||
|
||||
grant execute on function public.sales_order_set_total(uuid, integer) to authenticated;
|
||||
|
||||
-- =============================================================================
|
||||
-- I. Final schema reload
|
||||
-- =============================================================================
|
||||
|
||||
notify pgrst, 'reload schema';
|
||||
@@ -0,0 +1,209 @@
|
||||
-- 12-email-sent-and-more.sql
|
||||
-- Add email_sent column to sales_orders, update notify_lead_qualified() to include
|
||||
-- rental_type and email_sent, update qualify_lead() to set email_sent=0,
|
||||
-- add sales_order_update_email_sent and sales_order_get_email_details RPCs.
|
||||
-- Idempotent.
|
||||
|
||||
-- =============================================================================
|
||||
-- A. Add email_sent to sales_orders
|
||||
-- =============================================================================
|
||||
|
||||
alter table public.sales_orders add column if not exists email_sent integer not null default 0;
|
||||
create index if not exists sales_orders_email_sent_idx on public.sales_orders (email_sent);
|
||||
|
||||
-- =============================================================================
|
||||
-- B. Update notify_lead_qualified() trigger function
|
||||
-- (defined in 10-mietvertrag-workflow.sql, overridden by 11-consolidate-km-rental.sql)
|
||||
-- Since migration 12 runs after 11, this is the final version.
|
||||
-- =============================================================================
|
||||
|
||||
create or replace function public.notify_lead_qualified()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
security definer
|
||||
as $$
|
||||
begin
|
||||
-- Skip notification for 'individuell' rental type
|
||||
if NEW.rental_type = 'individuell' then
|
||||
return NEW;
|
||||
end if;
|
||||
|
||||
perform pg_notify('lead_qualified', json_build_object(
|
||||
'sales_order_id', NEW.id,
|
||||
'customer_id', NEW.customer_id,
|
||||
'lead_id', NEW.lead_id,
|
||||
'order_number', NEW.order_number,
|
||||
'total_eur', NEW.total_eur,
|
||||
'deposit_eur', NEW.deposit_eur,
|
||||
'date_from', NEW.date_from,
|
||||
'date_to', NEW.date_to,
|
||||
'vehicle_label', NEW.vehicle_label,
|
||||
'rental_type', NEW.rental_type,
|
||||
'email_sent', NEW.email_sent
|
||||
)::text);
|
||||
return NEW;
|
||||
end;
|
||||
$$;
|
||||
|
||||
-- =============================================================================
|
||||
-- C. Update qualify_lead() RPC
|
||||
-- Add email_sent = 0 to the sales_orders insert.
|
||||
-- =============================================================================
|
||||
|
||||
create or replace function public.qualify_lead(p_lead_id uuid, p_notes text default '')
|
||||
returns public.customers
|
||||
language plpgsql
|
||||
security invoker
|
||||
as $$
|
||||
declare
|
||||
v_lead public.leads;
|
||||
v_customer public.customers;
|
||||
v_sales_order public.sales_orders;
|
||||
v_user uuid := auth.uid();
|
||||
v_order_num text;
|
||||
v_year integer;
|
||||
v_count integer;
|
||||
begin
|
||||
select * into v_lead from public.leads where id = p_lead_id for update;
|
||||
if not found then
|
||||
raise exception 'lead % not found', p_lead_id;
|
||||
end if;
|
||||
|
||||
if v_lead.status = 'qualified' then
|
||||
select * into v_customer from public.customers where lower(email) = lower(v_lead.email) limit 1;
|
||||
return v_customer;
|
||||
end if;
|
||||
|
||||
update public.leads
|
||||
set status = 'qualified',
|
||||
is_active = false,
|
||||
qualified_at = now(),
|
||||
qualified_by = v_user,
|
||||
admin_notes = coalesce(nullif(p_notes, ''), admin_notes)
|
||||
where id = v_lead.id;
|
||||
|
||||
insert into public.customers (lead_id, name, email, phone, notes, created_by)
|
||||
values (v_lead.id, v_lead.name, v_lead.email, v_lead.phone, coalesce(p_notes,''), v_user)
|
||||
on conflict ((lower(email))) do update
|
||||
set name = excluded.name,
|
||||
phone = excluded.phone,
|
||||
notes = case when excluded.notes <> '' then excluded.notes else public.customers.notes end,
|
||||
updated_at = now()
|
||||
returning * into v_customer;
|
||||
|
||||
v_year := extract(year from now())::integer;
|
||||
select coalesce(count(*), 0) + 1 into v_count
|
||||
from public.sales_orders
|
||||
where extract(year from created_at)::integer = v_year;
|
||||
v_order_num := 'SO-' || v_year || '-' || lpad(v_count::text, 4, '0');
|
||||
|
||||
insert into public.sales_orders (
|
||||
customer_id, lead_id, order_number, private_notes,
|
||||
daily_subtotal, weekend_subtotal, subtotal_eur, vat_eur, total_eur, deposit_eur,
|
||||
total_days, weekday_count, weekend_day_count,
|
||||
date_from, date_to, vehicle_label, rental_type, email_sent
|
||||
) values (
|
||||
v_customer.id, v_lead.id, v_order_num, coalesce(v_lead.admin_notes, ''),
|
||||
coalesce(v_lead.daily_subtotal, 0), coalesce(v_lead.weekend_subtotal, 0),
|
||||
coalesce(v_lead.subtotal_eur, 0), coalesce(v_lead.vat_eur, 0),
|
||||
coalesce(v_lead.total_eur, 0), coalesce(v_lead.deposit_eur, 0),
|
||||
coalesce(v_lead.total_days, 0), coalesce(v_lead.weekday_count, 0),
|
||||
coalesce(v_lead.weekend_day_count, 0),
|
||||
v_lead.date_from, v_lead.date_to, v_lead.vehicle_label, v_lead.rental_type, 0
|
||||
) returning * into v_sales_order;
|
||||
|
||||
insert into public.customer_attachments (customer_id, lead_id, sales_order_id, bucket, file_path, file_name, mime_type, kind, created_at)
|
||||
select v_customer.id, la.lead_id, v_sales_order.id, la.bucket, la.file_path, la.file_name, la.mime_type, la.kind, la.created_at
|
||||
from public.lead_attachments la
|
||||
where la.lead_id = v_lead.id
|
||||
and not exists (
|
||||
select 1 from public.customer_attachments ca
|
||||
where ca.customer_id = v_customer.id
|
||||
and ca.file_path = la.file_path
|
||||
);
|
||||
|
||||
insert into public.sales_order_attachments (sales_order_id, bucket, file_path, file_name, mime_type, kind, created_at)
|
||||
select v_sales_order.id, la.bucket, la.file_path, la.file_name, la.mime_type, la.kind, la.created_at
|
||||
from public.lead_attachments la
|
||||
where la.lead_id = v_lead.id;
|
||||
|
||||
return v_customer;
|
||||
end;
|
||||
$$;
|
||||
|
||||
-- =============================================================================
|
||||
-- D. New RPC: sales_order_update_email_sent
|
||||
-- =============================================================================
|
||||
|
||||
create or replace function public.sales_order_update_email_sent(p_so_id uuid, p_status integer)
|
||||
returns void
|
||||
language plpgsql
|
||||
security invoker
|
||||
as $$
|
||||
declare
|
||||
v_so public.sales_orders;
|
||||
begin
|
||||
select * into v_so from public.sales_orders where id = p_so_id for update;
|
||||
if not found then
|
||||
raise exception 'sales order % not found', p_so_id;
|
||||
end if;
|
||||
if p_status not in (0, 1, 2) then
|
||||
raise exception 'invalid email_sent status: %', p_status;
|
||||
end if;
|
||||
update public.sales_orders
|
||||
set email_sent = p_status, updated_at = now()
|
||||
where id = p_so_id;
|
||||
end;
|
||||
$$;
|
||||
|
||||
grant execute on function public.sales_order_update_email_sent(uuid, integer) to authenticated;
|
||||
|
||||
-- =============================================================================
|
||||
-- E. New RPC: sales_order_get_email_details
|
||||
-- =============================================================================
|
||||
|
||||
create or replace function public.sales_order_get_email_details(p_so_id uuid)
|
||||
returns jsonb
|
||||
language plpgsql
|
||||
security definer
|
||||
as $$
|
||||
declare
|
||||
v_result jsonb;
|
||||
begin
|
||||
select jsonb_build_object(
|
||||
'order_number', so.order_number,
|
||||
'total_eur', so.total_eur,
|
||||
'deposit_eur', so.deposit_eur,
|
||||
'date_from', so.date_from,
|
||||
'date_to', so.date_to,
|
||||
'vehicle_label', so.vehicle_label,
|
||||
'customer_name', c.name,
|
||||
'customer_email', c.email,
|
||||
'customer_phone', c.phone,
|
||||
'daily_subtotal', so.daily_subtotal,
|
||||
'weekend_subtotal', so.weekend_subtotal,
|
||||
'subtotal_eur', so.subtotal_eur,
|
||||
'vat_eur', so.vat_eur,
|
||||
'total_days', so.total_days,
|
||||
'weekday_count', so.weekday_count,
|
||||
'weekend_day_count', so.weekend_day_count
|
||||
) into v_result
|
||||
from public.sales_orders so
|
||||
join public.customers c on c.id = so.customer_id
|
||||
where so.id = p_so_id;
|
||||
|
||||
if v_result is null then
|
||||
raise exception 'sales order % not found', p_so_id;
|
||||
end if;
|
||||
|
||||
return v_result;
|
||||
end;
|
||||
$$;
|
||||
|
||||
grant execute on function public.sales_order_get_email_details(uuid) to authenticated;
|
||||
|
||||
-- =============================================================================
|
||||
-- F. Final schema reload
|
||||
-- =============================================================================
|
||||
|
||||
notify pgrst, 'reload schema';
|
||||
@@ -0,0 +1,232 @@
|
||||
-- 13-rental-type-daily-and-email-guard.sql
|
||||
-- Introduce explicit 'single_day' rental_type, normalize legacy values,
|
||||
-- and harden auto-email guard for individuell rentals.
|
||||
|
||||
-- =============================================================================
|
||||
-- A. Normalize and expand rental_type checks
|
||||
-- =============================================================================
|
||||
|
||||
alter table public.leads drop constraint if exists leads_rental_type_check;
|
||||
alter table public.sales_orders drop constraint if exists sales_orders_rental_type_check;
|
||||
|
||||
update public.leads
|
||||
set rental_type = lower(trim(coalesce(rental_type, '')));
|
||||
|
||||
update public.sales_orders
|
||||
set rental_type = lower(trim(coalesce(rental_type, '')));
|
||||
|
||||
update public.leads
|
||||
set rental_type = 'individuell'
|
||||
where rental_type in ('individual', 'custom');
|
||||
|
||||
update public.sales_orders
|
||||
set rental_type = 'individuell'
|
||||
where rental_type in ('individual', 'custom');
|
||||
|
||||
update public.leads
|
||||
set rental_type = 'single_day'
|
||||
where rental_type in ('day', 'daily', '1 tag', '1_tag', 'single_day');
|
||||
|
||||
update public.sales_orders
|
||||
set rental_type = 'single_day'
|
||||
where rental_type in ('day', 'daily', '1 tag', '1_tag', 'single_day');
|
||||
|
||||
-- Existing one-day bookings should be single_day.
|
||||
update public.leads
|
||||
set rental_type = 'single_day'
|
||||
where rental_type = 'weekend'
|
||||
and total_days = 1;
|
||||
|
||||
update public.sales_orders
|
||||
set rental_type = 'single_day'
|
||||
where rental_type = 'weekend'
|
||||
and total_days = 1;
|
||||
|
||||
-- Two-day non-Saturday starts are effectively single_day rentals, not weekend packages.
|
||||
update public.leads
|
||||
set rental_type = 'single_day'
|
||||
where rental_type = 'weekend'
|
||||
and total_days = 2
|
||||
and date_from is not null
|
||||
and extract(isodow from date_from) <> 6;
|
||||
|
||||
update public.sales_orders
|
||||
set rental_type = 'single_day'
|
||||
where rental_type = 'weekend'
|
||||
and total_days = 2
|
||||
and date_from is not null
|
||||
and extract(isodow from date_from) <> 6;
|
||||
|
||||
-- Fallback for any unexpected value.
|
||||
update public.leads
|
||||
set rental_type = 'weekend'
|
||||
where rental_type not in ('single_day', 'weekend', 'individuell');
|
||||
|
||||
update public.sales_orders
|
||||
set rental_type = 'weekend'
|
||||
where rental_type not in ('single_day', 'weekend', 'individuell');
|
||||
|
||||
alter table public.leads
|
||||
alter column rental_type set default 'weekend';
|
||||
|
||||
alter table public.sales_orders
|
||||
alter column rental_type set default 'weekend';
|
||||
|
||||
alter table public.leads
|
||||
add constraint leads_rental_type_check
|
||||
check (rental_type in ('single_day', 'weekend', 'individuell'));
|
||||
|
||||
alter table public.sales_orders
|
||||
add constraint sales_orders_rental_type_check
|
||||
check (rental_type in ('single_day', 'weekend', 'individuell'));
|
||||
|
||||
-- =============================================================================
|
||||
-- B. Harden notify_lead_qualified() against malformed rental_type values
|
||||
-- =============================================================================
|
||||
|
||||
create or replace function public.notify_lead_qualified()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
security definer
|
||||
as $$
|
||||
declare
|
||||
v_rental_type text := coalesce(lower(trim(NEW.rental_type)), 'weekend');
|
||||
begin
|
||||
-- Never auto-email individuell orders (including legacy synonyms).
|
||||
if v_rental_type in ('individuell', 'individual', 'custom') then
|
||||
return NEW;
|
||||
end if;
|
||||
|
||||
perform pg_notify('lead_qualified', json_build_object(
|
||||
'sales_order_id', NEW.id,
|
||||
'customer_id', NEW.customer_id,
|
||||
'lead_id', NEW.lead_id,
|
||||
'order_number', NEW.order_number,
|
||||
'total_eur', NEW.total_eur,
|
||||
'deposit_eur', NEW.deposit_eur,
|
||||
'date_from', NEW.date_from,
|
||||
'date_to', NEW.date_to,
|
||||
'vehicle_label', NEW.vehicle_label,
|
||||
'rental_type', v_rental_type,
|
||||
'email_sent', NEW.email_sent
|
||||
)::text);
|
||||
|
||||
return NEW;
|
||||
end;
|
||||
$$;
|
||||
|
||||
-- =============================================================================
|
||||
-- C. Update create_lead() classification logic to include daily
|
||||
-- =============================================================================
|
||||
|
||||
create or replace function public.create_lead(
|
||||
p_name text,
|
||||
p_email text,
|
||||
p_phone text default '',
|
||||
p_vehicle_id uuid default null,
|
||||
p_vehicle_label text default '',
|
||||
p_date_from date default null,
|
||||
p_date_to date default null,
|
||||
p_message text default '',
|
||||
p_source text default 'website',
|
||||
p_ip_address text default '',
|
||||
p_ip_country text default ''
|
||||
)
|
||||
returns uuid
|
||||
language plpgsql
|
||||
security definer
|
||||
as $$
|
||||
declare
|
||||
v_lead_id uuid;
|
||||
v_vehicle record;
|
||||
v_total_days integer := 0;
|
||||
v_weekend_days integer := 0;
|
||||
v_weekdays integer := 0;
|
||||
v_daily_subtotal integer := 0;
|
||||
v_weekend_subtotal integer := 0;
|
||||
v_subtotal_eur integer := 0;
|
||||
v_vat_eur integer := 0;
|
||||
v_total_eur integer := 0;
|
||||
v_deposit_eur integer := 0;
|
||||
v_rental_type text := 'weekend';
|
||||
v_cur date;
|
||||
v_dow integer;
|
||||
begin
|
||||
if p_vehicle_id is not null and p_date_from is not null and p_date_to is not null and p_date_to > p_date_from then
|
||||
select daily_price_eur, weekend_price_eur, kaution_eur
|
||||
into v_vehicle
|
||||
from public.vehicles
|
||||
where id = p_vehicle_id;
|
||||
|
||||
if found then
|
||||
v_total_days := (p_date_to - p_date_from);
|
||||
|
||||
-- Classification:
|
||||
-- 1 day => single_day
|
||||
-- 2 days starting Saturday => weekend package
|
||||
-- 2 days otherwise => single_day
|
||||
-- > 2 days => individuell (manual processing)
|
||||
if v_total_days > 2 then
|
||||
v_rental_type := 'individuell';
|
||||
elsif v_total_days = 1 then
|
||||
v_rental_type := 'single_day';
|
||||
elsif v_total_days = 2 and extract(isodow from p_date_from) = 6 then
|
||||
v_rental_type := 'weekend';
|
||||
elsif v_total_days = 2 then
|
||||
v_rental_type := 'single_day';
|
||||
else
|
||||
v_rental_type := 'weekend';
|
||||
end if;
|
||||
|
||||
if v_rental_type = 'individuell' then
|
||||
v_daily_subtotal := 0;
|
||||
v_weekend_subtotal := 0;
|
||||
v_subtotal_eur := 0;
|
||||
v_vat_eur := 0;
|
||||
v_total_eur := 0;
|
||||
v_deposit_eur := 0;
|
||||
else
|
||||
v_cur := p_date_from;
|
||||
while v_cur < p_date_to loop
|
||||
v_dow := extract(isodow from v_cur);
|
||||
if v_dow in (6, 7) then
|
||||
v_weekend_days := v_weekend_days + 1;
|
||||
end if;
|
||||
v_cur := v_cur + 1;
|
||||
end loop;
|
||||
|
||||
v_weekdays := v_total_days - v_weekend_days;
|
||||
v_daily_subtotal := v_weekdays * v_vehicle.daily_price_eur;
|
||||
v_weekend_subtotal := v_weekend_days * (case when v_vehicle.weekend_price_eur > 0 then v_vehicle.weekend_price_eur else v_vehicle.daily_price_eur end);
|
||||
v_subtotal_eur := v_daily_subtotal + v_weekend_subtotal;
|
||||
v_vat_eur := round(v_subtotal_eur * 0.20);
|
||||
v_total_eur := v_subtotal_eur + v_vat_eur;
|
||||
v_deposit_eur := coalesce(nullif(v_vehicle.kaution_eur, 0), 5000);
|
||||
end if;
|
||||
end if;
|
||||
end if;
|
||||
|
||||
insert into public.leads (
|
||||
name, email, phone, vehicle_id, vehicle_label, date_from, date_to,
|
||||
message, source,
|
||||
daily_subtotal, weekend_subtotal, subtotal_eur, vat_eur, total_eur, deposit_eur,
|
||||
total_days, weekday_count, weekend_day_count, ip_address, ip_country,
|
||||
rental_type
|
||||
) values (
|
||||
p_name, p_email, p_phone, p_vehicle_id, p_vehicle_label, p_date_from, p_date_to,
|
||||
p_message, p_source,
|
||||
v_daily_subtotal, v_weekend_subtotal, v_subtotal_eur, v_vat_eur, v_total_eur, v_deposit_eur,
|
||||
v_total_days, v_weekdays, v_weekend_days, p_ip_address, p_ip_country,
|
||||
v_rental_type
|
||||
)
|
||||
returning id into v_lead_id;
|
||||
|
||||
return v_lead_id;
|
||||
end;
|
||||
$$;
|
||||
|
||||
grant execute on function public.create_lead(
|
||||
text, text, text, uuid, text, date, date, text, text, text, text
|
||||
) to anon, authenticated, service_role;
|
||||
|
||||
notify pgrst, 'reload schema';
|
||||
@@ -0,0 +1,29 @@
|
||||
-- 14-sales-order-set-deposit.sql
|
||||
-- Adds sales_order_set_deposit RPC for updating deposit from admin pricing tab.
|
||||
|
||||
-- =============================================================================
|
||||
-- A. RPC: sales_order_set_deposit
|
||||
-- =============================================================================
|
||||
|
||||
create or replace function public.sales_order_set_deposit(p_so_id uuid, p_deposit_eur integer)
|
||||
returns void
|
||||
language plpgsql
|
||||
security invoker
|
||||
as $$
|
||||
begin
|
||||
update public.sales_orders
|
||||
set deposit_eur = p_deposit_eur, updated_at = now()
|
||||
where id = p_so_id;
|
||||
if not found then
|
||||
raise exception 'sales order % not found', p_so_id;
|
||||
end if;
|
||||
end;
|
||||
$$;
|
||||
|
||||
grant execute on function public.sales_order_set_deposit(uuid, integer) to authenticated;
|
||||
|
||||
-- =============================================================================
|
||||
-- B. Schema reload
|
||||
-- =============================================================================
|
||||
|
||||
notify pgrst, 'reload schema';
|
||||
@@ -0,0 +1,61 @@
|
||||
-- Ensure individuell orders persist net/vat components when total is manually set
|
||||
-- and backfill existing records where these fields are still zero.
|
||||
|
||||
create or replace function public.sales_order_set_total(p_so_id uuid, p_total_eur integer)
|
||||
returns void
|
||||
language plpgsql
|
||||
security invoker
|
||||
as $$
|
||||
declare
|
||||
v_so public.sales_orders;
|
||||
v_subtotal_eur integer := 0;
|
||||
v_vat_eur integer := 0;
|
||||
begin
|
||||
select * into v_so from public.sales_orders where id = p_so_id for update;
|
||||
if not found then
|
||||
raise exception 'sales order % not found', p_so_id;
|
||||
end if;
|
||||
|
||||
if v_so.rental_type != 'individuell' then
|
||||
raise exception 'can only set total for individuell orders';
|
||||
end if;
|
||||
|
||||
if coalesce(p_total_eur, 0) < 0 then
|
||||
raise exception 'total must be >= 0';
|
||||
end if;
|
||||
|
||||
if p_total_eur > 0 then
|
||||
v_subtotal_eur := round(p_total_eur / 1.2);
|
||||
v_vat_eur := p_total_eur - v_subtotal_eur;
|
||||
end if;
|
||||
|
||||
update public.sales_orders
|
||||
set total_eur = p_total_eur,
|
||||
subtotal_eur = v_subtotal_eur,
|
||||
vat_eur = v_vat_eur,
|
||||
daily_subtotal = v_subtotal_eur,
|
||||
weekend_subtotal = 0,
|
||||
weekday_count = coalesce(total_days, 0),
|
||||
weekend_day_count = 0,
|
||||
updated_at = now()
|
||||
where id = p_so_id;
|
||||
end;
|
||||
$$;
|
||||
|
||||
grant execute on function public.sales_order_set_total(uuid, integer) to authenticated;
|
||||
|
||||
-- Backfill already existing individuell orders with missing net/vat split.
|
||||
update public.sales_orders
|
||||
set subtotal_eur = round(total_eur / 1.2),
|
||||
vat_eur = total_eur - round(total_eur / 1.2),
|
||||
daily_subtotal = round(total_eur / 1.2),
|
||||
weekend_subtotal = 0,
|
||||
weekday_count = coalesce(total_days, 0),
|
||||
weekend_day_count = 0,
|
||||
updated_at = now()
|
||||
where rental_type = 'individuell'
|
||||
and coalesce(total_eur, 0) > 0
|
||||
and coalesce(subtotal_eur, 0) = 0
|
||||
and coalesce(vat_eur, 0) = 0;
|
||||
|
||||
notify pgrst, 'reload schema';
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"status": "failed",
|
||||
"failedTests": []
|
||||
}
|
||||
Reference in New Issue
Block a user