feat: implement Policy Engine and Master Data API services along with associated components
This commit is contained in:
@@ -99,7 +99,7 @@ export function submitActionPayload(payload: ActionSubmissionPayload): Promise<a
|
||||
// ─── Master Data Options Loader ──────────────────────────────────────────────
|
||||
|
||||
export function getMasterDataOptions(categoryCode: string): Promise<{ label: string; value: string; id?: string }[]> {
|
||||
return ApiClient.get<any, any[]>(`/master-data/category-values/${categoryCode}`)
|
||||
return ApiClient.get<any, any[]>(`/master-data/${categoryCode}`)
|
||||
.then((res) => {
|
||||
if (Array.isArray(res)) {
|
||||
return res.map((item) => {
|
||||
|
||||
@@ -49,7 +49,6 @@ export default function ActionBuilderPage() {
|
||||
|
||||
const [masterLookupOptions, setMasterLookupOptions] = useState<{ label: string; value: string }[]>([
|
||||
{ label: 'None (Manual / Free-text)', value: '' },
|
||||
{ label: 'Currencies (CURRENCIES)', value: 'CURRENCIES' },
|
||||
]);
|
||||
|
||||
// Loading & Notification States
|
||||
@@ -162,7 +161,6 @@ export default function ActionBuilderPage() {
|
||||
}));
|
||||
setMasterLookupOptions([
|
||||
{ label: 'None (Manual / Free-text)', value: '' },
|
||||
{ label: 'Currencies (CURRENCIES)', value: 'CURRENCIES' },
|
||||
...opts,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -21,11 +21,22 @@ function normalizeItems(items: any[]): MasterDataItem[] {
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Categories List Endpoint ───────────────────────────────────────────────
|
||||
// ─── Lookup Tables & Categories List Endpoint ─────────────────────────────────
|
||||
|
||||
export function getLookupTables(): Promise<MasterDataCategoryItem[]> {
|
||||
return ApiClient.get<any, MasterDataCategoryItem[]>('/master-data/lookup-tables')
|
||||
.then((res) => (Array.isArray(res) ? res : []))
|
||||
.catch(() => []);
|
||||
}
|
||||
|
||||
export function getMasterCategories(): Promise<MasterDataCategoryItem[]> {
|
||||
return ApiClient.get<any, MasterDataCategoryItem[]>('/master-data/categories')
|
||||
.then((res) => (Array.isArray(res) ? res : []))
|
||||
return getLookupTables()
|
||||
.then((tables) => {
|
||||
if (Array.isArray(tables) && tables.length > 0) return tables;
|
||||
return ApiClient.get<any, MasterDataCategoryItem[]>('/master-data/categories')
|
||||
.then((res) => (Array.isArray(res) ? res : []))
|
||||
.catch(() => []);
|
||||
})
|
||||
.catch(() => []);
|
||||
}
|
||||
|
||||
@@ -33,13 +44,9 @@ export function getMasterCategories(): Promise<MasterDataCategoryItem[]> {
|
||||
|
||||
export function getCategoryValues(categoryCode: string): Promise<MasterDataItem[]> {
|
||||
if (!categoryCode) return Promise.resolve([]);
|
||||
return ApiClient.get<any, any[]>(`/master-data/category-values/${categoryCode}`)
|
||||
return ApiClient.get<any, any[]>(`/master-data/${categoryCode}`)
|
||||
.then((res) => normalizeItems(res))
|
||||
.catch(() =>
|
||||
ApiClient.get<any, any[]>(`/master-data/${categoryCode}`)
|
||||
.then((res) => normalizeItems(res))
|
||||
.catch(() => []),
|
||||
);
|
||||
.catch(() => []);
|
||||
}
|
||||
|
||||
// ─── Generic Master Data Item CRUD ──────────────────────────────────────────
|
||||
|
||||
@@ -121,11 +121,11 @@ export const MasterItemTable: React.FC<MasterItemTableProps> = ({
|
||||
</td>
|
||||
|
||||
<td className="py-3 px-4 font-bold text-[#0F172B]">
|
||||
{item.value || item.label || item.name}
|
||||
{item.label || item.value}
|
||||
</td>
|
||||
|
||||
<td className="py-3 px-4 font-mono text-slate-500 text-[12px]">
|
||||
{item.code || '—'}
|
||||
{item.value || '—'}
|
||||
</td>
|
||||
|
||||
<td className="py-3 px-4">
|
||||
@@ -182,11 +182,10 @@ export const MasterItemTable: React.FC<MasterItemTableProps> = ({
|
||||
<button
|
||||
key={page}
|
||||
onClick={() => onPageChange(page)}
|
||||
className={`min-w-[32px] h-8 px-2 rounded-lg text-[13px] font-semibold transition-colors ${
|
||||
currentPage === page
|
||||
? 'bg-[#1E7D5C] text-white shadow-sm font-bold'
|
||||
: 'bg-white text-slate-600 border border-gray-200 hover:bg-slate-50'
|
||||
}`}
|
||||
className={`min-w-[32px] h-8 px-2 rounded-lg text-[13px] font-semibold transition-colors ${currentPage === page
|
||||
? 'bg-[#1E7D5C] text-white shadow-sm font-bold'
|
||||
: 'bg-white text-slate-600 border border-gray-200 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
|
||||
@@ -45,13 +45,9 @@ function normalizeItems(items: any[]): any[] {
|
||||
|
||||
function fetchCategoryValues(categoryCodeOrId: string): Promise<any[]> {
|
||||
if (!categoryCodeOrId) return Promise.resolve([]);
|
||||
return ApiClient.get<any, any[]>(`/master-data/category-values/${categoryCodeOrId}`)
|
||||
return ApiClient.get<any, any[]>(`/master-data/${categoryCodeOrId}`)
|
||||
.then((res) => normalizeItems(res))
|
||||
.catch(() =>
|
||||
ApiClient.get<any, any[]>(`/master-data/${categoryCodeOrId}`)
|
||||
.then((res) => normalizeItems(res))
|
||||
.catch(() => []),
|
||||
);
|
||||
.catch(() => []);
|
||||
}
|
||||
|
||||
function fetchMastersCategories(): Promise<any[]> {
|
||||
@@ -187,17 +183,18 @@ export function getConditionOptions(categoryCodeOrId?: string): Promise<OptionIt
|
||||
}
|
||||
|
||||
export function getOperatorOptions(): Promise<OptionItem[]> {
|
||||
return fetchCategoryValues('operator')
|
||||
.then((items) =>
|
||||
(items || [])
|
||||
return ApiClient.get<any, any[]>('/master-data/operators')
|
||||
.then((res) => {
|
||||
const items = Array.isArray(res) ? res : [];
|
||||
return items
|
||||
.filter((i) => i.isActive !== false)
|
||||
.map((i) => ({
|
||||
label: i.label || i.name || i.value || '',
|
||||
label: i.name || i.symbol || i.label || i.code || '',
|
||||
value: i.id || i.code || i.value || '',
|
||||
id: i.id,
|
||||
code: i.code,
|
||||
})),
|
||||
)
|
||||
}));
|
||||
})
|
||||
.catch(() => []);
|
||||
}
|
||||
|
||||
@@ -294,3 +291,34 @@ export function updatePolicyStatus(id: string, status: string): Promise<PolicyEn
|
||||
export function deletePolicy(id: string): Promise<void> {
|
||||
return ApiClient.delete<any, void>(`/policy-engine/${id}`);
|
||||
}
|
||||
|
||||
// ─── 3-Level Metadata API Functions ──────────────────────────────────────────
|
||||
|
||||
export function getConditionGroupsForCategory(categoryCodeOrId: string): Promise<any[]> {
|
||||
if (!categoryCodeOrId) return Promise.resolve([]);
|
||||
return ApiClient.get<any, any[]>(`/master-data/rule-categories/${categoryCodeOrId}/condition-groups`)
|
||||
.then((res) => (Array.isArray(res) ? res : []))
|
||||
.catch(() => []);
|
||||
}
|
||||
|
||||
export function getConditionFieldsForGroup(groupIdOrCode: string): Promise<any[]> {
|
||||
if (!groupIdOrCode) return Promise.resolve([]);
|
||||
return ApiClient.get<any, any[]>(`/master-data/condition-groups/${groupIdOrCode}/fields`)
|
||||
.then((res) => (Array.isArray(res) ? res : []))
|
||||
.catch(() => []);
|
||||
}
|
||||
|
||||
export function getFieldLookupOptions(fieldIdOrCode: string): Promise<OptionItem[]> {
|
||||
if (!fieldIdOrCode) return Promise.resolve([]);
|
||||
return ApiClient.get<any, any[]>(`/master-data/condition-fields/${fieldIdOrCode}/lookup-values`)
|
||||
.then((res) =>
|
||||
(res || []).map((i) => ({
|
||||
label: i.label || i.name || i.value || '',
|
||||
value: i.id || i.code || i.value || '',
|
||||
id: i.id,
|
||||
code: i.code,
|
||||
})),
|
||||
)
|
||||
.catch(() => []);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,3 +12,31 @@ export interface PaginatedPolicyEngineResponse {
|
||||
totalPages: number;
|
||||
page: number;
|
||||
}
|
||||
|
||||
export interface ConditionGroupItem {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
displayOrder?: number;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface ConditionFieldItem {
|
||||
id: string;
|
||||
groupId: string;
|
||||
code: string;
|
||||
name: string;
|
||||
lookupTable?: string;
|
||||
dataType: 'STRING' | 'NUMBER' | 'BOOLEAN' | 'ENUM' | 'DATE';
|
||||
operatorType: 'COMPARISON' | 'TEXT' | 'SET' | 'BOOLEAN';
|
||||
displayOrder?: number;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface LookupOptionItem {
|
||||
id: string;
|
||||
code: string;
|
||||
label: string;
|
||||
value: string;
|
||||
displayOrder?: number;
|
||||
}
|
||||
|
||||
@@ -30,6 +30,9 @@ import {
|
||||
getPolicy,
|
||||
createPolicy,
|
||||
updatePolicy,
|
||||
getConditionGroupsForCategory,
|
||||
getConditionFieldsForGroup,
|
||||
getFieldLookupOptions,
|
||||
type OptionItem,
|
||||
type ActionTypeField,
|
||||
} from '../PolicyEngineApi';
|
||||
@@ -75,7 +78,6 @@ type Rule = {
|
||||
const LOGIC_OPTIONS = [
|
||||
{ label: 'AND', value: 'AND' },
|
||||
{ label: 'OR', value: 'OR' },
|
||||
{ label: 'NOT', value: 'NOT' },
|
||||
];
|
||||
|
||||
// Per-gate color scheme (exact Figma tokens): AND=blue, OR=yellow, NOT=red.
|
||||
@@ -175,15 +177,76 @@ export default function AddPolicyEngine() {
|
||||
getActionCategoryOptions().then(setActionCategoryOptions);
|
||||
}, []);
|
||||
|
||||
const handleCategoryChange = (ruleId: string, category: string) => {
|
||||
handleUpdateRule(ruleId, { category });
|
||||
if (category && !categoryConditionMap[category]) {
|
||||
getConditionOptions(category).then((opts) => {
|
||||
const [conditionFieldsMetaMap, setConditionFieldsMetaMap] = useState<Record<string, any>>({});
|
||||
const [conditionValueLookupMap, setConditionValueLookupMap] = useState<Record<string, OptionItem[]>>({});
|
||||
|
||||
const loadCategoryConditions = async (category: string) => {
|
||||
if (!category) return [];
|
||||
try {
|
||||
const groups = await getConditionGroupsForCategory(category);
|
||||
const allFields: OptionItem[] = [];
|
||||
const metaMap: Record<string, any> = {};
|
||||
|
||||
for (const g of groups) {
|
||||
const fields = await getConditionFieldsForGroup(g.id || g.code);
|
||||
fields.forEach((f: any) => {
|
||||
const val = f.id || f.code;
|
||||
allFields.push({
|
||||
label: `${g.name} › ${f.name}`,
|
||||
value: val,
|
||||
id: f.id,
|
||||
code: f.code,
|
||||
});
|
||||
metaMap[val] = f;
|
||||
});
|
||||
}
|
||||
|
||||
setCategoryConditionMap((prev) => ({
|
||||
...prev,
|
||||
[category]: allFields,
|
||||
}));
|
||||
setConditionFieldsMetaMap((prev) => ({
|
||||
...prev,
|
||||
...metaMap,
|
||||
}));
|
||||
return allFields;
|
||||
} catch (err) {
|
||||
console.error('Failed to load condition groups/fields for category', err);
|
||||
try {
|
||||
const fallbackOpts = await getConditionOptions(category);
|
||||
setCategoryConditionMap((prev) => ({
|
||||
...prev,
|
||||
[category]: opts,
|
||||
[category]: fallbackOpts,
|
||||
}));
|
||||
});
|
||||
return fallbackOpts;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCategoryChange = async (ruleId: string, category: string) => {
|
||||
handleUpdateRule(ruleId, { category });
|
||||
if (category) {
|
||||
await loadCategoryConditions(category);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConditionFieldSelect = async (ruleId: string, conditionId: string, selectedFieldIdOrCode: string) => {
|
||||
handleUpdateCondition(ruleId, conditionId, { condition: selectedFieldIdOrCode, value: '' });
|
||||
|
||||
if (selectedFieldIdOrCode) {
|
||||
try {
|
||||
const opts = await getFieldLookupOptions(selectedFieldIdOrCode);
|
||||
if (opts && opts.length > 0) {
|
||||
setConditionValueLookupMap((prev) => ({
|
||||
...prev,
|
||||
[selectedFieldIdOrCode]: opts,
|
||||
}));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load lookup options for condition field', err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -324,22 +387,33 @@ export default function AddPolicyEngine() {
|
||||
const cat = matchedRuleCat ? matchedRuleCat.value : rawCat;
|
||||
|
||||
if (cat) {
|
||||
getConditionOptions(cat).then((opts) => {
|
||||
setCategoryConditionMap((prev) => ({ ...prev, [cat]: opts }));
|
||||
});
|
||||
await loadCategoryConditions(cat);
|
||||
}
|
||||
|
||||
const loadedConditions: Condition[] =
|
||||
Array.isArray(r.conditions) && r.conditions.length > 0
|
||||
? r.conditions.map((c: any) => ({
|
||||
id: c.id || crypto.randomUUID(),
|
||||
condition: c.fieldId || c.condition || '',
|
||||
operator: c.operatorId || c.operator || '',
|
||||
value: c.valueText || c.value || '',
|
||||
logic: c.logicalOperator || c.logic || 'AND',
|
||||
}))
|
||||
id: c.id || crypto.randomUUID(),
|
||||
condition: c.fieldId || c.condition || '',
|
||||
operator: c.operatorId || c.operator || '',
|
||||
value: c.valueText || c.value || '',
|
||||
logic: c.logicalOperator || c.logic || 'AND',
|
||||
}))
|
||||
: [{ id: crypto.randomUUID(), condition: '', operator: '', value: '', logic: 'AND' }];
|
||||
|
||||
for (const c of loadedConditions) {
|
||||
if (c.condition) {
|
||||
try {
|
||||
const opts = await getFieldLookupOptions(c.condition);
|
||||
if (opts && opts.length > 0) {
|
||||
setConditionValueLookupMap((prev) => ({ ...prev, [c.condition]: opts }));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load lookup options for condition', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const loadedActions: Action[] = [];
|
||||
if (Array.isArray(r.actions) && r.actions.length > 0) {
|
||||
for (const a of r.actions) {
|
||||
@@ -454,9 +528,9 @@ export default function AddPolicyEngine() {
|
||||
targetAudiences:
|
||||
audienceType === 'Selected Cohorts'
|
||||
? selectedCohorts.map((cohortId) => ({
|
||||
targetType: 'COHORT',
|
||||
targetId: String(cohortId),
|
||||
}))
|
||||
targetType: 'COHORT',
|
||||
targetId: String(cohortId),
|
||||
}))
|
||||
: [],
|
||||
rules: rules.map((r, rIdx) => ({
|
||||
ruleCategoryId:
|
||||
@@ -862,69 +936,99 @@ export default function AddPolicyEngine() {
|
||||
<div className="mb-8">
|
||||
<h4 className="text-[14px] font-bold text-[#0F172B] mb-4">Visual Condition Builder</h4>
|
||||
<div className="flex flex-col gap-3">
|
||||
{rule.conditions.map((condition, cIdx) => (
|
||||
<div key={condition.id} className="flex items-end gap-3">
|
||||
<div className="flex-1 flex flex-col gap-2">
|
||||
<CustomDropdown
|
||||
label='Condition'
|
||||
options={categoryConditionMap[rule.category] || []}
|
||||
value={condition.condition}
|
||||
onChange={(val) => handleUpdateCondition(rule.id, condition.id, { condition: val })}
|
||||
placeholder={rule.category ? "Select Condition..." : "Select Rule Category first"}
|
||||
disabled={!rule.category}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col gap-2">
|
||||
<CustomDropdown
|
||||
label='Operator'
|
||||
options={operatorOptions}
|
||||
value={condition.operator}
|
||||
onChange={(val) => handleUpdateCondition(rule.id, condition.id, { operator: val })}
|
||||
placeholder="Selected Option"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col gap-2">
|
||||
<CustomInput
|
||||
label='Value'
|
||||
value={condition.value}
|
||||
onChange={(e) => handleUpdateCondition(rule.id, condition.id, { value: e.target.value })}
|
||||
placeholder="Selected Option"
|
||||
/>
|
||||
</div>
|
||||
{cIdx < rule.conditions.length - 1 ? (
|
||||
<div className="w-[163px] flex flex-col gap-2">
|
||||
<label className="text-[14px] font-medium leading-none text-[#001811]">Logic</label>
|
||||
<LogicDropdown
|
||||
value={condition.logic}
|
||||
onChange={(val) => handleUpdateCondition(rule.id, condition.id, { logic: val })}
|
||||
{rule.conditions.map((condition, cIdx) => {
|
||||
const fieldMeta = conditionFieldsMetaMap[condition.condition];
|
||||
const lookupOpts = conditionValueLookupMap[condition.condition] || [];
|
||||
const isLookup = (fieldMeta && (fieldMeta.lookupTable || fieldMeta.dataType === 'ENUM')) || lookupOpts.length > 0;
|
||||
const isBoolean = fieldMeta?.dataType === 'BOOLEAN';
|
||||
const isNumber = fieldMeta?.dataType === 'NUMBER';
|
||||
|
||||
return (
|
||||
<div key={condition.id} className="flex items-end gap-3">
|
||||
<div className="flex-1 flex flex-col gap-2">
|
||||
<CustomDropdown
|
||||
label='Condition'
|
||||
options={categoryConditionMap[rule.category] || []}
|
||||
value={condition.condition}
|
||||
onChange={(val) => handleConditionFieldSelect(rule.id, condition.id, val)}
|
||||
placeholder={rule.category ? "Select Condition..." : "Select Rule Category first"}
|
||||
disabled={!rule.category}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-[163px] flex items-center">
|
||||
<CustomButton
|
||||
variant="secondary"
|
||||
className="!w-full !justify-center !bg-[#EAFAF5] hover:!bg-[#d9ece4] !px-4 !gap-2.5 !h-[49px] !rounded-[12px] whitespace-nowrap"
|
||||
leftIcon={<PlusIcon size={16} color="#14704E" />}
|
||||
onClick={() => handleAddCondition(rule.id)}
|
||||
>
|
||||
<span className="font-bold text-[14px] leading-none text-center bg-gradient-to-b from-[#1B9869] to-[#14704E] bg-clip-text text-transparent">
|
||||
Add Condition
|
||||
</span>
|
||||
</CustomButton>
|
||||
<div className="flex-1 flex flex-col gap-2">
|
||||
<CustomDropdown
|
||||
label='Operator'
|
||||
options={operatorOptions}
|
||||
value={condition.operator}
|
||||
onChange={(val) => handleUpdateCondition(rule.id, condition.id, { operator: val })}
|
||||
placeholder="Selected Option"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center h-11">
|
||||
{rule.conditions.length > 1 && (
|
||||
<button
|
||||
onClick={() => handleDeleteCondition(rule.id, condition.id)}
|
||||
className="w-11 h-11 flex items-center justify-center bg-red-50 text-[#D40000] rounded-[10px] hover:bg-red-100 transition-colors"
|
||||
>
|
||||
<TrashIcon size={16} strokeWidth={1.5} color="#D40000" />
|
||||
</button>
|
||||
<div className="flex-1 flex flex-col gap-2">
|
||||
{isLookup ? (
|
||||
<CustomDropdown
|
||||
label='Value'
|
||||
options={lookupOpts}
|
||||
value={condition.value}
|
||||
onChange={(val) => handleUpdateCondition(rule.id, condition.id, { value: val })}
|
||||
placeholder="Select Value..."
|
||||
/>
|
||||
) : isBoolean ? (
|
||||
<CustomDropdown
|
||||
label='Value'
|
||||
options={[
|
||||
{ label: 'True', value: 'true' },
|
||||
{ label: 'False', value: 'false' },
|
||||
]}
|
||||
value={condition.value}
|
||||
onChange={(val) => handleUpdateCondition(rule.id, condition.id, { value: val })}
|
||||
placeholder="Select..."
|
||||
/>
|
||||
) : (
|
||||
<CustomInput
|
||||
label='Value'
|
||||
type={isNumber ? 'number' : 'text'}
|
||||
value={condition.value}
|
||||
onChange={(e) => handleUpdateCondition(rule.id, condition.id, { value: e.target.value })}
|
||||
placeholder="Enter Value..."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{cIdx < rule.conditions.length - 1 ? (
|
||||
<div className="w-[163px] flex flex-col gap-2">
|
||||
<label className="text-[14px] font-medium leading-none text-[#001811]">Logic</label>
|
||||
<LogicDropdown
|
||||
value={condition.logic}
|
||||
onChange={(val) => handleUpdateCondition(rule.id, condition.id, { logic: val })}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-[163px] flex items-center">
|
||||
<CustomButton
|
||||
variant="secondary"
|
||||
className="!w-full !justify-center !bg-[#EAFAF5] hover:!bg-[#d9ece4] !px-4 !gap-2.5 !h-[49px] !rounded-[12px] whitespace-nowrap"
|
||||
leftIcon={<PlusIcon size={16} color="#14704E" />}
|
||||
onClick={() => handleAddCondition(rule.id)}
|
||||
>
|
||||
<span className="font-bold text-[14px] leading-none text-center bg-gradient-to-b from-[#1B9869] to-[#14704E] bg-clip-text text-transparent">
|
||||
Add Condition
|
||||
</span>
|
||||
</CustomButton>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center h-11">
|
||||
{rule.conditions.length > 1 && (
|
||||
<button
|
||||
onClick={() => handleDeleteCondition(rule.id, condition.id)}
|
||||
className="w-11 h-11 flex items-center justify-center bg-red-50 text-[#D40000] rounded-[10px] hover:bg-red-100 transition-colors"
|
||||
>
|
||||
<TrashIcon size={16} strokeWidth={1.5} color="#D40000" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user