feat: Complete admin dashboard overhaul with professional features
- Add Events Timeline tab showing chronological project activity - Add Create User modal with email, password, plan, role selection - Add Delete User button with confirmation dialog - Add Plan and Role management dropdowns per user - Add event type summary cards with color-coded icons - Add period selector for events (7d, 14d, 30d, 90d) - Add i18n translations for all new features (EN + AR) - Add new API functions: createAdminUser, deleteAdminUser, updateAdminUserPlan, updateAdminUserRole, getProjectEvents
This commit is contained in:
@@ -44,10 +44,17 @@ import {
|
|||||||
type InternalAdminUser,
|
type InternalAdminUser,
|
||||||
getDatabaseStats,
|
getDatabaseStats,
|
||||||
type DatabaseStats,
|
type DatabaseStats,
|
||||||
|
getProjectEvents,
|
||||||
|
type ProjectEvent,
|
||||||
|
type ProjectEventsResponse,
|
||||||
|
createAdminUser,
|
||||||
|
deleteAdminUser,
|
||||||
|
updateAdminUserPlan,
|
||||||
|
updateAdminUserRole,
|
||||||
} from '@/services/api';
|
} from '@/services/api';
|
||||||
import { useAuthStore } from '@/stores/authStore';
|
import { useAuthStore } from '@/stores/authStore';
|
||||||
|
|
||||||
type AdminTab = 'overview' | 'users' | 'tools' | 'ratings' | 'contacts' | 'system' | 'database';
|
type AdminTab = 'overview' | 'users' | 'tools' | 'ratings' | 'contacts' | 'system' | 'database' | 'events';
|
||||||
type Lang = 'ar' | 'en';
|
type Lang = 'ar' | 'en';
|
||||||
|
|
||||||
const TRANSLATIONS: Record<Lang, Record<string, string>> = {
|
const TRANSLATIONS: Record<Lang, Record<string, string>> = {
|
||||||
@@ -78,6 +85,7 @@ const TRANSLATIONS: Record<Lang, Record<string, string>> = {
|
|||||||
tabContacts: 'Inbox',
|
tabContacts: 'Inbox',
|
||||||
tabSystem: 'System Health',
|
tabSystem: 'System Health',
|
||||||
tabDatabase: 'Database',
|
tabDatabase: 'Database',
|
||||||
|
tabEvents: 'Events Timeline',
|
||||||
// Overview cards
|
// Overview cards
|
||||||
totalUsers: 'Total users',
|
totalUsers: 'Total users',
|
||||||
filesProcessed: 'Files processed',
|
filesProcessed: 'Files processed',
|
||||||
@@ -192,6 +200,32 @@ const TRANSLATIONS: Record<Lang, Record<string, string>> = {
|
|||||||
updatePlanError: 'Unable to update plan.',
|
updatePlanError: 'Unable to update plan.',
|
||||||
updateRoleError: 'Unable to update role.',
|
updateRoleError: 'Unable to update role.',
|
||||||
updateContactError: 'Unable to update contact message.',
|
updateContactError: 'Unable to update contact message.',
|
||||||
|
// Events tab
|
||||||
|
eventsTimeline: 'Events Timeline',
|
||||||
|
eventsDesc: 'Chronological view of all important project activities.',
|
||||||
|
eventUserRegistered: 'User registered',
|
||||||
|
eventFileProcessed: 'File processed',
|
||||||
|
eventFileFailed: 'File failed',
|
||||||
|
eventContactMessage: 'Contact message',
|
||||||
|
eventSummary: 'Event Summary',
|
||||||
|
totalEvents: 'Total events',
|
||||||
|
periodDays: 'Last {days} days',
|
||||||
|
// User management
|
||||||
|
createUser: 'Create User',
|
||||||
|
deleteUser: 'Delete',
|
||||||
|
deleteUserConfirm: 'Are you sure you want to delete this user? This cannot be undone.',
|
||||||
|
createUserTitle: 'Create New User',
|
||||||
|
createUserDesc: 'Add a new user to the system.',
|
||||||
|
emailLabel: 'Email',
|
||||||
|
passwordLabel: 'Password',
|
||||||
|
planLabel: 'Plan',
|
||||||
|
roleLabel: 'Role',
|
||||||
|
createBtn: 'Create',
|
||||||
|
cancelBtn: 'Cancel',
|
||||||
|
userCreated: 'User created successfully.',
|
||||||
|
userDeleted: 'User deleted successfully.',
|
||||||
|
planUpdated: 'Plan updated successfully.',
|
||||||
|
roleUpdated: 'Role updated successfully.',
|
||||||
},
|
},
|
||||||
ar: {
|
ar: {
|
||||||
// Page & header
|
// Page & header
|
||||||
@@ -220,6 +254,7 @@ const TRANSLATIONS: Record<Lang, Record<string, string>> = {
|
|||||||
tabContacts: 'صندوق الوارد',
|
tabContacts: 'صندوق الوارد',
|
||||||
tabSystem: 'صحة النظام',
|
tabSystem: 'صحة النظام',
|
||||||
tabDatabase: 'قاعدة البيانات',
|
tabDatabase: 'قاعدة البيانات',
|
||||||
|
tabEvents: 'الجدول الزمني للأحداث',
|
||||||
// Overview cards
|
// Overview cards
|
||||||
totalUsers: 'إجمالي المستخدمين',
|
totalUsers: 'إجمالي المستخدمين',
|
||||||
filesProcessed: 'الملفات المعالجة',
|
filesProcessed: 'الملفات المعالجة',
|
||||||
@@ -334,6 +369,32 @@ const TRANSLATIONS: Record<Lang, Record<string, string>> = {
|
|||||||
updatePlanError: 'تعذّر تحديث الخطة.',
|
updatePlanError: 'تعذّر تحديث الخطة.',
|
||||||
updateRoleError: 'تعذّر تحديث الدور.',
|
updateRoleError: 'تعذّر تحديث الدور.',
|
||||||
updateContactError: 'تعذّر تحديث رسالة التواصل.',
|
updateContactError: 'تعذّر تحديث رسالة التواصل.',
|
||||||
|
// Events tab
|
||||||
|
eventsTimeline: 'الجدول الزمني للأحداث',
|
||||||
|
eventsDesc: 'عرض زمني لجميع أنشطة المشروع المهمة.',
|
||||||
|
eventUserRegistered: 'تسجيل مستخدم',
|
||||||
|
eventFileProcessed: 'معالجة ملف',
|
||||||
|
eventFileFailed: 'فشل ملف',
|
||||||
|
eventContactMessage: 'رسالة تواصل',
|
||||||
|
eventSummary: 'ملخص الأحداث',
|
||||||
|
totalEvents: 'إجمالي الأحداث',
|
||||||
|
periodDays: 'آخر {days} يوم',
|
||||||
|
// User management
|
||||||
|
createUser: 'إنشاء مستخدم',
|
||||||
|
deleteUser: 'حذف',
|
||||||
|
deleteUserConfirm: 'هل أنت متأكد من حذف هذا المستخدم؟ لا يمكن التراجع عن هذا.',
|
||||||
|
createUserTitle: 'إنشاء مستخدم جديد',
|
||||||
|
createUserDesc: 'إضافة مستخدم جديد إلى النظام.',
|
||||||
|
emailLabel: 'البريد الإلكتروني',
|
||||||
|
passwordLabel: 'كلمة المرور',
|
||||||
|
planLabel: 'الخطة',
|
||||||
|
roleLabel: 'الدور',
|
||||||
|
createBtn: 'إنشاء',
|
||||||
|
cancelBtn: 'إلغاء',
|
||||||
|
userCreated: 'تم إنشاء المستخدم بنجاح.',
|
||||||
|
userDeleted: 'تم حذف المستخدم بنجاح.',
|
||||||
|
planUpdated: 'تم تحديث الخطة بنجاح.',
|
||||||
|
roleUpdated: 'تم تحديث الدور بنجاح.',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -403,6 +464,17 @@ export default function InternalAdminPage() {
|
|||||||
// Database state
|
// Database state
|
||||||
const [databaseStats, setDatabaseStats] = useState<DatabaseStats | null>(null);
|
const [databaseStats, setDatabaseStats] = useState<DatabaseStats | null>(null);
|
||||||
|
|
||||||
|
// Events state
|
||||||
|
const [projectEvents, setProjectEvents] = useState<ProjectEventsResponse | null>(null);
|
||||||
|
const [eventsDays, setEventsDays] = useState(30);
|
||||||
|
|
||||||
|
// User management state
|
||||||
|
const [showCreateUser, setShowCreateUser] = useState(false);
|
||||||
|
const [newUserEmail, setNewUserEmail] = useState('');
|
||||||
|
const [newUserPassword, setNewUserPassword] = useState('');
|
||||||
|
const [newUserPlan, setNewUserPlan] = useState('free');
|
||||||
|
const [newUserRole, setNewUserRole] = useState('user');
|
||||||
|
|
||||||
// Language
|
// Language
|
||||||
const [lang, setLang] = useState<Lang>(() => (localStorage.getItem('admin-lang') as Lang) ?? 'en');
|
const [lang, setLang] = useState<Lang>(() => (localStorage.getItem('admin-lang') as Lang) ?? 'en');
|
||||||
const isRtl = lang === 'ar';
|
const isRtl = lang === 'ar';
|
||||||
@@ -431,6 +503,7 @@ export default function InternalAdminPage() {
|
|||||||
{ key: 'contacts', label: t('tabContacts'), icon: Inbox },
|
{ key: 'contacts', label: t('tabContacts'), icon: Inbox },
|
||||||
{ key: 'system', label: t('tabSystem'), icon: ShieldCheck },
|
{ key: 'system', label: t('tabSystem'), icon: ShieldCheck },
|
||||||
{ key: 'database', label: t('tabDatabase'), icon: Database },
|
{ key: 'database', label: t('tabDatabase'), icon: Database },
|
||||||
|
{ key: 'events', label: t('tabEvents'), icon: Clock },
|
||||||
];
|
];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -499,6 +572,11 @@ export default function InternalAdminPage() {
|
|||||||
setDatabaseStats(dbStats);
|
setDatabaseStats(dbStats);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 'events': {
|
||||||
|
const events = await getProjectEvents(eventsDays);
|
||||||
|
setProjectEvents(events);
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : t('loadError');
|
const msg = e instanceof Error ? e.message : t('loadError');
|
||||||
@@ -531,7 +609,7 @@ export default function InternalAdminPage() {
|
|||||||
setUpdatingUserId(userId);
|
setUpdatingUserId(userId);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await updateInternalAdminUserPlan(userId, plan);
|
await updateAdminUserPlan(userId, plan);
|
||||||
await loadTab('users');
|
await loadTab('users');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : t('updatePlanError');
|
const msg = e instanceof Error ? e.message : t('updatePlanError');
|
||||||
@@ -547,7 +625,7 @@ export default function InternalAdminPage() {
|
|||||||
setUpdatingRoleUserId(userId);
|
setUpdatingRoleUserId(userId);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await updateInternalAdminUserRole(userId, role);
|
await updateAdminUserRole(userId, role);
|
||||||
await loadTab('users');
|
await loadTab('users');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : t('updateRoleError');
|
const msg = e instanceof Error ? e.message : t('updateRoleError');
|
||||||
@@ -819,12 +897,13 @@ export default function InternalAdminPage() {
|
|||||||
<article className="rounded-3xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-700 dark:bg-slate-900/70">
|
<article className="rounded-3xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-700 dark:bg-slate-900/70">
|
||||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
<h2 className="text-xl font-bold text-slate-900 dark:text-white">{t('userManagement')}</h2>
|
<h2 className="text-xl font-bold text-slate-900 dark:text-white">{t('userManagement')}</h2>
|
||||||
|
<div className="flex w-full max-w-md items-center gap-2">
|
||||||
<form
|
<form
|
||||||
onSubmit={(e: FormEvent<HTMLFormElement>) => {
|
onSubmit={(e: FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
void loadTab('users');
|
void loadTab('users');
|
||||||
}}
|
}}
|
||||||
className="flex w-full max-w-md items-center gap-2"
|
className="flex flex-1 items-center gap-2"
|
||||||
>
|
>
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<Search className="pointer-events-none absolute start-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
<Search className="pointer-events-none absolute start-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||||||
@@ -843,6 +922,14 @@ export default function InternalAdminPage() {
|
|||||||
{t('searchBtn')}
|
{t('searchBtn')}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowCreateUser(true)}
|
||||||
|
className="shrink-0 rounded-2xl bg-primary-600 px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-primary-700"
|
||||||
|
>
|
||||||
|
{t('createUser')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 overflow-x-auto">
|
<div className="mt-6 overflow-x-auto">
|
||||||
@@ -909,6 +996,13 @@ export default function InternalAdminPage() {
|
|||||||
>
|
>
|
||||||
{t('btnAdmin')}
|
{t('btnAdmin')}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleDeleteUser(u.id)}
|
||||||
|
className="rounded-full border border-red-300 px-3 py-1.5 text-xs font-semibold text-red-700 transition-colors hover:bg-red-50 dark:border-red-600 dark:text-red-300 dark:hover:bg-red-500/20"
|
||||||
|
>
|
||||||
|
{t('deleteUser')}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -1481,6 +1575,160 @@ export default function InternalAdminPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ====================== EVENTS TAB ======================
|
||||||
|
|
||||||
|
function renderEventsTab() {
|
||||||
|
if (!projectEvents) return null;
|
||||||
|
|
||||||
|
const eventColors: Record<string, string> = {
|
||||||
|
user_registered: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-300',
|
||||||
|
file_processed: 'bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300',
|
||||||
|
file_failed: 'bg-red-100 text-red-700 dark:bg-red-500/20 dark:text-red-300',
|
||||||
|
contact_message: 'bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-300',
|
||||||
|
};
|
||||||
|
|
||||||
|
const eventIcons: Record<string, typeof Activity> = {
|
||||||
|
user_registered: Users,
|
||||||
|
file_processed: Zap,
|
||||||
|
file_failed: AlertTriangle,
|
||||||
|
contact_message: MessageSquare,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Summary cards */}
|
||||||
|
<section className="grid gap-4 md:grid-cols-4">
|
||||||
|
<article className="rounded-3xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-700 dark:bg-slate-900/70">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-slate-500 dark:text-slate-400">{t('totalEvents')}</p>
|
||||||
|
<p className="mt-3 text-2xl font-bold text-slate-900 dark:text-white">{projectEvents.total_events.toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
<BarChart3 className="h-5 w-5 text-slate-400" />
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{Object.entries(projectEvents.summary).map(([type, count]) => {
|
||||||
|
const Icon = eventIcons[type] || Activity;
|
||||||
|
return (
|
||||||
|
<article key={type} className="rounded-3xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-700 dark:bg-slate-900/70">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-slate-500 dark:text-slate-400">
|
||||||
|
{t(`event${type.split('_').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join('')}`)}
|
||||||
|
</p>
|
||||||
|
<p className="mt-3 text-2xl font-bold text-slate-900 dark:text-white">{count.toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
<Icon className="h-5 w-5 text-slate-400" />
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Period selector */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-sm text-slate-500 dark:text-slate-400">{t('periodDays').replace('{days}', String(eventsDays))}</span>
|
||||||
|
{[7, 14, 30, 90].map(d => (
|
||||||
|
<button
|
||||||
|
key={d}
|
||||||
|
onClick={() => { setEventsDays(d); void loadTab('events'); }}
|
||||||
|
className={`rounded-full px-4 py-1.5 text-sm font-medium transition-colors ${
|
||||||
|
eventsDays === d
|
||||||
|
? 'bg-primary-600 text-white'
|
||||||
|
: 'border border-slate-300 text-slate-600 hover:border-slate-400 dark:border-slate-600 dark:text-slate-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{d}d
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Events timeline */}
|
||||||
|
<article className="rounded-3xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-700 dark:bg-slate-900/70">
|
||||||
|
<h2 className="text-xl font-bold text-slate-900 dark:text-white">{t('eventsTimeline')}</h2>
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
{projectEvents.events.length === 0 ? (
|
||||||
|
<p className="py-8 text-center text-slate-500 dark:text-slate-400">No events found.</p>
|
||||||
|
) : (
|
||||||
|
projectEvents.events.map((event, i) => {
|
||||||
|
const Icon = eventIcons[event.type] || Activity;
|
||||||
|
const colorClass = eventColors[event.type] || 'bg-slate-100 text-slate-700 dark:bg-slate-500/20 dark:text-slate-300';
|
||||||
|
return (
|
||||||
|
<div key={`${event.entity_id}-${i}`} className="flex items-start gap-4 rounded-2xl border border-slate-100 p-4 dark:border-slate-800">
|
||||||
|
<div className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-xl ${colorClass}`}>
|
||||||
|
<Icon className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`inline-block rounded-full px-2.5 py-0.5 text-xs font-medium ${colorClass}`}>
|
||||||
|
{t(`event${event.type.split('_').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join('')}`)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 truncate text-sm text-slate-700 dark:text-slate-200">{event.detail}</p>
|
||||||
|
<p className="mt-0.5 text-xs text-slate-400 dark:text-slate-500">{new Date(event.time).toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====================== CREATE USER MODAL ======================
|
||||||
|
|
||||||
|
async function handleCreateUser(event: FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!newUserEmail || !newUserPassword) return;
|
||||||
|
try {
|
||||||
|
await createAdminUser(newUserEmail, newUserPassword, newUserPlan, newUserRole);
|
||||||
|
toast.success(t('userCreated'));
|
||||||
|
setShowCreateUser(false);
|
||||||
|
setNewUserEmail('');
|
||||||
|
setNewUserPassword('');
|
||||||
|
setNewUserPlan('free');
|
||||||
|
setNewUserRole('user');
|
||||||
|
void loadTab('users');
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : t('loadError');
|
||||||
|
toast.error(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteUser(userId: number) {
|
||||||
|
if (!confirm(t('deleteUserConfirm'))) return;
|
||||||
|
try {
|
||||||
|
await deleteAdminUser(userId);
|
||||||
|
toast.success(t('userDeleted'));
|
||||||
|
void loadTab('users');
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : t('loadError');
|
||||||
|
toast.error(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpdateUserPlan(userId: number, plan: string) {
|
||||||
|
try {
|
||||||
|
await updateAdminUserPlan(userId, plan);
|
||||||
|
toast.success(t('planUpdated'));
|
||||||
|
void loadTab('users');
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(t('updatePlanError'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpdateUserRole(userId: number, role: string) {
|
||||||
|
try {
|
||||||
|
await updateAdminUserRole(userId, role);
|
||||||
|
toast.success(t('roleUpdated'));
|
||||||
|
void loadTab('users');
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(t('updateRoleError'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ====================== MAIN RENDER ======================
|
// ====================== MAIN RENDER ======================
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1650,9 +1898,82 @@ export default function InternalAdminPage() {
|
|||||||
{activeTab === 'contacts' && renderContactsTab()}
|
{activeTab === 'contacts' && renderContactsTab()}
|
||||||
{activeTab === 'system' && renderSystemTab()}
|
{activeTab === 'system' && renderSystemTab()}
|
||||||
{activeTab === 'database' && renderDatabaseTab()}
|
{activeTab === 'database' && renderDatabaseTab()}
|
||||||
|
{activeTab === 'events' && renderEventsTab()}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Create User Modal */}
|
||||||
|
{showCreateUser && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={() => setShowCreateUser(false)}>
|
||||||
|
<div className="w-full max-w-md rounded-3xl border border-slate-200 bg-white p-6 shadow-xl dark:border-slate-700 dark:bg-slate-900" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h2 className="text-xl font-bold text-slate-900 dark:text-white">{t('createUserTitle')}</h2>
|
||||||
|
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">{t('createUserDesc')}</p>
|
||||||
|
<form onSubmit={handleCreateUser} className="mt-5 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300">{t('emailLabel')}</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={newUserEmail}
|
||||||
|
onChange={(e) => setNewUserEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
className="mt-1 w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-900 outline-none transition focus:border-primary-500 focus:ring-2 focus:ring-primary-200 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300">{t('passwordLabel')}</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={newUserPassword}
|
||||||
|
onChange={(e) => setNewUserPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
className="mt-1 w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-900 outline-none transition focus:border-primary-500 focus:ring-2 focus:ring-primary-200 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300">{t('planLabel')}</label>
|
||||||
|
<select
|
||||||
|
value={newUserPlan}
|
||||||
|
onChange={(e) => setNewUserPlan(e.target.value)}
|
||||||
|
className="mt-1 w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-900 outline-none transition focus:border-primary-500 focus:ring-2 focus:ring-primary-200 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-100"
|
||||||
|
>
|
||||||
|
<option value="free">Free</option>
|
||||||
|
<option value="pro">Pro</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300">{t('roleLabel')}</label>
|
||||||
|
<select
|
||||||
|
value={newUserRole}
|
||||||
|
onChange={(e) => setNewUserRole(e.target.value)}
|
||||||
|
className="mt-1 w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-900 outline-none transition focus:border-primary-500 focus:ring-2 focus:ring-primary-200 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-100"
|
||||||
|
>
|
||||||
|
<option value="user">User</option>
|
||||||
|
<option value="admin">Admin</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="flex-1 rounded-xl bg-primary-600 px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-primary-700"
|
||||||
|
>
|
||||||
|
{t('createBtn')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowCreateUser(false)}
|
||||||
|
className="flex-1 rounded-xl border border-slate-300 px-4 py-2.5 text-sm font-semibold text-slate-700 transition-colors hover:border-slate-400 dark:border-slate-600 dark:text-slate-200"
|
||||||
|
>
|
||||||
|
{t('cancelBtn')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -922,6 +922,47 @@ export async function getDatabaseStats(): Promise<DatabaseStats> {
|
|||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProjectEvent {
|
||||||
|
time: string;
|
||||||
|
type: string;
|
||||||
|
detail: string;
|
||||||
|
entity_id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectEventsResponse {
|
||||||
|
events: ProjectEvent[];
|
||||||
|
summary: Record<string, number>;
|
||||||
|
total_events: number;
|
||||||
|
period_days: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getProjectEvents(days = 30): Promise<ProjectEventsResponse> {
|
||||||
|
const response = await api.get<ProjectEventsResponse>('/internal/admin/project-events', {
|
||||||
|
params: { days },
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createAdminUser(email: string, password: string, plan = 'free', role = 'user'): Promise<{ message: string; user: InternalAdminUser }> {
|
||||||
|
const response = await api.post('/internal/admin/users/create', { email, password, plan, role });
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAdminUser(userId: number): Promise<{ message: string }> {
|
||||||
|
const response = await api.delete(`/internal/admin/users/${userId}`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAdminUserPlan(userId: number, plan: string): Promise<{ message: string; user: InternalAdminUser }> {
|
||||||
|
const response = await api.put(`/internal/admin/users/${userId}/plan`, { plan });
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAdminUserRole(userId: number, role: string): Promise<{ message: string; user: InternalAdminUser }> {
|
||||||
|
const response = await api.put(`/internal/admin/users/${userId}/role`, { role });
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
// --- Account / Usage / API Keys ---
|
// --- Account / Usage / API Keys ---
|
||||||
|
|
||||||
export interface UsageSummary {
|
export interface UsageSummary {
|
||||||
|
|||||||
Reference in New Issue
Block a user