This commit is contained in:
Vitrixxl 2025-12-08 23:43:28 +01:00
parent 072fc047a4
commit 0d08b60d07
15 changed files with 2271 additions and 167 deletions

View File

@ -4,4 +4,8 @@ DB_HOST=34.155.247.19
DB_USERNAME=postgres
DB_PASSWORD=3cd70e73f0b73a2411c595f3a2f8cf125ee3937498f82628906ed946e8499a00f7733c6ec588cdff4aec386de3c3cc51d0a166369c6a99e73b9c95d2a1bd115a
API_TOKEN=B0uKZQN+qIsLc0yNR/t9xCOkgP6Keg0oarLUiZkO2Mo=
FLUXIMMO_API_KEY=a
# FLUXIMMO_API_KEY=trial_default_real-immo_8b9cbce3-8636-4c88-894c-031359db0630
FLUXIMMO_API_KEY=prod_default_immonator-1_a9261cc6-c229-409f-ade6-0dabcdeff36f
GOOGLE_CREDENTIALS_PATH=/home/vitrix/.config/gcloud/extraction-gemini-461016-42184c10da74.json
GOOGLE_PROJECT_ID=extraction-gemini-461016

Binary file not shown.

View File

@ -18,6 +18,10 @@ from dotenv import load_dotenv
from flask import Flask, abort, jsonify, request, g
from flask_cors import CORS
import requests
from google import genai
from google.genai import types
from schema_docs import SCRAPER_SCHEMA_DOC, PROFILE_SCHEMA_DOC
load_dotenv()
@ -25,8 +29,11 @@ API_TOKEN = os.getenv("API_TOKEN")
FLUXIMMO_API_KEY = os.getenv("FLUXIMMO_API_KEY")
FLUXIMMO_COUNT_URL = os.getenv(
"FLUXIMMO_COUNT_URL",
"https://analytics.fluximmo.io/properties/count",
"https://api.fluximmo.io/v2/protected/analytics/property/count",
)
GOOGLE_CREDENTIALS_PATH = os.getenv("GOOGLE_CREDENTIALS_PATH")
GOOGLE_PROJECT_ID = os.getenv("GOOGLE_PROJECT_ID")
GOOGLE_LOCATION = os.getenv("GOOGLE_LOCATION", "europe-west1")
REQUIRED_DB_SETTINGS = {
@ -61,6 +68,12 @@ INVESTMENT_PROFILE_TABLE = os.getenv(
"DB_TABLE_INVESTMENT_PROFILES", "users_investmentprofile"
)
SCRAPER_TABLE = os.getenv("DB_TABLE_SCRAPERS", "scraper")
SUBSCRIPTION_TABLE = os.getenv(
"DB_TABLE_SUBSCRIPTIONS", "engagements_investorprofilesubscription"
)
PROPERTY_MATCH_TABLE = os.getenv(
"DB_TABLE_PROPERTY_MATCHES", "engagements_investorpropertymatch"
)
if not API_TOKEN:
raise RuntimeError(
@ -242,12 +255,35 @@ def _validate_property_types(value: str | None) -> str | None:
abort(
400,
description=f"Invalid property types: {', '.join(invalid_types)}. "
f"Allowed values: {', '.join(sorted(valid_types))}"
f"Allowed values: {', '.join(sorted(valid_types))}",
)
return value.strip()
def _cleanup_empty_values(data: Any) -> Any:
"""Recursively remove empty lists, empty dicts, and None values from data structure."""
if isinstance(data, dict):
cleaned = {}
for key, value in data.items():
cleaned_value = _cleanup_empty_values(value)
# Only include non-empty values
if (
cleaned_value is not None
and cleaned_value != []
and cleaned_value != {}
):
cleaned[key] = cleaned_value
return cleaned if cleaned else None
elif isinstance(data, list):
cleaned = [_cleanup_empty_values(item) for item in data]
# Remove None values from list
cleaned = [item for item in cleaned if item is not None]
return cleaned if cleaned else None
else:
return data
def build_scraper_params(
params: Dict[str, Any],
first_seen_days: int | None,
@ -281,7 +317,10 @@ def build_scraper_params(
base = deepcopy(params)
merged = always_merger.merge(base, dynamic_params)
return merged
# Clean up empty values before returning
cleaned = _cleanup_empty_values(merged)
return cleaned if cleaned else {}
def _require_bearer_token(header_value: str | None) -> str:
@ -420,6 +459,49 @@ def _abort_for_integrity_error(exc: psycopg.IntegrityError) -> None:
abort(409, description=detail or "Database constraint violation")
def _hash_django_password(password: str, iterations: int = 260000) -> str:
"""Hash a password using Django's PBKDF2 format: pbkdf2_sha256$<iterations>$<salt>$<hash>"""
import hashlib
import base64
import secrets
# Generate random salt
salt = secrets.token_urlsafe(12)
# Hash the password
hash_bytes = hashlib.pbkdf2_hmac(
"sha256", password.encode("utf-8"), salt.encode("utf-8"), iterations
)
# Encode to base64
hash_b64 = base64.b64encode(hash_bytes).decode("ascii")
return f"pbkdf2_sha256${iterations}${salt}${hash_b64}"
def _get_user_profiles(user_id: int) -> List[Dict[str, Any]]:
"""Fetch all profiles subscribed by a user."""
query = sql.SQL(
"""
SELECT {profile_columns}
FROM {profile_table}
INNER JOIN {subscription_table}
ON {profile_table}.profile_id = {subscription_table}.investment_profile_id
WHERE {subscription_table}.investor_id = {user_id}
ORDER BY {subscription_table}.subscribed_at DESC
"""
).format(
profile_columns=_columns_sql(PROFILE_RESPONSE_FIELDS),
profile_table=sql.Identifier(INVESTMENT_PROFILE_TABLE),
subscription_table=sql.Identifier(SUBSCRIPTION_TABLE),
user_id=sql.Placeholder("user_id"),
)
profiles = _fetch_all(query, {"user_id": user_id})
return [
_serialize_row(p, datetime_fields=PROFILE_DATETIME_FIELDS) for p in profiles
]
USER_RESPONSE_FIELDS = (
"id",
"username",
@ -464,9 +546,7 @@ SCRAPER_RESPONSE_FIELDS = (
"once",
)
SCRAPER_BOOL_FIELDS = (
"once",
)
SCRAPER_BOOL_FIELDS = ("once",)
SCRAPER_INT_FIELDS = (
"last_seen_days",
@ -616,6 +696,26 @@ def update_profile(profile_id: str):
@app.delete("/profiles/<profile_id>")
def delete_profile(profile_id: str):
profile_uuid = _parse_uuid(profile_id, "profile_id")
# Delete all property matches for this profile first
delete_matches_query = sql.SQL(
"DELETE FROM {table} WHERE investment_profile_id = {profile_id}"
).format(
table=sql.Identifier(PROPERTY_MATCH_TABLE),
profile_id=sql.Placeholder("profile_id"),
)
_execute(delete_matches_query, {"profile_id": profile_uuid})
# Delete all subscriptions for this profile
delete_subscriptions_query = sql.SQL(
"DELETE FROM {table} WHERE investment_profile_id = {profile_id}"
).format(
table=sql.Identifier(SUBSCRIPTION_TABLE),
profile_id=sql.Placeholder("profile_id"),
)
_execute(delete_subscriptions_query, {"profile_id": profile_uuid})
# Now delete the profile
deleted = _delete_row(INVESTMENT_PROFILE_TABLE, "profile_id", profile_uuid)
if not deleted:
abort(404, description="Profile not found")
@ -630,9 +730,11 @@ def get_users():
table=sql.Identifier(USER_TABLE),
)
)
payload = [
_serialize_row(row, datetime_fields=USER_DATETIME_FIELDS) for row in rows
]
payload = []
for row in rows:
user = _serialize_row(row, datetime_fields=USER_DATETIME_FIELDS)
user["profiles"] = _get_user_profiles(row["id"])
payload.append(user)
return jsonify(payload)
@ -648,7 +750,9 @@ def get_user(user_id: int):
)
if row is None:
abort(404, description="User not found")
return jsonify(_serialize_row(row, datetime_fields=USER_DATETIME_FIELDS))
user = _serialize_row(row, datetime_fields=USER_DATETIME_FIELDS)
user["profiles"] = _get_user_profiles(user_id)
return jsonify(user)
@app.post("/users")
@ -656,7 +760,8 @@ def create_user():
payload = _get_json_body()
user_data: Dict[str, Any] = {}
user_data["password"] = _parse_string(payload.get("password"), "password")
raw_password = _parse_string(payload.get("password"), "password")
user_data["password"] = _hash_django_password(raw_password)
user_data["username"] = _parse_string(payload.get("username"), "username")
user_data["first_name"] = _parse_string(payload.get("first_name"), "first_name")
user_data["last_name"] = _parse_string(payload.get("last_name"), "last_name")
@ -676,15 +781,62 @@ def create_user():
user_data["last_login"] = _parse_datetime(payload.get("last_login"), "last_login")
# Parse profile_ids if provided
profile_ids: List[UUID] = []
if "profile_ids" in payload:
raw_profile_ids = payload["profile_ids"]
if not isinstance(raw_profile_ids, list):
abort(400, description="Field 'profile_ids' must be a list")
for idx, pid in enumerate(raw_profile_ids):
profile_ids.append(_parse_uuid(pid, f"profile_ids[{idx}]"))
try:
row = _insert_row(USER_TABLE, user_data, USER_RESPONSE_FIELDS)
except psycopg.IntegrityError as exc:
_abort_for_integrity_error(exc)
return (
jsonify(_serialize_row(row, datetime_fields=USER_DATETIME_FIELDS)),
201,
)
user_id = row["id"]
# Create profile subscriptions
for profile_id in profile_ids:
subscription_data = {
"subscription_id": uuid4(),
"investor_id": user_id,
"investment_profile_id": profile_id,
"subscribed_at": datetime.now(timezone.utc),
}
try:
_insert_row(
SUBSCRIPTION_TABLE,
subscription_data,
(
"subscription_id",
"investor_id",
"investment_profile_id",
"subscribed_at",
),
)
except psycopg.IntegrityError as exc:
_abort_for_integrity_error(exc)
# Fetch profiles for response
profiles = []
if profile_ids:
profiles_query = sql.SQL(
"SELECT {columns} FROM {table} WHERE profile_id = ANY({profile_ids})"
).format(
columns=_columns_sql(PROFILE_RESPONSE_FIELDS),
table=sql.Identifier(INVESTMENT_PROFILE_TABLE),
profile_ids=sql.Placeholder("profile_ids"),
)
profiles = _fetch_all(profiles_query, {"profile_ids": profile_ids})
response = _serialize_row(row, datetime_fields=USER_DATETIME_FIELDS)
response["profiles"] = [
_serialize_row(p, datetime_fields=PROFILE_DATETIME_FIELDS) for p in profiles
]
return jsonify(response), 201
@app.put("/users/<int:user_id>")
@ -693,7 +845,8 @@ def update_user(user_id: int):
updates: Dict[str, Any] = {}
if "password" in payload:
updates["password"] = _parse_string(payload["password"], "password")
raw_password = _parse_string(payload["password"], "password")
updates["password"] = _hash_django_password(raw_password)
if "username" in payload:
updates["username"] = _parse_string(payload["username"], "username")
if "first_name" in payload:
@ -712,22 +865,91 @@ def update_user(user_id: int):
if "last_login" in payload:
updates["last_login"] = _parse_datetime(payload["last_login"], "last_login")
if not updates:
# Handle profile_ids update
update_profiles = False
profile_ids: List[UUID] = []
if "profile_ids" in payload:
update_profiles = True
raw_profile_ids = payload["profile_ids"]
if not isinstance(raw_profile_ids, list):
abort(400, description="Field 'profile_ids' must be a list")
for idx, pid in enumerate(raw_profile_ids):
profile_ids.append(_parse_uuid(pid, f"profile_ids[{idx}]"))
if not updates and not update_profiles:
abort(400, description="No updatable fields provided")
try:
row = _update_row(USER_TABLE, "id", user_id, updates, USER_RESPONSE_FIELDS)
except psycopg.IntegrityError as exc:
_abort_for_integrity_error(exc)
if updates:
try:
row = _update_row(USER_TABLE, "id", user_id, updates, USER_RESPONSE_FIELDS)
except psycopg.IntegrityError as exc:
_abort_for_integrity_error(exc)
if row is None:
abort(404, description="User not found")
if row is None:
abort(404, description="User not found")
else:
# Verify user exists
row = _fetch_one(
sql.SQL("SELECT {columns} FROM {table} WHERE id = {identifier}").format(
columns=_columns_sql(USER_RESPONSE_FIELDS),
table=sql.Identifier(USER_TABLE),
identifier=sql.Placeholder("user_id"),
),
{"user_id": user_id},
)
if row is None:
abort(404, description="User not found")
return jsonify(_serialize_row(row, datetime_fields=USER_DATETIME_FIELDS))
# Update profile subscriptions if requested
if update_profiles:
# Delete existing subscriptions
delete_query = sql.SQL(
"DELETE FROM {table} WHERE investor_id = {user_id}"
).format(
table=sql.Identifier(SUBSCRIPTION_TABLE),
user_id=sql.Placeholder("user_id"),
)
_execute(delete_query, {"user_id": user_id})
# Create new subscriptions
for profile_id in profile_ids:
subscription_data = {
"subscription_id": uuid4(),
"investor_id": user_id,
"investment_profile_id": profile_id,
"subscribed_at": datetime.now(timezone.utc),
}
try:
_insert_row(
SUBSCRIPTION_TABLE,
subscription_data,
(
"subscription_id",
"investor_id",
"investment_profile_id",
"subscribed_at",
),
)
except psycopg.IntegrityError as exc:
_abort_for_integrity_error(exc)
user = _serialize_row(row, datetime_fields=USER_DATETIME_FIELDS)
user["profiles"] = _get_user_profiles(user_id)
return jsonify(user)
@app.delete("/users/<int:user_id>")
def delete_user(user_id: int):
# Delete all profile subscriptions for this user first
delete_subscriptions_query = sql.SQL(
"DELETE FROM {table} WHERE investor_id = {user_id}"
).format(
table=sql.Identifier(SUBSCRIPTION_TABLE),
user_id=sql.Placeholder("user_id"),
)
_execute(delete_subscriptions_query, {"user_id": user_id})
# Now delete the user
deleted = _delete_row(USER_TABLE, "id", user_id)
if not deleted:
abort(404, description="User not found")
@ -778,7 +1000,11 @@ def create_scraper():
# Validation spéciale pour property_types
if "property_types" in payload:
value = payload["property_types"]
parsed_value = None if value is None else _parse_string(value, "property_types", allow_empty=True)
parsed_value = (
None
if value is None
else _parse_string(value, "property_types", allow_empty=True)
)
data["property_types"] = _validate_property_types(parsed_value)
for field in SCRAPER_INT_FIELDS:
@ -814,7 +1040,11 @@ def update_scraper(scraper_id: str):
# Validation spéciale pour property_types
if "property_types" in payload:
value = payload["property_types"]
parsed_value = None if value is None else _parse_string(value, "property_types", allow_empty=True)
parsed_value = (
None
if value is None
else _parse_string(value, "property_types", allow_empty=True)
)
updates["property_types"] = _validate_property_types(parsed_value)
for field in SCRAPER_INT_FIELDS:
@ -858,17 +1088,26 @@ def delete_scraper(scraper_id: str):
@app.post("/scrapers/count")
def count_scraper_properties():
payload = _get_json_body()
print(f"[COUNT] Received payload: {payload}")
base_params = _load_scraper_params(payload.get("params"))
print(f"[COUNT] Base params: {base_params}")
first_seen_days = _parse_optional_int(
payload.get("first_seen_days"), "first_seen_days"
)
last_seen_days = _parse_optional_int(
payload.get("last_seen_days"), "last_seen_days"
)
print(
f"[COUNT] first_seen_days: {first_seen_days}, last_seen_days: {last_seen_days}"
)
query_filters = build_scraper_params(base_params, first_seen_days, last_seen_days)
flux_payload = {"query": query_filters}
print(f"[COUNT] Query filters after build: {query_filters}")
flux_payload = {"query": {"filterProperty": query_filters}}
print(f"[COUNT] Fluximmo payload: {json.dumps(flux_payload, indent=2)}")
headers = {
"x-api-key": FLUXIMMO_API_KEY,
@ -879,7 +1118,10 @@ def count_scraper_properties():
response = requests.post(
FLUXIMMO_COUNT_URL, json=flux_payload, headers=headers, timeout=15
)
except requests.RequestException:
print(f"[COUNT] Fluximmo response status: {response.status_code}")
print(f"[COUNT] Fluximmo response body: {response.text}")
except requests.RequestException as e:
print(f"[COUNT] Request exception: {e}")
abort(502, description="Fluximmo request failed")
if response.status_code >= 400:
@ -906,5 +1148,100 @@ def count_scraper_properties():
return jsonify({"count": count_value})
def _get_genai_client():
"""Get or create a Gemini client using Vertex AI."""
if not GOOGLE_PROJECT_ID:
abort(503, description="GOOGLE_PROJECT_ID not configured")
# Set credentials path if provided
if GOOGLE_CREDENTIALS_PATH:
os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = GOOGLE_CREDENTIALS_PATH
client = genai.Client(
vertexai=True,
project=GOOGLE_PROJECT_ID,
location=GOOGLE_LOCATION,
)
return client
def _call_gemini(system_prompt: str, user_prompt: str) -> str:
"""Call Gemini API via Vertex AI and return the generated text."""
try:
client = _get_genai_client()
response = client.models.generate_content(
model="gemini-2.5-flash",
contents=user_prompt,
config=types.GenerateContentConfig(
system_instruction=system_prompt,
temperature=0.2,
top_k=40,
top_p=0.95,
max_output_tokens=8192,
),
)
print(
f"[GEMINI] Response: {response.text[:500] if response.text else 'No text'}"
)
if not response.text:
abort(502, description="Gemini returned no content")
return response.text
except Exception as e:
print(f"[GEMINI] Error: {e}")
abort(502, description=f"Gemini request failed: {str(e)}")
def _extract_json_from_response(text: str) -> Dict[str, Any]:
"""Extract JSON from Gemini response, handling markdown code blocks."""
# Remove markdown code blocks if present
text = text.strip()
if text.startswith("```json"):
text = text[7:]
elif text.startswith("```"):
text = text[3:]
if text.endswith("```"):
text = text[:-3]
text = text.strip()
try:
return json.loads(text)
except json.JSONDecodeError as e:
abort(400, description=f"Failed to parse Gemini response as JSON: {e}")
@app.post("/ai/generate-scraper")
def generate_scraper():
"""Generate scraper JSON from natural language prompt using Gemini."""
payload = _get_json_body()
user_prompt = payload.get("prompt")
if not user_prompt:
abort(400, description="Field 'prompt' is required")
generated_text = _call_gemini(SCRAPER_SCHEMA_DOC, user_prompt)
result = _extract_json_from_response(generated_text)
return jsonify({"params": result})
@app.post("/ai/generate-profile")
def generate_profile():
"""Generate profile JSON from natural language prompt using Gemini."""
payload = _get_json_body()
user_prompt = payload.get("prompt")
if not user_prompt:
abort(400, description="Field 'prompt' is required")
generated_text = _call_gemini(PROFILE_SCHEMA_DOC, user_prompt)
result = _extract_json_from_response(generated_text)
# Return the result directly at root level (not wrapped in criteria)
return jsonify(result)
if __name__ == "__main__":
app.run(host="0.0.0.0", port=int(os.getenv("PORT", "3000")), debug=False)

View File

@ -4,3 +4,4 @@ psycopg[binary]>=3.1,<4.0
Flask-Cors>=4.0.0,<5.0.0
requests>=2.31.0,<3.0.0
deepmerge>=1.1.0,<2.0.0
google-genai>=1.0.0

488
backend/schema_docs.py Normal file
View File

@ -0,0 +1,488 @@
"""Documentation détaillée des schémas pour l'assistant IA."""
SCRAPER_SCHEMA_DOC = """
# Schéma Scraper Fluximmo
Tu dois générer un JSON valide pour configurer un scraper de recherche immobilière.
Le JSON doit respecter exactement la structure suivante.
## Structure principale
```json
{
"location": [...], // Obligatoire: liste des localisations
"type": [...], // Obligatoire: types de biens
"offer": [...], // Obligatoire: type d'offre
"meta": {...}, // Optionnel: filtres temporels
"price": {...}, // Optionnel: filtres de prix
"habitation": {...}, // Optionnel: caractéristiques du bien
"land": {...}, // Optionnel: caractéristiques du terrain
"adverts": [...], // Optionnel: filtres sur les annonces
"process": [...], // Optionnel: statut des annonces
"tags": [...] // Optionnel: tags personnalisés
}
```
## Champs détaillés
### location (OBLIGATOIRE)
Liste d'objets de localisation. UTILISE UNIQUEMENT les codes suivants:
1. **Pour une région/département entier**: utilise `department` avec le code département à 2 chiffres
- Bretagne: "22" (Côtes-d'Armor), "29" (Finistère), "35" (Ille-et-Vilaine), "56" (Morbihan)
- Autres exemples: "44" (Loire-Atlantique), "75" (Paris), "13" (Bouches-du-Rhône)
2. **Pour des communes spécifiques**: utilise `inseeCode` avec le code INSEE à 5 chiffres
- Exemples: "35238" (Rennes), "56260" (Vannes), "29019" (Brest), "22278" (Saint-Brieuc)
IMPORTANT: N'utilise JAMAIS de codes postaux ! Utilise uniquement `department` ou `inseeCode`.
Exemple pour toute la Bretagne:
```json
"location": [
{ "department": "22" },
{ "department": "29" },
{ "department": "35" },
{ "department": "56" }
]
```
Exemple pour des communes spécifiques:
```json
"location": [
{ "inseeCode": "35238" },
{ "inseeCode": "56260" }
]
```
### type (OBLIGATOIRE)
Liste des types de biens. Valeurs possibles:
- "CLASS_HOUSE" - Maison
- "CLASS_FLAT" - Appartement
- "CLASS_BUILDING" - Immeuble
- "CLASS_LAND" - Terrain
- "CLASS_PARKING" - Parking
- "CLASS_SHOP" - Commerce
- "CLASS_OFFICE" - Bureau
- "CLASS_PREMISES" - Local commercial
- "CLASS_PROGRAM" - Programme neuf
Exemple:
```json
"type": ["CLASS_HOUSE", "CLASS_FLAT"]
```
### offer (OBLIGATOIRE)
Liste d'objets d'offre. Chaque objet contient:
- `type`: string - Type de transaction:
- "OFFER_BUY" - Achat
- "OFFER_RENT" - Location
- "OFFER_LIFE_ANNUITY_SALE" - Viager
- "OFFER_BUSINESS_TAKE_OVER" - Reprise de commerce
- "OFFER_HOLIDAYS" - Location vacances
Exemple:
```json
"offer": [
{ "type": "OFFER_BUY" }
]
```
### meta (optionnel)
Filtres temporels sur les annonces:
- `firstSeenAt`: { "min": "date ISO", "max": "date ISO" } - Date de première apparition
- `lastSeenAt`: { "min": "date ISO", "max": "date ISO" } - Date de dernière vue
- `lastPublishedAt`: { "min": "date ISO", "max": "date ISO" } - Date de publication
- `isTotallyOffline`: boolean - Annonces retirées
### price (optionnel)
Filtres de prix:
- `latest.value`: { "min": number, "max": number } - Fourchette de prix en euros
- `latest.valuePerArea`: { "min": number, "max": number } - Prix au
- `isAuction`: boolean - Vente aux enchères
- `scope`: ["PRICING_ONE_OFF"] pour achat, ["PRICING_MENSUAL"] pour location
Exemple:
```json
"price": {
"latest": {
"value": { "min": 50000, "max": 200000 }
}
}
```
### habitation (optionnel)
Caractéristiques du bien:
- `roomCount`: { "min": number, "max": number } - Nombre de pièces
- `bedroomCount`: { "min": number, "max": number } - Nombre de chambres
- `bathroomCount`: { "min": number, "max": number } - Salles de bain
- `surface.total`: { "min": number, "max": number } - Surface totale
- `surface.livingSpace`: { "min": number, "max": number } - Surface habitable
- `characteristics`: objet avec booléens:
- `hasGarden`, `hasPool`, `hasGarage`, `hasParking`, `hasTerrace`, `hasBalcony`
- `hasCellar`, `hasLift`, `hasFireplace`, etc.
- `climate.epcEnergy`: Liste de classes DPE ["ENERGY_CLASSIFICATION_A", ..., "ENERGY_CLASSIFICATION_G"]
- `climate.epcClimate`: Liste de classes GES
Exemple:
```json
"habitation": {
"bedroomCount": { "min": 3 },
"surface": {
"total": { "min": 80, "max": 150 }
},
"characteristics": {
"hasGarden": true
}
}
```
### land (optionnel)
Pour les terrains:
- `surface`: { "min": number, "max": number } - Surface en
- `canConstruct`: boolean - Constructible
- `isServiced`: boolean - Viabilisé
- `type`: ["LAND_BUILDING_PLOT", "LAND_AGRICULTURAL", ...]
### adverts (optionnel)
Filtres sur les annonces:
- `isOnline`: boolean - Annonce en ligne
- `isPro`: boolean - Annonce professionnelle
- `isExclusive`: boolean - Exclusivité
Exemple:
```json
"adverts": [
{ "isOnline": true }
]
```
### process (optionnel)
Statut de l'annonce. Valeurs possibles:
- "PROCESS_AVAILABLE_ON_MARKET" - Disponible
- "PROCESS_UNDER_COMPROMISE" - Sous compromis
- "PROCESS_RENTED_SOLD" - Vendu/Loué
- "PROCESS_RESERVED" - Réservé
## VALEURS PAR DÉFAUT OBLIGATOIRES
IMPORTANT: Tous les scrapers générés DOIVENT inclure la section habitation.climate avec toutes les classes DPE et GES:
```json
"habitation": {
"climate": {
"epcClimate": [
"GREENHOUSE_CLASSIFICATION_A",
"GREENHOUSE_CLASSIFICATION_B",
"GREENHOUSE_CLASSIFICATION_C",
"GREENHOUSE_CLASSIFICATION_D",
"GREENHOUSE_CLASSIFICATION_E",
"GREENHOUSE_CLASSIFICATION_F",
"GREENHOUSE_CLASSIFICATION_G"
],
"epcEnergy": [
"ENERGY_CLASSIFICATION_A",
"ENERGY_CLASSIFICATION_B",
"ENERGY_CLASSIFICATION_C",
"ENERGY_CLASSIFICATION_D",
"ENERGY_CLASSIFICATION_E",
"ENERGY_CLASSIFICATION_F",
"ENERGY_CLASSIFICATION_G"
]
}
}
```
## Exemples complets
### Maisons en Bretagne à acheter
```json
{
"location": [
{ "department": "22" },
{ "department": "29" },
{ "department": "35" },
{ "department": "56" }
],
"type": ["CLASS_HOUSE"],
"offer": [
{ "type": "OFFER_BUY" }
],
"habitation": {
"climate": {
"epcClimate": [
"GREENHOUSE_CLASSIFICATION_A",
"GREENHOUSE_CLASSIFICATION_B",
"GREENHOUSE_CLASSIFICATION_C",
"GREENHOUSE_CLASSIFICATION_D",
"GREENHOUSE_CLASSIFICATION_E",
"GREENHOUSE_CLASSIFICATION_F",
"GREENHOUSE_CLASSIFICATION_G"
],
"epcEnergy": [
"ENERGY_CLASSIFICATION_A",
"ENERGY_CLASSIFICATION_B",
"ENERGY_CLASSIFICATION_C",
"ENERGY_CLASSIFICATION_D",
"ENERGY_CLASSIFICATION_E",
"ENERGY_CLASSIFICATION_F",
"ENERGY_CLASSIFICATION_G"
]
}
}
}
```
### Appartements à louer à Rennes avec 2+ chambres
```json
{
"location": [
{ "inseeCode": "35238" }
],
"type": ["CLASS_FLAT"],
"offer": [
{ "type": "OFFER_RENT" }
],
"habitation": {
"bedroomCount": { "min": 2 },
"climate": {
"epcClimate": [
"GREENHOUSE_CLASSIFICATION_A",
"GREENHOUSE_CLASSIFICATION_B",
"GREENHOUSE_CLASSIFICATION_C",
"GREENHOUSE_CLASSIFICATION_D",
"GREENHOUSE_CLASSIFICATION_E",
"GREENHOUSE_CLASSIFICATION_F",
"GREENHOUSE_CLASSIFICATION_G"
],
"epcEnergy": [
"ENERGY_CLASSIFICATION_A",
"ENERGY_CLASSIFICATION_B",
"ENERGY_CLASSIFICATION_C",
"ENERGY_CLASSIFICATION_D",
"ENERGY_CLASSIFICATION_E",
"ENERGY_CLASSIFICATION_F",
"ENERGY_CLASSIFICATION_G"
]
}
}
}
```
### Immeubles de rapport en Bretagne
```json
{
"location": [
{ "department": "35" },
{ "department": "56" }
],
"type": ["CLASS_BUILDING"],
"offer": [
{ "type": "OFFER_BUY" }
],
"price": {
"latest": {
"value": { "max": 500000 }
}
},
"habitation": {
"climate": {
"epcClimate": [
"GREENHOUSE_CLASSIFICATION_A",
"GREENHOUSE_CLASSIFICATION_B",
"GREENHOUSE_CLASSIFICATION_C",
"GREENHOUSE_CLASSIFICATION_D",
"GREENHOUSE_CLASSIFICATION_E",
"GREENHOUSE_CLASSIFICATION_F",
"GREENHOUSE_CLASSIFICATION_G"
],
"epcEnergy": [
"ENERGY_CLASSIFICATION_A",
"ENERGY_CLASSIFICATION_B",
"ENERGY_CLASSIFICATION_C",
"ENERGY_CLASSIFICATION_D",
"ENERGY_CLASSIFICATION_E",
"ENERGY_CLASSIFICATION_F",
"ENERGY_CLASSIFICATION_G"
]
}
}
}
```
IMPORTANT: Réponds UNIQUEMENT avec le JSON, sans texte avant ou après. Le JSON doit être valide et parsable. N'oublie JAMAIS d'inclure la section habitation.climate avec toutes les classes DPE et GES!
"""
PROFILE_SCHEMA_DOC = """
# Schéma Profile d'Investissement
Tu dois générer un JSON valide pour configurer un profil d'investissement immobilier.
Le JSON doit respecter exactement la structure suivante.
## Structure principale
```json
{
"transaction_type": "sale", // Type de transaction
"property_type": [...], // Types de biens
"codes_insee": [...], // Codes INSEE des communes
"min_price": number, // Prix minimum
"max_price": number, // Prix maximum
"min_size": number, // Surface minimale
"max_size": number, // Surface maximale
"min_bedrooms": number, // Chambres minimum
"max_bedrooms": number, // Chambres maximum
"dpe_classes": [...], // Classes DPE acceptées
"ges_classes": [...], // Classes GES acceptées
"characteristics": [...], // Caractéristiques requises
"target_yield": number, // Rendement cible %
"require_address": boolean // Adresse requise
}
```
## Champs détaillés
### transaction_type
Type de transaction:
- "sale" - Achat/Vente
- "rent" - Location
- "viager" - Viager
### property_type
Liste des types de biens recherchés:
- "house" - Maison
- "apartment" - Appartement
- "building" - Immeuble
- "land" - Terrain
- "parking" - Parking
Exemple:
```json
"property_type": ["house", "apartment"]
```
### codes_insee
Liste des codes de localisation. UTILISE UNIQUEMENT:
- **Codes département** (2 chiffres) pour une région entière: "22", "29", "35", "56"
- **Codes INSEE** (5 chiffres) pour des communes spécifiques: "35238", "56260"
IMPORTANT: N'utilise JAMAIS de codes postaux !
Exemple pour toute la Bretagne:
```json
"codes_insee": ["22", "29", "35", "56"]
```
Exemple pour des communes spécifiques:
```json
"codes_insee": ["35238", "56260", "22168", "29039"]
```
### Filtres de prix
- `min_price`: number - Prix minimum en euros
- `max_price`: number - Prix maximum en euros
### Filtres de surface
- `min_size`: number - Surface minimale en
- `max_size`: number - Surface maximale en
### Filtres de chambres
- `min_bedrooms`: number - Nombre minimum de chambres
- `max_bedrooms`: number - Nombre maximum de chambres
- `min_bathrooms`: number - Nombre minimum de salles de bain
### dpe_classes / ges_classes
Classes énergétiques acceptées: ["A", "B", "C", "D", "E", "F", "G", "NC"]
Exemple:
```json
"dpe_classes": ["A", "B", "C", "D"]
```
### characteristics
Liste de groupes de caractéristiques. Chaque groupe:
```json
{
"type": "any" | "all" | "none",
"description": "Description du groupe",
"items": ["has_garden", "has_pool", ...]
}
```
Caractéristiques disponibles:
- has_alarm, has_balcony, has_cellar, has_lift, has_pool
- has_garage, has_garden, has_terrace, has_parking, has_fireplace
- has_mezzanine, has_concierge, has_digicode, has_interphone
- has_jacuzzi, has_land, has_land_division, has_grenier
- has_vis_a_vis, is_peaceful, has_two_doors_at_entrance, is_squatted
Exemple:
```json
"characteristics": [
{
"type": "any",
"items": ["has_garden", "has_terrace"]
}
]
```
### target_yield
Rendement locatif cible en pourcentage (ex: 7.5 pour 7.5%)
### require_address
Boolean - Exiger une adresse complète pour les biens
## Exemples complets
### Maisons en Bretagne à acheter (toute la région)
```json
{
"transaction_type": "sale",
"property_type": ["house"],
"codes_insee": ["22", "29", "35", "56"],
"max_price": 200000
}
```
### Appartements pour investissement locatif à Rennes
```json
{
"transaction_type": "sale",
"property_type": ["apartment"],
"codes_insee": ["35238"],
"min_size": 40,
"max_size": 80,
"min_bedrooms": 2,
"target_yield": 6,
"dpe_classes": ["A", "B", "C", "D"]
}
```
### Immeubles de rapport en Ille-et-Vilaine et Morbihan
```json
{
"transaction_type": "sale",
"property_type": ["building"],
"codes_insee": ["35", "56"],
"max_price": 500000,
"target_yield": 8
}
```
### Maisons avec terrain divisible en Bretagne
```json
{
"transaction_type": "sale",
"property_type": ["house"],
"codes_insee": ["22", "29", "35", "56"],
"characteristics": [
{
"type": "all",
"items": ["has_land_division"]
}
]
}
```
IMPORTANT: Réponds UNIQUEMENT avec le JSON, sans texte avant ou après. Le JSON doit être valide et parsable. N'utilise JAMAIS de codes postaux, uniquement des codes département (2 chiffres) ou INSEE (5 chiffres)!
"""

View File

@ -6,15 +6,18 @@
"dependencies": {
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@tailwindcss/vite": "^4.1.14",
"@tanstack/react-query": "^5.90.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.545.0",
"react": "^19.1.1",
"react-day-picker": "^9.11.1",
"react-dom": "^19.1.1",
"react-hook-form": "^7.65.0",
"tailwind-merge": "^3.3.1",
@ -80,6 +83,8 @@
"@babel/types": ["@babel/types@7.28.4", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q=="],
"@date-fns/tz": ["@date-fns/tz@1.4.1", "", {}, "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA=="],
"@emnapi/core": ["@emnapi/core@1.5.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg=="],
"@emnapi/runtime": ["@emnapi/runtime@1.5.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ=="],
@ -170,6 +175,8 @@
"@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="],
"@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA=="],
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="],
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],
@ -182,7 +189,7 @@
"@radix-ui/react-select": ["@radix-ui/react-select@2.2.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ=="],
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
"@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="],
@ -362,6 +369,10 @@
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
"date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="],
"date-fns-jalali": ["date-fns-jalali@4.1.0-0", "", {}, "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
@ -548,6 +559,8 @@
"react": ["react@19.2.0", "", {}, "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ=="],
"react-day-picker": ["react-day-picker@9.11.1", "", { "dependencies": { "@date-fns/tz": "^1.4.1", "date-fns": "^4.1.0", "date-fns-jalali": "^4.1.0-0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-l3ub6o8NlchqIjPKrRFUCkTUEq6KwemQlfv3XZzzwpUeGwmDJ+0u0Upmt38hJyd7D/vn2dQoOoLV/qAp0o3uUw=="],
"react-dom": ["react-dom@19.2.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ=="],
"react-hook-form": ["react-hook-form@7.65.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-xtOzDz063WcXvGWaHgLNrNzlsdFgtUWcb32E6WFaGTd7kPZG3EeDusjdZfUsPwKCKVXy1ZlntifaHZ4l8pAsmw=="],
@ -630,6 +643,16 @@
"@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
"@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-popover/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-select/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@tailwindcss/node/lightningcss": ["lightningcss@1.30.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.30.1", "lightningcss-darwin-x64": "1.30.1", "lightningcss-freebsd-x64": "1.30.1", "lightningcss-linux-arm-gnueabihf": "1.30.1", "lightningcss-linux-arm64-gnu": "1.30.1", "lightningcss-linux-arm64-musl": "1.30.1", "lightningcss-linux-x64-gnu": "1.30.1", "lightningcss-linux-x64-musl": "1.30.1", "lightningcss-win32-arm64-msvc": "1.30.1", "lightningcss-win32-x64-msvc": "1.30.1" } }, "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.5.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg=="],

View File

@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<body class="dark">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>

View File

@ -12,15 +12,18 @@
"dependencies": {
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@tailwindcss/vite": "^4.1.14",
"@tanstack/react-query": "^5.90.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.545.0",
"react": "^19.1.1",
"react-day-picker": "^9.11.1",
"react-dom": "^19.1.1",
"react-hook-form": "^7.65.0",
"tailwind-merge": "^3.3.1",

View File

@ -1,81 +1,16 @@
import { useEffect, useState } from "react";
import { clearSessionToken, getSessionToken, setSessionToken } from "@/lib/api";
import { ProfilesTab } from "@/features/profiles/ProfilesTab";
import { ScrapersTab } from "@/features/scrapers/ScrapersTab";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { UsersTab } from "@/features/users/UsersTab";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
function SessionTokenPanel() {
const [token, setToken] = useState<string>(() => getSessionToken() ?? "");
const [feedback, setFeedback] = useState<string | null>(null);
useEffect(() => {
setToken(getSessionToken() ?? "");
}, []);
const handleSave = () => {
setSessionToken(token.trim());
setFeedback("Token sauvegarde.");
};
const handleClear = () => {
clearSessionToken();
setToken("");
setFeedback("Token efface.");
};
return (
<Card>
<CardHeader>
<CardTitle>Session API</CardTitle>
<CardDescription>
Fournissez le jeton recu depuis <code>/auth/login</code> pour authentifier les appels.
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-3 md:flex-row md:items-center">
<Input
placeholder="X-Session-Token"
value={token}
onChange={(event) => {
setToken(event.target.value);
setFeedback(null);
}}
/>
<div className="flex gap-2">
<Button type="button" onClick={handleSave}>
Sauvegarder
</Button>
<Button type="button" variant="outline" onClick={handleClear}>
Effacer
</Button>
</div>
</div>
{feedback ? (
<p className="text-muted-foreground mt-3 text-sm">{feedback}</p>
) : null}
</CardContent>
</Card>
);
}
function App() {
return (
<div className="mx-auto flex max-w-6xl flex-col gap-6 p-6">
<SessionTokenPanel />
<Tabs defaultValue="profiles" className="space-y-6">
<TabsList>
<TabsTrigger value="profiles">Profils</TabsTrigger>
<TabsTrigger value="scrapers">Scrapers</TabsTrigger>
<TabsTrigger value="users">Utilisateurs</TabsTrigger>
</TabsList>
<TabsContent value="profiles">
<ProfilesTab />
@ -83,6 +18,9 @@ function App() {
<TabsContent value="scrapers">
<ScrapersTab />
</TabsContent>
<TabsContent value="users">
<UsersTab />
</TabsContent>
</Tabs>
</div>
);

View File

@ -0,0 +1,214 @@
import * as React from "react"
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react"
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
}) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString("default", { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"flex gap-4 flex-col md:flex-row relative",
defaultClassNames.months
),
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
nav: cn(
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_next
),
month_caption: cn(
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
defaultClassNames.month_caption
),
dropdowns: cn(
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
defaultClassNames.dropdown_root
),
dropdown: cn(
"absolute bg-popover inset-0 opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
"select-none font-medium",
captionLayout === "label"
? "text-sm"
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
defaultClassNames.weekday
),
week: cn("flex w-full mt-2", defaultClassNames.week),
week_number_header: cn(
"select-none w-(--cell-size)",
defaultClassNames.week_number_header
),
week_number: cn(
"text-[0.8rem] select-none text-muted-foreground",
defaultClassNames.week_number
),
day: cn(
"relative w-full h-full p-0 text-center [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
props.showWeekNumber
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md"
: "[&:first-child[data-selected=true]_button]:rounded-l-md",
defaultClassNames.day
),
range_start: cn(
"rounded-l-md bg-accent",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
today: cn(
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
)
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
)
}
if (orientation === "right") {
return (
<ChevronRightIcon
className={cn("size-4", className)}
{...props}
/>
)
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
)
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
)
},
...components,
}}
{...props}
/>
)
}
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }

View File

@ -0,0 +1,46 @@
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View File

@ -1,6 +1,7 @@
import { useMemo, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowDown, ArrowUp, Sparkles } from "lucide-react";
import { ApiError, api } from "@/lib/api";
import { cn } from "@/lib/utils";
@ -16,6 +17,15 @@ import {
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { SchemaAwareJsonField } from "@/components/schema-builder";
import { getSchema } from "@/schemas/loader";
import { buildDefaultValue } from "@/schemas/utils";
@ -192,6 +202,8 @@ export function ProfilesTab() {
const [criteriaValid, setCriteriaValid] = useState(true);
const [editingProfileId, setEditingProfileId] = useState<string | null>(null);
const [statusMessage, setStatusMessage] = useState<string | null>(null);
const [isAiDialogOpen, setIsAiDialogOpen] = useState(false);
const [aiPrompt, setAiPrompt] = useState("");
const queryClient = useQueryClient();
const initialValues = useMemo(() => createDefaultValues(), []);
const form = useForm<ProfileFormValues>({
@ -243,6 +255,25 @@ export function ProfilesTab() {
},
});
const aiGenerateMutation = useMutation({
mutationFn: (prompt: string) =>
api.post<{ prompt: string }, JsonObject>("/ai/generate-profile", { prompt }),
onSuccess: (data) => {
if (data && Object.keys(data).length > 0) {
form.setValue("criteria", data);
setStatusMessage("Profil genere par IA avec succes.");
setIsAiDialogOpen(false);
setAiPrompt("");
}
},
});
const handleAiGenerate = () => {
if (aiPrompt.trim()) {
aiGenerateMutation.mutate(aiPrompt.trim());
}
};
const onSubmit = form.handleSubmit(async (values) => {
setStatusMessage(null);
if (editingProfileId) {
@ -259,14 +290,60 @@ export function ProfilesTab() {
const profiles = useMemo(() => profilesQuery.data ?? [], [profilesQuery.data]);
const scrollToTop = () => {
window.scrollTo({ top: 0, behavior: "instant" });
};
const scrollToList = () => {
const listElement = document.getElementById("profiles-list");
if (listElement) {
listElement.scrollIntoView({ behavior: "instant" });
}
};
return (
<div className="space-y-6">
<div className="fixed bottom-6 right-6 flex flex-col gap-2 z-50">
<Button
type="button"
size="icon"
variant="secondary"
className="rounded-full shadow-lg"
onClick={scrollToList}
title="Aller à la liste"
>
<ArrowDown className="h-4 w-4" />
</Button>
<Button
type="button"
size="icon"
variant="secondary"
className="rounded-full shadow-lg"
onClick={scrollToTop}
title="Retour en haut"
>
<ArrowUp className="h-4 w-4" />
</Button>
</div>
<Card>
<CardHeader>
<CardTitle>Creer un profil</CardTitle>
<CardDescription>
Renseignez les informations du profil ainsi que les criteres JSON a utiliser.
</CardDescription>
<div className="flex items-center justify-between">
<div>
<CardTitle>Creer un profil</CardTitle>
<CardDescription>
Renseignez les informations du profil ainsi que les criteres JSON a utiliser.
</CardDescription>
</div>
<Button
type="button"
variant="outline"
onClick={() => setIsAiDialogOpen(true)}
>
<Sparkles className="mr-2 h-4 w-4" />
Generer avec IA
</Button>
</div>
</CardHeader>
<CardContent>
<form className="space-y-6" onSubmit={onSubmit}>
@ -398,16 +475,34 @@ export function ProfilesTab() {
</CardContent>
</Card>
<Card>
<Card id="profiles-list">
<CardHeader>
<CardTitle>Profils existants</CardTitle>
<CardDescription>
{profilesQuery.isLoading
? "Chargement des profils..."
: profiles.length === 0
? "Aucun profil enregistre."
: "Liste recue depuis l'API."}
</CardDescription>
<div className="flex items-center justify-between">
<div>
<CardTitle>Profils existants</CardTitle>
<CardDescription>
{profilesQuery.isLoading
? "Chargement des profils..."
: profiles.length === 0
? "Aucun profil enregistre."
: "Liste recue depuis l'API."}
</CardDescription>
</div>
{profiles.length > 0 && (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
navigator.clipboard.writeText(JSON.stringify(profiles, null, 2));
setStatusMessage("Profils copies dans le presse-papier!");
setTimeout(() => setStatusMessage(null), 3000);
}}
>
Copier tous (JSON)
</Button>
)}
</div>
</CardHeader>
<CardContent className="overflow-x-auto">
{profilesQuery.isError ? (
@ -434,9 +529,23 @@ export function ProfilesTab() {
{profile.description ?? "-"}
</td>
<td className="border-b px-3 py-2">
<pre className="max-h-40 overflow-auto rounded bg-muted/50 p-2 text-xs">
{JSON.stringify(profile.criteria, null, 2)}
</pre>
<div className="space-y-2">
<pre className="max-h-40 overflow-auto rounded bg-muted/50 p-2 text-xs">
{JSON.stringify(profile.criteria, null, 2)}
</pre>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
navigator.clipboard.writeText(JSON.stringify(profile.criteria, null, 2));
setStatusMessage("JSON copié dans le presse-papier!");
setTimeout(() => setStatusMessage(null), 3000);
}}
>
Copier JSON
</Button>
</div>
</td>
<td className="border-b px-3 py-2">
<span
@ -509,6 +618,58 @@ export function ProfilesTab() {
)}
</CardContent>
</Card>
<Dialog
open={isAiDialogOpen}
onOpenChange={(open) => {
setIsAiDialogOpen(open);
if (!open) {
aiGenerateMutation.reset();
setAiPrompt("");
}
}}
>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Generer un profil avec l'IA</DialogTitle>
<DialogDescription>
Decrivez en langage naturel le type de profil d'investissement que vous souhaitez creer.
L'IA generera les criteres JSON correspondants.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<Textarea
placeholder="Ex: Je cherche des maisons en Bretagne a acheter, avec un budget maximum de 200000 euros, au moins 3 chambres, et un jardin..."
value={aiPrompt}
onChange={(e) => setAiPrompt(e.target.value)}
rows={5}
className="resize-none"
/>
<Button
onClick={handleAiGenerate}
disabled={aiGenerateMutation.isPending || !aiPrompt.trim()}
className="w-full"
>
<Sparkles className="mr-2 h-4 w-4" />
{aiGenerateMutation.isPending ? "Generation en cours..." : "Generer le profil"}
</Button>
{aiGenerateMutation.isError && (
<p className="text-destructive text-sm">
{getErrorMessage(aiGenerateMutation.error)}
</p>
)}
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Annuler</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -1,6 +1,8 @@
import { useMemo, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { format } from "date-fns";
import { CalendarIcon, ArrowDown, ArrowUp, Sparkles } from "lucide-react";
import { ApiError, api } from "@/lib/api";
import { cn } from "@/lib/utils";
@ -15,6 +17,13 @@ import {
} from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Calendar } from "@/components/ui/calendar";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { SchemaAwareJsonField } from "@/components/schema-builder";
import {
Dialog,
@ -119,6 +128,8 @@ function transformPayload(values: ScraperFormValues): CreateScraperPayload {
const payload: CreateScraperPayload = {};
const paramsObject = values.params ?? {};
console.log("transformPayload - paramsObject:", paramsObject);
console.log("transformPayload - paramsObject keys:", Object.keys(paramsObject));
if (Object.keys(paramsObject).length > 0) {
payload.params = JSON.stringify(paramsObject);
@ -205,6 +216,10 @@ export function ScrapersTab() {
const [editingScraperId, setEditingScraperId] = useState<string | null>(null);
const [statusMessage, setStatusMessage] = useState<string | null>(null);
const [isCountDialogOpen, setIsCountDialogOpen] = useState(false);
const [countFirstSeenDate, setCountFirstSeenDate] = useState<Date | undefined>(undefined);
const [countLastSeenDate, setCountLastSeenDate] = useState<Date | undefined>(undefined);
const [isAiDialogOpen, setIsAiDialogOpen] = useState(false);
const [aiPrompt, setAiPrompt] = useState("");
const queryClient = useQueryClient();
const initialValues = useMemo(() => createDefaultScraperValues(), []);
const form = useForm<ScraperFormValues>({
@ -262,13 +277,50 @@ export function ScrapersTab() {
type ScraperCountResponse = { count: number };
const countScraperMutation = useMutation({
mutationFn: (values: ScraperFormValues) =>
api.post<CreateScraperPayload, ScraperCountResponse>(
mutationFn: (payload: { values: ScraperFormValues; firstSeenDate?: Date; lastSeenDate?: Date }) => {
const basePayload = transformPayload(payload.values);
// Override first_seen_days and last_seen_days with date-based calculations if dates are provided
if (payload.firstSeenDate) {
const daysSinceFirstSeen = Math.floor((new Date().getTime() - payload.firstSeenDate.getTime()) / (1000 * 60 * 60 * 24));
basePayload.first_seen_days = daysSinceFirstSeen;
}
if (payload.lastSeenDate) {
const daysSinceLastSeen = Math.floor((new Date().getTime() - payload.lastSeenDate.getTime()) / (1000 * 60 * 60 * 24));
basePayload.last_seen_days = daysSinceLastSeen;
}
console.log("Count payload being sent:", basePayload);
return api.post<CreateScraperPayload, ScraperCountResponse>(
"/scrapers/count",
transformPayload(values),
),
basePayload,
);
},
});
type AiGenerateResponse = { params: JsonObject };
const aiGenerateMutation = useMutation({
mutationFn: (prompt: string) =>
api.post<{ prompt: string }, AiGenerateResponse>("/ai/generate-scraper", { prompt }),
onSuccess: (data) => {
if (data.params) {
form.setValue("params", data.params);
setStatusMessage("Scraper genere par IA avec succes.");
setIsAiDialogOpen(false);
setAiPrompt("");
}
},
});
const handleAiGenerate = () => {
if (aiPrompt.trim()) {
aiGenerateMutation.mutate(aiPrompt.trim());
}
};
const onSubmit = form.handleSubmit(async (values) => {
setStatusMessage(null);
if (editingScraperId) {
@ -297,8 +349,17 @@ export function ScrapersTab() {
}
countScraperMutation.reset();
setCountFirstSeenDate(undefined);
setCountLastSeenDate(undefined);
setIsCountDialogOpen(true);
countScraperMutation.mutate(form.getValues());
};
const handleCountWithDates = () => {
countScraperMutation.mutate({
values: form.getValues(),
firstSeenDate: countFirstSeenDate,
lastSeenDate: countLastSeenDate,
});
};
const scrapers = useMemo(
@ -306,15 +367,61 @@ export function ScrapersTab() {
[scrapersQuery.data],
);
const scrollToTop = () => {
window.scrollTo({ top: 0, behavior: "instant" });
};
const scrollToList = () => {
const listElement = document.getElementById("scrapers-list");
if (listElement) {
listElement.scrollIntoView({ behavior: "instant" });
}
};
return (
<div className="space-y-6">
<div className="fixed bottom-6 right-6 flex flex-col gap-2 z-50">
<Button
type="button"
size="icon"
variant="secondary"
className="rounded-full shadow-lg"
onClick={scrollToList}
title="Aller à la liste"
>
<ArrowDown className="h-4 w-4" />
</Button>
<Button
type="button"
size="icon"
variant="secondary"
className="rounded-full shadow-lg"
onClick={scrollToTop}
title="Retour en haut"
>
<ArrowUp className="h-4 w-4" />
</Button>
</div>
<Card>
<CardHeader>
<CardTitle>Creer un scraper</CardTitle>
<CardDescription>
Definissez la configuration et les parametres d'execution du
scraper.
</CardDescription>
<div className="flex items-center justify-between">
<div>
<CardTitle>Creer un scraper</CardTitle>
<CardDescription>
Definissez la configuration et les parametres d'execution du
scraper.
</CardDescription>
</div>
<Button
type="button"
variant="outline"
onClick={() => setIsAiDialogOpen(true)}
>
<Sparkles className="mr-2 h-4 w-4" />
Generer avec IA
</Button>
</div>
</CardHeader>
<CardContent>
<form className="space-y-6" onSubmit={onSubmit}>
@ -622,16 +729,34 @@ export function ScrapersTab() {
</CardContent>
</Card>
<Card>
<Card id="scrapers-list">
<CardHeader>
<CardTitle>Scrapers existants</CardTitle>
<CardDescription>
{scrapersQuery.isLoading
? "Chargement des scrapers..."
: scrapers.length === 0
? "Aucun scraper enregistre."
: "Liste recue depuis l'API."}
</CardDescription>
<div className="flex items-center justify-between">
<div>
<CardTitle>Scrapers existants</CardTitle>
<CardDescription>
{scrapersQuery.isLoading
? "Chargement des scrapers..."
: scrapers.length === 0
? "Aucun scraper enregistre."
: "Liste recue depuis l'API."}
</CardDescription>
</div>
{scrapers.length > 0 && (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
navigator.clipboard.writeText(JSON.stringify(scrapers, null, 2));
setStatusMessage("Scrapers copies dans le presse-papier!");
setTimeout(() => setStatusMessage(null), 3000);
}}
>
Copier tous (JSON)
</Button>
)}
</div>
</CardHeader>
<CardContent className="overflow-x-auto">
{scrapersQuery.isError ? (
@ -667,13 +792,29 @@ export function ScrapersTab() {
{scraper.frequency ?? "-"}
</td>
<td className="border-b px-3 py-2">
{paramsDisplay ? (
<pre className="max-h-40 overflow-auto rounded bg-muted/50 p-2 text-xs">
{paramsDisplay}
</pre>
) : (
<span className="text-muted-foreground">-</span>
)}
<div className="space-y-2">
{paramsDisplay ? (
<pre className="max-h-40 overflow-auto rounded bg-muted/50 p-2 text-xs">
{paramsDisplay}
</pre>
) : (
<span className="text-muted-foreground">-</span>
)}
{scraper.params && (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
navigator.clipboard.writeText(scraper.params || "");
setStatusMessage("JSON copié dans le presse-papier!");
setTimeout(() => setStatusMessage(null), 3000);
}}
>
Copier JSON
</Button>
)}
</div>
</td>
<td className="border-b px-3 py-2">
<div className="space-y-1 text-xs">
@ -792,40 +933,114 @@ export function ScrapersTab() {
setIsCountDialogOpen(open);
if (!open) {
countScraperMutation.reset();
setCountFirstSeenDate(undefined);
setCountLastSeenDate(undefined);
}
}}
>
<DialogContent>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Estimation du nombre d'annonces</DialogTitle>
<DialogDescription>
Calcul base sur la configuration actuelle du scraper.
Calcul base sur la configuration actuelle du scraper. Vous pouvez filtrer par période.
</DialogDescription>
</DialogHeader>
<div className="min-h-[80px]">
{countScraperMutation.isPending ? (
<p className="text-sm text-muted-foreground">
Calcul en cours...
</p>
) : countScraperMutation.isError ? (
<p className="text-destructive text-sm">
{getErrorMessage(countScraperMutation.error)}
</p>
) : countScraperMutation.data ? (
<p className="text-sm">
Ce scraper correspond a
<span className="font-semibold">
{" "}
{countScraperMutation.data.count}{" "}
</span>
annonce(s) potentielle(s).
</p>
) : (
<p className="text-muted-foreground text-sm">
Aucun calcul n'a encore ete effectue.
</p>
)}
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium">Date de première vue (min)</label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"w-full justify-start text-left font-normal",
!countFirstSeenDate && "text-muted-foreground"
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{countFirstSeenDate ? format(countFirstSeenDate, "PPP") : "Sélectionner une date"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0">
<Calendar
mode="single"
selected={countFirstSeenDate}
onSelect={setCountFirstSeenDate}
initialFocus
/>
</PopoverContent>
</Popover>
<p className="text-xs text-muted-foreground">
Annonces vues pour la première fois après cette date
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Date de dernière vue (max)</label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"w-full justify-start text-left font-normal",
!countLastSeenDate && "text-muted-foreground"
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{countLastSeenDate ? format(countLastSeenDate, "PPP") : "Sélectionner une date"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0">
<Calendar
mode="single"
selected={countLastSeenDate}
onSelect={setCountLastSeenDate}
initialFocus
/>
</PopoverContent>
</Popover>
<p className="text-xs text-muted-foreground">
Annonces vues pour la dernière fois avant cette date
</p>
</div>
</div>
<Button
onClick={handleCountWithDates}
disabled={countScraperMutation.isPending}
className="w-full"
>
{countScraperMutation.isPending ? "Calcul en cours..." : "Calculer le nombre d'annonces"}
</Button>
<div className="min-h-[60px] rounded-md border p-4">
{countScraperMutation.isPending ? (
<p className="text-sm text-muted-foreground">
Calcul en cours...
</p>
) : countScraperMutation.isError ? (
<p className="text-destructive text-sm">
{getErrorMessage(countScraperMutation.error)}
</p>
) : countScraperMutation.data ? (
<p className="text-sm">
Ce scraper correspond a
<span className="font-semibold text-lg">
{" "}
{countScraperMutation.data.count}{" "}
</span>
annonce(s) potentielle(s).
</p>
) : (
<p className="text-muted-foreground text-sm">
Cliquez sur "Calculer" pour obtenir le nombre d'annonces.
</p>
)}
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Fermer</Button>
@ -833,6 +1048,58 @@ export function ScrapersTab() {
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={isAiDialogOpen}
onOpenChange={(open) => {
setIsAiDialogOpen(open);
if (!open) {
aiGenerateMutation.reset();
setAiPrompt("");
}
}}
>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Generer un scraper avec l'IA</DialogTitle>
<DialogDescription>
Decrivez en langage naturel le type de scraper que vous souhaitez creer.
L'IA generera les parametres JSON correspondants.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<Textarea
placeholder="Ex: Je veux un scraper pour les maisons en Bretagne (departements 22, 29, 35, 56) a acheter, avec un prix maximum de 200000 euros..."
value={aiPrompt}
onChange={(e) => setAiPrompt(e.target.value)}
rows={5}
className="resize-none"
/>
<Button
onClick={handleAiGenerate}
disabled={aiGenerateMutation.isPending || !aiPrompt.trim()}
className="w-full"
>
<Sparkles className="mr-2 h-4 w-4" />
{aiGenerateMutation.isPending ? "Generation en cours..." : "Generer le scraper"}
</Button>
{aiGenerateMutation.isError && (
<p className="text-destructive text-sm">
{getErrorMessage(aiGenerateMutation.error)}
</p>
)}
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Annuler</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -0,0 +1,611 @@
import { useMemo, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ApiError, api } from "@/lib/api";
import type { Profile } from "@/features/profiles/ProfilesTab";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
export interface User {
id: number;
username: string;
first_name: string;
last_name: string;
email: string;
is_superuser: boolean;
is_staff: boolean;
is_active: boolean;
date_joined: string;
last_login: string | null;
profiles: Profile[];
}
interface UserFormValues {
username: string;
email: string;
first_name: string;
last_name: string;
password: string;
is_superuser: boolean;
is_staff: boolean;
is_active: boolean;
date_joined: string;
profile_ids: string[];
}
interface CreateUserPayload {
username: string;
email: string;
first_name: string;
last_name: string;
password?: string;
is_superuser?: boolean;
is_staff?: boolean;
is_active?: boolean;
date_joined?: string;
profile_ids?: string[];
}
function getErrorMessage(error: unknown) {
if (error instanceof ApiError) {
if (typeof error.payload === "string" && error.payload.trim() !== "") {
return error.payload;
}
if (error.payload && typeof error.payload === "object") {
try {
return JSON.stringify(error.payload);
} catch (stringifyError) {
return (stringifyError as Error).message;
}
}
return `Requete rejetee (${error.status}).`;
}
if (error instanceof Error) {
return error.message;
}
return "Erreur inconnue.";
}
function transformPayload(values: UserFormValues, isEdit: boolean): CreateUserPayload {
const payload: CreateUserPayload = {
username: values.username.trim(),
email: values.email.trim(),
first_name: values.first_name.trim(),
last_name: values.last_name.trim(),
is_superuser: values.is_superuser,
is_staff: values.is_staff,
is_active: values.is_active,
};
// Only include password if provided (required for create, optional for edit)
if (values.password && values.password.trim()) {
payload.password = values.password.trim();
}
if (values.date_joined) {
const isoDate = new Date(values.date_joined);
if (!Number.isNaN(isoDate.getTime())) {
payload.date_joined = isoDate.toISOString();
}
}
// Include profile_ids
if (values.profile_ids && values.profile_ids.length > 0) {
payload.profile_ids = values.profile_ids;
}
return payload;
}
function createDefaultValues(): UserFormValues {
return {
username: "",
email: "",
first_name: "",
last_name: "",
password: "",
is_superuser: false,
is_staff: false,
is_active: true,
date_joined: "",
profile_ids: [],
};
}
export function UsersTab() {
const [editingUserId, setEditingUserId] = useState<number | null>(null);
const [statusMessage, setStatusMessage] = useState<string | null>(null);
const queryClient = useQueryClient();
const initialValues = useMemo(() => createDefaultValues(), []);
const form = useForm<UserFormValues>({
defaultValues: initialValues,
mode: "onBlur",
});
const usersQuery = useQuery({
queryKey: ["users"],
queryFn: () => api.get<User[]>("/users"),
});
const profilesQuery = useQuery({
queryKey: ["profiles"],
queryFn: () => api.get<Profile[]>("/profiles"),
});
const createUserMutation = useMutation({
mutationFn: (values: UserFormValues) =>
api.post<CreateUserPayload, User>("/users", transformPayload(values, false)),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["users"] });
setEditingUserId(null);
form.reset(createDefaultValues());
setStatusMessage("Utilisateur cree avec succes.");
},
});
const updateUserMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: UserFormValues }) => {
const payload = transformPayload(data, true);
return api.put<CreateUserPayload, User>(`/users/${id}`, payload);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["users"] });
setEditingUserId(null);
form.reset(createDefaultValues());
setStatusMessage("Utilisateur mis a jour.");
},
});
const deleteUserMutation = useMutation({
mutationFn: (id: number) => api.delete(`/users/${id}`),
onSuccess: (_, id) => {
if (editingUserId === id) {
setEditingUserId(null);
form.reset(createDefaultValues());
}
queryClient.invalidateQueries({ queryKey: ["users"] });
setStatusMessage("Utilisateur supprime.");
},
});
const onSubmit = form.handleSubmit(async (values) => {
setStatusMessage(null);
if (editingUserId) {
await updateUserMutation.mutateAsync({ id: editingUserId, data: values });
} else {
await createUserMutation.mutateAsync(values);
}
});
const formError =
(createUserMutation.isError && getErrorMessage(createUserMutation.error)) ||
(updateUserMutation.isError && getErrorMessage(updateUserMutation.error)) ||
null;
const users = useMemo(() => usersQuery.data ?? [], [usersQuery.data]);
const profiles = useMemo(() => profilesQuery.data ?? [], [profilesQuery.data]);
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Creer un utilisateur</CardTitle>
<CardDescription>
Renseignez les informations de l'utilisateur Django et selectionnez ses profils.
</CardDescription>
</CardHeader>
<CardContent>
<form className="space-y-6" onSubmit={onSubmit}>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-1">
<label className="text-sm font-medium" htmlFor="username">
Nom d'utilisateur
</label>
<Input
id="username"
aria-invalid={Boolean(form.formState.errors.username)}
placeholder="johndoe"
{...form.register("username", {
required: "Le nom d'utilisateur est obligatoire.",
})}
/>
{form.formState.errors.username ? (
<p className="text-destructive text-xs">
{form.formState.errors.username.message}
</p>
) : null}
</div>
<div className="space-y-1">
<label className="text-sm font-medium" htmlFor="email">
Email
</label>
<Input
id="email"
type="email"
aria-invalid={Boolean(form.formState.errors.email)}
placeholder="john@example.com"
{...form.register("email", { required: "L'email est obligatoire." })}
/>
{form.formState.errors.email ? (
<p className="text-destructive text-xs">
{form.formState.errors.email.message}
</p>
) : null}
</div>
<div className="space-y-1">
<label className="text-sm font-medium" htmlFor="first_name">
Prenom
</label>
<Input
id="first_name"
placeholder="John"
{...form.register("first_name", { required: "Le prenom est obligatoire." })}
/>
{form.formState.errors.first_name ? (
<p className="text-destructive text-xs">
{form.formState.errors.first_name.message}
</p>
) : null}
</div>
<div className="space-y-1">
<label className="text-sm font-medium" htmlFor="last_name">
Nom
</label>
<Input
id="last_name"
placeholder="Doe"
{...form.register("last_name", { required: "Le nom est obligatoire." })}
/>
{form.formState.errors.last_name ? (
<p className="text-destructive text-xs">
{form.formState.errors.last_name.message}
</p>
) : null}
</div>
<div className="space-y-1 md:col-span-2">
<label className="text-sm font-medium" htmlFor="password">
Mot de passe
</label>
<Input
id="password"
type="password"
placeholder={editingUserId ? "(laisser vide pour ne pas modifier)" : "Mot de passe"}
{...form.register("password", {
required: editingUserId ? false : "Le mot de passe est obligatoire.",
})}
/>
{form.formState.errors.password ? (
<p className="text-destructive text-xs">
{form.formState.errors.password.message}
</p>
) : null}
<p className="text-muted-foreground text-xs">
{editingUserId
? "Laissez vide pour conserver le mot de passe actuel."
: "Le mot de passe sera hashe avec Django (pbkdf2_sha256)."}
</p>
</div>
<div className="space-y-1">
<label className="text-sm font-medium" htmlFor="date_joined">
Date d'inscription (optionnelle)
</label>
<Input
id="date_joined"
type="datetime-local"
step="1"
{...form.register("date_joined")}
/>
<p className="text-muted-foreground text-xs">
Laisse vide pour utiliser la date actuelle.
</p>
</div>
<div className="space-y-1">
<label className="text-sm font-medium">Profils d'investissement</label>
<Controller
control={form.control}
name="profile_ids"
render={({ field }) => (
<div className="space-y-2 rounded border p-3 max-h-40 overflow-y-auto">
{profiles.length === 0 ? (
<p className="text-muted-foreground text-sm">
Aucun profil disponible
</p>
) : (
profiles.map((profile) => (
<div key={profile.profile_id} className="flex items-center gap-2">
<Checkbox
id={`profile-${profile.profile_id}`}
checked={field.value.includes(profile.profile_id)}
onCheckedChange={(checked) => {
if (checked) {
field.onChange([...field.value, profile.profile_id]);
} else {
field.onChange(
field.value.filter((id) => id !== profile.profile_id)
);
}
}}
/>
<label
htmlFor={`profile-${profile.profile_id}`}
className="text-sm cursor-pointer"
>
{profile.name}
</label>
</div>
))
)}
</div>
)}
/>
<p className="text-muted-foreground text-xs">
Selectionnez les profils a associer a cet utilisateur.
</p>
</div>
<div className="grid gap-4 md:col-span-2 md:grid-cols-3">
<Controller
control={form.control}
name="is_active"
render={({ field }) => (
<div className="space-y-1">
<label className="flex items-center gap-2 text-sm font-medium">
<Checkbox
checked={field.value}
onCheckedChange={(checked) => field.onChange(Boolean(checked))}
/>
Actif
</label>
<p className="text-xs text-muted-foreground ml-6">
Permet a l'utilisateur de se connecter
</p>
</div>
)}
/>
<Controller
control={form.control}
name="is_staff"
render={({ field }) => (
<div className="space-y-1">
<label className="flex items-center gap-2 text-sm font-medium">
<Checkbox
checked={field.value}
onCheckedChange={(checked) => field.onChange(Boolean(checked))}
/>
Staff
</label>
<p className="text-xs text-muted-foreground ml-6">
Acces a l'interface d'administration Django
</p>
</div>
)}
/>
<Controller
control={form.control}
name="is_superuser"
render={({ field }) => (
<div className="space-y-1">
<label className="flex items-center gap-2 text-sm font-medium">
<Checkbox
checked={field.value}
onCheckedChange={(checked) => field.onChange(Boolean(checked))}
/>
Superutilisateur
</label>
<p className="text-xs text-muted-foreground ml-6">
Droits complets sur tous les objets
</p>
</div>
)}
/>
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button
type="submit"
disabled={
editingUserId
? updateUserMutation.isPending
: createUserMutation.isPending
}
>
{editingUserId
? updateUserMutation.isPending
? "Mise a jour..."
: "Mettre a jour l'utilisateur"
: createUserMutation.isPending
? "Creation..."
: "Creer l'utilisateur"}
</Button>
{statusMessage ? (
<p className="text-emerald-600 text-sm">{statusMessage}</p>
) : null}
{formError ? <p className="text-destructive text-sm">{formError}</p> : null}
{editingUserId ? (
<Button
type="button"
variant="outline"
onClick={() => {
setStatusMessage(null);
setEditingUserId(null);
form.reset(createDefaultValues());
}}
>
Annuler la modification
</Button>
) : null}
</div>
</form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Utilisateurs existants</CardTitle>
<CardDescription>
{usersQuery.isLoading
? "Chargement des utilisateurs..."
: users.length === 0
? "Aucun utilisateur enregistre."
: "Liste recue depuis l'API."}
</CardDescription>
</CardHeader>
<CardContent className="overflow-x-auto">
{usersQuery.isError ? (
<p className="text-destructive text-sm">
{getErrorMessage(usersQuery.error)}
</p>
) : (
<table className="min-w-full table-fixed border-collapse text-sm">
<thead>
<tr className="text-left">
<th className="border-b px-3 py-2 font-semibold">ID</th>
<th className="border-b px-3 py-2 font-semibold">Utilisateur</th>
<th className="border-b px-3 py-2 font-semibold">Email</th>
<th className="border-b px-3 py-2 font-semibold">Profils</th>
<th className="border-b px-3 py-2 font-semibold">Statut</th>
<th className="border-b px-3 py-2 font-semibold">Date d'inscription</th>
<th className="border-b px-3 py-2 font-semibold">Actions</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id} className="align-top">
<td className="border-b px-3 py-2 font-medium">{user.id}</td>
<td className="border-b px-3 py-2">
<div>
<p className="font-medium">{user.username}</p>
<p className="text-xs text-muted-foreground">
{user.first_name} {user.last_name}
</p>
</div>
</td>
<td className="border-b px-3 py-2 text-muted-foreground">
{user.email}
</td>
<td className="border-b px-3 py-2">
{user.profiles.length > 0 ? (
<div className="space-y-1">
{user.profiles.map((profile) => (
<span
key={profile.profile_id}
className="inline-block rounded bg-blue-100 px-2 py-0.5 text-xs text-blue-700 mr-1"
>
{profile.name}
</span>
))}
</div>
) : (
<span className="text-muted-foreground text-xs">
Aucun profil
</span>
)}
</td>
<td className="border-b px-3 py-2">
<div className="space-y-1 text-xs">
<span
className={cn(
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 font-medium",
user.is_active
? "bg-emerald-100 text-emerald-700"
: "bg-gray-200 text-gray-600"
)}
>
{user.is_active ? "Actif" : "Inactif"}
</span>
{user.is_superuser ? (
<span className="block text-red-600 font-medium">
Superuser
</span>
) : null}
{user.is_staff ? (
<span className="block text-purple-600">Staff</span>
) : null}
</div>
</td>
<td className="border-b px-3 py-2 text-muted-foreground text-xs">
{new Date(user.date_joined).toLocaleString()}
</td>
<td className="border-b px-3 py-2">
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setStatusMessage(null);
setEditingUserId(user.id);
const dateJoinedInput = user.date_joined
? new Date(user.date_joined).toISOString().slice(0, 16)
: "";
form.reset({
username: user.username,
email: user.email,
first_name: user.first_name,
last_name: user.last_name,
password: "",
is_superuser: user.is_superuser,
is_staff: user.is_staff,
is_active: user.is_active,
date_joined: dateJoinedInput,
profile_ids: user.profiles.map((p) => p.profile_id),
});
}}
>
Modifier
</Button>
<Button
type="button"
variant="destructive"
size="sm"
disabled={deleteUserMutation.isPending}
onClick={() => {
if (
window.confirm(
"Confirmez la suppression de cet utilisateur ?"
)
) {
deleteUserMutation.mutate(user.id);
}
}}
>
Supprimer
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</CardContent>
</Card>
</div>
);
}

View File

@ -388,6 +388,17 @@ export const profileSchema: SchemaDefinition = {
description: "Code INSEE de la commune",
},
},
{
key: "codes_insee",
required: false,
schema: {
kind: "array",
required: false,
element: { kind: "primitive", type: "string", required: false },
label: "Codes INSEE",
description: "Liste des codes INSEE des communes recherchées",
},
},
{
key: "target_yield",
required: false,