Step 4: Add admin UI page for pricing configuration

- Add pricing API functions to admin.ts
- Create admin pricing page with form and validation
- Add MANAGE_PRICING permission to auth context
- Add pricing to admin navigation
- Add translations for pricing page (en, ca, es)
- Update PageLayout and Header types for new page
This commit is contained in:
counterweight 2025-12-26 20:17:48 +01:00
parent 4d0dad8e2b
commit d838d1be96
Signed by: counterweight
GPG key ID: 883EDBAA726BD96C
11 changed files with 509 additions and 5 deletions

View file

@ -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<FormData | null>(null);
const [errors, setErrors] = useState<ValidationErrors>({});
const [error, setError] = useState<string | null>(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 (
<PageLayout currentPage="admin-pricing" isLoading={isLoading} isAuthorized={isAuthorized}>
<div />
</PageLayout>
);
}
const hasErrors = Object.keys(errors).length > 0;
const displayError = error || saveError;
return (
<PageLayout
currentPage="admin-pricing"
isLoading={isLoading}
isAuthorized={isAuthorized}
error={displayError}
>
<div style={cardStyles.card}>
<h1 style={cardStyles.title}>{t("pricing.title")}</h1>
<p style={cardStyles.subtitle}>{t("pricing.subtitle")}</p>
{success && <div style={bannerStyles.success}>{t("pricing.success")}</div>}
<form
onSubmit={(e) => {
e.preventDefault();
handleSave();
}}
>
<div style={formStyles.section}>
<h2 style={formStyles.sectionTitle}>{t("pricing.premiumSettings")}</h2>
<div style={formStyles.field}>
<label style={formStyles.label}>{t("pricing.premiumBuy")} (%)</label>
<input
type="number"
value={formData.premium_buy}
onChange={(e) => handleFieldChange("premium_buy", e.target.value)}
min={-100}
max={100}
style={{
...formStyles.input,
...(errors.premium_buy ? formStyles.inputError : {}),
}}
/>
{errors.premium_buy && <div style={formStyles.error}>{errors.premium_buy}</div>}
</div>
<div style={formStyles.field}>
<label style={formStyles.label}>{t("pricing.premiumSell")} (%)</label>
<input
type="number"
value={formData.premium_sell}
onChange={(e) => handleFieldChange("premium_sell", e.target.value)}
min={-100}
max={100}
style={{
...formStyles.input,
...(errors.premium_sell ? formStyles.inputError : {}),
}}
/>
{errors.premium_sell && <div style={formStyles.error}>{errors.premium_sell}</div>}
</div>
<div style={formStyles.field}>
<label style={formStyles.label}>{t("pricing.smallTradeThreshold")} (EUR)</label>
<input
type="number"
value={formData.small_trade_threshold_eur / 100}
onChange={(e) =>
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 && (
<div style={formStyles.error}>{errors.small_trade_threshold_eur}</div>
)}
</div>
<div style={formStyles.field}>
<label style={formStyles.label}>{t("pricing.smallTradeExtraPremium")} (%)</label>
<input
type="number"
value={formData.small_trade_extra_premium}
onChange={(e) => 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 && (
<div style={formStyles.error}>{errors.small_trade_extra_premium}</div>
)}
</div>
</div>
<div style={formStyles.section}>
<h2 style={formStyles.sectionTitle}>{t("pricing.tradeLimitsBuy")}</h2>
<div style={formStyles.field}>
<label style={formStyles.label}>{t("pricing.minAmount")} (EUR)</label>
<input
type="number"
value={formData.eur_min_buy / 100}
onChange={(e) =>
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 && <div style={formStyles.error}>{errors.eur_min_buy}</div>}
</div>
<div style={formStyles.field}>
<label style={formStyles.label}>{t("pricing.maxAmount")} (EUR)</label>
<input
type="number"
value={formData.eur_max_buy / 100}
onChange={(e) =>
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 && <div style={formStyles.error}>{errors.eur_max_buy}</div>}
</div>
</div>
<div style={formStyles.section}>
<h2 style={formStyles.sectionTitle}>{t("pricing.tradeLimitsSell")}</h2>
<div style={formStyles.field}>
<label style={formStyles.label}>{t("pricing.minAmount")} (EUR)</label>
<input
type="number"
value={formData.eur_min_sell / 100}
onChange={(e) =>
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 && <div style={formStyles.error}>{errors.eur_min_sell}</div>}
</div>
<div style={formStyles.field}>
<label style={formStyles.label}>{t("pricing.maxAmount")} (EUR)</label>
<input
type="number"
value={formData.eur_max_sell / 100}
onChange={(e) =>
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 && <div style={formStyles.error}>{errors.eur_max_sell}</div>}
</div>
</div>
<div style={formStyles.actions}>
<ConfirmationButton
onClick={handleSave}
disabled={hasErrors || isSaving}
confirmMessage={t("pricing.confirmSave")}
style={{
...buttonStyles.primary,
...(hasErrors || isSaving ? buttonStyles.disabled : {}),
}}
>
{isSaving ? tCommon("saving") : t("pricing.save")}
</ConfirmationButton>
</div>
</form>
</div>
</PageLayout>
);
}

View file

@ -6,6 +6,8 @@ type PaginatedInvites = components["schemas"]["PaginatedResponse_InviteResponse_
type AdminExchangeResponse = components["schemas"]["AdminExchangeResponse"]; type AdminExchangeResponse = components["schemas"]["AdminExchangeResponse"];
type AvailabilityResponse = components["schemas"]["AvailabilityResponse"]; type AvailabilityResponse = components["schemas"]["AvailabilityResponse"];
type PriceHistoryRecord = components["schemas"]["PriceHistoryResponse"]; type PriceHistoryRecord = components["schemas"]["PriceHistoryResponse"];
type PricingConfigResponse = components["schemas"]["PricingConfigResponse"];
type PricingConfigUpdate = components["schemas"]["PricingConfigUpdate"];
interface CreateInviteRequest { interface CreateInviteRequest {
godfather_id: number; godfather_id: number;
@ -153,4 +155,18 @@ export const adminApi = {
fetchPrice(): Promise<PriceHistoryRecord> { fetchPrice(): Promise<PriceHistoryRecord> {
return client.post<PriceHistoryRecord>("/api/audit/price-history/fetch", {}); return client.post<PriceHistoryRecord>("/api/audit/price-history/fetch", {});
}, },
/**
* Get current pricing configuration
*/
getPricingConfig(): Promise<PricingConfigResponse> {
return client.get<PricingConfigResponse>("/api/admin/pricing");
},
/**
* Update pricing configuration
*/
updatePricingConfig(request: PricingConfigUpdate): Promise<PricingConfigResponse> {
return client.put<PricingConfigResponse>("/api/admin/pricing", request);
},
}; };

View file

@ -24,6 +24,7 @@ export const Permission: Record<string, PermissionType> = {
CANCEL_OWN_EXCHANGE: "cancel_own_exchange", CANCEL_OWN_EXCHANGE: "cancel_own_exchange",
// Availability/Exchange permissions (admin) // Availability/Exchange permissions (admin)
MANAGE_AVAILABILITY: "manage_availability", MANAGE_AVAILABILITY: "manage_availability",
MANAGE_PRICING: "manage_pricing",
VIEW_ALL_EXCHANGES: "view_all_exchanges", VIEW_ALL_EXCHANGES: "view_all_exchanges",
CANCEL_ANY_EXCHANGE: "cancel_any_exchange", CANCEL_ANY_EXCHANGE: "cancel_any_exchange",
COMPLETE_EXCHANGE: "complete_exchange", COMPLETE_EXCHANGE: "complete_exchange",

View file

@ -17,7 +17,8 @@ type PageId =
| "admin-invites" | "admin-invites"
| "admin-availability" | "admin-availability"
| "admin-trades" | "admin-trades"
| "admin-price-history"; | "admin-price-history"
| "admin-pricing";
interface HeaderProps { interface HeaderProps {
currentPage: PageId; currentPage: PageId;
@ -48,6 +49,7 @@ const ADMIN_NAV_ITEMS: NavItem[] = [
}, },
{ id: "admin-invites", labelKey: "invites", href: "/admin/invites", adminOnly: true }, { id: "admin-invites", labelKey: "invites", href: "/admin/invites", adminOnly: true },
{ id: "admin-price-history", labelKey: "prices", href: "/admin/price-history", 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) { export function Header({ currentPage }: HeaderProps) {

View file

@ -12,7 +12,8 @@ type PageId =
| "admin-invites" | "admin-invites"
| "admin-availability" | "admin-availability"
| "admin-trades" | "admin-trades"
| "admin-price-history"; | "admin-price-history"
| "admin-pricing";
interface PageLayoutProps { interface PageLayoutProps {
/** Current page ID for navigation highlighting */ /** Current page ID for navigation highlighting */

View file

@ -109,5 +109,31 @@
"clearFailed": "Error en netejar", "clearFailed": "Error en netejar",
"copyFailed": "Error en copiar" "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"
}
} }
} }

View file

@ -7,5 +7,6 @@
"trades": "Operacions", "trades": "Operacions",
"availability": "Disponibilitat", "availability": "Disponibilitat",
"invites": "Invitacions", "invites": "Invitacions",
"prices": "Preus" "prices": "Preus",
"pricing": "Preus"
} }

View file

@ -109,5 +109,31 @@
"clearFailed": "Failed to clear", "clearFailed": "Failed to clear",
"copyFailed": "Failed to copy" "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"
}
} }
} }

View file

@ -7,5 +7,6 @@
"trades": "Trades", "trades": "Trades",
"availability": "Availability", "availability": "Availability",
"invites": "Invites", "invites": "Invites",
"prices": "Prices" "prices": "Prices",
"pricing": "Pricing"
} }

View file

@ -109,5 +109,31 @@
"clearFailed": "Error al limpiar", "clearFailed": "Error al limpiar",
"copyFailed": "Error al copiar" "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"
}
} }
} }

View file

@ -7,5 +7,6 @@
"trades": "Operaciones", "trades": "Operaciones",
"availability": "Disponibilidad", "availability": "Disponibilidad",
"invites": "Invitaciones", "invites": "Invitaciones",
"prices": "Precios" "prices": "Precios",
"pricing": "Precios"
} }