arbret/frontend/app/admin/pricing/page.tsx
2025-12-26 23:27:33 +01:00

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>
);
}