Ready to merge before import from excel button
This commit is contained in:
parent
43fccacc47
commit
0574d76c35
336
backend/main.py
336
backend/main.py
@ -1,18 +1,24 @@
|
|||||||
from fastapi import FastAPI, Depends, HTTPException, Query, Request
|
from fastapi import FastAPI, Depends, HTTPException, Query, Request, UploadFile, File
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import RedirectResponse, HTMLResponse
|
from fastapi.responses import RedirectResponse, HTMLResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import or_
|
from sqlalchemy import or_
|
||||||
import uvicorn
|
import uvicorn
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
from uuid import UUID
|
from uuid import UUID, uuid4
|
||||||
import os
|
import os
|
||||||
|
import io
|
||||||
|
import csv
|
||||||
|
import json
|
||||||
import secrets
|
import secrets
|
||||||
|
import logging
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
import httpx
|
import httpx
|
||||||
from urllib.parse import urlencode, quote
|
from urllib.parse import urlencode, quote
|
||||||
from datetime import timezone, timedelta
|
from datetime import timezone, timedelta
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
import models
|
import models
|
||||||
import schemas
|
import schemas
|
||||||
import crud
|
import crud
|
||||||
@ -1463,5 +1469,331 @@ def rsvp_submit(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Contact Import Endpoint
|
||||||
|
# POST /admin/import/contacts?event_id=<uuid>&dry_run=false
|
||||||
|
# Accepts: multipart/form-data with file field "file" (CSV or JSON)
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
def _normalize_phone(raw: str) -> str:
|
||||||
|
"""Normalize a phone number to E.164 (+972…) format, best-effort."""
|
||||||
|
if not raw:
|
||||||
|
return raw
|
||||||
|
try:
|
||||||
|
from whatsapp import WhatsAppService as _WAS
|
||||||
|
return _WAS.normalize_phone_to_e164(raw) or raw.strip()
|
||||||
|
except Exception:
|
||||||
|
return raw.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_csv_rows(content: bytes) -> list[dict]:
|
||||||
|
"""Parse a CSV file and return a list of dicts (header row as keys)."""
|
||||||
|
text = content.decode("utf-8-sig", errors="replace") # handle BOM
|
||||||
|
reader = csv.DictReader(io.StringIO(text))
|
||||||
|
return [dict(row) for row in reader]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_json_rows(content: bytes) -> list[dict]:
|
||||||
|
"""Parse a JSON file — supports array at root OR {data: [...]}."""
|
||||||
|
payload = json.loads(content.decode("utf-8-sig", errors="replace"))
|
||||||
|
if isinstance(payload, list):
|
||||||
|
return payload
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
for key in ("data", "contacts", "guests", "rows"):
|
||||||
|
if key in payload and isinstance(payload[key], list):
|
||||||
|
return payload[key]
|
||||||
|
raise ValueError("JSON must be an array or an object with a list field.")
|
||||||
|
|
||||||
|
|
||||||
|
# Case-insensitive header normalization map
|
||||||
|
_FIELD_ALIASES: dict[str, str] = {
|
||||||
|
# first_name
|
||||||
|
"first name": "first_name", "firstname": "first_name", "שם פרטי": "first_name",
|
||||||
|
# last_name
|
||||||
|
"last name": "last_name", "lastname": "last_name", "שם משפחה": "last_name",
|
||||||
|
# full_name
|
||||||
|
"full name": "full_name", "fullname": "full_name", "name": "full_name", "שם מלא": "full_name",
|
||||||
|
# phone
|
||||||
|
"phone": "phone", "phone number": "phone", "mobile": "phone",
|
||||||
|
"טלפון": "phone", "נייד": "phone", "phone_number": "phone",
|
||||||
|
# email
|
||||||
|
"email": "email", "email address": "email", "אימייל": "email",
|
||||||
|
# rsvp status
|
||||||
|
"rsvp": "rsvp_status", "rsvp status": "rsvp_status", "status": "rsvp_status",
|
||||||
|
"סטטוס": "rsvp_status",
|
||||||
|
# meal
|
||||||
|
"meal": "meal_preference", "meal preference": "meal_preference",
|
||||||
|
"meal_preference": "meal_preference", "העדפת ארוחה": "meal_preference",
|
||||||
|
# notes
|
||||||
|
"notes": "notes", "הערות": "notes",
|
||||||
|
# side
|
||||||
|
"side": "side", "צד": "side",
|
||||||
|
# table
|
||||||
|
"table": "table_number", "table number": "table_number", "שולחן": "table_number",
|
||||||
|
# has_plus_one
|
||||||
|
"plus one": "has_plus_one", "has plus one": "has_plus_one",
|
||||||
|
"has_plus_one": "has_plus_one",
|
||||||
|
# plus_one_name
|
||||||
|
"plus one name": "plus_one_name", "plus_one_name": "plus_one_name",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_row(raw: dict) -> dict:
|
||||||
|
"""Normalise column headers to canonical field names."""
|
||||||
|
out = {}
|
||||||
|
for k, v in raw.items():
|
||||||
|
canonical = _FIELD_ALIASES.get(k.strip().lower(), k.strip().lower())
|
||||||
|
out[canonical] = v.strip() if isinstance(v, str) else v
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _split_full_name(full: str) -> tuple[str, str]:
|
||||||
|
parts = full.strip().split(None, 1)
|
||||||
|
if len(parts) == 2:
|
||||||
|
return parts[0], parts[1]
|
||||||
|
return parts[0], "" if parts else ("", "")
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_bool(val) -> bool:
|
||||||
|
if isinstance(val, bool):
|
||||||
|
return val
|
||||||
|
if isinstance(val, str):
|
||||||
|
return val.strip().lower() in ("1", "yes", "true", "כן")
|
||||||
|
return bool(val)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/admin/import/contacts", response_model=schemas.ImportContactsResponse)
|
||||||
|
async def import_contacts(
|
||||||
|
event_id: UUID = Query(..., description="Target event UUID"),
|
||||||
|
dry_run: bool = Query(False, description="If true, validate and preview but do not write"),
|
||||||
|
file: UploadFile = File(..., description="CSV or JSON file"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user_id=Depends(get_current_user_id),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Import contacts from a CSV or JSON file into an event's guest list.
|
||||||
|
|
||||||
|
• Idempotent: if a guest with the same phone_number already exists in this
|
||||||
|
event, their *missing* fields are filled in — existing data is never
|
||||||
|
overwritten unless the existing value is blank.
|
||||||
|
• dry_run=true: returns the preview without touching the database.
|
||||||
|
• source is always set to 'google' for imported rows.
|
||||||
|
"""
|
||||||
|
# ── Auth / access check ──────────────────────────────────────────────────
|
||||||
|
if not current_user_id:
|
||||||
|
raise HTTPException(status_code=401, detail="Authentication required.")
|
||||||
|
|
||||||
|
event = db.query(models.Event).filter(models.Event.id == event_id).first()
|
||||||
|
if not event:
|
||||||
|
raise HTTPException(status_code=404, detail="Event not found.")
|
||||||
|
|
||||||
|
# ── Read and parse the uploaded file ────────────────────────────────────
|
||||||
|
content = await file.read()
|
||||||
|
filename = (file.filename or "").lower()
|
||||||
|
try:
|
||||||
|
if filename.endswith(".json"):
|
||||||
|
raw_rows = _parse_json_rows(content)
|
||||||
|
elif filename.endswith(".csv") or filename.endswith(".xlsx"):
|
||||||
|
# For XLSX export from our own app, treat as CSV (xlsx export from
|
||||||
|
# GuestList produces proper column headers in English)
|
||||||
|
raw_rows = _parse_csv_rows(content)
|
||||||
|
else:
|
||||||
|
# Sniff: try JSON then CSV
|
||||||
|
try:
|
||||||
|
raw_rows = _parse_json_rows(content)
|
||||||
|
except Exception:
|
||||||
|
raw_rows = _parse_csv_rows(content)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=f"Cannot parse file: {exc}")
|
||||||
|
|
||||||
|
if not raw_rows:
|
||||||
|
raise HTTPException(status_code=422, detail="File is empty or could not be parsed.")
|
||||||
|
|
||||||
|
# ── Resolve or create the user record for added_by_user_id ──────────────
|
||||||
|
# current_user_id can be a UUID object, a UUID string, or a plain string (email/admin-user)
|
||||||
|
user = None
|
||||||
|
try:
|
||||||
|
_uid = UUID(str(current_user_id))
|
||||||
|
user = db.query(models.User).filter(models.User.id == _uid).first()
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
pass
|
||||||
|
if user is None:
|
||||||
|
user = db.query(models.User).filter(
|
||||||
|
models.User.email == str(current_user_id)
|
||||||
|
).first()
|
||||||
|
if user is None:
|
||||||
|
# Create a stub user (can happen if logged in via Google but no DB row yet)
|
||||||
|
user = models.User(email=str(current_user_id))
|
||||||
|
if not dry_run:
|
||||||
|
db.add(user)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
# ── Process rows ─────────────────────────────────────────────────────────
|
||||||
|
results: list[schemas.ImportRowResult] = []
|
||||||
|
counters = {"created": 0, "updated": 0, "skipped": 0, "errors": 0}
|
||||||
|
|
||||||
|
for idx, raw in enumerate(raw_rows, start=1):
|
||||||
|
row = _normalize_row(raw)
|
||||||
|
|
||||||
|
# Resolve name
|
||||||
|
first = row.get("first_name") or ""
|
||||||
|
last = row.get("last_name") or ""
|
||||||
|
if not first and row.get("full_name"):
|
||||||
|
first, last = _split_full_name(row["full_name"])
|
||||||
|
|
||||||
|
if not first:
|
||||||
|
counters["skipped"] += 1
|
||||||
|
results.append(schemas.ImportRowResult(
|
||||||
|
row=idx, action="skipped", reason="No name found"
|
||||||
|
))
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Resolve phone
|
||||||
|
raw_phone = row.get("phone") or row.get("phone_number") or ""
|
||||||
|
phone = _normalize_phone(raw_phone) if raw_phone else None
|
||||||
|
|
||||||
|
email = row.get("email") or None
|
||||||
|
|
||||||
|
# Must have at least phone or email to identify the guest
|
||||||
|
if not phone and not email:
|
||||||
|
counters["skipped"] += 1
|
||||||
|
results.append(schemas.ImportRowResult(
|
||||||
|
row=idx, action="skipped", name=f"{first} {last}".strip(),
|
||||||
|
reason="No phone or email — cannot identify guest"
|
||||||
|
))
|
||||||
|
continue
|
||||||
|
|
||||||
|
# ── Idempotent lookup ──────────────────────────────────────────────
|
||||||
|
existing = None
|
||||||
|
if phone:
|
||||||
|
existing = db.query(models.Guest).filter(
|
||||||
|
models.Guest.event_id == event_id,
|
||||||
|
or_(
|
||||||
|
models.Guest.phone_number == phone,
|
||||||
|
models.Guest.phone == phone,
|
||||||
|
models.Guest.phone_number == raw_phone,
|
||||||
|
models.Guest.phone == raw_phone,
|
||||||
|
),
|
||||||
|
).first()
|
||||||
|
if existing is None and email:
|
||||||
|
existing = db.query(models.Guest).filter(
|
||||||
|
models.Guest.event_id == event_id,
|
||||||
|
models.Guest.email == email,
|
||||||
|
).first()
|
||||||
|
|
||||||
|
# ── Map optional fields ────────────────────────────────────────────
|
||||||
|
rsvp_raw = (row.get("rsvp_status") or "").lower()
|
||||||
|
rsvp_map = {"accepted": "confirmed", "pending": "invited", "yes": "confirmed",
|
||||||
|
"no": "declined", "כן": "confirmed", "לא": "declined"}
|
||||||
|
rsvp = rsvp_map.get(rsvp_raw, rsvp_raw) if rsvp_raw else None
|
||||||
|
if rsvp and rsvp not in ("invited", "confirmed", "declined"):
|
||||||
|
rsvp = "invited"
|
||||||
|
|
||||||
|
has_plus = _coerce_bool(row["has_plus_one"]) if "has_plus_one" in row else None
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
# Update only blank fields — never overwrite existing data
|
||||||
|
changed = False
|
||||||
|
if not existing.first_name and first:
|
||||||
|
existing.first_name = first; changed = True
|
||||||
|
if not existing.last_name and last:
|
||||||
|
existing.last_name = last; changed = True
|
||||||
|
if not existing.email and email:
|
||||||
|
existing.email = email; changed = True
|
||||||
|
if not existing.phone_number and phone:
|
||||||
|
existing.phone_number = phone; existing.phone = phone; changed = True
|
||||||
|
if not existing.meal_preference and row.get("meal_preference"):
|
||||||
|
existing.meal_preference = row["meal_preference"]; changed = True
|
||||||
|
if not existing.notes and row.get("notes"):
|
||||||
|
existing.notes = row["notes"]; changed = True
|
||||||
|
if not existing.side and row.get("side"):
|
||||||
|
existing.side = row["side"]; changed = True
|
||||||
|
if not existing.table_number and row.get("table_number"):
|
||||||
|
existing.table_number = row["table_number"]; changed = True
|
||||||
|
if has_plus is not None and not existing.has_plus_one:
|
||||||
|
existing.has_plus_one = has_plus; changed = True
|
||||||
|
if not existing.plus_one_name and row.get("plus_one_name"):
|
||||||
|
existing.plus_one_name = row["plus_one_name"]; changed = True
|
||||||
|
if rsvp and existing.rsvp_status in (None, "invited", models.GuestStatus.invited):
|
||||||
|
existing.rsvp_status = rsvp; changed = True
|
||||||
|
|
||||||
|
if changed:
|
||||||
|
counters["updated"] += 1
|
||||||
|
action = "updated"
|
||||||
|
else:
|
||||||
|
counters["skipped"] += 1
|
||||||
|
action = "skipped"
|
||||||
|
|
||||||
|
results.append(schemas.ImportRowResult(
|
||||||
|
row=idx, action=action,
|
||||||
|
name=f"{existing.first_name} {existing.last_name}".strip(),
|
||||||
|
phone=existing.phone_number,
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
# Create new guest
|
||||||
|
# On dry_run user.id may be None (not flushed); use a placeholder UUID
|
||||||
|
_added_by = user.id
|
||||||
|
if _added_by is None:
|
||||||
|
try:
|
||||||
|
_added_by = UUID(str(current_user_id))
|
||||||
|
except ValueError:
|
||||||
|
_added_by = uuid4() # unreachable in prod, safety net
|
||||||
|
new_guest = models.Guest(
|
||||||
|
event_id=event_id,
|
||||||
|
added_by_user_id=_added_by,
|
||||||
|
first_name=first,
|
||||||
|
last_name=last,
|
||||||
|
email=email,
|
||||||
|
phone_number=phone,
|
||||||
|
phone=phone,
|
||||||
|
rsvp_status=rsvp or models.GuestStatus.invited,
|
||||||
|
meal_preference=row.get("meal_preference") or None,
|
||||||
|
has_plus_one=has_plus or False,
|
||||||
|
plus_one_name=row.get("plus_one_name") or None,
|
||||||
|
table_number=row.get("table_number") or None,
|
||||||
|
side=row.get("side") or None,
|
||||||
|
notes=row.get("notes") or None,
|
||||||
|
owner_email=str(current_user_id),
|
||||||
|
source="google",
|
||||||
|
)
|
||||||
|
if not dry_run:
|
||||||
|
db.add(new_guest)
|
||||||
|
counters["created"] += 1
|
||||||
|
results.append(schemas.ImportRowResult(
|
||||||
|
row=idx, action="created" if not dry_run else "would_create",
|
||||||
|
name=f"{first} {last}".strip(),
|
||||||
|
phone=phone,
|
||||||
|
))
|
||||||
|
|
||||||
|
# ── Commit or rollback ────────────────────────────────────────────────
|
||||||
|
if dry_run:
|
||||||
|
db.rollback()
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
db.commit()
|
||||||
|
except Exception as exc:
|
||||||
|
db.rollback()
|
||||||
|
logger.error("Import commit failed: %s", exc)
|
||||||
|
raise HTTPException(status_code=500, detail=f"Database error: {exc}")
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Import %s event=%s total=%d created=%d updated=%d skipped=%d errors=%d",
|
||||||
|
"[dry-run]" if dry_run else "[committed]",
|
||||||
|
event_id, len(raw_rows),
|
||||||
|
counters["created"], counters["updated"],
|
||||||
|
counters["skipped"], counters["errors"],
|
||||||
|
)
|
||||||
|
|
||||||
|
return schemas.ImportContactsResponse(
|
||||||
|
dry_run=dry_run,
|
||||||
|
total=len(raw_rows),
|
||||||
|
created=counters["created"],
|
||||||
|
updated=counters["updated"],
|
||||||
|
skipped=counters["skipped"],
|
||||||
|
errors=counters["errors"],
|
||||||
|
rows=results,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
|
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
|
||||||
388
backend/migrate_production.sql
Normal file
388
backend/migrate_production.sql
Normal file
@ -0,0 +1,388 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- INVY — Production Migration Script
|
||||||
|
-- =============================================================================
|
||||||
|
-- SAFE: Additive-only. Nothing is dropped. All blocks are idempotent.
|
||||||
|
-- Run once to bring a production DB (old schema) in sync with the new schema.
|
||||||
|
--
|
||||||
|
-- Order of execution:
|
||||||
|
-- 1. Enable extensions
|
||||||
|
-- 2. Create new tables (IF NOT EXISTS)
|
||||||
|
-- 3. Patch existing tables (ADD COLUMN IF NOT EXISTS / ALTER/ADD CONSTRAINT)
|
||||||
|
-- 4. Migrate old `guests` rows → `guests_v2` (only when guests_v2 is empty)
|
||||||
|
-- 5. Add indexes and triggers (IF NOT EXISTS)
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- STEP 1 — Enable UUID extension
|
||||||
|
-- =============================================================================
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||||
|
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- STEP 2a — Create `users` table
|
||||||
|
-- =============================================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||||
|
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- STEP 2b — Create `events` table
|
||||||
|
-- =============================================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS events (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
date TIMESTAMP WITH TIME ZONE,
|
||||||
|
location TEXT,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at);
|
||||||
|
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- STEP 2c — Create `event_members` table
|
||||||
|
-- =============================================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS event_members (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
role TEXT NOT NULL DEFAULT 'admin'
|
||||||
|
CHECK (role IN ('admin', 'editor', 'viewer')),
|
||||||
|
display_name TEXT,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(event_id, user_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_event_members_event_id ON event_members(event_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_event_members_user_id ON event_members(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_event_members_event_user ON event_members(event_id, user_id);
|
||||||
|
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- STEP 2d — Create `guests_v2` table
|
||||||
|
-- =============================================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS guests_v2 (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE,
|
||||||
|
added_by_user_id UUID NOT NULL REFERENCES users(id),
|
||||||
|
|
||||||
|
-- identity
|
||||||
|
first_name TEXT NOT NULL,
|
||||||
|
last_name TEXT NOT NULL DEFAULT '',
|
||||||
|
email TEXT,
|
||||||
|
phone TEXT, -- legacy alias
|
||||||
|
phone_number TEXT,
|
||||||
|
|
||||||
|
-- RSVP
|
||||||
|
rsvp_status TEXT NOT NULL DEFAULT 'invited'
|
||||||
|
CHECK (rsvp_status IN ('invited', 'confirmed', 'declined')),
|
||||||
|
meal_preference TEXT,
|
||||||
|
|
||||||
|
-- plus-one
|
||||||
|
has_plus_one BOOLEAN DEFAULT FALSE,
|
||||||
|
plus_one_name TEXT,
|
||||||
|
|
||||||
|
-- seating
|
||||||
|
table_number TEXT,
|
||||||
|
side TEXT, -- e.g. "groom", "bride"
|
||||||
|
|
||||||
|
-- provenance
|
||||||
|
owner_email TEXT,
|
||||||
|
source TEXT NOT NULL DEFAULT 'manual'
|
||||||
|
CHECK (source IN ('google', 'manual', 'self-service')),
|
||||||
|
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_guests_v2_event_id ON guests_v2(event_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_guests_v2_added_by ON guests_v2(added_by_user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_guests_v2_event_user ON guests_v2(event_id, added_by_user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_guests_v2_phone_number ON guests_v2(phone_number);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_guests_v2_event_phone ON guests_v2(event_id, phone_number);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_guests_v2_event_status ON guests_v2(event_id, rsvp_status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_guests_v2_owner_email ON guests_v2(event_id, owner_email);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_guests_v2_source ON guests_v2(event_id, source);
|
||||||
|
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- STEP 2e — Create `rsvp_tokens` table
|
||||||
|
-- =============================================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS rsvp_tokens (
|
||||||
|
token TEXT PRIMARY KEY,
|
||||||
|
event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE,
|
||||||
|
guest_id UUID REFERENCES guests_v2(id) ON DELETE SET NULL,
|
||||||
|
phone TEXT,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
expires_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
used_at TIMESTAMP WITH TIME ZONE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_rsvp_tokens_event_id ON rsvp_tokens(event_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_rsvp_tokens_guest_id ON rsvp_tokens(guest_id);
|
||||||
|
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- STEP 3a — Patch `events` table: add WhatsApp / RSVP columns (IF NOT EXISTS)
|
||||||
|
-- =============================================================================
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE events ADD COLUMN partner1_name TEXT; EXCEPTION WHEN duplicate_column THEN NULL;
|
||||||
|
END $$;
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE events ADD COLUMN partner2_name TEXT; EXCEPTION WHEN duplicate_column THEN NULL;
|
||||||
|
END $$;
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE events ADD COLUMN venue TEXT; EXCEPTION WHEN duplicate_column THEN NULL;
|
||||||
|
END $$;
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE events ADD COLUMN event_time TEXT; EXCEPTION WHEN duplicate_column THEN NULL;
|
||||||
|
END $$;
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE events ADD COLUMN guest_link TEXT; EXCEPTION WHEN duplicate_column THEN NULL;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_events_guest_link ON events(guest_link);
|
||||||
|
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- STEP 3b — Patch `guests_v2`: add any missing columns (forward-compat)
|
||||||
|
-- =============================================================================
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE guests_v2 ADD COLUMN phone TEXT;
|
||||||
|
EXCEPTION WHEN duplicate_column THEN NULL;
|
||||||
|
END $$;
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE guests_v2 ADD COLUMN phone_number TEXT;
|
||||||
|
EXCEPTION WHEN duplicate_column THEN NULL;
|
||||||
|
END $$;
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE guests_v2 ADD COLUMN last_name TEXT NOT NULL DEFAULT '';
|
||||||
|
EXCEPTION WHEN duplicate_column THEN NULL;
|
||||||
|
END $$;
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE guests_v2 ADD COLUMN notes TEXT;
|
||||||
|
EXCEPTION WHEN duplicate_column THEN NULL;
|
||||||
|
END $$;
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE guests_v2 ADD COLUMN side TEXT;
|
||||||
|
EXCEPTION WHEN duplicate_column THEN NULL;
|
||||||
|
END $$;
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE guests_v2 ADD COLUMN source TEXT NOT NULL DEFAULT 'manual';
|
||||||
|
EXCEPTION WHEN duplicate_column THEN NULL;
|
||||||
|
END $$;
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE guests_v2 ADD COLUMN owner_email TEXT;
|
||||||
|
EXCEPTION WHEN duplicate_column THEN NULL;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
|
||||||
|
-- Fix rsvp_status constraint: old versions used 'status' column name or enum
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
-- rename `status` → `rsvp_status` if that old column exists
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'guests_v2' AND column_name = 'status'
|
||||||
|
) AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'guests_v2' AND column_name = 'rsvp_status'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE guests_v2 RENAME COLUMN status TO rsvp_status;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Ensure CHECK constraint is present (safe drop+add)
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE guests_v2 DROP CONSTRAINT IF EXISTS guests_v2_rsvp_status_check;
|
||||||
|
ALTER TABLE guests_v2 ADD CONSTRAINT guests_v2_rsvp_status_check
|
||||||
|
CHECK (rsvp_status IN ('invited', 'confirmed', 'declined'));
|
||||||
|
EXCEPTION WHEN OTHERS THEN NULL;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE guests_v2 DROP CONSTRAINT IF EXISTS guests_v2_source_check;
|
||||||
|
ALTER TABLE guests_v2 ADD CONSTRAINT guests_v2_source_check
|
||||||
|
CHECK (source IN ('google', 'manual', 'self-service'));
|
||||||
|
EXCEPTION WHEN OTHERS THEN NULL;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- STEP 3c — updated_at triggers
|
||||||
|
-- =============================================================================
|
||||||
|
CREATE OR REPLACE FUNCTION _update_updated_at()
|
||||||
|
RETURNS TRIGGER LANGUAGE plpgsql AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
CREATE TRIGGER trg_guests_v2_updated_at
|
||||||
|
BEFORE UPDATE ON guests_v2
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION _update_updated_at();
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
CREATE TRIGGER trg_events_updated_at
|
||||||
|
BEFORE UPDATE ON events
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION _update_updated_at();
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- STEP 4 — Migrate old `guests` rows → `guests_v2`
|
||||||
|
--
|
||||||
|
-- Conditions:
|
||||||
|
-- • The old `guests` table must exist.
|
||||||
|
-- • guests_v2 must be EMPTY (idempotent guard — never runs twice).
|
||||||
|
--
|
||||||
|
-- Strategy:
|
||||||
|
-- • For each distinct `owner` in the old table create a row in `users`.
|
||||||
|
-- • Create one migration event ("Migrated Wedding") owned by the first user.
|
||||||
|
-- • Insert event_members for every owner → that event (role = admin).
|
||||||
|
-- • Insert guests mapping:
|
||||||
|
-- rsvp_status: 'pending' → 'invited', 'accepted' → 'confirmed', else as-is
|
||||||
|
-- phone_number field → phone_number + phone columns
|
||||||
|
-- owner → owner_email
|
||||||
|
-- source = 'google' (they came from Google import originally)
|
||||||
|
-- =============================================================================
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
old_table_exists BOOLEAN;
|
||||||
|
new_table_empty BOOLEAN;
|
||||||
|
migration_event_id UUID;
|
||||||
|
default_user_id UUID;
|
||||||
|
owner_row RECORD;
|
||||||
|
owner_user_id UUID;
|
||||||
|
BEGIN
|
||||||
|
-- Check preconditions
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.tables
|
||||||
|
WHERE table_name = 'guests' AND table_schema = 'public'
|
||||||
|
) INTO old_table_exists;
|
||||||
|
|
||||||
|
SELECT (COUNT(*) = 0) FROM guests_v2 INTO new_table_empty;
|
||||||
|
|
||||||
|
IF NOT old_table_exists OR NOT new_table_empty THEN
|
||||||
|
RAISE NOTICE 'Migration skipped: old_table_exists=%, new_table_empty=%',
|
||||||
|
old_table_exists, new_table_empty;
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RAISE NOTICE 'Starting data migration from guests → guests_v2 …';
|
||||||
|
|
||||||
|
-- ── Create one user per distinct owner ──────────────────────────────────
|
||||||
|
FOR owner_row IN
|
||||||
|
SELECT DISTINCT COALESCE(NULLIF(TRIM(owner), ''), 'imported@invy.app') AS email
|
||||||
|
FROM guests
|
||||||
|
LOOP
|
||||||
|
INSERT INTO users (email)
|
||||||
|
VALUES (owner_row.email)
|
||||||
|
ON CONFLICT (email) DO NOTHING;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
-- ── Pick (or create) the migration event ────────────────────────────────
|
||||||
|
SELECT id INTO migration_event_id FROM events LIMIT 1;
|
||||||
|
|
||||||
|
IF migration_event_id IS NULL THEN
|
||||||
|
INSERT INTO events (name, date, location)
|
||||||
|
VALUES ('Migrated Wedding', CURRENT_TIMESTAMP, 'Imported from previous system')
|
||||||
|
RETURNING id INTO migration_event_id;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- ── Get a fallback user (the first one alphabetically) ──────────────────
|
||||||
|
SELECT id INTO default_user_id FROM users ORDER BY email LIMIT 1;
|
||||||
|
|
||||||
|
-- ── Create event_members entries for each owner ──────────────────────────
|
||||||
|
FOR owner_row IN
|
||||||
|
SELECT DISTINCT COALESCE(NULLIF(TRIM(owner), ''), 'imported@invy.app') AS email
|
||||||
|
FROM guests
|
||||||
|
LOOP
|
||||||
|
SELECT id INTO owner_user_id FROM users WHERE email = owner_row.email;
|
||||||
|
|
||||||
|
INSERT INTO event_members (event_id, user_id, role)
|
||||||
|
VALUES (migration_event_id, owner_user_id, 'admin')
|
||||||
|
ON CONFLICT (event_id, user_id) DO NOTHING;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
-- ── Copy guests ──────────────────────────────────────────────────────────
|
||||||
|
INSERT INTO guests_v2 (
|
||||||
|
event_id,
|
||||||
|
added_by_user_id,
|
||||||
|
first_name,
|
||||||
|
last_name,
|
||||||
|
email,
|
||||||
|
phone_number,
|
||||||
|
phone,
|
||||||
|
rsvp_status,
|
||||||
|
meal_preference,
|
||||||
|
has_plus_one,
|
||||||
|
plus_one_name,
|
||||||
|
table_number,
|
||||||
|
owner_email,
|
||||||
|
source,
|
||||||
|
notes,
|
||||||
|
created_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
migration_event_id,
|
||||||
|
COALESCE(
|
||||||
|
(SELECT id FROM users WHERE email = NULLIF(TRIM(g.owner), '')),
|
||||||
|
default_user_id
|
||||||
|
),
|
||||||
|
g.first_name,
|
||||||
|
COALESCE(g.last_name, ''),
|
||||||
|
g.email,
|
||||||
|
g.phone_number,
|
||||||
|
g.phone_number,
|
||||||
|
CASE g.rsvp_status
|
||||||
|
WHEN 'accepted' THEN 'confirmed'
|
||||||
|
WHEN 'pending' THEN 'invited'
|
||||||
|
WHEN 'declined' THEN 'declined'
|
||||||
|
ELSE 'invited'
|
||||||
|
END,
|
||||||
|
g.meal_preference,
|
||||||
|
COALESCE(g.has_plus_one, FALSE),
|
||||||
|
g.plus_one_name,
|
||||||
|
g.table_number::TEXT,
|
||||||
|
NULLIF(TRIM(COALESCE(g.owner, '')), ''),
|
||||||
|
'google',
|
||||||
|
g.notes,
|
||||||
|
COALESCE(g.created_at, CURRENT_TIMESTAMP)
|
||||||
|
FROM guests g;
|
||||||
|
|
||||||
|
RAISE NOTICE 'Migration complete. Rows inserted: %', (SELECT COUNT(*) FROM guests_v2);
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- DONE
|
||||||
|
-- =============================================================================
|
||||||
|
SELECT
|
||||||
|
(SELECT COUNT(*) FROM users) AS users_total,
|
||||||
|
(SELECT COUNT(*) FROM events) AS events_total,
|
||||||
|
(SELECT COUNT(*) FROM guests_v2) AS guests_v2_total;
|
||||||
@ -5,3 +5,4 @@ psycopg2-binary>=2.9.9
|
|||||||
pydantic[email]>=2.5.0
|
pydantic[email]>=2.5.0
|
||||||
httpx>=0.25.2
|
httpx>=0.25.2
|
||||||
python-dotenv>=1.0.0
|
python-dotenv>=1.0.0
|
||||||
|
python-multipart>=0.0.7
|
||||||
|
|||||||
111
backend/run_production_migration.py
Normal file
111
backend/run_production_migration.py
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
run_production_migration.py
|
||||||
|
─────────────────────────────────────────────────────────────────────────────
|
||||||
|
Execute migrate_production.sql against the configured DATABASE_URL.
|
||||||
|
|
||||||
|
Usage
|
||||||
|
─────
|
||||||
|
python run_production_migration.py # normal run
|
||||||
|
python run_production_migration.py --dry-run # parse SQL but do NOT commit
|
||||||
|
|
||||||
|
Environment variables read from .env (or already in shell):
|
||||||
|
DATABASE_URL postgresql://user:pass@host:port/dbname
|
||||||
|
|
||||||
|
Exit codes:
|
||||||
|
0 success
|
||||||
|
1 error
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import psycopg2
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
MIGRATION_FILE = Path(__file__).parent / "migrate_production.sql"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args():
|
||||||
|
p = argparse.ArgumentParser(description="Run Invy production migration")
|
||||||
|
p.add_argument(
|
||||||
|
"--dry-run",
|
||||||
|
action="store_true",
|
||||||
|
help="Parse and validate the SQL but roll back instead of committing.",
|
||||||
|
)
|
||||||
|
return p.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
args = parse_args()
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
db_url = os.getenv(
|
||||||
|
"DATABASE_URL",
|
||||||
|
"postgresql://wedding_admin:Aa123456@localhost:5432/wedding_guests",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not MIGRATION_FILE.exists():
|
||||||
|
print(f"❌ Migration file not found: {MIGRATION_FILE}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
sql = MIGRATION_FILE.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
print(f"{'[DRY-RUN] ' if args.dry_run else ''}Connecting to database …")
|
||||||
|
try:
|
||||||
|
conn = psycopg2.connect(db_url)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"❌ Cannot connect: {exc}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
conn.autocommit = False
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Capture NOTICE messages from PL/pgSQL RAISE NOTICE
|
||||||
|
import warnings
|
||||||
|
conn.notices = []
|
||||||
|
|
||||||
|
def _notice_handler(diag):
|
||||||
|
msg = diag.message_primary or str(diag)
|
||||||
|
conn.notices.append(msg)
|
||||||
|
print(f" [DB] {msg}")
|
||||||
|
|
||||||
|
conn.add_notice_handler(_notice_handler)
|
||||||
|
|
||||||
|
try:
|
||||||
|
print("Running migration …")
|
||||||
|
cursor.execute(sql)
|
||||||
|
|
||||||
|
# Print the summary SELECT result
|
||||||
|
try:
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
print(
|
||||||
|
f"\n📊 Summary after migration:\n"
|
||||||
|
f" users : {row[0]}\n"
|
||||||
|
f" events : {row[1]}\n"
|
||||||
|
f" guests_v2 : {row[2]}\n"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
conn.rollback()
|
||||||
|
print("✅ Dry-run complete — rolled back (no changes written).")
|
||||||
|
else:
|
||||||
|
conn.commit()
|
||||||
|
print("✅ Migration committed successfully.")
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
conn.rollback()
|
||||||
|
print(f"\n❌ Migration failed — rolled back.\n Error: {exc}")
|
||||||
|
sys.exit(1)
|
||||||
|
finally:
|
||||||
|
cursor.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@ -310,3 +310,43 @@ class RsvpSubmitResponse(BaseModel):
|
|||||||
message: str
|
message: str
|
||||||
guest_id: Optional[str] = None
|
guest_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Contact Import Schemas
|
||||||
|
# ============================================
|
||||||
|
class ImportContactRow(BaseModel):
|
||||||
|
"""Represents a single row from an uploaded CSV / JSON import file."""
|
||||||
|
first_name: Optional[str] = None
|
||||||
|
last_name: Optional[str] = None
|
||||||
|
full_name: Optional[str] = None # alternative: "Full Name" column
|
||||||
|
phone: Optional[str] = None
|
||||||
|
phone_number: Optional[str] = None
|
||||||
|
email: Optional[str] = None
|
||||||
|
rsvp_status: Optional[str] = None
|
||||||
|
meal_preference: Optional[str] = None
|
||||||
|
notes: Optional[str] = None
|
||||||
|
side: Optional[str] = None
|
||||||
|
table_number: Optional[str] = None
|
||||||
|
has_plus_one: Optional[bool] = None
|
||||||
|
plus_one_name: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ImportRowResult(BaseModel):
|
||||||
|
"""Per-row result returned in the import response."""
|
||||||
|
row: int
|
||||||
|
action: str # "created" | "updated" | "skipped" | "error"
|
||||||
|
name: Optional[str] = None
|
||||||
|
phone: Optional[str] = None
|
||||||
|
reason: Optional[str] = None # for errors / skips
|
||||||
|
|
||||||
|
|
||||||
|
class ImportContactsResponse(BaseModel):
|
||||||
|
"""Full response from POST /admin/import/contacts."""
|
||||||
|
dry_run: bool
|
||||||
|
total: int
|
||||||
|
created: int
|
||||||
|
updated: int
|
||||||
|
skipped: int
|
||||||
|
errors: int
|
||||||
|
rows: List[ImportRowResult]
|
||||||
|
|
||||||
|
|||||||
@ -302,4 +302,29 @@ export const sendWhatsAppInvitationToGuest = async (eventId, guestId, phoneOverr
|
|||||||
return response.data
|
return response.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Contact Import
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a CSV or JSON file and import its contacts into an event.
|
||||||
|
*
|
||||||
|
* @param {string} eventId - UUID of the target event
|
||||||
|
* @param {File} file - the user-selected CSV / JSON File object
|
||||||
|
* @param {boolean} dryRun - if true, preview only (no DB writes)
|
||||||
|
* @returns {ImportContactsResponse}
|
||||||
|
*/
|
||||||
|
export const importContacts = async (eventId, file, dryRun = false) => {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('file', file)
|
||||||
|
|
||||||
|
const response = await api.post(
|
||||||
|
`/admin/import/contacts?event_id=${encodeURIComponent(eventId)}&dry_run=${dryRun}`,
|
||||||
|
form,
|
||||||
|
{ headers: { 'Content-Type': 'multipart/form-data' } }
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
export default api
|
export default api
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'
|
|||||||
import { getGuests, getGuestOwners, createGuest, updateGuest, deleteGuest, sendWhatsAppInvitationToGuests, getEvent } from '../api/api'
|
import { getGuests, getGuestOwners, createGuest, updateGuest, deleteGuest, sendWhatsAppInvitationToGuests, getEvent } from '../api/api'
|
||||||
import GuestForm from './GuestForm'
|
import GuestForm from './GuestForm'
|
||||||
import GoogleImport from './GoogleImport'
|
import GoogleImport from './GoogleImport'
|
||||||
|
import ImportContacts from './ImportContacts'
|
||||||
import SearchFilter from './SearchFilter'
|
import SearchFilter from './SearchFilter'
|
||||||
import DuplicateManager from './DuplicateManager'
|
import DuplicateManager from './DuplicateManager'
|
||||||
import WhatsAppInviteModal from './WhatsAppInviteModal'
|
import WhatsAppInviteModal from './WhatsAppInviteModal'
|
||||||
@ -328,6 +329,7 @@ function GuestList({ eventId, onBack, onShowMembers }) {
|
|||||||
🔍 חיפוש כפולויות
|
🔍 חיפוש כפולויות
|
||||||
</button>
|
</button>
|
||||||
<GoogleImport eventId={eventId} onImportComplete={loadGuests} />
|
<GoogleImport eventId={eventId} onImportComplete={loadGuests} />
|
||||||
|
<ImportContacts eventId={eventId} onImportComplete={loadGuests} />
|
||||||
<button className="btn-export" onClick={exportToExcel}>
|
<button className="btn-export" onClick={exportToExcel}>
|
||||||
{he.exportExcel}
|
{he.exportExcel}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
272
frontend/src/components/ImportContacts.css
Normal file
272
frontend/src/components/ImportContacts.css
Normal file
@ -0,0 +1,272 @@
|
|||||||
|
/* ImportContacts.css */
|
||||||
|
|
||||||
|
/* ── Trigger Button ──────────────────────────────────────────────────────── */
|
||||||
|
.btn-import {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: 1.5px solid var(--border-color, #d1d5db);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-secondary, #f9fafb);
|
||||||
|
color: var(--text-primary, #1f2937);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
.btn-import:hover:not(:disabled) {
|
||||||
|
background: var(--bg-hover, #f3f4f6);
|
||||||
|
border-color: var(--accent, #6366f1);
|
||||||
|
}
|
||||||
|
.btn-import:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Modal Overlay ───────────────────────────────────────────────────────── */
|
||||||
|
.import-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1100;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-modal {
|
||||||
|
background: var(--bg-primary, #fff);
|
||||||
|
border-radius: 16px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 680px;
|
||||||
|
max-height: 88vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.25);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Header ──────────────────────────────────────────────────────────────── */
|
||||||
|
.import-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 20px 24px 16px;
|
||||||
|
border-bottom: 1px solid var(--border-color, #e5e7eb);
|
||||||
|
}
|
||||||
|
.import-header h2 {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary, #111827);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.import-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-secondary, #6b7280);
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.import-close:hover { background: var(--bg-hover, #f3f4f6); }
|
||||||
|
|
||||||
|
/* ── Body ────────────────────────────────────────────────────────────────── */
|
||||||
|
.import-body {
|
||||||
|
padding: 20px 24px 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Drop Zone ───────────────────────────────────────────────────────────── */
|
||||||
|
.drop-zone {
|
||||||
|
border: 2px dashed var(--border-color, #d1d5db);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 32px 20px;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s, background 0.2s;
|
||||||
|
background: var(--bg-secondary, #fafafa);
|
||||||
|
}
|
||||||
|
.drop-zone:hover, .drop-zone.dragging {
|
||||||
|
border-color: var(--accent, #6366f1);
|
||||||
|
background: #eff0fe;
|
||||||
|
}
|
||||||
|
.drop-zone.has-file {
|
||||||
|
border-style: solid;
|
||||||
|
border-color: #10b981;
|
||||||
|
background: #ecfdf5;
|
||||||
|
}
|
||||||
|
.drop-icon { font-size: 2rem; display: block; margin-bottom: 8px; }
|
||||||
|
.drop-text { font-size: 1rem; font-weight: 600; margin: 0 0 4px; color: var(--text-primary, #111827); }
|
||||||
|
.drop-filename { font-size: 0.95rem; font-weight: 600; color: #10b981; margin: 0 0 4px; }
|
||||||
|
.drop-hint { font-size: 0.8rem; color: var(--text-secondary, #6b7280); margin: 0; }
|
||||||
|
|
||||||
|
/* ── Format Hint ─────────────────────────────────────────────────────────── */
|
||||||
|
.import-hint details {
|
||||||
|
border: 1px solid var(--border-color, #e5e7eb);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--text-secondary, #6b7280);
|
||||||
|
}
|
||||||
|
.import-hint summary { cursor: pointer; font-weight: 600; }
|
||||||
|
.hint-body { margin-top: 8px; display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.hint-body code {
|
||||||
|
background: var(--bg-secondary, #f3f4f6);
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
display: block;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Dry Run Toggle ──────────────────────────────────────────────────────── */
|
||||||
|
.dry-run-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-primary, #374151);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.dry-run-toggle input { width: 16px; height: 16px; cursor: pointer; }
|
||||||
|
|
||||||
|
/* ── Error ───────────────────────────────────────────────────────────────── */
|
||||||
|
.import-error {
|
||||||
|
background: #fef2f2;
|
||||||
|
border: 1px solid #fca5a5;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Upload Button ───────────────────────────────────────────────────────── */
|
||||||
|
.btn-upload {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--accent, #6366f1);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.btn-upload:hover:not(:disabled) { opacity: 0.9; }
|
||||||
|
.btn-upload:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* ── Results ─────────────────────────────────────────────────────────────── */
|
||||||
|
.import-results { display: flex; flex-direction: column; gap: 14px; }
|
||||||
|
|
||||||
|
.results-banner {
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.results-banner.dry { background: #fffbeb; color: #d97706; border: 1px solid #fcd34d; }
|
||||||
|
.results-banner.live { background: #ecfdf5; color: #059669; border: 1px solid #6ee7b7; }
|
||||||
|
|
||||||
|
.results-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.stat {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 64px;
|
||||||
|
text-align: center;
|
||||||
|
background: var(--bg-secondary, #f9fafb);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 8px;
|
||||||
|
border: 1px solid var(--border-color, #e5e7eb);
|
||||||
|
}
|
||||||
|
.stat span { display: block; font-size: 1.5rem; font-weight: 800; color: var(--text-primary, #111827); }
|
||||||
|
.stat small { font-size: 0.75rem; color: var(--text-secondary, #6b7280); }
|
||||||
|
.stat.created span { color: #10b981; }
|
||||||
|
.stat.updated span { color: #3b82f6; }
|
||||||
|
.stat.skipped span { color: #9ca3af; }
|
||||||
|
.stat.errors span { color: #ef4444; }
|
||||||
|
|
||||||
|
/* ── Rows table ──────────────────────────────────────────────────────────── */
|
||||||
|
.results-table-wrap {
|
||||||
|
max-height: 280px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid var(--border-color, #e5e7eb);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.results-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
.results-table th {
|
||||||
|
background: var(--bg-secondary, #f3f4f6);
|
||||||
|
padding: 7px 10px;
|
||||||
|
text-align: right;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary, #6b7280);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
.results-table td {
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-top: 1px solid var(--border-color, #f3f4f6);
|
||||||
|
color: var(--text-primary, #374151);
|
||||||
|
max-width: 160px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.row-error td { background: #fef2f2; }
|
||||||
|
.row-skipped td { color: var(--text-secondary, #9ca3af); }
|
||||||
|
|
||||||
|
/* Badges */
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.badge-created { background: #d1fae5; color: #065f46; }
|
||||||
|
.badge-updated { background: #dbeafe; color: #1e40af; }
|
||||||
|
.badge-skipped { background: #f3f4f6; color: #6b7280; }
|
||||||
|
.badge-error { background: #fee2e2; color: #991b1b; }
|
||||||
|
.badge-dry { background: #fef3c7; color: #92400e; }
|
||||||
|
|
||||||
|
/* ── Post-result actions ─────────────────────────────────────────────────── */
|
||||||
|
.results-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.btn-reset {
|
||||||
|
padding: 9px 18px;
|
||||||
|
background: none;
|
||||||
|
border: 1.5px solid var(--border-color, #d1d5db);
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-primary, #374151);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.btn-reset:hover { background: var(--bg-hover, #f9fafb); }
|
||||||
|
.btn-close-after {
|
||||||
|
padding: 9px 18px;
|
||||||
|
background: none;
|
||||||
|
border: 1.5px solid var(--border-color, #d1d5db);
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary, #6b7280);
|
||||||
|
}
|
||||||
|
.btn-close-after:hover { background: var(--bg-hover, #f9fafb); }
|
||||||
250
frontend/src/components/ImportContacts.jsx
Normal file
250
frontend/src/components/ImportContacts.jsx
Normal file
@ -0,0 +1,250 @@
|
|||||||
|
import { useState, useRef } from 'react'
|
||||||
|
import { importContacts } from '../api/api'
|
||||||
|
import './ImportContacts.css'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ImportContacts
|
||||||
|
*
|
||||||
|
* Opens a modal that lets the user upload a CSV or JSON file of contacts and
|
||||||
|
* import them into the current event's guest list.
|
||||||
|
*
|
||||||
|
* Props:
|
||||||
|
* eventId – UUID of the current event
|
||||||
|
* onImportComplete – callback called when a real (non-dry-run) import succeeds
|
||||||
|
*/
|
||||||
|
function ImportContacts({ eventId, onImportComplete }) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [file, setFile] = useState(null)
|
||||||
|
const [isDryRun, setIsDryRun] = useState(false)
|
||||||
|
const [dragging, setDragging] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [result, setResult] = useState(null) // ImportContactsResponse
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const fileInputRef = useRef()
|
||||||
|
|
||||||
|
// ── helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
setFile(null)
|
||||||
|
setResult(null)
|
||||||
|
setError('')
|
||||||
|
setLoading(false)
|
||||||
|
setIsDryRun(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setOpen(false)
|
||||||
|
reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFileChange = (e) => {
|
||||||
|
const f = e.target.files?.[0]
|
||||||
|
if (f) { setFile(f); setResult(null); setError('') }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDrop = (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setDragging(false)
|
||||||
|
const f = e.dataTransfer.files?.[0]
|
||||||
|
if (f) { setFile(f); setResult(null); setError('') }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── submit ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const handleUpload = async () => {
|
||||||
|
if (!file) { setError('אנא בחר קובץ לפני ההעלאה.'); return }
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
setResult(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await importContacts(eventId, file, isDryRun)
|
||||||
|
setResult(res)
|
||||||
|
if (!isDryRun && (res.created > 0 || res.updated > 0) && onImportComplete) {
|
||||||
|
onImportComplete()
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err?.response?.data?.detail || err.message || 'שגיאה בעת ייבוא הקובץ.'
|
||||||
|
setError(typeof msg === 'string' ? msg : JSON.stringify(msg))
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── action label helpers ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const actionLabel = {
|
||||||
|
created: { text: 'נוצר', cls: 'badge-created' },
|
||||||
|
updated: { text: 'עודכן', cls: 'badge-updated' },
|
||||||
|
skipped: { text: 'דולג', cls: 'badge-skipped' },
|
||||||
|
error: { text: 'שגיאה', cls: 'badge-error' },
|
||||||
|
would_create: { text: 'ייווצר', cls: 'badge-dry' },
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── modal ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="btn btn-import"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
disabled={!eventId}
|
||||||
|
title={!eventId ? 'בחר אירוע תחילה' : 'ייבוא אנשי קשר מקובץ CSV / JSON'}
|
||||||
|
>
|
||||||
|
📂 ייבוא קובץ
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="import-overlay" onClick={(e) => e.target === e.currentTarget && handleClose()}>
|
||||||
|
<div className="import-modal" dir="rtl">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="import-header">
|
||||||
|
<h2>📂 ייבוא אנשי קשר מקובץ</h2>
|
||||||
|
<button className="import-close" onClick={handleClose}>✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="import-body">
|
||||||
|
{/* File drop zone */}
|
||||||
|
<div
|
||||||
|
className={`drop-zone ${dragging ? 'dragging' : ''} ${file ? 'has-file' : ''}`}
|
||||||
|
onDragOver={(e) => { e.preventDefault(); setDragging(true) }}
|
||||||
|
onDragLeave={() => setDragging(false)}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".csv,.json"
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
onChange={handleFileChange}
|
||||||
|
/>
|
||||||
|
{file ? (
|
||||||
|
<>
|
||||||
|
<span className="drop-icon">✅</span>
|
||||||
|
<p className="drop-filename">{file.name}</p>
|
||||||
|
<p className="drop-hint">לחץ להחלפת הקובץ</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="drop-icon">📄</span>
|
||||||
|
<p className="drop-text">גרור קובץ CSV או JSON לכאן</p>
|
||||||
|
<p className="drop-hint">או לחץ לבחירת קובץ</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Format hint */}
|
||||||
|
<div className="import-hint">
|
||||||
|
<details>
|
||||||
|
<summary>פורמטים נתמכים</summary>
|
||||||
|
<div className="hint-body">
|
||||||
|
<p><strong>CSV</strong> — כל שורה = אורח. עמודות נתמכות:</p>
|
||||||
|
<code>First Name, Last Name, Full Name, Phone, Email, RSVP, Meal, Notes, Side, Table</code>
|
||||||
|
<p><strong>JSON</strong> — מערך של אובייקטים עם אותן שדות, או <code>{`{"data":[…]}`}</code>.</p>
|
||||||
|
<p>הייבוא ניתן לביצוע כפעולת מה-Excel שיצאת: אותן עמודות שמופיעות בייצוא לאקסל.</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dry-run toggle */}
|
||||||
|
<label className="dry-run-toggle">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isDryRun}
|
||||||
|
onChange={(e) => setIsDryRun(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>בדיקה בלבד (Dry Run) — הצג מה היה קורה ללא שמירה</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{error && <div className="import-error">{error}</div>}
|
||||||
|
|
||||||
|
{/* Upload button */}
|
||||||
|
{!result && (
|
||||||
|
<button
|
||||||
|
className="btn-upload"
|
||||||
|
onClick={handleUpload}
|
||||||
|
disabled={loading || !file}
|
||||||
|
>
|
||||||
|
{loading ? '⏳ מייבא…' : isDryRun ? '🔍 הרץ בדיקה' : '📥 ייבא אנשי קשר'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
{result && (
|
||||||
|
<div className="import-results">
|
||||||
|
<div className={`results-banner ${result.dry_run ? 'dry' : 'live'}`}>
|
||||||
|
{result.dry_run ? '🔍 תוצאות בדיקה (לא נשמר)' : '✅ הייבוא הושלם!'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="results-stats">
|
||||||
|
<div className="stat"><span>{result.total}</span><small>סה"כ</small></div>
|
||||||
|
<div className="stat created"><span>{result.created}</span><small>{result.dry_run ? 'ייווצרו' : 'נוצרו'}</small></div>
|
||||||
|
<div className="stat updated"><span>{result.updated}</span><small>עודכנו</small></div>
|
||||||
|
<div className="stat skipped"><span>{result.skipped}</span><small>דולגו</small></div>
|
||||||
|
{result.errors > 0 && (
|
||||||
|
<div className="stat errors"><span>{result.errors}</span><small>שגיאות</small></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row-level table */}
|
||||||
|
{result.rows.length > 0 && (
|
||||||
|
<div className="results-table-wrap">
|
||||||
|
<table className="results-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>שם</th>
|
||||||
|
<th>טלפון</th>
|
||||||
|
<th>פעולה</th>
|
||||||
|
<th>הערה</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{result.rows.map((r) => {
|
||||||
|
const lbl = actionLabel[r.action] || { text: r.action, cls: '' }
|
||||||
|
return (
|
||||||
|
<tr key={r.row} className={`row-${r.action}`}>
|
||||||
|
<td>{r.row}</td>
|
||||||
|
<td>{r.name || '—'}</td>
|
||||||
|
<td dir="ltr">{r.phone || '—'}</td>
|
||||||
|
<td><span className={`badge ${lbl.cls}`}>{lbl.text}</span></td>
|
||||||
|
<td>{r.reason || ''}</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Post-result actions */}
|
||||||
|
<div className="results-actions">
|
||||||
|
{result.dry_run && (
|
||||||
|
<button
|
||||||
|
className="btn-upload"
|
||||||
|
onClick={() => { setIsDryRun(false); setResult(null) }}
|
||||||
|
>
|
||||||
|
✅ אישור — ייבא עכשיו
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button className="btn-reset" onClick={reset}>
|
||||||
|
📂 ייבא קובץ חדש
|
||||||
|
</button>
|
||||||
|
<button className="btn-close-after" onClick={handleClose}>
|
||||||
|
סגור
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ImportContacts
|
||||||
Loading…
x
Reference in New Issue
Block a user