diff --git a/frontend/app/admin/pricing/page.tsx b/frontend/app/admin/pricing/page.tsx new file mode 100644 index 0000000..9db4a0d --- /dev/null +++ b/frontend/app/admin/pricing/page.tsx @@ -0,0 +1,403 @@ +"use client"; + +export const dynamic = "force-dynamic"; + +import { useEffect, useState, useCallback } from "react"; +import { Permission } from "../../auth-context"; +import { adminApi } from "../../api"; +import { PageLayout } from "../../components/PageLayout"; +import { ConfirmationButton } from "../../components/ConfirmationButton"; +import { useRequireAuth } from "../../hooks/useRequireAuth"; +import { useMutation } from "../../hooks/useMutation"; +import { useTranslation } from "../../hooks/useTranslation"; +import { components } from "../../generated/api"; +import { cardStyles, formStyles, buttonStyles, bannerStyles } from "../../styles/shared"; + +type PricingConfigUpdate = components["schemas"]["PricingConfigUpdate"]; + +interface FormData { + premium_buy: number; + premium_sell: number; + small_trade_threshold_eur: number; + small_trade_extra_premium: number; + eur_min_buy: number; + eur_max_buy: number; + eur_min_sell: number; + eur_max_sell: number; +} + +interface ValidationErrors { + premium_buy?: string; + premium_sell?: string; + small_trade_threshold_eur?: string; + small_trade_extra_premium?: string; + eur_min_buy?: string; + eur_max_buy?: string; + eur_min_sell?: string; + eur_max_sell?: string; +} + +export default function AdminPricingPage() { + const t = useTranslation("admin"); + const tCommon = useTranslation("common"); + const { user, isLoading, isAuthorized } = useRequireAuth({ + requiredPermission: Permission.MANAGE_PRICING, + fallbackRedirect: "/", + }); + + const [formData, setFormData] = useState(null); + const [errors, setErrors] = useState({}); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + + const fetchConfig = useCallback(async () => { + setError(null); + try { + const data = await adminApi.getPricingConfig(); + setFormData({ + premium_buy: data.premium_buy, + premium_sell: data.premium_sell, + small_trade_threshold_eur: data.small_trade_threshold_eur, + small_trade_extra_premium: data.small_trade_extra_premium, + eur_min_buy: data.eur_min_buy, + eur_max_buy: data.eur_max_buy, + eur_min_sell: data.eur_min_sell, + eur_max_sell: data.eur_max_sell, + }); + } catch (err) { + setError(err instanceof Error ? err.message : t("pricing.errors.loadFailed")); + } + }, [t]); + + useEffect(() => { + if (user && isAuthorized) { + fetchConfig(); + } + }, [user, isAuthorized, fetchConfig]); + + const validateField = (field: keyof FormData, value: number): string | undefined => { + switch (field) { + case "premium_buy": + case "premium_sell": + case "small_trade_extra_premium": + if (value < -100 || value > 100) { + return t("pricing.validation.premiumRange"); + } + break; + case "eur_min_buy": + case "eur_min_sell": + case "eur_max_buy": + case "eur_max_sell": + case "small_trade_threshold_eur": + if (value <= 0) { + return t("pricing.validation.positive"); + } + break; + } + return undefined; + }; + + const validateForm = (data: FormData): ValidationErrors => { + const newErrors: ValidationErrors = {}; + + // Validate individual fields + for (const [key, value] of Object.entries(data)) { + const error = validateField(key as keyof FormData, value); + if (error) { + newErrors[key as keyof ValidationErrors] = error; + } + } + + // Validate min < max + if (data.eur_min_buy >= data.eur_max_buy) { + newErrors.eur_min_buy = t("pricing.validation.minMaxBuy"); + newErrors.eur_max_buy = t("pricing.validation.minMaxBuy"); + } + if (data.eur_min_sell >= data.eur_max_sell) { + newErrors.eur_min_sell = t("pricing.validation.minMaxSell"); + newErrors.eur_max_sell = t("pricing.validation.minMaxSell"); + } + + return newErrors; + }; + + const handleFieldChange = (field: keyof FormData, value: string) => { + if (!formData) return; + + const numValue = parseInt(value, 10); + if (isNaN(numValue)) return; + + const newData = { ...formData, [field]: numValue }; + setFormData(newData); + + // Clear error for this field + const newErrors = { ...errors }; + delete newErrors[field]; + setErrors(newErrors); + + // Validate field + const error = validateField(field, numValue); + if (error) { + newErrors[field] = error; + } + + // Validate min < max if relevant + if (field === "eur_min_buy" || field === "eur_max_buy") { + if (newData.eur_min_buy >= newData.eur_max_buy) { + newErrors.eur_min_buy = t("pricing.validation.minMaxBuy"); + newErrors.eur_max_buy = t("pricing.validation.minMaxBuy"); + } else { + delete newErrors.eur_min_buy; + delete newErrors.eur_max_buy; + } + } + if (field === "eur_min_sell" || field === "eur_max_sell") { + if (newData.eur_min_sell >= newData.eur_max_sell) { + newErrors.eur_min_sell = t("pricing.validation.minMaxSell"); + newErrors.eur_max_sell = t("pricing.validation.minMaxSell"); + } else { + delete newErrors.eur_min_sell; + delete newErrors.eur_max_sell; + } + } + + setErrors(newErrors); + }; + + const { + mutate: updateConfig, + isLoading: isSaving, + error: saveError, + } = useMutation((data: PricingConfigUpdate) => adminApi.updatePricingConfig(data), { + onSuccess: () => { + setSuccess(true); + setError(null); + fetchConfig(); + setTimeout(() => setSuccess(false), 3000); + }, + onError: (err) => { + setError(err instanceof Error ? err.message : t("pricing.errors.saveFailed")); + }, + }); + + const handleSave = () => { + if (!formData) return; + + const validationErrors = validateForm(formData); + if (Object.keys(validationErrors).length > 0) { + setErrors(validationErrors); + return; + } + + updateConfig({ + premium_buy: formData.premium_buy, + premium_sell: formData.premium_sell, + small_trade_threshold_eur: formData.small_trade_threshold_eur, + small_trade_extra_premium: formData.small_trade_extra_premium, + eur_min_buy: formData.eur_min_buy, + eur_max_buy: formData.eur_max_buy, + eur_min_sell: formData.eur_min_sell, + eur_max_sell: formData.eur_max_sell, + }); + }; + + if (isLoading || !formData) { + return ( + +
+ + ); + } + + const hasErrors = Object.keys(errors).length > 0; + const displayError = error || saveError; + + return ( + +
+

{t("pricing.title")}

+

{t("pricing.subtitle")}

+ + {success &&
{t("pricing.success")}
} + +
{ + e.preventDefault(); + handleSave(); + }} + > +
+

{t("pricing.premiumSettings")}

+ +
+ + handleFieldChange("premium_buy", e.target.value)} + min={-100} + max={100} + style={{ + ...formStyles.input, + ...(errors.premium_buy ? formStyles.inputError : {}), + }} + /> + {errors.premium_buy &&
{errors.premium_buy}
} +
+ +
+ + handleFieldChange("premium_sell", e.target.value)} + min={-100} + max={100} + style={{ + ...formStyles.input, + ...(errors.premium_sell ? formStyles.inputError : {}), + }} + /> + {errors.premium_sell &&
{errors.premium_sell}
} +
+ +
+ + + handleFieldChange( + "small_trade_threshold_eur", + (parseFloat(e.target.value) * 100).toString() + ) + } + min={1} + style={{ + ...formStyles.input, + ...(errors.small_trade_threshold_eur ? formStyles.inputError : {}), + }} + /> + {errors.small_trade_threshold_eur && ( +
{errors.small_trade_threshold_eur}
+ )} +
+ +
+ + handleFieldChange("small_trade_extra_premium", e.target.value)} + min={-100} + max={100} + style={{ + ...formStyles.input, + ...(errors.small_trade_extra_premium ? formStyles.inputError : {}), + }} + /> + {errors.small_trade_extra_premium && ( +
{errors.small_trade_extra_premium}
+ )} +
+
+ +
+

{t("pricing.tradeLimitsBuy")}

+ +
+ + + handleFieldChange("eur_min_buy", (parseFloat(e.target.value) * 100).toString()) + } + min={1} + style={{ + ...formStyles.input, + ...(errors.eur_min_buy ? formStyles.inputError : {}), + }} + /> + {errors.eur_min_buy &&
{errors.eur_min_buy}
} +
+ +
+ + + handleFieldChange("eur_max_buy", (parseFloat(e.target.value) * 100).toString()) + } + min={1} + style={{ + ...formStyles.input, + ...(errors.eur_max_buy ? formStyles.inputError : {}), + }} + /> + {errors.eur_max_buy &&
{errors.eur_max_buy}
} +
+
+ +
+

{t("pricing.tradeLimitsSell")}

+ +
+ + + handleFieldChange("eur_min_sell", (parseFloat(e.target.value) * 100).toString()) + } + min={1} + style={{ + ...formStyles.input, + ...(errors.eur_min_sell ? formStyles.inputError : {}), + }} + /> + {errors.eur_min_sell &&
{errors.eur_min_sell}
} +
+ +
+ + + handleFieldChange("eur_max_sell", (parseFloat(e.target.value) * 100).toString()) + } + min={1} + style={{ + ...formStyles.input, + ...(errors.eur_max_sell ? formStyles.inputError : {}), + }} + /> + {errors.eur_max_sell &&
{errors.eur_max_sell}
} +
+
+ +
+ + {isSaving ? tCommon("saving") : t("pricing.save")} + +
+
+
+
+ ); +} diff --git a/frontend/app/api/admin.ts b/frontend/app/api/admin.ts index 93cd1a4..8d632e2 100644 --- a/frontend/app/api/admin.ts +++ b/frontend/app/api/admin.ts @@ -6,6 +6,8 @@ type PaginatedInvites = components["schemas"]["PaginatedResponse_InviteResponse_ type AdminExchangeResponse = components["schemas"]["AdminExchangeResponse"]; type AvailabilityResponse = components["schemas"]["AvailabilityResponse"]; type PriceHistoryRecord = components["schemas"]["PriceHistoryResponse"]; +type PricingConfigResponse = components["schemas"]["PricingConfigResponse"]; +type PricingConfigUpdate = components["schemas"]["PricingConfigUpdate"]; interface CreateInviteRequest { godfather_id: number; @@ -153,4 +155,18 @@ export const adminApi = { fetchPrice(): Promise { return client.post("/api/audit/price-history/fetch", {}); }, + + /** + * Get current pricing configuration + */ + getPricingConfig(): Promise { + return client.get("/api/admin/pricing"); + }, + + /** + * Update pricing configuration + */ + updatePricingConfig(request: PricingConfigUpdate): Promise { + return client.put("/api/admin/pricing", request); + }, }; diff --git a/frontend/app/auth-context.tsx b/frontend/app/auth-context.tsx index a12b73c..d2fa0e2 100644 --- a/frontend/app/auth-context.tsx +++ b/frontend/app/auth-context.tsx @@ -24,6 +24,7 @@ export const Permission: Record = { CANCEL_OWN_EXCHANGE: "cancel_own_exchange", // Availability/Exchange permissions (admin) MANAGE_AVAILABILITY: "manage_availability", + MANAGE_PRICING: "manage_pricing", VIEW_ALL_EXCHANGES: "view_all_exchanges", CANCEL_ANY_EXCHANGE: "cancel_any_exchange", COMPLETE_EXCHANGE: "complete_exchange", diff --git a/frontend/app/components/Header.tsx b/frontend/app/components/Header.tsx index 6929e64..530c3c4 100644 --- a/frontend/app/components/Header.tsx +++ b/frontend/app/components/Header.tsx @@ -17,7 +17,8 @@ type PageId = | "admin-invites" | "admin-availability" | "admin-trades" - | "admin-price-history"; + | "admin-price-history" + | "admin-pricing"; interface HeaderProps { currentPage: PageId; @@ -48,6 +49,7 @@ const ADMIN_NAV_ITEMS: NavItem[] = [ }, { id: "admin-invites", labelKey: "invites", href: "/admin/invites", adminOnly: true }, { id: "admin-price-history", labelKey: "prices", href: "/admin/price-history", adminOnly: true }, + { id: "admin-pricing", labelKey: "pricing", href: "/admin/pricing", adminOnly: true }, ]; export function Header({ currentPage }: HeaderProps) { diff --git a/frontend/app/components/PageLayout.tsx b/frontend/app/components/PageLayout.tsx index 00023c5..99ec42e 100644 --- a/frontend/app/components/PageLayout.tsx +++ b/frontend/app/components/PageLayout.tsx @@ -12,7 +12,8 @@ type PageId = | "admin-invites" | "admin-availability" | "admin-trades" - | "admin-price-history"; + | "admin-price-history" + | "admin-pricing"; interface PageLayoutProps { /** Current page ID for navigation highlighting */ diff --git a/frontend/locales/ca/admin.json b/frontend/locales/ca/admin.json index 12ece06..69a9f1f 100644 --- a/frontend/locales/ca/admin.json +++ b/frontend/locales/ca/admin.json @@ -109,5 +109,31 @@ "clearFailed": "Error en netejar", "copyFailed": "Error en copiar" } + }, + "pricing": { + "title": "Configuració de Preus", + "subtitle": "Configura els preus de prima i els límits d'import de les operacions", + "premiumSettings": "Configuració de Prima", + "premiumBuy": "Prima per COMPRAR", + "premiumSell": "Prima per VENDRE", + "smallTradeThreshold": "Umbral d'Operacions Petites", + "smallTradeExtraPremium": "Prima Extra per Operacions Petites", + "tradeLimitsBuy": "Límits d'Import d'Operacions (COMPRAR)", + "tradeLimitsSell": "Límits d'Import d'Operacions (VENDRE)", + "minAmount": "Import Mínim", + "maxAmount": "Import Màxim", + "save": "Guardar Canvis", + "success": "Configuració de preus guardada correctament", + "confirmSave": "Estàs segur que vols guardar aquests canvis de preus? Això afectarà totes les noves operacions immediatament.", + "validation": { + "premiumRange": "La prima ha de ser entre -100% i 100%", + "positive": "L'import ha de ser positiu", + "minMaxBuy": "El mínim ha de ser menor que el màxim per COMPRAR", + "minMaxSell": "El mínim ha de ser menor que el màxim per VENDRE" + }, + "errors": { + "loadFailed": "Error en carregar la configuració de preus", + "saveFailed": "Error en guardar la configuració de preus" + } } } diff --git a/frontend/locales/ca/navigation.json b/frontend/locales/ca/navigation.json index db6e10d..02fbf94 100644 --- a/frontend/locales/ca/navigation.json +++ b/frontend/locales/ca/navigation.json @@ -7,5 +7,6 @@ "trades": "Operacions", "availability": "Disponibilitat", "invites": "Invitacions", - "prices": "Preus" + "prices": "Preus", + "pricing": "Preus" } diff --git a/frontend/locales/en/admin.json b/frontend/locales/en/admin.json index d2ce2f4..548dc2e 100644 --- a/frontend/locales/en/admin.json +++ b/frontend/locales/en/admin.json @@ -109,5 +109,31 @@ "clearFailed": "Failed to clear", "copyFailed": "Failed to copy" } + }, + "pricing": { + "title": "Pricing Configuration", + "subtitle": "Configure premium pricing and trade amount limits", + "premiumSettings": "Premium Settings", + "premiumBuy": "Premium for BUY", + "premiumSell": "Premium for SELL", + "smallTradeThreshold": "Small Trade Threshold", + "smallTradeExtraPremium": "Extra Premium for Small Trades", + "tradeLimitsBuy": "Trade Amount Limits (BUY)", + "tradeLimitsSell": "Trade Amount Limits (SELL)", + "minAmount": "Minimum Amount", + "maxAmount": "Maximum Amount", + "save": "Save Changes", + "success": "Pricing configuration saved successfully", + "confirmSave": "Are you sure you want to save these pricing changes? This will affect all new trades immediately.", + "validation": { + "premiumRange": "Premium must be between -100% and 100%", + "positive": "Amount must be positive", + "minMaxBuy": "Minimum must be less than maximum for BUY", + "minMaxSell": "Minimum must be less than maximum for SELL" + }, + "errors": { + "loadFailed": "Failed to load pricing configuration", + "saveFailed": "Failed to save pricing configuration" + } } } diff --git a/frontend/locales/en/navigation.json b/frontend/locales/en/navigation.json index 441b294..225ece0 100644 --- a/frontend/locales/en/navigation.json +++ b/frontend/locales/en/navigation.json @@ -7,5 +7,6 @@ "trades": "Trades", "availability": "Availability", "invites": "Invites", - "prices": "Prices" + "prices": "Prices", + "pricing": "Pricing" } diff --git a/frontend/locales/es/admin.json b/frontend/locales/es/admin.json index 7691c2a..d82431d 100644 --- a/frontend/locales/es/admin.json +++ b/frontend/locales/es/admin.json @@ -109,5 +109,31 @@ "clearFailed": "Error al limpiar", "copyFailed": "Error al copiar" } + }, + "pricing": { + "title": "Configuración de Precios", + "subtitle": "Configura los precios de prima y los límites de importe de las operaciones", + "premiumSettings": "Configuración de Prima", + "premiumBuy": "Prima para COMPRAR", + "premiumSell": "Prima para VENDER", + "smallTradeThreshold": "Umbral de Operaciones Pequeñas", + "smallTradeExtraPremium": "Prima Extra para Operaciones Pequeñas", + "tradeLimitsBuy": "Límites de Importe de Operaciones (COMPRAR)", + "tradeLimitsSell": "Límites de Importe de Operaciones (VENDER)", + "minAmount": "Importe Mínimo", + "maxAmount": "Importe Máximo", + "save": "Guardar Cambios", + "success": "Configuración de precios guardada correctamente", + "confirmSave": "¿Estás seguro de que quieres guardar estos cambios de precios? Esto afectará todas las nuevas operaciones inmediatamente.", + "validation": { + "premiumRange": "La prima debe estar entre -100% y 100%", + "positive": "El importe debe ser positivo", + "minMaxBuy": "El mínimo debe ser menor que el máximo para COMPRAR", + "minMaxSell": "El mínimo debe ser menor que el máximo para VENDER" + }, + "errors": { + "loadFailed": "Error al cargar la configuración de precios", + "saveFailed": "Error al guardar la configuración de precios" + } } } diff --git a/frontend/locales/es/navigation.json b/frontend/locales/es/navigation.json index d85ff25..7ae73ff 100644 --- a/frontend/locales/es/navigation.json +++ b/frontend/locales/es/navigation.json @@ -7,5 +7,6 @@ "trades": "Operaciones", "availability": "Disponibilidad", "invites": "Invitaciones", - "prices": "Precios" + "prices": "Precios", + "pricing": "Precios" }