ia
This commit is contained in:
parent
072fc047a4
commit
0d08b60d07
@ -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
|
||||
|
||||
|
||||
BIN
backend/__pycache__/schema_docs.cpython-313.pyc
Normal file
BIN
backend/__pycache__/schema_docs.cpython-313.pyc
Normal file
Binary file not shown.
393
backend/app.py
393
backend/app.py
@ -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)
|
||||
|
||||
@ -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
488
backend/schema_docs.py
Normal 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 m²
|
||||
- `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 m²
|
||||
- `surface.livingSpace`: { "min": number, "max": number } - Surface habitable m²
|
||||
- `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 m²
|
||||
- `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 m²
|
||||
"max_size": number, // Surface maximale m²
|
||||
"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 m²
|
||||
- `max_size`: number - Surface maximale en m²
|
||||
|
||||
### 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)!
|
||||
"""
|
||||
@ -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=="],
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
214
frontend/src/components/ui/calendar.tsx
Normal file
214
frontend/src/components/ui/calendar.tsx
Normal 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 }
|
||||
46
frontend/src/components/ui/popover.tsx
Normal file
46
frontend/src/components/ui/popover.tsx
Normal 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 }
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
611
frontend/src/features/users/UsersTab.tsx
Normal file
611
frontend/src/features/users/UsersTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user