326 lines
11 KiB
TypeScript
326 lines
11 KiB
TypeScript
'use strict';
|
|
'use client';
|
|
|
|
import React, { useState, useEffect } from 'react';
|
|
import Link from 'next/link';
|
|
import { useRouter, useParams } from 'next/navigation';
|
|
import styles from './page.module.css';
|
|
import Header from '@/components/Header';
|
|
import { useLocale } from '@/lib/i18n/LocaleContext';
|
|
|
|
interface Rule {
|
|
id?: number;
|
|
tempId?: string;
|
|
type: 'TIER' | 'BONUS';
|
|
minAchievement: number;
|
|
maxAchievement: number;
|
|
rate: number;
|
|
payoutAmount: number;
|
|
}
|
|
|
|
interface Plan {
|
|
id: number;
|
|
name: string;
|
|
code: string;
|
|
type: string;
|
|
status: string;
|
|
version: number;
|
|
rules: Rule[];
|
|
}
|
|
|
|
export default function RulesPage() {
|
|
const { t } = useLocale();
|
|
const router = useRouter();
|
|
const params = useParams();
|
|
const planId = params?.id ? parseInt(params.id as string) : null;
|
|
|
|
const [plan, setPlan] = useState<Plan | null>(null);
|
|
const [rules, setRules] = useState<Rule[]>([]);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [success, setSuccess] = useState<string | null>(null);
|
|
const [role, setRole] = useState<string | null>(null);
|
|
|
|
const fetchPlan = async () => {
|
|
if (!planId) return;
|
|
try {
|
|
const res = await fetch(`/api/plans/${planId}`);
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
setPlan(data.plan);
|
|
if (data.plan.rules && data.plan.rules.length > 0) {
|
|
// Format rules from server (Decimal fields come as strings/numbers)
|
|
const formatted = data.plan.rules.map((r: any) => ({
|
|
id: r.id,
|
|
tempId: Math.random().toString(36).substr(2, 9),
|
|
type: r.type as 'TIER' | 'BONUS',
|
|
minAchievement: parseFloat(r.minAchievement),
|
|
maxAchievement: parseFloat(r.maxAchievement),
|
|
rate: parseFloat(r.rate),
|
|
payoutAmount: parseFloat(r.payoutAmount)
|
|
}));
|
|
setRules(formatted);
|
|
} else {
|
|
// Default initial empty row
|
|
setRules([
|
|
{
|
|
tempId: Math.random().toString(36).substr(2, 9),
|
|
type: 'TIER',
|
|
minAchievement: 0.0,
|
|
maxAchievement: 1.0,
|
|
rate: 0.0,
|
|
payoutAmount: 0.0
|
|
}
|
|
]);
|
|
}
|
|
} else {
|
|
setError(t('plans.errorFind'));
|
|
}
|
|
} catch (err) {
|
|
setError(t('plans.errorLoad'));
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
fetch('/api/auth/me')
|
|
.then(res => res.ok ? res.json() : null)
|
|
.then(data => {
|
|
if (data?.user) setRole(data.user.role);
|
|
})
|
|
.catch(err => console.error('Failed to fetch user role', err));
|
|
fetchPlan();
|
|
}, [planId]);
|
|
|
|
|
|
|
|
const handleAddRow = () => {
|
|
// Generate a default values row, potentially continuing from previous max
|
|
const lastRule = rules[rules.length - 1];
|
|
const newMin = lastRule ? lastRule.maxAchievement : 0.0;
|
|
setRules([
|
|
...rules,
|
|
{
|
|
tempId: Math.random().toString(36).substr(2, 9),
|
|
type: 'TIER',
|
|
minAchievement: newMin,
|
|
maxAchievement: newMin + 0.1 > 1.0 ? 1.0 : newMin + 0.1,
|
|
rate: 0.0,
|
|
payoutAmount: 0.0
|
|
}
|
|
]);
|
|
};
|
|
|
|
const handleDeleteRow = (tempId: string) => {
|
|
if (rules.length === 1) {
|
|
setError(t('rules.errorAtLeastOne'));
|
|
return;
|
|
}
|
|
setRules(rules.filter(r => r.tempId !== tempId));
|
|
};
|
|
|
|
const handleUpdateRow = (tempId: string, field: keyof Rule, value: any) => {
|
|
setError(null);
|
|
setSuccess(null);
|
|
setRules(
|
|
rules.map(r => {
|
|
if (r.tempId === tempId) {
|
|
let parsedValue = value;
|
|
if (field === 'minAchievement' || field === 'maxAchievement' || field === 'rate') {
|
|
parsedValue = parseFloat(value) || 0.0;
|
|
} else if (field === 'payoutAmount') {
|
|
parsedValue = parseFloat(value) || 0.0;
|
|
}
|
|
return { ...r, [field]: parsedValue };
|
|
}
|
|
return r;
|
|
})
|
|
);
|
|
};
|
|
|
|
const handleSaveRules = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setError(null);
|
|
setSuccess(null);
|
|
|
|
// Validate boundaries
|
|
for (let i = 0; i < rules.length; i++) {
|
|
const r = rules[i];
|
|
if (r.minAchievement >= r.maxAchievement) {
|
|
setError(t('rules.errorBoundary').replace('{row}', String(i + 1)).replace('{min}', String(r.minAchievement)).replace('{max}', String(r.maxAchievement)));
|
|
return;
|
|
}
|
|
if (r.minAchievement < 0 || r.maxAchievement < 0 || r.rate < 0 || r.payoutAmount < 0) {
|
|
setError(t('rules.errorNegative').replace('{row}', String(i + 1)));
|
|
return;
|
|
}
|
|
}
|
|
|
|
try {
|
|
const res = await fetch(`/api/plans/${planId}/rules`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
rules: rules.map(r => ({
|
|
type: r.type,
|
|
minAchievement: r.minAchievement,
|
|
maxAchievement: r.maxAchievement,
|
|
rate: r.rate,
|
|
payoutAmount: r.payoutAmount
|
|
}))
|
|
})
|
|
});
|
|
|
|
const data = await res.json();
|
|
if (!res.ok) {
|
|
setError(data.error || t('rules.errorSave'));
|
|
return;
|
|
}
|
|
|
|
setSuccess(t('rules.successSave'));
|
|
// Refresh current plan
|
|
fetchPlan();
|
|
} catch (err) {
|
|
setError(t('rules.errorNetwork'));
|
|
}
|
|
};
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className={styles.container}>
|
|
<div style={{ textAlign: 'center', padding: '100px' }}>{t('rules.loading')}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className={styles.container}>
|
|
{/* Shared Dashboard Header */}
|
|
<Header activeTab="plans" />
|
|
|
|
<main className={styles.main}>
|
|
<div className={styles.titleArea}>
|
|
<Link href="/plans" className={styles.backLink}>
|
|
← {t('rules.back')}
|
|
</Link>
|
|
<h1 className={styles.title}>{t('rules.title')}</h1>
|
|
<p className={styles.subtitle}>
|
|
{t('rules.subtitle').replace('{name}', plan?.name || '').replace('{code}', plan?.code || '').replace('{version}', String(plan?.version || '')).replace('{status}', t('status.' + plan?.status) || '')}
|
|
</p>
|
|
</div>
|
|
|
|
{error && <div className={styles.errorMsg} id="rules-error-msg">{error}</div>}
|
|
{success && <div className={styles.successMsg} id="rules-success-msg">{success}</div>}
|
|
|
|
<div className={styles.section}>
|
|
<form onSubmit={handleSaveRules}>
|
|
<div className={styles.tableHeader}>
|
|
<span>{t('rules.thType')}</span>
|
|
<span>{t('rules.thMin')}</span>
|
|
<span>{t('rules.thMax')}</span>
|
|
<span>{t('rules.thRate')}</span>
|
|
<span>{t('rules.thPayout')}</span>
|
|
<span></span>
|
|
</div>
|
|
|
|
<div className={styles.rows}>
|
|
{rules.map((rule, idx) => (
|
|
<div key={rule.tempId || idx} className={styles.row} data-rule-row={idx}>
|
|
<select
|
|
className={styles.input}
|
|
value={rule.type}
|
|
onChange={(e) => handleUpdateRow(rule.tempId!, 'type', e.target.value)}
|
|
id={`rule-type-${idx}`}
|
|
disabled={!(role === 'admin' || role === 'director')}
|
|
>
|
|
<option value="TIER">{t('rules.types.TIER')}</option>
|
|
<option value="BONUS">{t('rules.types.BONUS')}</option>
|
|
</select>
|
|
|
|
<input
|
|
type="number"
|
|
step="0.0001"
|
|
className={styles.input}
|
|
value={rule.minAchievement}
|
|
onChange={(e) => handleUpdateRow(rule.tempId!, 'minAchievement', e.target.value)}
|
|
id={`rule-min-${idx}`}
|
|
required
|
|
disabled={!(role === 'admin' || role === 'director')}
|
|
/>
|
|
|
|
<input
|
|
type="number"
|
|
step="0.0001"
|
|
className={styles.input}
|
|
value={rule.maxAchievement}
|
|
onChange={(e) => handleUpdateRow(rule.tempId!, 'maxAchievement', e.target.value)}
|
|
id={`rule-max-${idx}`}
|
|
required
|
|
disabled={!(role === 'admin' || role === 'director')}
|
|
/>
|
|
|
|
<input
|
|
type="number"
|
|
step="0.0001"
|
|
className={styles.input}
|
|
value={rule.rate}
|
|
onChange={(e) => handleUpdateRow(rule.tempId!, 'rate', e.target.value)}
|
|
id={`rule-rate-${idx}`}
|
|
required
|
|
disabled={!(role === 'admin' || role === 'director')}
|
|
/>
|
|
|
|
<input
|
|
type="number"
|
|
step="0.01"
|
|
className={styles.input}
|
|
value={rule.payoutAmount}
|
|
onChange={(e) => handleUpdateRow(rule.tempId!, 'payoutAmount', e.target.value)}
|
|
id={`rule-payout-${idx}`}
|
|
required
|
|
disabled={!(role === 'admin' || role === 'director')}
|
|
/>
|
|
|
|
{(role === 'admin' || role === 'director') && (
|
|
<button
|
|
type="button"
|
|
onClick={() => handleDeleteRow(rule.tempId!)}
|
|
className={styles.btnDelete}
|
|
id={`btn-delete-rule-${idx}`}
|
|
title={t('rules.delete')}
|
|
>
|
|
×
|
|
</button>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{(role === 'admin' || role === 'director') && (
|
|
<button
|
|
type="button"
|
|
onClick={handleAddRow}
|
|
className={styles.btnSecondary}
|
|
id="btn-add-rule"
|
|
>
|
|
{t('rules.addRule')}
|
|
</button>
|
|
)}
|
|
|
|
<div className={styles.footerActions}>
|
|
<Link href="/plans" className={styles.btnSecondary} style={{ marginRight: 'auto' }}>
|
|
{ (role === 'admin' || role === 'director') ? t('rules.cancel') : t('rules.backBtn') }
|
|
</Link>
|
|
{(role === 'admin' || role === 'director') && (
|
|
<button type="submit" className={styles.btnPrimary} id="btn-save-rules">
|
|
{t('rules.save')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|