RBAC, Permissions, and Export/Import

This commit is contained in:
Koncept Kit
2025-12-16 20:04:00 +07:00
parent 02e38e1050
commit 9ed778db1c
30 changed files with 4579 additions and 487 deletions

View File

@@ -0,0 +1,336 @@
import React, { useState } from 'react';
import api from '../utils/api';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from './ui/dialog';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { toast } from 'sonner';
import { Loader2, UserPlus } from 'lucide-react';
const CreateMemberDialog = ({ open, onOpenChange, onSuccess }) => {
const [formData, setFormData] = useState({
email: '',
password: '',
first_name: '',
last_name: '',
phone: '',
address: '',
city: '',
state: '',
zipcode: '',
date_of_birth: '',
member_since: '',
role: 'member'
});
const [loading, setLoading] = useState(false);
const [errors, setErrors] = useState({});
const handleChange = (field, value) => {
setFormData(prev => ({ ...prev, [field]: value }));
// Clear error when user starts typing
if (errors[field]) {
setErrors(prev => ({ ...prev, [field]: null }));
}
};
const validate = () => {
const newErrors = {};
if (!formData.email) {
newErrors.email = 'Email is required';
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
newErrors.email = 'Invalid email format';
}
if (!formData.password || formData.password.length < 8) {
newErrors.password = 'Password must be at least 8 characters';
}
if (!formData.first_name) {
newErrors.first_name = 'First name is required';
}
if (!formData.last_name) {
newErrors.last_name = 'Last name is required';
}
if (!formData.phone) {
newErrors.phone = 'Phone is required';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!validate()) {
return;
}
setLoading(true);
try {
// Format dates for backend
const payload = { ...formData };
if (payload.date_of_birth === '') {
delete payload.date_of_birth;
}
if (payload.member_since === '') {
delete payload.member_since;
}
await api.post('/admin/users/create', payload);
toast.success('Member created successfully');
// Reset form
setFormData({
email: '',
password: '',
first_name: '',
last_name: '',
phone: '',
address: '',
city: '',
state: '',
zipcode: '',
date_of_birth: '',
member_since: '',
role: 'member'
});
onOpenChange(false);
if (onSuccess) onSuccess();
} catch (error) {
const errorMessage = error.response?.data?.detail || 'Failed to create member';
toast.error(errorMessage);
} finally {
setLoading(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[700px] rounded-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-2xl text-[#422268] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
<UserPlus className="h-6 w-6" />
Create Member
</DialogTitle>
<DialogDescription className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Create a new member account with direct login access. Member will be created immediately.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="grid gap-6 py-4">
{/* Email & Password Row */}
<div className="grid md:grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="email" className="text-[#422268]">
Email <span className="text-red-500">*</span>
</Label>
<Input
id="email"
type="email"
value={formData.email}
onChange={(e) => handleChange('email', e.target.value)}
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
placeholder="member@example.com"
/>
{errors.email && (
<p className="text-sm text-red-500">{errors.email}</p>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="password" className="text-[#422268]">
Password <span className="text-red-500">*</span>
</Label>
<Input
id="password"
type="password"
value={formData.password}
onChange={(e) => handleChange('password', e.target.value)}
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
placeholder="Minimum 8 characters"
/>
{errors.password && (
<p className="text-sm text-red-500">{errors.password}</p>
)}
</div>
</div>
{/* Name Row */}
<div className="grid md:grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="first_name" className="text-[#422268]">
First Name <span className="text-red-500">*</span>
</Label>
<Input
id="first_name"
value={formData.first_name}
onChange={(e) => handleChange('first_name', e.target.value)}
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
placeholder="John"
/>
{errors.first_name && (
<p className="text-sm text-red-500">{errors.first_name}</p>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="last_name" className="text-[#422268]">
Last Name <span className="text-red-500">*</span>
</Label>
<Input
id="last_name"
value={formData.last_name}
onChange={(e) => handleChange('last_name', e.target.value)}
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
placeholder="Doe"
/>
{errors.last_name && (
<p className="text-sm text-red-500">{errors.last_name}</p>
)}
</div>
</div>
{/* Phone */}
<div className="grid gap-2">
<Label htmlFor="phone" className="text-[#422268]">
Phone <span className="text-red-500">*</span>
</Label>
<Input
id="phone"
type="tel"
value={formData.phone}
onChange={(e) => handleChange('phone', e.target.value)}
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
placeholder="(555) 123-4567"
/>
{errors.phone && (
<p className="text-sm text-red-500">{errors.phone}</p>
)}
</div>
{/* Address */}
<div className="grid gap-2">
<Label htmlFor="address" className="text-[#422268]">
Address
</Label>
<Input
id="address"
value={formData.address}
onChange={(e) => handleChange('address', e.target.value)}
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
placeholder="123 Main St"
/>
</div>
{/* City, State, Zipcode Row */}
<div className="grid md:grid-cols-3 gap-4">
<div className="grid gap-2">
<Label htmlFor="city" className="text-[#422268]">City</Label>
<Input
id="city"
value={formData.city}
onChange={(e) => handleChange('city', e.target.value)}
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
placeholder="San Francisco"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="state" className="text-[#422268]">State</Label>
<Input
id="state"
value={formData.state}
onChange={(e) => handleChange('state', e.target.value)}
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
placeholder="CA"
maxLength={2}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="zipcode" className="text-[#422268]">Zipcode</Label>
<Input
id="zipcode"
value={formData.zipcode}
onChange={(e) => handleChange('zipcode', e.target.value)}
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
placeholder="94102"
/>
</div>
</div>
{/* Dates Row */}
<div className="grid md:grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="date_of_birth" className="text-[#422268]">Date of Birth</Label>
<Input
id="date_of_birth"
type="date"
value={formData.date_of_birth}
onChange={(e) => handleChange('date_of_birth', e.target.value)}
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="member_since" className="text-[#422268]">Member Since</Label>
<Input
id="member_since"
type="date"
value={formData.member_since}
onChange={(e) => handleChange('member_since', e.target.value)}
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
/>
</div>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
className="rounded-xl"
disabled={loading}
>
Cancel
</Button>
<Button
type="submit"
className="rounded-xl bg-[#81B29A] hover:bg-[#6DA085] text-white"
disabled={loading}
>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Creating...
</>
) : (
<>
<UserPlus className="h-4 w-4 mr-2" />
Create Member
</>
)}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
};
export default CreateMemberDialog;