456 lines
16 KiB
TypeScript
456 lines
16 KiB
TypeScript
"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 [isConfirming, setIsConfirming] = 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 handleConfirmSave = () => {
|
|
if (!formData) {
|
|
setIsConfirming(false);
|
|
return;
|
|
}
|
|
|
|
const validationErrors = validateForm(formData);
|
|
if (Object.keys(validationErrors).length > 0) {
|
|
setErrors(validationErrors);
|
|
setIsConfirming(false);
|
|
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,
|
|
});
|
|
setIsConfirming(false);
|
|
};
|
|
|
|
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}
|
|
contentStyle={{
|
|
flex: 1,
|
|
display: "flex",
|
|
alignItems: "flex-start",
|
|
justifyContent: "center",
|
|
padding: "2rem",
|
|
overflowY: "auto",
|
|
}}
|
|
>
|
|
<div style={{ ...cardStyles.card, width: "100%", maxWidth: "1400px" }}>
|
|
<h1 style={cardStyles.cardTitle}>{t("pricing.title")}</h1>
|
|
<p style={cardStyles.cardSubtitle}>{t("pricing.subtitle")}</p>
|
|
|
|
{success && <div style={bannerStyles.success}>{t("pricing.success")}</div>}
|
|
|
|
<form
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
setIsConfirming(true);
|
|
}}
|
|
>
|
|
{/* Premium Settings - Full Width */}
|
|
<div style={formStyles.section}>
|
|
<h2 style={formStyles.sectionTitle}>{t("pricing.premiumSettings")}</h2>
|
|
<div
|
|
style={{
|
|
display: "grid",
|
|
gridTemplateColumns: "repeat(4, minmax(200px, 1fr))",
|
|
gap: "1.5rem",
|
|
alignItems: "start",
|
|
}}
|
|
>
|
|
<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,
|
|
width: "100%",
|
|
maxWidth: "180px",
|
|
...(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,
|
|
width: "100%",
|
|
maxWidth: "180px",
|
|
...(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,
|
|
width: "100%",
|
|
maxWidth: "180px",
|
|
...(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,
|
|
width: "100%",
|
|
maxWidth: "180px",
|
|
...(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>
|
|
|
|
{/* Trade Limits - Side by Side */}
|
|
<div
|
|
style={{
|
|
display: "grid",
|
|
gridTemplateColumns: "repeat(2, minmax(300px, 1fr))",
|
|
gap: "2rem",
|
|
marginBottom: "2rem",
|
|
alignItems: "start",
|
|
}}
|
|
>
|
|
<div style={{ ...formStyles.section, marginBottom: 0 }}>
|
|
<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,
|
|
width: "100%",
|
|
maxWidth: "180px",
|
|
...(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,
|
|
width: "100%",
|
|
maxWidth: "180px",
|
|
...(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, marginBottom: 0 }}>
|
|
<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,
|
|
width: "100%",
|
|
maxWidth: "180px",
|
|
...(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,
|
|
width: "100%",
|
|
maxWidth: "180px",
|
|
...(errors.eur_max_sell ? formStyles.inputError : {}),
|
|
}}
|
|
/>
|
|
{errors.eur_max_sell && <div style={formStyles.error}>{errors.eur_max_sell}</div>}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div style={formStyles.actions}>
|
|
<ConfirmationButton
|
|
isConfirming={isConfirming}
|
|
onConfirm={handleConfirmSave}
|
|
onCancel={() => setIsConfirming(false)}
|
|
onActionClick={() => setIsConfirming(true)}
|
|
actionLabel={isSaving ? tCommon("saving") : t("pricing.save")}
|
|
confirmLabel={tCommon("confirm")}
|
|
cancelLabel={tCommon("cancel")}
|
|
isLoading={isSaving}
|
|
actionButtonStyle={{
|
|
...buttonStyles.primary,
|
|
...(hasErrors || isSaving ? buttonStyles.disabled : {}),
|
|
}}
|
|
/>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</PageLayout>
|
|
);
|
|
}
|