feat: enhance AddPolicyEngine with logic dropdown and priority management; update PolicyEngineList for search functionality

This commit is contained in:
amee
2026-07-13 18:17:49 +05:30
parent 4b21a57372
commit 741130732f
4 changed files with 183 additions and 81 deletions
@@ -1,6 +1,6 @@
import { useState } from 'react';
import { useState, useRef, useEffect } from 'react';
import {
ArrowLeft, Plus, Trash2, Minus, Bell, Book, Users, GitBranch
ArrowLeft, Plus, Trash2, Minus, Book, Users, GitBranch, ChevronDown
} from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import {
@@ -84,8 +84,70 @@ const ROOM_TYPE_TIER_OPTIONS = [
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.
// Fill = background per gate; border per gate; label/chevron text = #032D20 for all.
const LOGIC_STYLES: Record<string, { bg: string; text: string; border: string }> = {
AND: { bg: 'bg-[#F2FAFF]', text: 'text-[#032D20]', border: 'border-[#1A6597]' },
OR: { bg: 'bg-[#FFFDF2]', text: 'text-[#032D20]', border: 'border-[#977E1A]' },
NOT: { bg: 'bg-[#FFF2F2]', text: 'text-[#032D20]', border: 'border-[#971A1C]' },
};
// ─── Logic Gate Dropdown ─────────────────────────────────────────────────────
// Colored variant of the logic selector — the shared CustomDropdown hardcodes a
// white background and text color, so we render a dedicated colored control here.
function LogicDropdown({ value, onChange, readOnly = false }: { value: string; onChange?: (v: string) => void; readOnly?: boolean }) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (readOnly) return;
const handleClickOutside = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [readOnly]);
const style = LOGIC_STYLES[value] ?? LOGIC_STYLES.AND;
const boxClass = `w-full h-[50px] px-4 rounded-[10px] border flex items-center justify-between gap-2 text-[14px] font-semibold transition-colors shadow-[0_1px_2px_0_rgba(0,0,0,0.05)] ${style.bg} ${style.text} ${style.border}`;
// Fixed / non-editable gate (used by the Strategic Action Builder) — no menu.
if (readOnly) {
return <div className={boxClass}>{value}</div>;
}
return (
<div className="relative" ref={ref}>
<button
type="button"
onClick={() => setOpen(o => !o)}
className={boxClass}
>
{value}
<ChevronDown size={16} className={`transition-transform duration-200 ${open ? 'rotate-180' : ''}`} />
</button>
{open && (
<div className="absolute z-20 mt-1 w-full bg-white border border-gray-200 rounded-lg shadow-lg overflow-hidden">
{LOGIC_OPTIONS.map(opt => (
<button
key={opt.value}
type="button"
onClick={() => { onChange?.(opt.value); setOpen(false); }}
className="w-full text-left px-4 h-[42px] text-[14px] font-medium flex items-center text-gray-700 hover:bg-gray-50"
>
{opt.label}
</button>
))}
</div>
)}
</div>
);
}
// ─── Component ───────────────────────────────────────────────────────────────
export default function AddPolicyEngine() {
@@ -113,11 +175,20 @@ export default function AddPolicyEngine() {
// ─── Handlers ──────────────────────────────────────────────────────────────
// Returns the lowest priority (1-10) not already used by an existing rule.
const findFreePriority = () => {
const taken = rules.map(r => r.priority);
for (let p = 1; p <= 10; p++) {
if (!taken.includes(p)) return p;
}
return 1; // all 10 slots taken (>10 rules) — warning handles this edge case
};
const handleAddRule = () => {
setRules([...rules, {
id: crypto.randomUUID(),
category: '',
priority: 1,
priority: findFreePriority(),
conditions: [{ id: crypto.randomUUID(), condition: '', operator: '', value: '', logic: 'AND' }],
actions: [{ id: crypto.randomUUID(), actionType: '', roomTypeTier: '', duration: '', logic: 'AND' }]
}]);
@@ -131,6 +202,28 @@ export default function AddPolicyEngine() {
setRules(rules.map(r => r.id === ruleId ? { ...r, ...updates } : r));
};
// Priorities used by every OTHER rule — a rule may never land on one of these.
const priorityTakenByOthers = (rule: Rule) =>
rules.filter(r => r.id !== rule.id).map(r => r.priority);
// Next free priority below the current value (down to 1), or null if none.
const getPrevPriority = (rule: Rule): number | null => {
const taken = priorityTakenByOthers(rule);
for (let p = rule.priority - 1; p >= 1; p--) {
if (!taken.includes(p)) return p;
}
return null;
};
// Next free priority above the current value (up to 10), or null if none.
const getNextPriority = (rule: Rule): number | null => {
const taken = priorityTakenByOthers(rule);
for (let p = rule.priority + 1; p <= 10; p++) {
if (!taken.includes(p)) return p;
}
return null;
};
const handleAddCondition = (ruleId: string) => {
setRules(rules.map(r => {
if (r.id === ruleId) {
@@ -209,7 +302,7 @@ export default function AddPolicyEngine() {
);
return (
<div className="flex flex-col h-full bg-[#FAFAFA] min-h-screen">
<div className="flex flex-col h-full bg-white min-h-screen">
{/* ─── Header ────────────────────────────────────────────────────── */}
<div className="flex items-center justify-between px-8 py-4 bg-white border-b border-gray-200">
@@ -226,9 +319,6 @@ export default function AddPolicyEngine() {
</div>
</div>
<div className="flex items-center gap-3">
<button className="p-2 text-gray-400 hover:text-gray-600 transition-colors">
<Bell size={20} />
</button>
<CustomButton
variant="primary"
className="!bg-[#1E7D5C] hover:!bg-[#17664B] !h-10 !px-4 !rounded-[10px]"
@@ -244,7 +334,7 @@ export default function AddPolicyEngine() {
<div className="max-w-[1200px] mx-auto flex flex-col gap-6">
{/* Policy Information Card */}
<div className="bg-white rounded-[16px] p-6 shadow-sm border border-gray-100">
<div className="bg-[#FAFAFA] rounded-[16px] p-6 border border-gray-100">
<CardHeader icon={Book} title="Policy Information" />
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
@@ -305,7 +395,7 @@ export default function AddPolicyEngine() {
</div>
{/* Target Audience Card */}
<div className="bg-white rounded-[16px] p-6 shadow-sm border border-gray-100">
<div className="bg-[#FAFAFA] rounded-[16px] p-6 border border-gray-100">
<CardHeader icon={Users} title="Target Audience" />
<div className="flex flex-col gap-6">
@@ -347,22 +437,30 @@ export default function AddPolicyEngine() {
</div>
{/* Rule Engine Card */}
<div className="bg-white rounded-[16px] p-6 shadow-sm border border-gray-100">
<div className="bg-[#FAFAFA] rounded-[16px] p-6 border border-gray-100">
<div className="flex items-center justify-between mb-6">
<CardHeader icon={GitBranch} title="Rule Engine" />
<CustomButton
variant="outlined"
className="!border-[#1E7D5C] !text-[#1E7D5C] hover:!bg-[#E8F3EF] !rounded-[10px]"
leftIcon={<Plus size={16} />}
<button
type="button"
onClick={handleAddRule}
className="flex items-center justify-center gap-2.5 min-w-[181px] h-10 px-4 rounded-[12px] whitespace-nowrap transition-opacity hover:opacity-90"
style={{
border: '2px solid transparent',
backgroundImage: 'linear-gradient(#fff, #fff), linear-gradient(180deg, #1B9869 0%, #14704E 100%)',
backgroundOrigin: 'border-box',
backgroundClip: 'padding-box, border-box',
}}
>
<Plus size={16} color="#14704E" />
<span className="font-bold text-[14px] leading-none bg-gradient-to-b from-[#1B9869] to-[#14704E] bg-clip-text text-transparent">
Add Strategic Rule
</CustomButton>
</span>
</button>
</div>
<div className="flex flex-col gap-6">
{rules.map((rule, ruleIndex) => (
<div key={rule.id} className="border border-gray-100 rounded-[12px] p-6 bg-[#FAFAFA] relative">
<div key={rule.id} className="border border-gray-100 rounded-[12px] p-6 bg-white relative">
{/* Rule Header */}
<div className="flex items-center justify-between mb-5">
@@ -380,7 +478,7 @@ export default function AddPolicyEngine() {
{/* Rule Settings */}
<div className="flex items-end gap-6 mb-8">
<div className="w-1/3 flex flex-col gap-2">
<label className="text-[13px] font-semibold text-gray-700">Rule Category</label>
<label className="text-[14px] font-medium leading-none text-[#001811]">Rule Category</label>
<CustomDropdown
options={RULE_CATEGORY_OPTIONS}
value={rule.category}
@@ -389,11 +487,16 @@ export default function AddPolicyEngine() {
/>
</div>
<div className="flex flex-col gap-2">
<label className="text-[13px] font-semibold text-gray-700">Priority</label>
<label className="text-[14px] font-medium leading-none text-[#001811]">Priority</label>
{(() => {
const prevPriority = getPrevPriority(rule);
const nextPriority = getNextPriority(rule);
return (
<div className="flex items-center border border-gray-200 rounded-[10px] h-11 bg-white overflow-hidden w-[120px]">
<button
onClick={() => handleUpdateRule(rule.id, { priority: Math.max(1, rule.priority - 1) })}
className="flex-1 flex items-center justify-center h-full hover:bg-gray-50 text-gray-500"
onClick={() => prevPriority !== null && handleUpdateRule(rule.id, { priority: prevPriority })}
disabled={prevPriority === null}
className="flex-1 flex items-center justify-center h-full hover:bg-gray-50 text-gray-500 disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-transparent"
>
<Minus size={16} />
</button>
@@ -401,12 +504,15 @@ export default function AddPolicyEngine() {
{rule.priority}
</span>
<button
onClick={() => handleUpdateRule(rule.id, { priority: Math.min(10, rule.priority + 1) })}
className="flex-1 flex items-center justify-center h-full hover:bg-gray-50 text-gray-500"
onClick={() => nextPriority !== null && handleUpdateRule(rule.id, { priority: nextPriority })}
disabled={nextPriority === null}
className="flex-1 flex items-center justify-center h-full hover:bg-gray-50 text-gray-500 disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-transparent"
>
<Plus size={16} />
</button>
</div>
);
})()}
{rules.some(r => r.id !== rule.id && r.priority === rule.priority) && (
<span className="text-red-500 text-xs mt-1">Priority already exists</span>
)}
@@ -420,7 +526,7 @@ export default function AddPolicyEngine() {
{rule.conditions.map((condition, cIdx) => (
<div key={condition.id} className="flex items-end gap-3">
<div className="flex-1 flex flex-col gap-2">
<label className="text-[12px] font-medium text-gray-500">Condition</label>
<label className="text-[14px] font-medium leading-none text-[#001811]">Condition</label>
<CustomDropdown
options={CONDITION_OPTIONS}
value={condition.condition}
@@ -429,7 +535,7 @@ export default function AddPolicyEngine() {
/>
</div>
<div className="flex-1 flex flex-col gap-2">
<label className="text-[12px] font-medium text-gray-500">Operator</label>
<label className="text-[14px] font-medium leading-none text-[#001811]">Operator</label>
<CustomDropdown
options={OPERATOR_OPTIONS}
value={condition.operator}
@@ -438,7 +544,7 @@ export default function AddPolicyEngine() {
/>
</div>
<div className="flex-1 flex flex-col gap-2">
<label className="text-[12px] font-medium text-gray-500">Value</label>
<label className="text-[14px] font-medium leading-none text-[#001811]">Value</label>
<CustomInput
value={condition.value}
onChange={(e) => handleUpdateCondition(rule.id, condition.id, { value: e.target.value })}
@@ -446,24 +552,24 @@ export default function AddPolicyEngine() {
/>
</div>
{cIdx < rule.conditions.length - 1 ? (
<div className="w-[120px] flex flex-col gap-2">
<label className="text-[12px] font-medium text-gray-500">Logic</label>
<CustomDropdown
options={LOGIC_OPTIONS}
<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 })}
placeholder="AND"
/>
</div>
) : (
<div className="flex items-center">
<div className="w-[163px] flex items-center">
<CustomButton
variant="secondary"
className="!bg-[#E8F3EF] !text-[#1E7D5C] hover:!bg-[#d9ece4] !px-3 !h-11"
leftIcon={<Plus size={16} />}
className="!w-full !justify-center !bg-[#EAFAF5] hover:!bg-[#d9ece4] !px-4 !gap-2.5 !h-[49px] !rounded-[12px] whitespace-nowrap"
leftIcon={<Plus 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>
)}
@@ -471,9 +577,9 @@ export default function AddPolicyEngine() {
{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-red-500 rounded-[10px] hover:bg-red-100 transition-colors"
className="w-11 h-11 flex items-center justify-center bg-red-50 text-[#D40000] rounded-[10px] hover:bg-red-100 transition-colors"
>
<Trash2 size={16} />
<Trash2 size={16} strokeWidth={1.5} color="#D40000" />
</button>
)}
</div>
@@ -489,7 +595,7 @@ export default function AddPolicyEngine() {
{rule.actions.map((action, aIdx) => (
<div key={action.id} className="flex items-end gap-3">
<div className="flex-1 flex flex-col gap-2">
<label className="text-[12px] font-medium text-gray-500">Action Type</label>
<label className="text-[14px] font-medium leading-none text-[#001811]">Action Type</label>
<CustomDropdown
options={ACTION_TYPE_OPTIONS}
value={action.actionType}
@@ -498,7 +604,7 @@ export default function AddPolicyEngine() {
/>
</div>
<div className="flex-1 flex flex-col gap-2">
<label className="text-[12px] font-medium text-gray-500">Room Type / Tier</label>
<label className="text-[14px] font-medium leading-none text-[#001811]">Room Type / Tier</label>
<CustomDropdown
options={ROOM_TYPE_TIER_OPTIONS}
value={action.roomTypeTier}
@@ -507,7 +613,7 @@ export default function AddPolicyEngine() {
/>
</div>
<div className="flex-1 flex flex-col gap-2">
<label className="text-[12px] font-medium text-gray-500">Duration</label>
<label className="text-[14px] font-medium leading-none text-[#001811]">Duration</label>
<CustomInput
value={action.duration}
onChange={(e) => handleUpdateAction(rule.id, action.id, { duration: e.target.value })}
@@ -515,24 +621,21 @@ export default function AddPolicyEngine() {
/>
</div>
{aIdx < rule.actions.length - 1 ? (
<div className="w-[120px] flex flex-col gap-2">
<label className="text-[12px] font-medium text-gray-500">Logic</label>
<CustomDropdown
options={LOGIC_OPTIONS}
value={action.logic}
onChange={(val) => handleUpdateAction(rule.id, action.id, { logic: val })}
placeholder="AND"
/>
<div className="w-[163px] flex flex-col gap-2">
<label className="text-[14px] font-medium leading-none text-[#001811]">Logic</label>
<LogicDropdown value={action.logic} readOnly />
</div>
) : (
<div className="flex items-center">
<div className="w-[163px] flex items-center">
<CustomButton
variant="secondary"
className="!bg-[#E8F3EF] !text-[#1E7D5C] hover:!bg-[#d9ece4] !px-3 !h-11"
leftIcon={<Plus size={16} />}
className="!w-full !justify-center !bg-[#EAFAF5] hover:!bg-[#d9ece4] !px-4 !gap-2.5 !h-[49px] !rounded-[12px] whitespace-nowrap"
leftIcon={<Plus size={16} color="#14704E" />}
onClick={() => handleAddAction(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
</span>
</CustomButton>
</div>
)}
@@ -540,9 +643,9 @@ export default function AddPolicyEngine() {
{rule.actions.length > 1 && (
<button
onClick={() => handleDeleteAction(rule.id, action.id)}
className="w-11 h-11 flex items-center justify-center bg-red-50 text-red-500 rounded-[10px] hover:bg-red-100 transition-colors"
className="w-11 h-11 flex items-center justify-center bg-red-50 text-[#D40000] rounded-[10px] hover:bg-red-100 transition-colors"
>
<Trash2 size={16} />
<Trash2 size={16} strokeWidth={1.5} color="#D40000" />
</button>
)}
</div>
@@ -564,7 +667,7 @@ export default function AddPolicyEngine() {
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 px-8 py-4 flex items-center justify-between z-10 shadow-[0_-4px_10px_rgba(0,0,0,0.02)]">
<div className="flex items-center gap-3">
<span className="text-[14px] font-semibold text-gray-600">Status:</span>
<CustomStatus status="Active" />
<CustomStatus status={status} />
</div>
<div className="flex items-center gap-3">
<CustomButton
@@ -119,8 +119,9 @@ export default function PolicyEngineList() {
}, [search]);
useEffect(() => {
// Trigger a new fetch when page or search changes
fetchPolicies(currentPage);
}, [currentPage, fetchPolicies]);
}, [currentPage, search, fetchPolicies]);
// ─── Handlers ──────────────────────────────────────────────────────────────
+5 -7
View File
@@ -156,20 +156,18 @@ const CustomMultiSelect = React.forwardRef<HTMLDivElement, CustomMultiSelectProp
handleSelect(option);
}}
className={`
w-full text-left px-4 py-2.5 text-[15px]
w-full text-left px-4 min-h-[44px] text-[15px]
transition-colors duration-150 flex items-center gap-2
${isSelected
? "bg-[#F0FDF4] text-[#1B9869] font-medium"
? "bg-[#EAF7EE] text-[#1B9869] font-medium"
: option.disabled
? "text-gray-400 cursor-not-allowed"
: "text-gray-700 hover:bg-gray-50"
: "text-[#0F172B] hover:bg-gray-50"
}
`}
>
<div className={`w-4 h-4 rounded border flex items-center justify-center shrink-0 ${isSelected ? 'bg-[#1B9869] border-[#1B9869]' : 'border-gray-300'}`}>
{isSelected && <Check size={12} className="text-white" strokeWidth={3} />}
</div>
<span className="ml-1">{option.label}</span>
{isSelected && <Check size={16} className="text-[#1B9869] shrink-0" strokeWidth={2.5} />}
<span className={isSelected ? "ml-1" : "ml-6"}>{option.label}</span>
</button>
);
})
+1 -1
View File
@@ -34,7 +34,7 @@ const CustomStatus: React.FC<CustomStatusProps> = ({
const finalVariant = variant || getVariantFromStatus(status);
const styles = {
success: { bg: "bg-[#E4FAE7]", text: "text-[#1B9869]", dot: "bg-[#1B9869]", border: "border border-[#1B9869]" },
success: { bg: "bg-[#E4FAE7]", text: "text-[#1B9869]", dot: "bg-[#1B9869]", border: "" },
error: { bg: "bg-rose-50", text: "text-rose-600", dot: "bg-rose-600", border: "" },
warning: { bg: "bg-amber-50", text: "text-amber-600", dot: "bg-amber-600", border: "" },
info: { bg: "bg-sky-50", text: "text-sky-600", dot: "bg-sky-600", border: "" },