Compare commits
69 Commits
fa26764764
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2101ee3e4f | |||
| df16a76909 | |||
|
|
2e2e7f0ae2 | ||
|
|
196fee71ae | ||
|
|
4895aed55d | ||
|
|
bfba41b4fa | ||
|
|
d0579a19fc | ||
|
|
91af420e08 | ||
|
|
da82e90b50 | ||
|
|
edb3c3f744 | ||
|
|
2fdbd4280c | ||
|
|
974f5a863f | ||
|
|
fab8721f0f | ||
|
|
5cb9dcfb0e | ||
|
|
25911c7cb9 | ||
|
|
9b750ac846 | ||
|
|
fbd2214e4c | ||
|
|
b1999fa8af | ||
|
|
a89c7319fb | ||
|
|
9a9651ba15 | ||
|
|
8f90e9c6bc | ||
|
|
3f20d5f345 | ||
|
|
d83ee39022 | ||
|
|
6a5e541622 | ||
|
|
f396c2e79d | ||
|
|
1fa34c2a44 | ||
|
|
7d17b1946f | ||
|
|
7787f4b60f | ||
|
|
db225b299f | ||
|
|
5a2b468af1 | ||
|
|
7bffc99fcb | ||
|
|
c15c4939a2 | ||
|
|
66bb5d116d | ||
|
|
429be1df99 | ||
|
|
2e52821b63 | ||
|
|
2ad3f01887 | ||
|
|
8b12878cfa | ||
|
|
37326db652 | ||
|
|
8043d66898 | ||
|
|
2c4ce1378a | ||
|
|
f569e8bc7d | ||
|
|
0904402a4b | ||
|
|
69e04c46f9 | ||
|
|
1b8da0f58a | ||
|
|
6e9be5da9e | ||
|
|
22c9db526d | ||
|
|
24401fa33d | ||
|
|
8941bb79c7 | ||
|
|
79645a70bc | ||
|
|
a7a2f53ada | ||
|
|
6ec61f802e | ||
|
|
827cac0454 | ||
|
|
3297469bcc | ||
|
|
a7acd2f48c | ||
|
|
33bd968d50 | ||
|
|
41df986d77 | ||
|
|
0284fb7c41 | ||
|
|
c90155149a | ||
|
|
51469ada6d | ||
|
|
fa29bb1064 | ||
|
|
6591120626 | ||
|
|
a061b14914 | ||
|
|
4962f9a625 | ||
|
|
942c757f13 | ||
|
|
7ff67a1f8d | ||
|
|
f2598efddf | ||
|
|
66e0b4659d | ||
|
|
ebbaaa84eb | ||
|
|
82a8b334ba |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -29,6 +29,7 @@ npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
firebase-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
@@ -39,3 +40,4 @@ yarn-error.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
CLAUDE.md
|
||||
|
||||
@@ -11,7 +11,7 @@ WORKDIR /app
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci
|
||||
RUN npm ci --legacy-peer-deps
|
||||
|
||||
# Stage 2: Build
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
@@ -7,10 +7,9 @@ const nextConfig: NextConfig = {
|
||||
// Image optimization
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "**",
|
||||
},
|
||||
{ protocol: "https", hostname: "*.contabostorage.com" },
|
||||
{ protocol: "https", hostname: "*.amazonaws.com" },
|
||||
{ protocol: "https", hostname: "*.digitaloceanspaces.com" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
152
package-lock.json
generated
152
package-lock.json
generated
@@ -11,7 +11,8 @@
|
||||
"axios": "^1.13.2",
|
||||
"next": "16.1.0",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3"
|
||||
"react-dom": "19.2.3",
|
||||
"socket.io-client": "^4.8.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
@@ -21,7 +22,7 @@
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.0",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@alloc/quick-lru": {
|
||||
@@ -1234,6 +1235,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@socket.io/component-emitter": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
|
||||
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
"version": "0.5.15",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||
@@ -1466,6 +1473,66 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
|
||||
"version": "1.7.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.1.0",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||
"version": "1.7.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.1.0",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.0",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "^1.7.1",
|
||||
"@emnapi/runtime": "^1.7.1",
|
||||
"@tybys/wasm-util": "^0.10.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz",
|
||||
@@ -2726,7 +2793,6 @@
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
@@ -2843,6 +2909,28 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/engine.io-client": {
|
||||
"version": "6.6.4",
|
||||
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz",
|
||||
"integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.4.1",
|
||||
"engine.io-parser": "~5.2.1",
|
||||
"ws": "~8.18.3",
|
||||
"xmlhttprequest-ssl": "~2.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io-parser": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
|
||||
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/enhanced-resolve": {
|
||||
"version": "5.18.4",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz",
|
||||
@@ -5004,7 +5092,6 @@
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
@@ -5909,6 +5996,34 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-client": {
|
||||
"version": "4.8.3",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
|
||||
"integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.4.1",
|
||||
"engine.io-client": "~6.6.1",
|
||||
"socket.io-parser": "~4.2.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-parser": {
|
||||
"version": "4.2.6",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
|
||||
"integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.4.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -6579,6 +6694,35 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.18.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xmlhttprequest-ssl": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
|
||||
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
"axios": "^1.13.2",
|
||||
"next": "16.1.0",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3"
|
||||
"react-dom": "19.2.3",
|
||||
"socket.io-client": "^4.8.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
@@ -22,6 +23,6 @@
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.0",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
}
|
||||
|
||||
211
src/app/dashboard/admin-users/page.tsx
Normal file
211
src/app/dashboard/admin-users/page.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import api, { ApiResponse, getErrorMessage } from '@/services/api';
|
||||
|
||||
interface AdminUser {
|
||||
id: string;
|
||||
email: string;
|
||||
role: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
lastLoginAt: string | null;
|
||||
}
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
const { user, isSuperAdmin } = useAuth();
|
||||
const router = useRouter();
|
||||
const [admins, setAdmins] = useState<AdminUser[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [newEmail, setNewEmail] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
|
||||
// Redirect non-super-admins
|
||||
useEffect(() => {
|
||||
if (!isSuperAdmin) {
|
||||
router.push('/dashboard');
|
||||
}
|
||||
}, [isSuperAdmin, router]);
|
||||
|
||||
const fetchAdmins = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await api.get<ApiResponse<AdminUser[]>>('/users/admin/admins');
|
||||
setAdmins(response.data.data);
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { if (isSuperAdmin) fetchAdmins(); }, [isSuperAdmin]);
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newEmail.trim() || !newPassword.trim()) return;
|
||||
setCreating(true);
|
||||
setError('');
|
||||
try {
|
||||
await api.put('/users/admin/admins', { email: newEmail.trim(), password: newPassword });
|
||||
setShowCreate(false);
|
||||
setNewEmail('');
|
||||
setNewPassword('');
|
||||
fetchAdmins();
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err));
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Are you sure you want to delete this admin?')) return;
|
||||
setDeletingId(id);
|
||||
try {
|
||||
await api.delete(`/users/admin/admins/${id}`);
|
||||
setAdmins(prev => prev.filter(a => a.id !== id));
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err));
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isSuperAdmin) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Admin Users</h1>
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="px-4 py-2 bg-[#f5a623] text-white text-sm font-medium rounded-lg hover:bg-[#e09520] transition-colors"
|
||||
>
|
||||
+ Create Admin
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg mb-4">
|
||||
<p className="text-red-700 text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[#f5a623]"></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Email</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Role</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Created</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{admins.map(admin => (
|
||||
<tr key={admin.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 text-sm text-gray-900">{admin.email}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`px-2 py-1 text-xs font-semibold rounded-full ${
|
||||
admin.role === 'SUPER_ADMIN' ? 'bg-purple-100 text-purple-800' : 'bg-blue-100 text-blue-800'
|
||||
}`}>
|
||||
{admin.role === 'SUPER_ADMIN' ? 'Super Admin' : 'Admin'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="px-2 py-1 text-xs font-semibold rounded-full bg-green-100 text-green-800">
|
||||
{admin.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500">
|
||||
{new Date(admin.createdAt).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{admin.role === 'ADMIN' && admin.id !== user?.id ? (
|
||||
<button
|
||||
onClick={() => handleDelete(admin.id)}
|
||||
disabled={deletingId === admin.id}
|
||||
className="text-sm text-red-600 hover:text-red-800 disabled:opacity-50"
|
||||
>
|
||||
{deletingId === admin.id ? 'Deleting...' : 'Delete'}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-sm text-gray-400">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Admin Modal */}
|
||||
{showCreate && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-md w-full mx-4">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Create Admin User</h3>
|
||||
</div>
|
||||
<form onSubmit={handleCreate}>
|
||||
<div className="px-6 py-4 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={newEmail}
|
||||
onChange={e => setNewEmail(e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-gray-900 bg-white placeholder:text-gray-500"
|
||||
placeholder="admin@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={e => setNewPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-gray-900 bg-white placeholder:text-gray-500"
|
||||
placeholder="Min 8 characters"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t border-gray-200 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setShowCreate(false); setNewEmail(''); setNewPassword(''); }}
|
||||
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 text-sm"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={creating}
|
||||
className="px-4 py-2 bg-[#f5a623] text-white rounded-lg hover:bg-[#e09520] text-sm disabled:opacity-50"
|
||||
>
|
||||
{creating ? 'Creating...' : 'Create Admin'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
349
src/app/dashboard/agent-types/[id]/sections/page.tsx
Normal file
349
src/app/dashboard/agent-types/[id]/sections/page.tsx
Normal file
@@ -0,0 +1,349 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { profileSectionsService, AgentTypeSectionsResponse, SectionOrderItem, getErrorMessage } from '@/services';
|
||||
|
||||
interface SectionWithOrder {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string | null;
|
||||
icon: string | null;
|
||||
sortOrder: number;
|
||||
isActive: boolean;
|
||||
isGlobal: boolean;
|
||||
isSystem: boolean;
|
||||
isRequired: boolean;
|
||||
effectiveSortOrder: number;
|
||||
hasCustomOrder: boolean;
|
||||
_count?: {
|
||||
fields: number;
|
||||
};
|
||||
}
|
||||
|
||||
export default function AgentTypeSectionsPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const agentTypeId = params.id as string;
|
||||
|
||||
const [data, setData] = useState<AgentTypeSectionsResponse | null>(null);
|
||||
const [sections, setSections] = useState<SectionWithOrder[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const response = await profileSectionsService.getSectionsForAgentType(agentTypeId, false);
|
||||
setData(response);
|
||||
setSections(response.sections as SectionWithOrder[]);
|
||||
setHasChanges(false);
|
||||
} catch (err) {
|
||||
const errorMessage = getErrorMessage(err);
|
||||
setError(errorMessage);
|
||||
if (errorMessage.includes('Unauthorized')) {
|
||||
router.push('/login');
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [agentTypeId, router]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const handleDragStart = (index: number) => {
|
||||
setDraggedIndex(index);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent, index: number) => {
|
||||
e.preventDefault();
|
||||
if (draggedIndex === null || draggedIndex === index) return;
|
||||
|
||||
const newSections = [...sections];
|
||||
const draggedItem = newSections[draggedIndex];
|
||||
newSections.splice(draggedIndex, 1);
|
||||
newSections.splice(index, 0, draggedItem);
|
||||
|
||||
// Update effective sort orders
|
||||
newSections.forEach((section, idx) => {
|
||||
section.effectiveSortOrder = idx;
|
||||
});
|
||||
|
||||
setSections(newSections);
|
||||
setDraggedIndex(index);
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setDraggedIndex(null);
|
||||
};
|
||||
|
||||
const moveSection = (index: number, direction: 'up' | 'down') => {
|
||||
const newIndex = direction === 'up' ? index - 1 : index + 1;
|
||||
if (newIndex < 0 || newIndex >= sections.length) return;
|
||||
|
||||
const newSections = [...sections];
|
||||
const temp = newSections[index];
|
||||
newSections[index] = newSections[newIndex];
|
||||
newSections[newIndex] = temp;
|
||||
|
||||
// Update effective sort orders
|
||||
newSections.forEach((section, idx) => {
|
||||
section.effectiveSortOrder = idx;
|
||||
});
|
||||
|
||||
setSections(newSections);
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const toggleRequired = (index: number) => {
|
||||
const newSections = [...sections];
|
||||
newSections[index] = {
|
||||
...newSections[index],
|
||||
isRequired: !newSections[index].isRequired,
|
||||
};
|
||||
setSections(newSections);
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
const sectionOrders: SectionOrderItem[] = sections.map((section, index) => ({
|
||||
sectionId: section.id,
|
||||
sortOrder: index,
|
||||
isRequired: section.isRequired,
|
||||
}));
|
||||
await profileSectionsService.updateSectionOrderForAgentType(agentTypeId, sectionOrders);
|
||||
setHasChanges(false);
|
||||
// Refresh to get updated hasCustomOrder flags
|
||||
await fetchData();
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
fetchData();
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[#f5a623]"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Breadcrumb */}
|
||||
<div className="mb-6">
|
||||
<nav className="flex items-center space-x-2 text-sm text-[#666666]">
|
||||
<Link href="/dashboard/agent-types" className="hover:text-[#f5a623]">
|
||||
Agent Types
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span className="text-[#00293d] font-medium">{data?.agentType.name || 'Loading...'}</span>
|
||||
<span>/</span>
|
||||
<span className="text-[#00293d]">Sections</span>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Page Title */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-[#00293d] font-fractul">
|
||||
Manage Sections for {data?.agentType.name}
|
||||
</h1>
|
||||
<p className="text-[#666666] font-serif">
|
||||
Customize the order and requirements of profile sections for this agent type.
|
||||
Drag to reorder or use the arrow buttons.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Sections List */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-[#e5e7eb]">
|
||||
<div className="px-6 py-4 border-b border-[#e5e7eb]">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center space-x-4">
|
||||
<span className="text-sm text-[#666666]">
|
||||
{sections.length} section{sections.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
{hasChanges && (
|
||||
<span className="px-2 py-1 text-xs font-semibold rounded-full bg-yellow-100 text-yellow-800">
|
||||
Unsaved Changes
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex space-x-3">
|
||||
{hasChanges && (
|
||||
<>
|
||||
<button
|
||||
onClick={handleReset}
|
||||
disabled={isSaving}
|
||||
className="px-4 py-2 border border-[#e5e7eb] text-[#00293d] rounded-xl hover:bg-[#f5f9f8] transition-colors disabled:opacity-50"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="px-4 py-2 bg-[#f5a623] text-white rounded-xl hover:bg-[#e09620] transition-colors disabled:opacity-50 flex items-center"
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<svg className="animate-spin -ml-1 mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
'Save Order'
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="px-6 py-4 bg-red-50 border-b border-red-200">
|
||||
<p className="text-red-700 text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sections.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<svg className="mx-auto h-12 w-12 text-[#9ca3af]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
<p className="mt-2 text-[#666666] font-serif">No sections assigned to this agent type</p>
|
||||
<Link
|
||||
href="/dashboard/profile-sections"
|
||||
className="mt-4 inline-block px-4 py-2 bg-[#f5a623] hover:bg-[#e09620] text-white text-sm font-medium rounded-lg transition-colors"
|
||||
>
|
||||
Go to Profile Sections
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 space-y-3">
|
||||
{sections.map((section, index) => (
|
||||
<div
|
||||
key={section.id}
|
||||
draggable
|
||||
onDragStart={() => handleDragStart(index)}
|
||||
onDragOver={(e) => handleDragOver(e, index)}
|
||||
onDragEnd={handleDragEnd}
|
||||
className={`bg-white rounded-xl shadow-sm border border-[#e5e7eb] p-4 flex items-center justify-between hover:shadow-md cursor-move transition-all ${
|
||||
draggedIndex === index ? 'ring-2 ring-[#f5a623]/30 ring-offset-2 shadow-lg' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center space-x-4">
|
||||
{/* Drag Handle */}
|
||||
<div className="text-[#9ca3af] cursor-grab">
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 6a2 2 0 1 1-4 0 2 2 0 0 1 4 0zm0 6a2 2 0 1 1-4 0 2 2 0 0 1 4 0zm0 6a2 2 0 1 1-4 0 2 2 0 0 1 4 0zm8-12a2 2 0 1 1-4 0 2 2 0 0 1 4 0zm0 6a2 2 0 1 1-4 0 2 2 0 0 1 4 0zm0 6a2 2 0 1 1-4 0 2 2 0 0 1 4 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Order Number */}
|
||||
<div className="w-8 h-8 flex items-center justify-center rounded-full bg-[#e8f0ee] text-[#666666] text-sm font-medium">
|
||||
{index + 1}
|
||||
</div>
|
||||
|
||||
{/* Section Info */}
|
||||
<div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="font-medium text-[#00293d]">{section.name}</span>
|
||||
{section.isGlobal && (
|
||||
<span className="px-2 py-0.5 text-xs font-semibold rounded-full bg-[#fff7ed] text-[#f5a623]">
|
||||
Global
|
||||
</span>
|
||||
)}
|
||||
{section.isSystem && (
|
||||
<span className="px-2 py-0.5 text-xs font-semibold rounded-full bg-[#e8f0ee] text-[#5ba4a4]">
|
||||
System
|
||||
</span>
|
||||
)}
|
||||
{section.hasCustomOrder && (
|
||||
<span className="px-2 py-0.5 text-xs font-semibold rounded-full bg-yellow-100 text-yellow-800">
|
||||
Custom Order
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-[#666666]">
|
||||
{section.description || 'No description'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
{/* Required Toggle */}
|
||||
<label className="flex items-center space-x-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={section.isRequired}
|
||||
onChange={() => toggleRequired(index)}
|
||||
className="rounded border-[#e5e7eb] text-[#f5a623] focus:ring-0"
|
||||
/>
|
||||
<span className="text-sm text-[#666666]">Required</span>
|
||||
</label>
|
||||
|
||||
{/* Move Buttons */}
|
||||
<div className="flex space-x-1">
|
||||
<button
|
||||
onClick={() => moveSection(index, 'up')}
|
||||
disabled={index === 0}
|
||||
className="p-1.5 text-[#9ca3af] hover:text-[#666666] hover:bg-[#e8f0ee] rounded disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
title="Move up"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => moveSection(index, 'down')}
|
||||
disabled={index === sections.length - 1}
|
||||
className="p-1.5 text-[#9ca3af] hover:text-[#666666] hover:bg-[#e8f0ee] rounded disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
title="Move down"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="mt-6 p-4 bg-[#f5f9f8] rounded-xl border border-[#e5e7eb]">
|
||||
<h3 className="text-sm font-semibold text-[#00293d] mb-2 font-fractul">How Section Ordering Works</h3>
|
||||
<ul className="text-sm text-[#666666] space-y-1 font-serif">
|
||||
<li><strong>Global sections</strong> (purple badge) are automatically included for all agent types.</li>
|
||||
<li><strong>System sections</strong> (blue badge) cannot be deleted, only hidden.</li>
|
||||
<li><strong>Custom Order</strong> (yellow badge) indicates this agent type has a custom order for that section.</li>
|
||||
<li>Drag sections or use the arrow buttons to change the display order.</li>
|
||||
<li>Mark sections as <strong>Required</strong> to enforce completion by agents.</li>
|
||||
<li>Click <strong>Save Order</strong> to apply your changes.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
454
src/app/dashboard/agent-types/page.tsx
Normal file
454
src/app/dashboard/agent-types/page.tsx
Normal file
@@ -0,0 +1,454 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { agentTypesService, AgentType, CreateAgentTypeDto, UpdateAgentTypeDto, getErrorMessage } from '@/services';
|
||||
|
||||
type ModalType = 'create' | 'edit' | 'delete' | null;
|
||||
|
||||
export default function AgentTypesPage() {
|
||||
const router = useRouter();
|
||||
const [agentTypes, setAgentTypes] = useState<AgentType[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [showInactive, setShowInactive] = useState(true);
|
||||
|
||||
// Modal state
|
||||
const [modalType, setModalType] = useState<ModalType>(null);
|
||||
const [selectedType, setSelectedType] = useState<AgentType | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [modalError, setModalError] = useState('');
|
||||
|
||||
// Form state
|
||||
const [formData, setFormData] = useState<CreateAgentTypeDto>({
|
||||
name: '',
|
||||
description: '',
|
||||
icon: '',
|
||||
isActive: true,
|
||||
sortOrder: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchAgentTypes();
|
||||
}, [showInactive]);
|
||||
|
||||
const fetchAgentTypes = async () => {
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await agentTypesService.getAll(showInactive);
|
||||
setAgentTypes(data);
|
||||
} catch (err) {
|
||||
const errorMessage = getErrorMessage(err);
|
||||
setError(errorMessage);
|
||||
if (errorMessage.includes('Unauthorized')) {
|
||||
router.push('/login');
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openCreateModal = () => {
|
||||
setFormData({
|
||||
name: '',
|
||||
description: '',
|
||||
icon: '',
|
||||
isActive: true,
|
||||
sortOrder: 0,
|
||||
});
|
||||
setModalError('');
|
||||
setModalType('create');
|
||||
};
|
||||
|
||||
const openEditModal = (agentType: AgentType) => {
|
||||
setSelectedType(agentType);
|
||||
setFormData({
|
||||
name: agentType.name,
|
||||
description: agentType.description || '',
|
||||
icon: agentType.icon || '',
|
||||
isActive: agentType.isActive,
|
||||
sortOrder: agentType.sortOrder,
|
||||
});
|
||||
setModalError('');
|
||||
setModalType('edit');
|
||||
};
|
||||
|
||||
const openDeleteModal = (agentType: AgentType) => {
|
||||
setSelectedType(agentType);
|
||||
setModalError('');
|
||||
setModalType('delete');
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setModalType(null);
|
||||
setSelectedType(null);
|
||||
setModalError('');
|
||||
};
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
setModalError('');
|
||||
try {
|
||||
await agentTypesService.create(formData);
|
||||
closeModal();
|
||||
fetchAgentTypes();
|
||||
} catch (err) {
|
||||
setModalError(getErrorMessage(err));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!selectedType) return;
|
||||
setIsSubmitting(true);
|
||||
setModalError('');
|
||||
try {
|
||||
await agentTypesService.update(selectedType.id, formData as UpdateAgentTypeDto);
|
||||
closeModal();
|
||||
fetchAgentTypes();
|
||||
} catch (err) {
|
||||
setModalError(getErrorMessage(err));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!selectedType) return;
|
||||
setIsSubmitting(true);
|
||||
setModalError('');
|
||||
try {
|
||||
await agentTypesService.delete(selectedType.id);
|
||||
closeModal();
|
||||
fetchAgentTypes();
|
||||
} catch (err) {
|
||||
setModalError(getErrorMessage(err));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleStatus = async (agentType: AgentType) => {
|
||||
try {
|
||||
await agentTypesService.update(agentType.id, { isActive: !agentType.isActive });
|
||||
fetchAgentTypes();
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Page Title */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-[#00293d] font-fractul">Agent Types</h1>
|
||||
<p className="text-[#666666] font-serif">Manage agent categories and types</p>
|
||||
</div>
|
||||
|
||||
{/* Agent Types Table */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-[#e5e7eb]">
|
||||
<div className="px-6 py-4 border-b border-[#e5e7eb]">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center space-x-4">
|
||||
<label className="flex items-center space-x-2 text-sm text-[#666666] font-serif">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showInactive}
|
||||
onChange={(e) => setShowInactive(e.target.checked)}
|
||||
className="rounded border-[#e5e7eb] text-[#f5a623] focus:ring-0"
|
||||
/>
|
||||
<span>Show Inactive</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
onClick={fetchAgentTypes}
|
||||
className="px-4 py-2 border border-[#e5e7eb] hover:bg-[#f5f9f8] text-[#00293d] text-sm font-medium rounded-lg transition-colors flex items-center"
|
||||
>
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Refresh
|
||||
</button>
|
||||
<button
|
||||
onClick={openCreateModal}
|
||||
className="px-4 py-2 bg-[#f5a623] hover:bg-[#e09620] text-white text-sm font-medium rounded-lg transition-colors flex items-center"
|
||||
>
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Add Agent Type
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="px-6 py-4 bg-red-50 border-b border-red-200">
|
||||
<p className="text-red-700 text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[#f5a623]"></div>
|
||||
</div>
|
||||
) : agentTypes.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<svg className="mx-auto h-12 w-12 text-[#9ca3af]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
<p className="mt-2 text-[#666666]">No agent types found</p>
|
||||
<button
|
||||
onClick={openCreateModal}
|
||||
className="mt-4 px-4 py-2 bg-[#f5a623] hover:bg-[#e09620] text-white text-sm font-medium rounded-lg transition-colors"
|
||||
>
|
||||
Create First Agent Type
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<table className="min-w-full divide-y divide-[#e5e7eb]">
|
||||
<thead className="bg-[#f5f9f8]">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-[#666666] uppercase tracking-wider">
|
||||
Name
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-[#666666] uppercase tracking-wider">
|
||||
Description
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-[#666666] uppercase tracking-wider">
|
||||
Icon
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-[#666666] uppercase tracking-wider">
|
||||
Sort Order
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-[#666666] uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-[#666666] uppercase tracking-wider">
|
||||
Agents
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-[#666666] uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-[#e5e7eb]">
|
||||
{agentTypes.map((agentType) => (
|
||||
<tr key={agentType.id} className="hover:bg-[#f5f9f8]">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-[#00293d]">{agentType.name}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="text-sm text-[#666666] max-w-xs truncate">
|
||||
{agentType.description || '-'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-[#666666]">{agentType.icon || '-'}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-[#666666]">{agentType.sortOrder}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<button
|
||||
onClick={() => toggleStatus(agentType)}
|
||||
className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full cursor-pointer transition-colors ${
|
||||
agentType.isActive
|
||||
? 'bg-green-100 text-green-800 hover:bg-green-200'
|
||||
: 'bg-gray-100 text-gray-800 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{agentType.isActive ? 'Active' : 'Inactive'}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-[#666666]">{agentType._count?.agents || 0}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<button
|
||||
onClick={() => router.push(`/dashboard/agent-types/${agentType.id}/sections`)}
|
||||
className="text-[#5ba4a4] hover:text-[#5ba4a4]/80 mr-3 inline-flex items-center gap-1"
|
||||
title="Sections"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
||||
</svg>
|
||||
Sections
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openEditModal(agentType)}
|
||||
className="text-[#f5a623] hover:text-[#e09620] mr-3 inline-flex items-center gap-1"
|
||||
title="Edit"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openDeleteModal(agentType)}
|
||||
className="text-red-600 hover:text-red-900 inline-flex items-center gap-1"
|
||||
title="Delete"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
{(modalType === 'create' || modalType === 'edit') && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl shadow-xl max-w-md w-full mx-4">
|
||||
<div className="px-6 py-4 border-b border-[#e5e7eb]">
|
||||
<h3 className="text-lg font-semibold text-[#00293d]">
|
||||
{modalType === 'create' ? 'Create Agent Type' : 'Edit Agent Type'}
|
||||
</h3>
|
||||
</div>
|
||||
<form onSubmit={modalType === 'create' ? handleCreate : handleUpdate}>
|
||||
<div className="px-6 py-4 space-y-4">
|
||||
{modalError && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||
<p className="text-red-700 text-sm">{modalError}</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[#00293d] mb-1">
|
||||
Name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-[#e5e7eb] rounded-xl focus:outline-none focus:border-[#f5a623] text-[#00293d] bg-white"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[#00293d] mb-1">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-[#e5e7eb] rounded-xl focus:outline-none focus:border-[#f5a623] text-[#00293d] bg-white"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[#00293d] mb-1">
|
||||
Icon (emoji or icon name)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.icon}
|
||||
onChange={(e) => setFormData({ ...formData, icon: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-[#e5e7eb] rounded-xl focus:outline-none focus:border-[#f5a623] text-[#00293d] bg-white placeholder:text-[#666666]"
|
||||
placeholder="e.g. house, building, key"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[#00293d] mb-1">
|
||||
Sort Order
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.sortOrder === 0 ? '' : formData.sortOrder}
|
||||
onChange={(e) => setFormData({ ...formData, sortOrder: parseInt(e.target.value) || 0 })}
|
||||
onFocus={(e) => e.target.select()}
|
||||
placeholder="0"
|
||||
min={0}
|
||||
className="w-full px-3 py-2 border border-[#e5e7eb] rounded-xl focus:outline-none focus:border-[#f5a623] text-[#00293d] bg-white placeholder:text-[#666666]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="isActive"
|
||||
checked={formData.isActive}
|
||||
onChange={(e) => setFormData({ ...formData, isActive: e.target.checked })}
|
||||
className="rounded border-[#e5e7eb] text-[#f5a623] focus:ring-0"
|
||||
/>
|
||||
<label htmlFor="isActive" className="ml-2 text-sm text-[#00293d] font-serif">
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t border-[#e5e7eb] flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
className="px-4 py-2 border border-[#e5e7eb] text-[#00293d] rounded-xl hover:bg-[#f5f9f8] transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="px-4 py-2 bg-[#f5a623] text-white rounded-xl hover:bg-[#e09620] transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? 'Saving...' : modalType === 'create' ? 'Create' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{modalType === 'delete' && selectedType && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl shadow-xl max-w-md w-full mx-4">
|
||||
<div className="px-6 py-4 border-b border-[#e5e7eb]">
|
||||
<h3 className="text-lg font-semibold text-[#00293d]">Delete Agent Type</h3>
|
||||
</div>
|
||||
<div className="px-6 py-4">
|
||||
{modalError && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||
<p className="text-red-700 text-sm">{modalError}</p>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[#666666] font-serif">
|
||||
Are you sure you want to delete <span className="font-semibold">{selectedType.name}</span>?
|
||||
{selectedType._count?.agents && selectedType._count.agents > 0 && (
|
||||
<span className="block mt-2 text-red-600 text-sm">
|
||||
Warning: This agent type has {selectedType._count.agents} agent(s) assigned to it.
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t border-[#e5e7eb] flex justify-end space-x-3">
|
||||
<button
|
||||
onClick={closeModal}
|
||||
className="px-4 py-2 border border-[#e5e7eb] text-[#00293d] rounded-xl hover:bg-[#f5f9f8] transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={isSubmitting}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? 'Deleting...' : 'Delete'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
340
src/app/dashboard/audit-log/page.tsx
Normal file
340
src/app/dashboard/audit-log/page.tsx
Normal file
@@ -0,0 +1,340 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
auditLogService,
|
||||
type AuditLogEntry,
|
||||
type AuditLogQuery,
|
||||
} from '@/services';
|
||||
|
||||
const ACTION_OPTIONS: { value: string; label: string; group: string }[] = [
|
||||
{ value: '', label: 'All actions', group: '' },
|
||||
// Auth
|
||||
{ value: 'AUTH_SIGNUP', label: 'Auth: Signup', group: 'Auth' },
|
||||
{ value: 'AUTH_LOGIN_SUCCESS', label: 'Auth: Login Success', group: 'Auth' },
|
||||
{ value: 'AUTH_LOGIN_FAIL', label: 'Auth: Login Fail', group: 'Auth' },
|
||||
{ value: 'AUTH_LOGOUT', label: 'Auth: Logout', group: 'Auth' },
|
||||
{ value: 'AUTH_PASSWORD_RESET_REQUEST', label: 'Auth: Password Reset Requested', group: 'Auth' },
|
||||
{ value: 'AUTH_PASSWORD_RESET_CONFIRM', label: 'Auth: Password Reset Confirmed', group: 'Auth' },
|
||||
{ value: 'AUTH_PASSWORD_CHANGE', label: 'Auth: Password Change', group: 'Auth' },
|
||||
{ value: 'AUTH_EMAIL_CHANGE', label: 'Auth: Email Change', group: 'Auth' },
|
||||
{ value: 'AUTH_EMAIL_VERIFY', label: 'Auth: Email Verify', group: 'Auth' },
|
||||
// User / role
|
||||
{ value: 'USER_UPDATE', label: 'User: Update', group: 'User' },
|
||||
{ value: 'USER_DELETE', label: 'User: Delete', group: 'User' },
|
||||
{ value: 'USER_SUSPEND', label: 'User: Suspend', group: 'User' },
|
||||
{ value: 'USER_UNSUSPEND', label: 'User: Unsuspend', group: 'User' },
|
||||
{ value: 'USER_ROLE_CHANGE', label: 'User: Role Change', group: 'User' },
|
||||
// Agent
|
||||
{ value: 'AGENT_PROFILE_UPDATE', label: 'Agent: Profile Update', group: 'Agent' },
|
||||
{ value: 'AGENT_VERIFICATION_SUBMIT', label: 'Agent: Verification Submit', group: 'Agent' },
|
||||
{ value: 'AGENT_VERIFICATION_APPROVE', label: 'Agent: Verification Approve', group: 'Agent' },
|
||||
{ value: 'AGENT_VERIFICATION_REJECT', label: 'Agent: Verification Reject', group: 'Agent' },
|
||||
// Billing
|
||||
{ value: 'SUBSCRIPTION_CREATE', label: 'Billing: Subscription Create', group: 'Billing' },
|
||||
{ value: 'SUBSCRIPTION_CANCEL', label: 'Billing: Subscription Cancel', group: 'Billing' },
|
||||
{ value: 'PAYMENT_SUCCESS', label: 'Billing: Payment Success', group: 'Billing' },
|
||||
{ value: 'PAYMENT_FAILURE', label: 'Billing: Payment Failure', group: 'Billing' },
|
||||
// Moderation
|
||||
{ value: 'REPORT_STATUS_CHANGE', label: 'Moderation: Report Status Change', group: 'Moderation' },
|
||||
{ value: 'CONTACT_MESSAGE_RESPOND', label: 'Moderation: Contact Message', group: 'Moderation' },
|
||||
{ value: 'SUPPORT_TICKET_CLOSE', label: 'Moderation: Support Ticket Close', group: 'Moderation' },
|
||||
// Schema
|
||||
{ value: 'PROFILE_FIELD_UPDATE', label: 'Schema: Profile Field Update', group: 'Schema' },
|
||||
{ value: 'PROFILE_SECTION_UPDATE', label: 'Schema: Profile Section Update', group: 'Schema' },
|
||||
{ value: 'AGENT_TYPE_UPDATE', label: 'Schema: Agent Type Update', group: 'Schema' },
|
||||
// Connections
|
||||
{ value: 'CONNECTION_REQUEST_ACCEPT', label: 'Connection: Accept', group: 'Connection' },
|
||||
{ value: 'CONNECTION_REQUEST_REJECT', label: 'Connection: Reject', group: 'Connection' },
|
||||
{ value: 'CONNECTION_REQUEST_CANCEL', label: 'Connection: Cancel', group: 'Connection' },
|
||||
// CMS
|
||||
{ value: 'CMS_BLOG_PUBLISH', label: 'CMS: Publish', group: 'CMS' },
|
||||
{ value: 'CMS_BLOG_UNPUBLISH', label: 'CMS: Unpublish', group: 'CMS' },
|
||||
{ value: 'CMS_BLOG_DELETE', label: 'CMS: Delete', group: 'CMS' },
|
||||
];
|
||||
|
||||
const RESOURCE_OPTIONS = [
|
||||
'', 'User', 'AgentProfile', 'AgentSubscription', 'Payment', 'UserReport',
|
||||
'ContactMessage', 'SupportChat', 'CmsContent', 'ProfileField', 'ProfileSection',
|
||||
'AgentType', 'ConnectionRequest',
|
||||
];
|
||||
|
||||
function formatActor(entry: AuditLogEntry): string {
|
||||
if (!entry.actor) {
|
||||
if (entry.actorRole === 'SYSTEM') return 'SYSTEM';
|
||||
return entry.actorId ? `${entry.actorId.slice(0, 8)}…` : 'anonymous';
|
||||
}
|
||||
const profile = entry.actor.agentProfile ?? entry.actor.userProfile;
|
||||
const name = [profile?.firstName, profile?.lastName].filter(Boolean).join(' ');
|
||||
return name || entry.actor.email;
|
||||
}
|
||||
|
||||
function formatRelative(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
const diff = Date.now() - d.getTime();
|
||||
const sec = Math.floor(diff / 1000);
|
||||
if (sec < 60) return `${sec}s ago`;
|
||||
const min = Math.floor(sec / 60);
|
||||
if (min < 60) return `${min}m ago`;
|
||||
const hr = Math.floor(min / 60);
|
||||
if (hr < 24) return `${hr}h ago`;
|
||||
const day = Math.floor(hr / 24);
|
||||
if (day < 30) return `${day}d ago`;
|
||||
return d.toLocaleDateString();
|
||||
}
|
||||
|
||||
export default function AuditLogPage() {
|
||||
const [items, setItems] = useState<AuditLogEntry[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const limit = 25;
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
|
||||
// Filters
|
||||
const [actionFilter, setActionFilter] = useState('');
|
||||
const [resourceFilter, setResourceFilter] = useState('');
|
||||
const [actorIdFilter, setActorIdFilter] = useState('');
|
||||
const [fromDate, setFromDate] = useState('');
|
||||
const [toDate, setToDate] = useState('');
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const query: AuditLogQuery = {
|
||||
page,
|
||||
limit,
|
||||
action: actionFilter || undefined,
|
||||
resourceType: resourceFilter || undefined,
|
||||
actorId: actorIdFilter || undefined,
|
||||
from: fromDate ? new Date(fromDate).toISOString() : undefined,
|
||||
to: toDate ? new Date(toDate + 'T23:59:59').toISOString() : undefined,
|
||||
};
|
||||
const res = await auditLogService.list(query);
|
||||
setItems(res.items);
|
||||
setTotal(res.total);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch audit log:', err);
|
||||
setError('Failed to load audit log');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, actionFilter, resourceFilter, actorIdFilter, fromDate, toDate]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / limit));
|
||||
|
||||
const resetFilters = () => {
|
||||
setActionFilter('');
|
||||
setResourceFilter('');
|
||||
setActorIdFilter('');
|
||||
setFromDate('');
|
||||
setToDate('');
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Audit Log</h1>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Who did what, when, and from where. Filter to narrow the trail.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-4 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Action</label>
|
||||
<select
|
||||
value={actionFilter}
|
||||
onChange={(e) => { setActionFilter(e.target.value); setPage(1); }}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm text-gray-900 bg-white"
|
||||
>
|
||||
{ACTION_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Resource Type</label>
|
||||
<select
|
||||
value={resourceFilter}
|
||||
onChange={(e) => { setResourceFilter(e.target.value); setPage(1); }}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm text-gray-900 bg-white"
|
||||
>
|
||||
{RESOURCE_OPTIONS.map((opt) => (
|
||||
<option key={opt} value={opt}>{opt || 'All resources'}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Actor ID</label>
|
||||
<input
|
||||
type="text"
|
||||
value={actorIdFilter}
|
||||
onChange={(e) => setActorIdFilter(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && setPage(1)}
|
||||
placeholder="UUID"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm text-gray-900 bg-white placeholder:text-gray-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">From</label>
|
||||
<input
|
||||
type="date"
|
||||
value={fromDate}
|
||||
onChange={(e) => { setFromDate(e.target.value); setPage(1); }}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm text-gray-900 bg-white"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">To</label>
|
||||
<input
|
||||
type="date"
|
||||
value={toDate}
|
||||
onChange={(e) => { setToDate(e.target.value); setPage(1); }}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm text-gray-900 bg-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="lg:col-span-5 flex justify-end gap-2">
|
||||
<button
|
||||
onClick={resetFilters}
|
||||
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors text-sm"
|
||||
>
|
||||
Reset filters
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||
<p className="text-red-700 text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="py-12 text-center text-gray-500 text-sm">No audit entries match your filters.</div>
|
||||
) : (
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">When</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actor</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Action</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Resource</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">IP</th>
|
||||
<th className="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{items.map((entry) => (
|
||||
<>
|
||||
<tr key={entry.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 text-sm text-gray-900" title={new Date(entry.createdAt).toLocaleString()}>
|
||||
{formatRelative(entry.createdAt)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-900">
|
||||
<div className="font-medium">{formatActor(entry)}</div>
|
||||
<div className="text-xs text-gray-500">{entry.actorRole ?? 'SYSTEM'}</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
<span className="px-2 py-1 text-xs font-semibold rounded-full bg-blue-100 text-blue-800">
|
||||
{entry.action}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-900">
|
||||
{entry.resourceType ? (
|
||||
<>
|
||||
<div>{entry.resourceType}</div>
|
||||
{entry.resourceId && (
|
||||
<div className="text-xs text-gray-500 font-mono" title={entry.resourceId}>
|
||||
{entry.resourceId.slice(0, 8)}…
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-gray-400">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600 font-mono">
|
||||
{entry.ipAddress ?? '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button
|
||||
onClick={() => setExpandedId(expandedId === entry.id ? null : entry.id)}
|
||||
className="text-blue-600 hover:text-blue-800 text-sm"
|
||||
>
|
||||
{expandedId === entry.id ? 'Hide' : 'Details'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{expandedId === entry.id && (
|
||||
<tr key={`${entry.id}-detail`} className="bg-gray-50">
|
||||
<td colSpan={6} className="px-4 py-3">
|
||||
<div className="text-xs text-gray-700 space-y-1">
|
||||
<div><span className="font-semibold">Full timestamp:</span> {new Date(entry.createdAt).toLocaleString()}</div>
|
||||
<div><span className="font-semibold">Actor ID:</span> <span className="font-mono">{entry.actorId ?? '(none)'}</span></div>
|
||||
{entry.actor?.email && (
|
||||
<div><span className="font-semibold">Actor email:</span> {entry.actor.email}</div>
|
||||
)}
|
||||
{entry.userAgent && (
|
||||
<div><span className="font-semibold">User Agent:</span> <span className="font-mono">{entry.userAgent}</span></div>
|
||||
)}
|
||||
{entry.metadata && (
|
||||
<div>
|
||||
<span className="font-semibold">Metadata:</span>
|
||||
<pre className="mt-1 p-2 bg-white border border-gray-200 rounded text-xs font-mono overflow-x-auto">
|
||||
{JSON.stringify(entry.metadata, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{!loading && items.length > 0 && (
|
||||
<div className="px-4 py-3 border-t border-gray-200 flex items-center justify-between">
|
||||
<div className="text-sm text-gray-600">
|
||||
Showing {(page - 1) * limit + 1}–{Math.min(page * limit, total)} of {total}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="px-3 py-1 border border-gray-300 text-gray-700 rounded hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="px-3 py-1 text-sm text-gray-700">
|
||||
Page {page} of {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page >= totalPages}
|
||||
className="px-3 py-1 border border-gray-300 text-gray-700 rounded hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1637
src/app/dashboard/cms/page.tsx
Normal file
1637
src/app/dashboard/cms/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
193
src/app/dashboard/contact-messages/page.tsx
Normal file
193
src/app/dashboard/contact-messages/page.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { contactService, type ContactMessage, type ContactCounts } from '@/services/contact.service';
|
||||
|
||||
export default function ContactMessagesPage() {
|
||||
const [messages, setMessages] = useState<ContactMessage[]>([]);
|
||||
const [counts, setCounts] = useState<ContactCounts | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState<'all' | 'unread' | 'read'>('all');
|
||||
const [selectedMessage, setSelectedMessage] = useState<ContactMessage | null>(null);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const isRead = filter === 'unread' ? false : filter === 'read' ? true : undefined;
|
||||
const [msgs, cnts] = await Promise.all([
|
||||
contactService.getAllMessages(isRead),
|
||||
contactService.getCounts(),
|
||||
]);
|
||||
setMessages(msgs);
|
||||
setCounts(cnts);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch contact messages:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { fetchData(); }, [filter]);
|
||||
|
||||
const handleMarkAsRead = async (id: string) => {
|
||||
try {
|
||||
await contactService.markAsRead(id);
|
||||
setMessages(prev => prev.map(m => m.id === id ? { ...m, isRead: true } : m));
|
||||
setCounts(prev => prev ? { ...prev, unread: Math.max(0, prev.unread - 1) } : prev);
|
||||
if (selectedMessage?.id === id) setSelectedMessage(prev => prev ? { ...prev, isRead: true } : prev);
|
||||
} catch (err) {
|
||||
console.error('Failed to mark as read:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Delete this message?')) return;
|
||||
try {
|
||||
await contactService.deleteMessage(id);
|
||||
setMessages(prev => prev.filter(m => m.id !== id));
|
||||
if (selectedMessage?.id === id) setSelectedMessage(null);
|
||||
const cnts = await contactService.getCounts();
|
||||
setCounts(cnts);
|
||||
} catch (err) {
|
||||
console.error('Failed to delete:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString('en-US', {
|
||||
month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">Contact Messages</h1>
|
||||
|
||||
{/* Counts */}
|
||||
{counts && (
|
||||
<div className="grid grid-cols-2 gap-4 mb-6">
|
||||
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
|
||||
<p className="text-sm text-gray-600">Total</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{counts.total}</p>
|
||||
</div>
|
||||
<div className="bg-yellow-50 rounded-lg p-4 border border-gray-200">
|
||||
<p className="text-sm text-gray-600">Unread</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{counts.unread}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filter */}
|
||||
<div className="mb-4 flex gap-2">
|
||||
{(['all', 'unread', 'read'] as const).map(f => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
filter === f ? 'bg-[#00293d] text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{f.charAt(0).toUpperCase() + f.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
) : messages.length === 0 ? (
|
||||
<div className="text-center py-12 bg-white rounded-lg border border-gray-200">
|
||||
<p className="text-gray-500">No contact messages found</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Name</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Email</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Message</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Date</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{messages.map(msg => (
|
||||
<tr key={msg.id} className={`hover:bg-gray-50 ${!msg.isRead ? 'bg-yellow-50/50' : ''}`}>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{!msg.isRead && <span className="w-2 h-2 bg-[#e58625] rounded-full flex-shrink-0" />}
|
||||
<span className="text-sm font-medium text-gray-900">{msg.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500">{msg.email}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-700 max-w-[250px] truncate">{msg.message}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500 whitespace-nowrap">{formatDate(msg.createdAt)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => setSelectedMessage(msg)} className="text-sm text-blue-600 hover:text-blue-800">View</button>
|
||||
{!msg.isRead && (
|
||||
<button onClick={() => handleMarkAsRead(msg.id)} className="text-sm text-green-600 hover:text-green-800">Read</button>
|
||||
)}
|
||||
<button onClick={() => handleDelete(msg.id)} className="text-sm text-red-600 hover:text-red-800">Delete</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* View Modal */}
|
||||
{selectedMessage && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-lg w-full mx-4">
|
||||
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Contact Message</h3>
|
||||
<button onClick={() => setSelectedMessage(null)} className="text-gray-400 hover:text-gray-600 text-xl">×</button>
|
||||
</div>
|
||||
<div className="px-6 py-4 space-y-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-700">Name</p>
|
||||
<p className="text-sm text-gray-900">{selectedMessage.name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-700">Email</p>
|
||||
<p className="text-sm text-gray-900">{selectedMessage.email}</p>
|
||||
</div>
|
||||
{selectedMessage.phone && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-700">Phone</p>
|
||||
<p className="text-sm text-gray-900">{selectedMessage.phone}</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-700">Message</p>
|
||||
<p className="text-sm text-gray-900 whitespace-pre-wrap">{selectedMessage.message}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-700">Received</p>
|
||||
<p className="text-sm text-gray-900">{formatDate(selectedMessage.createdAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t border-gray-200 flex justify-end gap-2">
|
||||
{!selectedMessage.isRead && (
|
||||
<button
|
||||
onClick={() => handleMarkAsRead(selectedMessage.id)}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 text-sm"
|
||||
>
|
||||
Mark as Read
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => setSelectedMessage(null)} className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 text-sm">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -38,8 +38,8 @@ export default function DashboardLayout({
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
<div className="min-h-screen flex items-center justify-center bg-[#f5f9f8]">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[#f5a623]"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -49,35 +49,35 @@ export default function DashboardLayout({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="min-h-screen bg-[#f5f9f8]">
|
||||
{/* Sidebar */}
|
||||
<Sidebar />
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="ml-64">
|
||||
{/* Top Header */}
|
||||
<header className="bg-white border-b border-gray-200 sticky top-0 z-40">
|
||||
<header className="bg-white border-b border-[#e5e7eb] sticky top-0 z-40">
|
||||
<div className="px-8 py-3">
|
||||
<div className="flex justify-end items-center">
|
||||
{/* User Dropdown */}
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
||||
className="flex items-center gap-3 py-2 px-3 rounded-xl hover:bg-gray-50 transition-all duration-200"
|
||||
className="flex items-center gap-3 py-2 px-3 rounded-xl hover:bg-[#f5f9f8] transition-all duration-200"
|
||||
>
|
||||
<div className="w-9 h-9 bg-gradient-to-br from-blue-500 to-blue-600 rounded-full flex items-center justify-center shadow-sm">
|
||||
<span className="text-white font-semibold text-sm">
|
||||
<div className="w-9 h-9 bg-[#00293d] rounded-full flex items-center justify-center shadow-sm">
|
||||
<span className="text-white font-semibold text-sm font-fractul">
|
||||
{user?.firstName?.[0] || user?.email?.[0]?.toUpperCase() || 'A'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-left hidden sm:block">
|
||||
<p className="text-sm font-semibold text-gray-800">
|
||||
<p className="text-sm font-semibold text-[#00293d] font-fractul">
|
||||
{user?.firstName || 'Admin'} {user?.lastName || ''}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">{user?.email}</p>
|
||||
<p className="text-xs text-[#666666] font-serif">{user?.email}</p>
|
||||
</div>
|
||||
<svg
|
||||
className={`w-4 h-4 text-gray-400 transition-transform duration-200 ${isDropdownOpen ? 'rotate-180' : ''}`}
|
||||
className={`w-4 h-4 text-[#9ca3af] transition-transform duration-200 ${isDropdownOpen ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -88,19 +88,19 @@ export default function DashboardLayout({
|
||||
|
||||
{/* Dropdown Menu */}
|
||||
{isDropdownOpen && (
|
||||
<div className="absolute right-0 mt-2 w-56 bg-white rounded-xl shadow-lg border border-gray-100 overflow-hidden z-50">
|
||||
<div className="px-4 py-4 bg-gradient-to-br from-gray-50 to-white border-b border-gray-100">
|
||||
<div className="absolute right-0 mt-2 w-56 bg-white rounded-xl shadow-lg border border-[#e5e7eb] overflow-hidden z-50">
|
||||
<div className="px-4 py-4 bg-[#f5f9f8] border-b border-[#e5e7eb]">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-blue-600 rounded-full flex items-center justify-center shadow-sm">
|
||||
<span className="text-white font-semibold">
|
||||
<div className="w-10 h-10 bg-[#00293d] rounded-full flex items-center justify-center shadow-sm">
|
||||
<span className="text-white font-semibold font-fractul">
|
||||
{user?.firstName?.[0] || user?.email?.[0]?.toUpperCase() || 'A'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-gray-800">
|
||||
<p className="text-sm font-semibold text-[#00293d] font-fractul">
|
||||
{user?.firstName || 'Admin'} {user?.lastName || ''}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 truncate">{user?.email}</p>
|
||||
<p className="text-xs text-[#666666] truncate font-serif">{user?.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { usersService, User } from '@/services';
|
||||
import { usersService, User, uploadService } from '@/services';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function DashboardPage() {
|
||||
@@ -13,6 +13,7 @@ export default function DashboardPage() {
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [recentUsers, setRecentUsers] = useState<User[]>([]);
|
||||
const [avatarUrls, setAvatarUrls] = useState<Record<string, string>>({});
|
||||
|
||||
useEffect(() => {
|
||||
fetchDashboardData();
|
||||
@@ -29,7 +30,23 @@ export default function DashboardPage() {
|
||||
agents: users.filter((u) => u.role === 'AGENT').length,
|
||||
pendingUsers: users.filter((u) => u.status === 'PENDING_VERIFICATION').length,
|
||||
});
|
||||
setRecentUsers(users.slice(0, 5));
|
||||
const recent = users.slice(0, 5);
|
||||
setRecentUsers(recent);
|
||||
|
||||
// Fetch presigned URLs for avatars
|
||||
const urls: Record<string, string> = {};
|
||||
await Promise.all(
|
||||
recent.map(async (user) => {
|
||||
if (user.profile?.avatar && uploadService.isS3Key(user.profile.avatar)) {
|
||||
try {
|
||||
urls[user.id] = await uploadService.getPresignedDownloadUrl(user.profile.avatar);
|
||||
} catch {
|
||||
// Ignore errors for individual avatar fetches
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
setAvatarUrls(urls);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch dashboard data:', err);
|
||||
} finally {
|
||||
@@ -40,7 +57,7 @@ export default function DashboardPage() {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[#f5a623]"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -49,17 +66,17 @@ export default function DashboardPage() {
|
||||
<div>
|
||||
{/* Page Title */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
|
||||
<p className="text-gray-500">Welcome to Re-Quest Admin Panel</p>
|
||||
<h1 className="text-2xl font-bold text-[#00293d] font-fractul">Dashboard</h1>
|
||||
<p className="text-[#666666] font-serif">Welcome to Re-Quest Admin Panel</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="bg-white rounded-xl shadow-sm border border-[#e5e7eb] p-6 card-hover">
|
||||
<div className="flex items-center">
|
||||
<div className="p-3 bg-blue-100 rounded-lg">
|
||||
<div className="p-3 bg-[#e8f0ee] rounded-xl">
|
||||
<svg
|
||||
className="w-6 h-6 text-blue-600"
|
||||
className="w-6 h-6 text-[#5ba4a4]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -73,14 +90,14 @@ export default function DashboardPage() {
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-gray-500">Total Users</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{stats.totalUsers}</p>
|
||||
<p className="text-sm font-medium text-[#666666] font-serif">Total Users</p>
|
||||
<p className="text-2xl font-bold text-[#00293d] font-fractul">{stats.totalUsers}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="bg-white rounded-xl shadow-sm border border-[#e5e7eb] p-6 card-hover">
|
||||
<div className="flex items-center">
|
||||
<div className="p-3 bg-green-100 rounded-lg">
|
||||
<div className="p-3 bg-green-50 rounded-xl">
|
||||
<svg
|
||||
className="w-6 h-6 text-green-600"
|
||||
fill="none"
|
||||
@@ -96,16 +113,16 @@ export default function DashboardPage() {
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-gray-500">Active</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{stats.activeUsers}</p>
|
||||
<p className="text-sm font-medium text-[#666666] font-serif">Active</p>
|
||||
<p className="text-2xl font-bold text-[#00293d] font-fractul">{stats.activeUsers}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="bg-white rounded-xl shadow-sm border border-[#e5e7eb] p-6 card-hover">
|
||||
<div className="flex items-center">
|
||||
<div className="p-3 bg-purple-100 rounded-lg">
|
||||
<div className="p-3 bg-[#fff7ed] rounded-xl">
|
||||
<svg
|
||||
className="w-6 h-6 text-purple-600"
|
||||
className="w-6 h-6 text-[#f5a623]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -119,14 +136,14 @@ export default function DashboardPage() {
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-gray-500">Agents</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{stats.agents}</p>
|
||||
<p className="text-sm font-medium text-[#666666] font-serif">Agents</p>
|
||||
<p className="text-2xl font-bold text-[#00293d] font-fractul">{stats.agents}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="bg-white rounded-xl shadow-sm border border-[#e5e7eb] p-6 card-hover">
|
||||
<div className="flex items-center">
|
||||
<div className="p-3 bg-yellow-100 rounded-lg">
|
||||
<div className="p-3 bg-yellow-50 rounded-xl">
|
||||
<svg
|
||||
className="w-6 h-6 text-yellow-600"
|
||||
fill="none"
|
||||
@@ -142,21 +159,21 @@ export default function DashboardPage() {
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-gray-500">Pending</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{stats.pendingUsers}</p>
|
||||
<p className="text-sm font-medium text-[#666666] font-serif">Pending</p>
|
||||
<p className="text-2xl font-bold text-[#00293d] font-fractul">{stats.pendingUsers}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Users */}
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<div className="bg-white rounded-xl shadow-sm border border-[#e5e7eb]">
|
||||
<div className="px-6 py-4 border-b border-[#e5e7eb]">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Recent Users</h2>
|
||||
<h2 className="text-lg font-semibold text-[#00293d] font-fractul">Recent Users</h2>
|
||||
<Link
|
||||
href="/dashboard/users"
|
||||
className="text-blue-600 hover:text-blue-700 text-sm font-medium"
|
||||
className="text-[#f5a623] hover:text-[#e09620] text-sm font-medium font-serif transition-colors"
|
||||
>
|
||||
View All →
|
||||
</Link>
|
||||
@@ -166,7 +183,7 @@ export default function DashboardPage() {
|
||||
{recentUsers.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<svg
|
||||
className="mx-auto h-12 w-12 text-gray-400"
|
||||
className="mx-auto h-12 w-12 text-[#9ca3af]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -178,51 +195,51 @@ export default function DashboardPage() {
|
||||
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="mt-2 text-gray-500">No users found</p>
|
||||
<p className="mt-2 text-[#666666] font-serif">No users found</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<table className="min-w-full divide-y divide-[#e5e7eb]">
|
||||
<thead className="bg-[#f5f9f8]">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-[#666666] uppercase tracking-wider font-serif">
|
||||
User
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-[#666666] uppercase tracking-wider font-serif">
|
||||
Role
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-[#666666] uppercase tracking-wider font-serif">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-[#666666] uppercase tracking-wider font-serif">
|
||||
Joined
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
<tbody className="bg-white divide-y divide-[#e5e7eb]">
|
||||
{recentUsers.map((user) => (
|
||||
<tr key={user.id} className="hover:bg-gray-50">
|
||||
<tr key={user.id} className="hover:bg-[#f5f9f8] transition-colors">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0 h-10 w-10">
|
||||
{user.profile?.avatar ? (
|
||||
<img
|
||||
className="h-10 w-10 rounded-full object-cover"
|
||||
src={user.profile.avatar}
|
||||
alt=""
|
||||
/>
|
||||
) : (
|
||||
<div className="h-10 w-10 rounded-full bg-gray-200 flex items-center justify-center">
|
||||
<span className="text-gray-500 font-medium text-sm">
|
||||
<div className="h-10 w-10 rounded-full bg-[#e8f0ee] flex items-center justify-center relative overflow-hidden">
|
||||
<span className="text-[#00293d] font-medium text-sm font-fractul">
|
||||
{user.profile?.firstName?.[0] || user.email[0].toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
{avatarUrls[user.id] && (
|
||||
<img
|
||||
className="absolute inset-0 h-10 w-10 rounded-full object-cover"
|
||||
src={avatarUrls[user.id]}
|
||||
alt=""
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
<div className="text-sm font-medium text-[#00293d] font-serif">
|
||||
{user.profile?.firstName} {user.profile?.lastName}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">{user.email}</div>
|
||||
<div className="text-sm text-[#666666]">{user.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
@@ -232,8 +249,8 @@ export default function DashboardPage() {
|
||||
user.role === 'ADMIN'
|
||||
? 'bg-red-100 text-red-800'
|
||||
: user.role === 'AGENT'
|
||||
? 'bg-purple-100 text-purple-800'
|
||||
: 'bg-blue-100 text-blue-800'
|
||||
? 'bg-[#fff7ed] text-[#f5a623]'
|
||||
: 'bg-[#e8f0ee] text-[#5ba4a4]'
|
||||
}`}
|
||||
>
|
||||
{user.role}
|
||||
@@ -254,7 +271,7 @@ export default function DashboardPage() {
|
||||
{user.status === 'PENDING_VERIFICATION' ? 'PENDING' : user.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-[#666666] font-serif">
|
||||
{new Date(user.createdAt).toLocaleDateString()}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
1206
src/app/dashboard/profile-sections/[id]/page.tsx
Normal file
1206
src/app/dashboard/profile-sections/[id]/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
579
src/app/dashboard/profile-sections/page.tsx
Normal file
579
src/app/dashboard/profile-sections/page.tsx
Normal file
@@ -0,0 +1,579 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import SectionIcon from '@/components/SectionIcon';
|
||||
import {
|
||||
profileSectionsService,
|
||||
ProfileSection,
|
||||
CreateSectionDto,
|
||||
UpdateSectionDto,
|
||||
getErrorMessage,
|
||||
} from '@/services';
|
||||
|
||||
type ModalType = 'create' | 'edit' | 'delete' | null;
|
||||
|
||||
export default function ProfileSectionsPage() {
|
||||
const router = useRouter();
|
||||
const [sections, setSections] = useState<ProfileSection[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [showInactive, setShowInactive] = useState(true);
|
||||
|
||||
// Modal state
|
||||
const [modalType, setModalType] = useState<ModalType>(null);
|
||||
const [selectedSection, setSelectedSection] = useState<ProfileSection | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [modalError, setModalError] = useState('');
|
||||
|
||||
// Form state (slug is only editable in the edit modal)
|
||||
const [formData, setFormData] = useState<CreateSectionDto & { slug?: string }>({
|
||||
name: '',
|
||||
slug: '',
|
||||
description: '',
|
||||
icon: '',
|
||||
isActive: true,
|
||||
isGlobal: false,
|
||||
isRepeatable: false,
|
||||
sortOrder: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchSections();
|
||||
}, [showInactive]);
|
||||
|
||||
const fetchSections = async () => {
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await profileSectionsService.getAll(showInactive);
|
||||
setSections(data);
|
||||
} catch (err) {
|
||||
const errorMessage = getErrorMessage(err);
|
||||
setError(errorMessage);
|
||||
if (errorMessage.includes('Unauthorized')) {
|
||||
router.push('/login');
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openCreateModal = () => {
|
||||
setFormData({
|
||||
name: '',
|
||||
slug: '',
|
||||
description: '',
|
||||
icon: '',
|
||||
isActive: true,
|
||||
isGlobal: false,
|
||||
isRepeatable: false,
|
||||
sortOrder: 0,
|
||||
});
|
||||
setModalError('');
|
||||
setModalType('create');
|
||||
};
|
||||
|
||||
const openEditModal = (section: ProfileSection) => {
|
||||
setSelectedSection(section);
|
||||
setFormData({
|
||||
name: section.name,
|
||||
slug: section.slug,
|
||||
description: section.description || '',
|
||||
icon: section.icon || '',
|
||||
isActive: section.isActive,
|
||||
isGlobal: section.isGlobal,
|
||||
isRepeatable: section.isRepeatable || false,
|
||||
sortOrder: section.sortOrder,
|
||||
});
|
||||
setModalError('');
|
||||
setModalType('edit');
|
||||
};
|
||||
|
||||
const openDeleteModal = (section: ProfileSection) => {
|
||||
setSelectedSection(section);
|
||||
setModalError('');
|
||||
setModalType('delete');
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setModalType(null);
|
||||
setSelectedSection(null);
|
||||
setModalError('');
|
||||
};
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
setModalError('');
|
||||
try {
|
||||
// Backend auto-generates slug on create; don't send the form slug field
|
||||
const { slug: _slug, ...createPayload } = formData;
|
||||
void _slug;
|
||||
await profileSectionsService.create(createPayload);
|
||||
closeModal();
|
||||
fetchSections();
|
||||
} catch (err) {
|
||||
setModalError(getErrorMessage(err));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!selectedSection) return;
|
||||
setIsSubmitting(true);
|
||||
setModalError('');
|
||||
try {
|
||||
await profileSectionsService.update(selectedSection.id, formData as UpdateSectionDto);
|
||||
closeModal();
|
||||
fetchSections();
|
||||
} catch (err) {
|
||||
setModalError(getErrorMessage(err));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!selectedSection) return;
|
||||
setIsSubmitting(true);
|
||||
setModalError('');
|
||||
try {
|
||||
await profileSectionsService.delete(selectedSection.id);
|
||||
closeModal();
|
||||
fetchSections();
|
||||
} catch (err) {
|
||||
setModalError(getErrorMessage(err));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleStatus = async (section: ProfileSection) => {
|
||||
try {
|
||||
await profileSectionsService.update(section.id, { isActive: !section.isActive });
|
||||
fetchSections();
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Page Title */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-[#00293d] font-fractul">Profile Sections</h1>
|
||||
<p className="text-[#666666] font-serif">Manage profile sections and their fields for agent types</p>
|
||||
</div>
|
||||
|
||||
{/* Sections Table */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-[#e5e7eb]">
|
||||
<div className="px-6 py-4 border-b border-[#e5e7eb]">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center space-x-4">
|
||||
<label className="flex items-center space-x-2 text-sm text-[#666666] font-serif">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showInactive}
|
||||
onChange={(e) => setShowInactive(e.target.checked)}
|
||||
className="rounded border-[#e5e7eb] text-[#f5a623] focus:ring-0"
|
||||
/>
|
||||
<span>Show Inactive</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
onClick={fetchSections}
|
||||
className="px-4 py-2 border border-[#e5e7eb] hover:bg-[#f5f9f8] text-[#00293d] text-sm font-medium rounded-lg transition-colors flex items-center"
|
||||
>
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Refresh
|
||||
</button>
|
||||
<button
|
||||
onClick={openCreateModal}
|
||||
className="px-4 py-2 bg-[#f5a623] hover:bg-[#e09620] text-white text-sm font-medium rounded-lg transition-colors flex items-center"
|
||||
>
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Add Section
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="px-6 py-4 bg-red-50 border-b border-red-200">
|
||||
<p className="text-red-700 text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[#f5a623]"></div>
|
||||
</div>
|
||||
) : sections.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<svg className="mx-auto h-12 w-12 text-[#9ca3af]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
||||
</svg>
|
||||
<p className="mt-2 text-[#666666] font-serif">No profile sections found</p>
|
||||
<button
|
||||
onClick={openCreateModal}
|
||||
className="mt-4 px-4 py-2 bg-[#f5a623] hover:bg-[#e09620] text-white text-sm font-medium rounded-lg transition-colors"
|
||||
>
|
||||
Create First Section
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<table className="min-w-full divide-y divide-[#e5e7eb]">
|
||||
<thead className="bg-[#f5f9f8]">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-[#666666] uppercase tracking-wider">
|
||||
Section
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-[#666666] uppercase tracking-wider">
|
||||
Description
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-[#666666] uppercase tracking-wider">
|
||||
Fields
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-[#666666] uppercase tracking-wider">
|
||||
Agent Types
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-[#666666] uppercase tracking-wider">
|
||||
Global
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-[#666666] uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-[#666666] uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-[#e5e7eb]">
|
||||
{sections.map((section) => (
|
||||
<tr key={section.id} className="hover:bg-[#f5f9f8]">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
{section.icon && (
|
||||
<span className="mr-3 text-[#666666]">
|
||||
<SectionIcon name={section.icon} className="w-5 h-5" />
|
||||
</span>
|
||||
)}
|
||||
<div>
|
||||
<div className="text-sm font-medium text-[#00293d]">{section.name}</div>
|
||||
<div className="text-xs text-[#666666]">{section.slug}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="text-sm text-[#666666] max-w-xs truncate">
|
||||
{section.description || '-'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<Link
|
||||
href={`/dashboard/profile-sections/${section.id}`}
|
||||
className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-[#e8f0ee] text-[#5ba4a4] hover:bg-[#d5e5e1]"
|
||||
>
|
||||
{section._count?.fields || 0} fields
|
||||
<svg className="ml-1 w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-[#666666]">
|
||||
{section.isGlobal ? (
|
||||
<span className="text-[#5ba4a4] font-medium">All (Global)</span>
|
||||
) : (
|
||||
`${section._count?.agentTypeSections || 0} assigned`
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center space-x-2">
|
||||
{section.isSystem && (
|
||||
<span className="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-[#fff7ed] text-[#f5a623]">
|
||||
System
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
|
||||
section.isGlobal
|
||||
? 'bg-[#fff7ed] text-[#f5a623]'
|
||||
: 'bg-[#e8f0ee] text-[#666666]'
|
||||
}`}
|
||||
>
|
||||
{section.isGlobal ? 'Global' : 'Specific'}
|
||||
</span>
|
||||
{section.isRepeatable && (
|
||||
<span className="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-orange-100 text-orange-800">
|
||||
Repeatable
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<button
|
||||
onClick={() => toggleStatus(section)}
|
||||
className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full cursor-pointer transition-colors ${
|
||||
section.isActive
|
||||
? 'bg-green-100 text-green-800 hover:bg-green-200'
|
||||
: 'bg-gray-100 text-gray-800 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{section.isActive ? 'Active' : 'Inactive'}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<Link
|
||||
href={`/dashboard/profile-sections/${section.id}`}
|
||||
className="text-[#f5a623] hover:text-[#e09620] mr-3 inline-flex items-center gap-1"
|
||||
title="Manage"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
Manage
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => openEditModal(section)}
|
||||
className="text-[#5ba4a4] hover:text-[#5ba4a4]/80 mr-3 inline-flex items-center gap-1"
|
||||
title="Edit"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
Edit
|
||||
</button>
|
||||
{section.isSystem ? (
|
||||
<span
|
||||
className="text-[#9ca3af] cursor-not-allowed inline-flex items-center gap-1"
|
||||
title="System sections cannot be deleted"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
Delete
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => openDeleteModal(section)}
|
||||
className="text-red-600 hover:text-red-900 inline-flex items-center gap-1"
|
||||
title="Delete"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
{(modalType === 'create' || modalType === 'edit') && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl shadow-xl max-w-md w-full mx-4">
|
||||
<div className="px-6 py-4 border-b border-[#e5e7eb]">
|
||||
<h3 className="text-lg font-semibold text-[#00293d] font-fractul">
|
||||
{modalType === 'create' ? 'Create Profile Section' : 'Edit Profile Section'}
|
||||
</h3>
|
||||
</div>
|
||||
<form onSubmit={modalType === 'create' ? handleCreate : handleUpdate}>
|
||||
<div className="px-6 py-4 space-y-4">
|
||||
{modalError && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||
<p className="text-red-700 text-sm">{modalError}</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[#00293d] mb-1">
|
||||
Name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-[#e5e7eb] rounded-xl focus:outline-none focus:border-[#f5a623] text-[#00293d] bg-white placeholder:text-[#666666]"
|
||||
placeholder="e.g., Location, Experience, Education"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{modalType === 'edit' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[#00293d] mb-1">
|
||||
Slug <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.slug || ''}
|
||||
onChange={(e) => setFormData({ ...formData, slug: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-[#e5e7eb] rounded-xl focus:outline-none focus:border-[#f5a623] text-[#00293d] bg-white placeholder:text-[#666666] font-mono text-sm"
|
||||
placeholder="e.g., basic-information"
|
||||
pattern="^[a-z0-9]+(?:-[a-z0-9]+)*$"
|
||||
title="Lowercase letters, digits, and hyphens only (no leading/trailing/consecutive hyphens)"
|
||||
required
|
||||
/>
|
||||
<p className="mt-1 text-xs text-[#666666]">
|
||||
Lowercase letters, digits, and hyphens only. Used in API keys and field references — change with care.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[#00293d] mb-1">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-[#e5e7eb] rounded-xl focus:outline-none focus:border-[#f5a623] text-[#00293d] bg-white placeholder:text-[#666666]"
|
||||
placeholder="Brief description of this section"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[#00293d] mb-1">
|
||||
Icon (emoji)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.icon}
|
||||
onChange={(e) => setFormData({ ...formData, icon: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-[#e5e7eb] rounded-xl focus:outline-none focus:border-[#f5a623] text-[#00293d] bg-white placeholder:text-[#666666]"
|
||||
placeholder="e.g., location_on"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[#00293d] mb-1">
|
||||
Sort Order
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.sortOrder === 0 ? '' : formData.sortOrder}
|
||||
onChange={(e) => setFormData({ ...formData, sortOrder: parseInt(e.target.value) || 0 })}
|
||||
onFocus={(e) => e.target.select()}
|
||||
placeholder="0"
|
||||
min={0}
|
||||
className="w-full px-3 py-2 border border-[#e5e7eb] rounded-xl focus:outline-none focus:border-[#f5a623] text-[#00293d] bg-white placeholder:text-[#666666]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-6">
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="isActive"
|
||||
checked={formData.isActive}
|
||||
onChange={(e) => setFormData({ ...formData, isActive: e.target.checked })}
|
||||
className="rounded border-[#e5e7eb] text-[#f5a623] focus:ring-0"
|
||||
/>
|
||||
<label htmlFor="isActive" className="ml-2 text-sm text-[#00293d] font-serif">
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="isGlobal"
|
||||
checked={formData.isGlobal}
|
||||
onChange={(e) => setFormData({ ...formData, isGlobal: e.target.checked })}
|
||||
className="rounded border-[#e5e7eb] text-[#5ba4a4] focus:ring-0"
|
||||
/>
|
||||
<label htmlFor="isGlobal" className="ml-2 text-sm text-[#00293d] font-serif">
|
||||
Global (all agent types)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="isRepeatable"
|
||||
checked={formData.isRepeatable}
|
||||
onChange={(e) => setFormData({ ...formData, isRepeatable: e.target.checked })}
|
||||
className="rounded border-[#e5e7eb] text-[#f5a623] focus:ring-0"
|
||||
/>
|
||||
<label htmlFor="isRepeatable" className="ml-2 text-sm text-[#00293d] font-serif">
|
||||
Allow Multiple Entries (e.g., certifications, education)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t border-[#e5e7eb] flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
className="px-4 py-2 border border-[#e5e7eb] text-[#00293d] rounded-xl hover:bg-[#f5f9f8] transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="px-4 py-2 bg-[#f5a623] text-white rounded-xl hover:bg-[#e09620] transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? 'Saving...' : modalType === 'create' ? 'Create' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{modalType === 'delete' && selectedSection && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl shadow-xl max-w-md w-full mx-4">
|
||||
<div className="px-6 py-4 border-b border-[#e5e7eb]">
|
||||
<h3 className="text-lg font-semibold text-[#00293d] font-fractul">Delete Profile Section</h3>
|
||||
</div>
|
||||
<div className="px-6 py-4">
|
||||
{modalError && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||
<p className="text-red-700 text-sm">{modalError}</p>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[#666666] font-serif">
|
||||
Are you sure you want to delete <span className="font-semibold">{selectedSection.name}</span>?
|
||||
</p>
|
||||
{(selectedSection._count?.fields && selectedSection._count.fields > 0) && (
|
||||
<p className="mt-2 text-amber-600 text-sm">
|
||||
Warning: This section has {selectedSection._count.fields} field(s). They will also be deleted.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t border-[#e5e7eb] flex justify-end space-x-3">
|
||||
<button
|
||||
onClick={closeModal}
|
||||
className="px-4 py-2 border border-[#e5e7eb] text-[#00293d] rounded-xl hover:bg-[#f5f9f8] transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={isSubmitting}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? 'Deleting...' : 'Delete'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
253
src/app/dashboard/reports/page.tsx
Normal file
253
src/app/dashboard/reports/page.tsx
Normal file
@@ -0,0 +1,253 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { userReportsService, type UserReport, type ReportCounts } from '@/services/user-reports.service';
|
||||
import { getErrorMessage } from '@/services';
|
||||
|
||||
function getUserName(user: UserReport['reporter']): string {
|
||||
const profile = user.userProfile || user.agentProfile;
|
||||
if (profile?.firstName || profile?.lastName) {
|
||||
return [profile.firstName, profile.lastName].filter(Boolean).join(' ');
|
||||
}
|
||||
return user.email;
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
PENDING: 'bg-yellow-100 text-yellow-800',
|
||||
REVIEWED: 'bg-blue-100 text-blue-800',
|
||||
RESOLVED: 'bg-green-100 text-green-800',
|
||||
DISMISSED: 'bg-gray-100 text-gray-800',
|
||||
};
|
||||
|
||||
export default function ReportsPage() {
|
||||
const router = useRouter();
|
||||
const [reports, setReports] = useState<UserReport[]>([]);
|
||||
const [counts, setCounts] = useState<ReportCounts | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('');
|
||||
const [selectedReport, setSelectedReport] = useState<UserReport | null>(null);
|
||||
const [adminNotes, setAdminNotes] = useState('');
|
||||
const [updatingId, setUpdatingId] = useState<string | null>(null);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
const [reportsList, reportCounts] = await Promise.all([
|
||||
userReportsService.getAllReports(statusFilter || undefined),
|
||||
userReportsService.getReportCounts(),
|
||||
]);
|
||||
setReports(reportsList);
|
||||
setCounts(reportCounts);
|
||||
} catch (err) {
|
||||
const msg = getErrorMessage(err);
|
||||
setError(msg);
|
||||
if (msg.includes('Unauthorized')) router.push('/login');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [statusFilter]);
|
||||
|
||||
const handleUpdateStatus = async (reportId: string, status: string) => {
|
||||
try {
|
||||
setUpdatingId(reportId);
|
||||
const updated = await userReportsService.updateReport(reportId, status, adminNotes || undefined);
|
||||
setReports((prev) => prev.map((r) => (r.id === reportId ? updated : r)));
|
||||
setSelectedReport(null);
|
||||
setAdminNotes('');
|
||||
// Refresh counts
|
||||
const reportCounts = await userReportsService.getReportCounts();
|
||||
setCounts(reportCounts);
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err));
|
||||
} finally {
|
||||
setUpdatingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">User Reports</h1>
|
||||
|
||||
{/* Status Counts */}
|
||||
{counts && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
|
||||
{[
|
||||
{ label: 'Total', value: counts.total, color: 'bg-gray-50' },
|
||||
{ label: 'Pending', value: counts.pending, color: 'bg-yellow-50' },
|
||||
{ label: 'Reviewed', value: counts.reviewed, color: 'bg-blue-50' },
|
||||
{ label: 'Resolved', value: counts.resolved, color: 'bg-green-50' },
|
||||
{ label: 'Dismissed', value: counts.dismissed, color: 'bg-gray-50' },
|
||||
].map((stat) => (
|
||||
<div key={stat.label} className={`${stat.color} rounded-lg p-4 border border-gray-200`}>
|
||||
<p className="text-sm text-gray-600">{stat.label}</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{stat.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filter */}
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<label className="text-sm font-medium text-gray-700">Filter by status:</label>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white"
|
||||
>
|
||||
<option value="">All</option>
|
||||
<option value="PENDING">Pending</option>
|
||||
<option value="REVIEWED">Reviewed</option>
|
||||
<option value="RESOLVED">Resolved</option>
|
||||
<option value="DISMISSED">Dismissed</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg mb-4">
|
||||
<p className="text-red-700 text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
) : reports.length === 0 ? (
|
||||
<div className="text-center py-12 bg-white rounded-lg border border-gray-200">
|
||||
<p className="text-gray-500">No reports found</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Reporter</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Reported User</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Reason</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Date</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{reports.map((report) => (
|
||||
<tr key={report.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3">
|
||||
<p className="text-sm font-medium text-gray-900">{getUserName(report.reporter)}</p>
|
||||
<p className="text-xs text-gray-500">{report.reporter.email}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<p className="text-sm font-medium text-gray-900">{getUserName(report.reportedUser)}</p>
|
||||
<p className="text-xs text-gray-500">{report.reportedUser.email}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<p className="text-sm text-gray-900">{report.reason}</p>
|
||||
{report.description && (
|
||||
<p className="text-xs text-gray-500 mt-1 truncate max-w-[200px]">{report.description}</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`px-2 py-1 text-xs font-semibold rounded-full ${STATUS_COLORS[report.status]}`}>
|
||||
{report.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500">
|
||||
{new Date(report.createdAt).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedReport(report);
|
||||
setAdminNotes(report.adminNotes || '');
|
||||
}}
|
||||
className="text-sm text-blue-600 hover:text-blue-800 font-medium"
|
||||
>
|
||||
Review
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Review Modal */}
|
||||
{selectedReport && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-lg w-full mx-4">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Review Report</h3>
|
||||
</div>
|
||||
<div className="px-6 py-4 space-y-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-700">Reporter</p>
|
||||
<p className="text-sm text-gray-900">{getUserName(selectedReport.reporter)} ({selectedReport.reporter.email})</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-700">Reported User</p>
|
||||
<p className="text-sm text-gray-900">{getUserName(selectedReport.reportedUser)} ({selectedReport.reportedUser.email})</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-700">Reason</p>
|
||||
<p className="text-sm text-gray-900">{selectedReport.reason}</p>
|
||||
</div>
|
||||
{selectedReport.description && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-700">Description</p>
|
||||
<p className="text-sm text-gray-900">{selectedReport.description}</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700 block mb-1">Admin Notes</label>
|
||||
<textarea
|
||||
value={adminNotes}
|
||||
onChange={(e) => setAdminNotes(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white placeholder:text-gray-500"
|
||||
placeholder="Add notes about this report..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t border-gray-200 flex flex-wrap gap-2 justify-end">
|
||||
<button
|
||||
onClick={() => { setSelectedReport(null); setAdminNotes(''); }}
|
||||
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors text-sm"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleUpdateStatus(selectedReport.id, 'DISMISSED')}
|
||||
disabled={updatingId === selectedReport.id}
|
||||
className="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors text-sm disabled:opacity-50"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleUpdateStatus(selectedReport.id, 'REVIEWED')}
|
||||
disabled={updatingId === selectedReport.id}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm disabled:opacity-50"
|
||||
>
|
||||
Mark Reviewed
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleUpdateStatus(selectedReport.id, 'RESOLVED')}
|
||||
disabled={updatingId === selectedReport.id}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors text-sm disabled:opacity-50"
|
||||
>
|
||||
Resolve
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
164
src/app/dashboard/subscription-plans/page.tsx
Normal file
164
src/app/dashboard/subscription-plans/page.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { adminStripeService, SubscriptionPlan, getErrorMessage } from '@/services';
|
||||
|
||||
export default function SubscriptionPlansPage() {
|
||||
const router = useRouter();
|
||||
const [plans, setPlans] = useState<SubscriptionPlan[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [syncingId, setSyncingId] = useState<string | null>(null);
|
||||
const [syncMessage, setSyncMessage] = useState<{ planId: string; text: string; isError?: boolean } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPlans();
|
||||
}, []);
|
||||
|
||||
const fetchPlans = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await adminStripeService.getAdminPlans();
|
||||
setPlans(data);
|
||||
} catch (err) {
|
||||
const msg = getErrorMessage(err);
|
||||
setError(msg);
|
||||
if (msg.includes('Unauthorized')) router.push('/login');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSync = async (plan: SubscriptionPlan) => {
|
||||
setSyncingId(plan.id);
|
||||
setSyncMessage(null);
|
||||
try {
|
||||
const updated = await adminStripeService.syncPlanFromStripe(plan.id);
|
||||
setPlans((prev) => prev.map((p) => (p.id === updated.id ? updated : p)));
|
||||
setSyncMessage({
|
||||
planId: plan.id,
|
||||
text: `Synced: ${formatCurrency(updated.amount / 100)} / ${updated.interval}`,
|
||||
});
|
||||
} catch (err) {
|
||||
setSyncMessage({ planId: plan.id, text: getErrorMessage(err), isError: true });
|
||||
} finally {
|
||||
setSyncingId(null);
|
||||
setTimeout(() => setSyncMessage(null), 5000);
|
||||
}
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number) =>
|
||||
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Subscription Plans</h1>
|
||||
<p className="text-gray-600 mt-1">
|
||||
Manage subscription plans. Use "Sync from Stripe" to pull the latest amount,
|
||||
currency, interval, name, and description directly from Stripe for a given price.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h2 className="text-lg font-semibold text-gray-900">All Plans ({plans.length})</h2>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mx-6 mt-4 p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||
<p className="text-red-700 text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
|
||||
</div>
|
||||
) : plans.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500">No plans found</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Name
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Stripe Price ID
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Amount
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Interval
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Active
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{plans.map((plan) => (
|
||||
<tr key={plan.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900">{plan.name}</div>
|
||||
{plan.description && (
|
||||
<div className="text-sm text-gray-500">{plan.description}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<code className="text-xs text-gray-700 bg-gray-100 px-2 py-1 rounded">
|
||||
{plan.stripePriceId}
|
||||
</code>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{formatCurrency(plan.amount / 100)}
|
||||
<span className="text-gray-500 ml-1">{plan.currency.toUpperCase()}</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{plan.interval}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span
|
||||
className={`px-2 py-1 text-xs font-semibold rounded-full ${
|
||||
plan.isActive ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'
|
||||
}`}
|
||||
>
|
||||
{plan.isActive ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right">
|
||||
<button
|
||||
onClick={() => handleSync(plan)}
|
||||
disabled={syncingId === plan.id}
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
{syncingId === plan.id ? 'Syncing...' : 'Sync from Stripe'}
|
||||
</button>
|
||||
{syncMessage && syncMessage.planId === plan.id && (
|
||||
<p
|
||||
className={`mt-2 text-xs ${
|
||||
syncMessage.isError ? 'text-red-600' : 'text-green-600'
|
||||
}`}
|
||||
>
|
||||
{syncMessage.text}
|
||||
</p>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
234
src/app/dashboard/subscriptions/page.tsx
Normal file
234
src/app/dashboard/subscriptions/page.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
adminStripeService,
|
||||
AdminSubscription,
|
||||
RevenueStats,
|
||||
getErrorMessage,
|
||||
} from '@/services';
|
||||
|
||||
const STATUS_OPTIONS = ['', 'ACTIVE', 'PAST_DUE', 'CANCELED', 'INCOMPLETE', 'UNPAID'];
|
||||
|
||||
const statusBadge = (status: string) => {
|
||||
const styles: Record<string, string> = {
|
||||
ACTIVE: 'bg-green-100 text-green-800',
|
||||
PAST_DUE: 'bg-yellow-100 text-yellow-800',
|
||||
CANCELED: 'bg-red-100 text-red-800',
|
||||
INCOMPLETE: 'bg-gray-100 text-gray-800',
|
||||
UNPAID: 'bg-red-100 text-red-800',
|
||||
TRIALING: 'bg-blue-100 text-blue-800',
|
||||
};
|
||||
return (
|
||||
<span className={`px-2 py-1 text-xs font-semibold rounded-full ${styles[status] || 'bg-gray-100 text-gray-800'}`}>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default function SubscriptionsPage() {
|
||||
const router = useRouter();
|
||||
const [subscriptions, setSubscriptions] = useState<AdminSubscription[]>([]);
|
||||
const [stats, setStats] = useState<RevenueStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [statusFilter, page]);
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const [subsResult, statsResult] = await Promise.all([
|
||||
adminStripeService.getAllSubscriptions({
|
||||
status: statusFilter || undefined,
|
||||
page,
|
||||
limit: 20,
|
||||
}),
|
||||
adminStripeService.getRevenueStats(),
|
||||
]);
|
||||
setSubscriptions(subsResult.data);
|
||||
setTotalPages(subsResult.totalPages);
|
||||
setTotal(subsResult.total);
|
||||
setStats(statsResult);
|
||||
} catch (err) {
|
||||
const errorMessage = getErrorMessage(err);
|
||||
setError(errorMessage);
|
||||
if (errorMessage.includes('Unauthorized')) {
|
||||
router.push('/login');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string | null) => {
|
||||
if (!dateStr) return '-';
|
||||
return new Date(dateStr).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
const getAgentName = (sub: AdminSubscription) => {
|
||||
const profile = sub.user?.agentProfile;
|
||||
if (profile?.firstName || profile?.lastName) {
|
||||
return `${profile.firstName || ''} ${profile.lastName || ''}`.trim();
|
||||
}
|
||||
return sub.user?.email || 'Unknown';
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Subscriptions</h1>
|
||||
<p className="text-gray-600 mt-1">Manage agent subscriptions and view revenue</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<p className="text-sm text-gray-600">Active Subscriptions</p>
|
||||
<p className="text-2xl font-bold text-gray-900 mt-1">{stats.activeSubscriptions}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<p className="text-sm text-gray-600">Total Revenue</p>
|
||||
<p className="text-2xl font-bold text-green-600 mt-1">{formatCurrency(stats.totalRevenue)}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<p className="text-sm text-gray-600">This Month</p>
|
||||
<p className="text-2xl font-bold text-blue-600 mt-1">{formatCurrency(stats.monthlyRevenue)}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white rounded-lg shadow mb-6">
|
||||
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
All Subscriptions ({total})
|
||||
</h2>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => {
|
||||
setStatusFilter(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-gray-900 bg-white text-sm"
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
{STATUS_OPTIONS.filter(Boolean).map((s) => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mx-6 mt-4 p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||
<p className="text-red-700 text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
|
||||
</div>
|
||||
) : subscriptions.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
No subscriptions found
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Agent</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Plan</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Amount</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Period End</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{subscriptions.map((sub) => (
|
||||
<tr key={sub.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">{getAgentName(sub)}</div>
|
||||
<div className="text-sm text-gray-500">{sub.user?.email}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{sub.plan?.name || '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{statusBadge(sub.status)}
|
||||
{sub.cancelAtPeriodEnd && (
|
||||
<span className="ml-2 text-xs text-orange-600">(Canceling)</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{sub.plan ? formatCurrency(sub.plan.amount / 100) : '-'}
|
||||
{sub.plan && <span className="text-gray-500">/{sub.plan.interval}</span>}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{formatDate(sub.currentPeriodEnd)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{formatDate(sub.createdAt)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="px-6 py-4 border-t border-gray-200 flex items-center justify-between">
|
||||
<p className="text-sm text-gray-700">
|
||||
Page {page} of {totalPages}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="px-3 py-1 border border-gray-300 rounded text-sm disabled:opacity-50 hover:bg-gray-50"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
className="px-3 py-1 border border-gray-300 rounded text-sm disabled:opacity-50 hover:bg-gray-50"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
378
src/app/dashboard/support-chat/page.tsx
Normal file
378
src/app/dashboard/support-chat/page.tsx
Normal file
@@ -0,0 +1,378 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { supportChatService, getErrorMessage } from '@/services';
|
||||
import type { SupportChat, SupportMessage } from '@/services';
|
||||
|
||||
function getUserDisplayName(chat: SupportChat): string {
|
||||
const profile = chat.user.userProfile || chat.user.agentProfile;
|
||||
if (profile && (profile.firstName || profile.lastName)) {
|
||||
return [profile.firstName, profile.lastName].filter(Boolean).join(' ');
|
||||
}
|
||||
return chat.user.email;
|
||||
}
|
||||
|
||||
function formatTime(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return 'Just now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
function formatMessageTime(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
export default function SupportChatPage() {
|
||||
const [chats, setChats] = useState<SupportChat[]>([]);
|
||||
const [selectedChat, setSelectedChat] = useState<SupportChat | null>(null);
|
||||
const [messages, setMessages] = useState<SupportMessage[]>([]);
|
||||
const [messageInput, setMessageInput] = useState('');
|
||||
const [filter, setFilter] = useState<'OPEN' | 'CLOSED'>('OPEN');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [messagesLoading, setMessagesLoading] = useState(false);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const pollIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, []);
|
||||
|
||||
const fetchChats = useCallback(async () => {
|
||||
try {
|
||||
const data = await supportChatService.getAllChats(filter);
|
||||
setChats(data);
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filter]);
|
||||
|
||||
const fetchMessages = useCallback(async (chatId: string) => {
|
||||
try {
|
||||
const data = await supportChatService.getMessages(chatId);
|
||||
setMessages(data.messages);
|
||||
setTimeout(scrollToBottom, 100);
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err));
|
||||
}
|
||||
}, [scrollToBottom]);
|
||||
|
||||
// Fetch chats on mount and filter change
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setSelectedChat(null);
|
||||
setMessages([]);
|
||||
fetchChats();
|
||||
}, [fetchChats]);
|
||||
|
||||
// Polling for real-time updates
|
||||
useEffect(() => {
|
||||
if (pollIntervalRef.current) {
|
||||
clearInterval(pollIntervalRef.current);
|
||||
}
|
||||
|
||||
pollIntervalRef.current = setInterval(() => {
|
||||
fetchChats();
|
||||
if (selectedChat) {
|
||||
fetchMessages(selectedChat.id);
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
return () => {
|
||||
if (pollIntervalRef.current) {
|
||||
clearInterval(pollIntervalRef.current);
|
||||
}
|
||||
};
|
||||
}, [fetchChats, fetchMessages, selectedChat]);
|
||||
|
||||
const handleSelectChat = async (chat: SupportChat) => {
|
||||
setSelectedChat(chat);
|
||||
setMessagesLoading(true);
|
||||
setMessages([]);
|
||||
try {
|
||||
const data = await supportChatService.getMessages(chat.id);
|
||||
setMessages(data.messages);
|
||||
if (chat.adminUnreadCount > 0) {
|
||||
await supportChatService.markAsRead(chat.id);
|
||||
fetchChats();
|
||||
}
|
||||
setTimeout(scrollToBottom, 100);
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err));
|
||||
} finally {
|
||||
setMessagesLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendMessage = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!selectedChat || !messageInput.trim() || sending) return;
|
||||
|
||||
setSending(true);
|
||||
try {
|
||||
await supportChatService.sendMessage(selectedChat.id, messageInput.trim());
|
||||
setMessageInput('');
|
||||
await fetchMessages(selectedChat.id);
|
||||
fetchChats();
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err));
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseChat = async (chatId: string) => {
|
||||
try {
|
||||
await supportChatService.closeChat(chatId);
|
||||
if (selectedChat?.id === chatId) {
|
||||
setSelectedChat(null);
|
||||
setMessages([]);
|
||||
}
|
||||
fetchChats();
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Support Chat</h1>
|
||||
<p className="text-gray-600 mt-1">Manage user support conversations</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||
<p className="text-red-700 text-sm">{error}</p>
|
||||
<button
|
||||
onClick={() => setError('')}
|
||||
className="text-red-500 text-xs underline mt-1"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filter Tabs */}
|
||||
<div className="mb-4 flex space-x-2">
|
||||
<button
|
||||
onClick={() => setFilter('OPEN')}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||
filter === 'OPEN'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-white text-gray-700 border border-gray-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
Open
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilter('CLOSED')}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||
filter === 'CLOSED'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-white text-gray-700 border border-gray-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
Closed
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Two-Panel Layout */}
|
||||
<div className="bg-white rounded-lg shadow flex" style={{ height: 'calc(100vh - 240px)' }}>
|
||||
{/* Left Panel - Chat List */}
|
||||
<div className="w-80 border-r border-gray-200 flex flex-col">
|
||||
<div className="px-4 py-3 border-b border-gray-200">
|
||||
<h2 className="text-sm font-semibold text-gray-700">
|
||||
Conversations ({chats.length})
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
) : chats.length === 0 ? (
|
||||
<div className="p-4 text-center text-gray-500 text-sm">
|
||||
No {filter.toLowerCase()} conversations
|
||||
</div>
|
||||
) : (
|
||||
chats.map((chat) => (
|
||||
<button
|
||||
key={chat.id}
|
||||
onClick={() => handleSelectChat(chat)}
|
||||
className={`w-full text-left px-4 py-3 border-b border-gray-100 hover:bg-gray-50 transition-colors ${
|
||||
selectedChat?.id === chat.id ? 'bg-blue-50' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-900 truncate">
|
||||
{getUserDisplayName(chat)}
|
||||
</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
{chat.adminUnreadCount > 0 && (
|
||||
<span className="inline-flex items-center justify-center w-5 h-5 text-xs font-bold text-white bg-red-500 rounded-full">
|
||||
{chat.adminUnreadCount}
|
||||
</span>
|
||||
)}
|
||||
<span className={`px-2 py-0.5 text-xs font-semibold rounded-full ${
|
||||
chat.status === 'OPEN'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{chat.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{chat.lastMessageText && (
|
||||
<p className="text-xs text-gray-500 mt-1 truncate">
|
||||
{chat.lastMessageText}
|
||||
</p>
|
||||
)}
|
||||
{chat.lastMessageAt && (
|
||||
<p className="text-xs text-gray-400 mt-0.5">
|
||||
{formatTime(chat.lastMessageAt)}
|
||||
</p>
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Panel - Messages */}
|
||||
<div className="flex-1 flex flex-col">
|
||||
{selectedChat ? (
|
||||
<>
|
||||
{/* Chat Header */}
|
||||
<div className="px-6 py-3 border-b border-gray-200 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900">
|
||||
{getUserDisplayName(selectedChat)}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500">{selectedChat.user.email}</p>
|
||||
</div>
|
||||
{selectedChat.status === 'OPEN' && (
|
||||
<button
|
||||
onClick={() => handleCloseChat(selectedChat.id)}
|
||||
className="px-3 py-1.5 text-xs font-medium bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
|
||||
>
|
||||
Close Chat
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Messages Area */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-3">
|
||||
{messagesLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
) : messages.length === 0 ? (
|
||||
<div className="text-center text-gray-500 text-sm py-12">
|
||||
No messages yet
|
||||
</div>
|
||||
) : (
|
||||
messages.map((message) => {
|
||||
const isAdmin = message.senderRole === 'ADMIN';
|
||||
return (
|
||||
<div
|
||||
key={message.id}
|
||||
className={`flex ${isAdmin ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
<div
|
||||
className={`max-w-xs lg:max-w-md px-4 py-2 rounded-lg ${
|
||||
isAdmin
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 text-gray-900'
|
||||
}`}
|
||||
>
|
||||
<p className="text-sm whitespace-pre-wrap">{message.content}</p>
|
||||
<p
|
||||
className={`text-xs mt-1 ${
|
||||
isAdmin ? 'text-blue-200' : 'text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{formatMessageTime(message.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input Area */}
|
||||
{selectedChat.status === 'OPEN' && (
|
||||
<form
|
||||
onSubmit={handleSendMessage}
|
||||
className="px-6 py-3 border-t border-gray-200 flex items-center space-x-3"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={messageInput}
|
||||
onChange={(e) => setMessageInput(e.target.value)}
|
||||
placeholder="Type a message..."
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm text-gray-900 bg-white placeholder:text-gray-500"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!messageInput.trim() || sending}
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
{sending ? (
|
||||
<svg className="animate-spin h-5 w-5" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
) : (
|
||||
'Send'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{selectedChat.status === 'CLOSED' && (
|
||||
<div className="px-6 py-3 border-t border-gray-200 text-center">
|
||||
<p className="text-sm text-gray-500">This conversation has been closed</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<svg
|
||||
className="w-16 h-16 text-gray-300 mx-auto mb-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-gray-500 text-sm">Select a conversation to view messages</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
839
src/app/dashboard/users/[id]/page.tsx
Normal file
839
src/app/dashboard/users/[id]/page.tsx
Normal file
@@ -0,0 +1,839 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import {
|
||||
usersService,
|
||||
User,
|
||||
getErrorMessage,
|
||||
uploadService,
|
||||
agentTypesService,
|
||||
AgentType,
|
||||
VerificationStatus,
|
||||
VerificationDocument,
|
||||
VerificationHistoryEntry,
|
||||
AgentFieldValue,
|
||||
} from '@/services';
|
||||
|
||||
export default function UserDetailPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const userId = params.id as string;
|
||||
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
|
||||
|
||||
// Agent type editing
|
||||
const [agentTypes, setAgentTypes] = useState<AgentType[]>([]);
|
||||
const [selectedAgentTypeId, setSelectedAgentTypeId] = useState<string>('');
|
||||
const [isUpdatingAgentType, setIsUpdatingAgentType] = useState(false);
|
||||
const [updateSuccess, setUpdateSuccess] = useState('');
|
||||
|
||||
// Verification
|
||||
const [verificationDocuments, setVerificationDocuments] = useState<VerificationDocument[]>([]);
|
||||
const [isLoadingDocuments, setIsLoadingDocuments] = useState(false);
|
||||
const [verificationNote, setVerificationNote] = useState('');
|
||||
const [isUpdatingVerification, setIsUpdatingVerification] = useState(false);
|
||||
const [verificationHistory, setVerificationHistory] = useState<VerificationHistoryEntry[]>([]);
|
||||
const [agentFieldValues, setAgentFieldValues] = useState<AgentFieldValue[]>([]);
|
||||
const [isTogglingStatus, setIsTogglingStatus] = useState(false);
|
||||
|
||||
// Is the agent subscription active (paid)?
|
||||
const PAID_STATUSES = new Set(['ACTIVE', 'TRIALING', 'PAST_DUE']);
|
||||
const isSubscriptionActive = (status?: string | null) =>
|
||||
!!status && PAID_STATUSES.has(status.toUpperCase());
|
||||
|
||||
// Helper function to check if avatar is an S3 key
|
||||
const isS3Key = (avatar: string | null | undefined): boolean => {
|
||||
if (!avatar) return false;
|
||||
return !avatar.startsWith('http') && !avatar.startsWith('/');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchUser();
|
||||
fetchAgentTypes();
|
||||
}, [userId]);
|
||||
|
||||
// Fetch verification documents when user is loaded and is an agent
|
||||
useEffect(() => {
|
||||
if (user && user.role === 'AGENT') {
|
||||
fetchVerificationDocuments();
|
||||
fetchVerificationHistory();
|
||||
if (user.agentProfile?.id) {
|
||||
fetchAgentFieldValues(user.agentProfile.id);
|
||||
}
|
||||
}
|
||||
}, [user?.id, user?.role]);
|
||||
|
||||
const handleToggleStatus = async () => {
|
||||
if (!user) return;
|
||||
const newStatus = user.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE';
|
||||
const confirmed = window.confirm(
|
||||
newStatus === 'INACTIVE'
|
||||
? 'Deactivate this user? They will not be able to login and their profile will be hidden from search.'
|
||||
: 'Reactivate this user? They will be able to login again.'
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
setIsTogglingStatus(true);
|
||||
setError('');
|
||||
try {
|
||||
const updated = await usersService.updateUserStatus(user.id, newStatus);
|
||||
// Update local state immediately from API response so UI reflects the
|
||||
// new status without a full refetch (which toggles isLoading and hides
|
||||
// the content). Merge to preserve nested fields the PATCH may not return.
|
||||
setUser((prev) => (prev ? { ...prev, ...updated, status: updated?.status ?? newStatus } : prev));
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err));
|
||||
} finally {
|
||||
setIsTogglingStatus(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchUser = async () => {
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const userData = await usersService.getUserById(userId);
|
||||
setUser(userData);
|
||||
|
||||
// Set initial agent type
|
||||
if (userData.agentProfile?.agentTypeId) {
|
||||
setSelectedAgentTypeId(userData.agentProfile.agentTypeId);
|
||||
}
|
||||
|
||||
// Fetch avatar URL if needed
|
||||
const avatar = userData.profile?.avatar;
|
||||
if (avatar) {
|
||||
if (isS3Key(avatar)) {
|
||||
try {
|
||||
const presignedUrl = await uploadService.getPresignedDownloadUrl(avatar);
|
||||
setAvatarUrl(presignedUrl);
|
||||
} catch (err) {
|
||||
console.error('Failed to get avatar URL:', err);
|
||||
}
|
||||
} else {
|
||||
setAvatarUrl(avatar);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = getErrorMessage(err);
|
||||
setError(errorMessage);
|
||||
if (errorMessage.includes('Unauthorized')) {
|
||||
router.push('/login');
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchAgentTypes = async () => {
|
||||
try {
|
||||
const types = await agentTypesService.getAll();
|
||||
setAgentTypes(types);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch agent types:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchVerificationDocuments = async () => {
|
||||
if (!user || user.role !== 'AGENT') return;
|
||||
|
||||
setIsLoadingDocuments(true);
|
||||
try {
|
||||
const docs = await usersService.getVerificationDocuments(userId);
|
||||
// Fetch presigned URLs for each document
|
||||
const docsWithUrls = await Promise.all(
|
||||
docs.map(async (doc) => {
|
||||
try {
|
||||
// The S3 key is stored in 'id' field from the upload service
|
||||
// or in 'key' field if explicitly set. Fall back to id if key is missing.
|
||||
const fileKey = doc.key || doc.id;
|
||||
if (!fileKey || fileKey.startsWith('http')) {
|
||||
// If no key or already a URL, return document as-is
|
||||
return doc;
|
||||
}
|
||||
const url = await uploadService.getPresignedDownloadUrl(fileKey);
|
||||
return { ...doc, url };
|
||||
} catch {
|
||||
return doc;
|
||||
}
|
||||
})
|
||||
);
|
||||
setVerificationDocuments(docsWithUrls);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch verification documents:', err);
|
||||
} finally {
|
||||
setIsLoadingDocuments(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchVerificationHistory = async () => {
|
||||
try {
|
||||
const history = await usersService.getVerificationHistory(userId);
|
||||
setVerificationHistory(history);
|
||||
} catch {
|
||||
// History may not exist yet
|
||||
}
|
||||
};
|
||||
|
||||
const fetchAgentFieldValues = async (agentProfileId: string) => {
|
||||
try {
|
||||
const values = await usersService.getAgentFieldValues(agentProfileId);
|
||||
setAgentFieldValues(values);
|
||||
} catch {
|
||||
// Field values may not exist
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerification = async (status: VerificationStatus) => {
|
||||
if (!user) return;
|
||||
|
||||
setIsUpdatingVerification(true);
|
||||
setError('');
|
||||
setUpdateSuccess('');
|
||||
|
||||
try {
|
||||
await usersService.updateVerification(user.id, {
|
||||
status,
|
||||
note: verificationNote || undefined,
|
||||
});
|
||||
setUpdateSuccess(
|
||||
status === 'APPROVED'
|
||||
? 'Agent verification approved successfully'
|
||||
: 'Agent verification rejected'
|
||||
);
|
||||
setVerificationNote('');
|
||||
// Refresh user data and history
|
||||
await fetchUser();
|
||||
await fetchVerificationHistory();
|
||||
// Clear success message after 3 seconds
|
||||
setTimeout(() => setUpdateSuccess(''), 3000);
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err));
|
||||
} finally {
|
||||
setIsUpdatingVerification(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getVerificationStatusBadge = (status: VerificationStatus | undefined) => {
|
||||
switch (status) {
|
||||
case 'APPROVED':
|
||||
return (
|
||||
<span className="px-3 py-1 text-sm font-semibold rounded-full bg-green-100 text-green-800">
|
||||
Approved
|
||||
</span>
|
||||
);
|
||||
case 'REJECTED':
|
||||
return (
|
||||
<span className="px-3 py-1 text-sm font-semibold rounded-full bg-red-100 text-red-800">
|
||||
Rejected
|
||||
</span>
|
||||
);
|
||||
case 'PENDING_REVIEW':
|
||||
return (
|
||||
<span className="px-3 py-1 text-sm font-semibold rounded-full bg-yellow-100 text-yellow-800">
|
||||
Pending Review
|
||||
</span>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<span className="px-3 py-1 text-sm font-semibold rounded-full bg-gray-100 text-gray-800">
|
||||
Not Submitted
|
||||
</span>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
const handleUpdateAgentType = async () => {
|
||||
if (!selectedAgentTypeId || !user) return;
|
||||
|
||||
setIsUpdatingAgentType(true);
|
||||
setError('');
|
||||
setUpdateSuccess('');
|
||||
|
||||
try {
|
||||
await usersService.updateAgentType(user.id, selectedAgentTypeId);
|
||||
setUpdateSuccess('Agent type updated successfully');
|
||||
|
||||
// Refresh user data
|
||||
await fetchUser();
|
||||
|
||||
// Clear success message after 3 seconds
|
||||
setTimeout(() => setUpdateSuccess(''), 3000);
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err));
|
||||
} finally {
|
||||
setIsUpdatingAgentType(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string | null | undefined) => {
|
||||
if (!dateString) return 'N/A';
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[#f5a623]"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && !user) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<p className="text-red-700">{error}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => router.push('/dashboard/users')}
|
||||
className="mt-4 px-4 py-2 text-[#f5a623] hover:text-[#e09620]"
|
||||
>
|
||||
← Back to Users
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<p className="text-[#666666] font-serif">User not found</p>
|
||||
<button
|
||||
onClick={() => router.push('/dashboard/users')}
|
||||
className="mt-4 px-4 py-2 text-[#f5a623] hover:text-[#e09620]"
|
||||
>
|
||||
← Back to Users
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Back Button */}
|
||||
<button
|
||||
onClick={() => router.push('/dashboard/users')}
|
||||
className="mb-6 flex items-center text-[#666666] hover:text-[#00293d]"
|
||||
>
|
||||
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back to Users
|
||||
</button>
|
||||
|
||||
{/* Error/Success Messages */}
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||
<p className="text-red-700 text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
{updateSuccess && (
|
||||
<div className="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg">
|
||||
<p className="text-green-700 text-sm">{updateSuccess}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* User Header */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-[#e5e7eb] mb-6">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center">
|
||||
{/* Avatar */}
|
||||
<div className="flex-shrink-0 h-20 w-20">
|
||||
<div className="h-20 w-20 rounded-full bg-[#e8f0ee] flex items-center justify-center relative overflow-hidden">
|
||||
<span className="text-[#666666] font-medium text-2xl">
|
||||
{user.profile?.firstName?.[0] || user.email[0].toUpperCase()}
|
||||
</span>
|
||||
{avatarUrl && (
|
||||
<img className="absolute inset-0 h-20 w-20 rounded-full object-cover" src={avatarUrl} alt="" onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Name & Email */}
|
||||
<div className="ml-6 flex-1">
|
||||
<h1 className="text-2xl font-bold text-[#00293d] font-fractul">
|
||||
{user.profile?.firstName} {user.profile?.lastName}
|
||||
</h1>
|
||||
<p className="text-[#666666] font-serif">{user.email}</p>
|
||||
</div>
|
||||
|
||||
{/* Role & Status Badges */}
|
||||
<div className="flex items-center space-x-3">
|
||||
<span
|
||||
className={`px-3 py-1 text-sm font-semibold rounded-full ${
|
||||
user.role === 'ADMIN'
|
||||
? 'bg-red-100 text-red-800'
|
||||
: user.role === 'AGENT'
|
||||
? 'bg-[#fff7ed] text-[#f5a623]'
|
||||
: 'bg-[#e8f0ee] text-[#5ba4a4]'
|
||||
}`}
|
||||
>
|
||||
{user.role}
|
||||
</span>
|
||||
<span
|
||||
className={`px-3 py-1 text-sm font-semibold rounded-full ${
|
||||
user.status === 'ACTIVE'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}
|
||||
>
|
||||
{user.status}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleToggleStatus}
|
||||
disabled={isTogglingStatus}
|
||||
className={`px-4 py-1.5 text-sm font-semibold rounded-lg transition-colors disabled:opacity-50 shadow-sm border ${
|
||||
user.status === 'ACTIVE'
|
||||
? 'bg-red-600 text-white hover:bg-red-700 border-red-600'
|
||||
: 'bg-green-600 text-white hover:bg-green-700 border-green-600'
|
||||
}`}
|
||||
>
|
||||
{isTogglingStatus ? 'Updating...' : user.status === 'ACTIVE' ? 'Deactivate User' : 'Activate User'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Basic Information */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-[#e5e7eb] mb-6">
|
||||
<div className="px-6 py-4 border-b border-[#e5e7eb]">
|
||||
<h2 className="text-lg font-semibold text-[#00293d] font-fractul">Basic Information</h2>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||
<div>
|
||||
<p className="text-sm text-[#666666]">Provider</p>
|
||||
<p className="text-sm font-medium text-[#00293d]">
|
||||
{user.authProvider === 'LOCAL' ? 'Email' : user.authProvider}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-[#666666]">Email Verified</p>
|
||||
<div className="flex items-center">
|
||||
{user.emailVerified ? (
|
||||
<>
|
||||
<svg className="w-5 h-5 text-green-500 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-green-600">Verified</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-5 h-5 text-[#9ca3af] mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-[#666666]">Not Verified</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-[#666666]">Joined</p>
|
||||
<p className="text-sm font-medium text-[#00293d]">{formatDate(user.createdAt)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-[#666666]">Last Login</p>
|
||||
<p className="text-sm font-medium text-[#00293d]">
|
||||
{user.lastLoginAt ? formatDate(user.lastLoginAt) : 'Never'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dynamic Profile Data (only for AGENT role) */}
|
||||
{user.role === 'AGENT' && (
|
||||
<div className="bg-white rounded-xl shadow-sm border border-[#e5e7eb] mb-6">
|
||||
<div className="px-6 py-4 border-b border-[#e5e7eb]">
|
||||
<h2 className="text-lg font-semibold text-[#00293d] font-fractul">Profile Details</h2>
|
||||
</div>
|
||||
<div className="p-6 space-y-5">
|
||||
{/* Agent Type */}
|
||||
{user.agentProfile && (
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-[#00293d] uppercase tracking-wide mb-3 pb-2 border-b border-[#e5e7eb]">Agent Type</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<p className="text-xs text-[#666666] mb-0.5">Type</p>
|
||||
<p className="text-sm font-medium text-[#00293d]">
|
||||
{user.agentProfile.agentType?.name || 'Not assigned'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{Object.entries(
|
||||
agentFieldValues
|
||||
.filter((fv) => fv.sectionSlug !== 'upload-documents')
|
||||
.reduce((acc, fv) => {
|
||||
if (!acc[fv.sectionName]) acc[fv.sectionName] = [];
|
||||
acc[fv.sectionName].push(fv);
|
||||
return acc;
|
||||
}, {} as Record<string, AgentFieldValue[]>)
|
||||
).map(([sectionName, fields]) => (
|
||||
<div key={sectionName}>
|
||||
<h3 className="text-xs font-semibold text-[#00293d] uppercase tracking-wide mb-3 pb-2 border-b border-[#e5e7eb]">{sectionName}</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{fields.map((fv) => (
|
||||
<div key={fv.fieldSlug}>
|
||||
<p className="text-xs text-[#666666] mb-0.5">{fv.fieldName}</p>
|
||||
<p className="text-sm font-medium text-[#00293d]">
|
||||
{fv.value === null || fv.value === undefined ? <span className="text-gray-400">-</span> :
|
||||
Array.isArray(fv.value) ? fv.value.map((v: any) => typeof v === 'object' ? (v.label || v.name || JSON.stringify(v)) : String(v)).join(', ') :
|
||||
typeof fv.value === 'object' ? JSON.stringify(fv.value) :
|
||||
String(fv.value)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Verification Section (only for AGENT role) */}
|
||||
{user.role === 'AGENT' && user.agentProfile && (
|
||||
<div className="bg-white rounded-xl shadow-sm border border-[#e5e7eb] mb-6">
|
||||
<div className="px-6 py-4 border-b border-[#e5e7eb]">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-[#00293d] font-fractul">Verification Status</h2>
|
||||
{getVerificationStatusBadge(user.agentProfile.verificationStatus as VerificationStatus)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
{/* Payment / subscription status */}
|
||||
<div className="mb-4 flex items-center justify-between p-3 rounded-lg border"
|
||||
style={{
|
||||
backgroundColor: isSubscriptionActive(user.agentProfile.subscriptionStatus) ? '#f0fdf4' : '#fef2f2',
|
||||
borderColor: isSubscriptionActive(user.agentProfile.subscriptionStatus) ? '#bbf7d0' : '#fecaca',
|
||||
}}>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">Payment Status</p>
|
||||
<p className="text-xs text-gray-600 mt-0.5">
|
||||
{user.agentProfile.subscriptionStatus || 'No subscription'}
|
||||
</p>
|
||||
</div>
|
||||
{isSubscriptionActive(user.agentProfile.subscriptionStatus) ? (
|
||||
<span className="px-3 py-1 text-xs font-semibold rounded-full bg-green-100 text-green-800">
|
||||
Paid
|
||||
</span>
|
||||
) : (
|
||||
<span className="px-3 py-1 text-xs font-semibold rounded-full bg-red-100 text-red-800">
|
||||
Not Paid
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Verification Info */}
|
||||
{user.agentProfile.isVerified && user.agentProfile.verifiedAt && (
|
||||
<div className="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg">
|
||||
<p className="text-green-700 text-sm">
|
||||
Verified on {formatDate(user.agentProfile.verifiedAt)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{user.agentProfile.verificationStatus === 'REJECTED' && user.agentProfile.verificationNote && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||
<p className="text-red-700 text-sm font-medium">Rejection Note:</p>
|
||||
<p className="text-red-600 text-sm mt-1">{user.agentProfile.verificationNote}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Uploaded Documents */}
|
||||
<div className="mb-6">
|
||||
<h3 className="text-sm font-medium text-[#00293d] mb-3">Uploaded Documents</h3>
|
||||
{isLoadingDocuments ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-[#f5a623]"></div>
|
||||
</div>
|
||||
) : verificationDocuments.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{verificationDocuments.map((doc, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between p-3 bg-[#f5f9f8] border border-[#e5e7eb] rounded-xl"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<svg
|
||||
className="w-8 h-8 text-[#9ca3af] mr-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[#00293d]">{doc.name}</p>
|
||||
<p className="text-xs text-[#666666]">{formatFileSize(doc.size)}</p>
|
||||
</div>
|
||||
</div>
|
||||
{doc.url && (
|
||||
<a
|
||||
href={doc.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="px-3 py-1 text-sm text-[#f5a623] hover:text-[#e09620] hover:bg-[#fff7ed] rounded transition-colors"
|
||||
>
|
||||
Download
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-[#666666] py-4 text-center">
|
||||
No documents uploaded yet
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Admin Actions */}
|
||||
{user.agentProfile.verificationStatus !== 'APPROVED' && (
|
||||
<div className="border-t border-[#e5e7eb] pt-6">
|
||||
<h3 className="text-sm font-medium text-[#00293d] mb-3">Admin Actions</h3>
|
||||
|
||||
{/* Note Input */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm text-[#666666] mb-1">
|
||||
Note (optional, required for rejection)
|
||||
</label>
|
||||
<textarea
|
||||
value={verificationNote}
|
||||
onChange={(e) => setVerificationNote(e.target.value)}
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 border border-[#e5e7eb] rounded-xl focus:outline-none focus:border-[#f5a623] text-[#00293d] bg-white placeholder:text-[#666666]"
|
||||
placeholder="Enter a note for the agent (e.g., rejection reason)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
onClick={() => handleVerification('APPROVED')}
|
||||
disabled={isUpdatingVerification}
|
||||
className="flex-1 px-4 py-2 bg-green-600 hover:bg-green-700 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isUpdatingVerification ? (
|
||||
<span className="flex items-center justify-center">
|
||||
<svg
|
||||
className="animate-spin -ml-1 mr-2 h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
Processing...
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<svg
|
||||
className="w-4 h-4 inline mr-1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
Approve Verification
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleVerification('REJECTED')}
|
||||
disabled={isUpdatingVerification || !verificationNote.trim()}
|
||||
className="flex-1 px-4 py-2 bg-red-600 hover:bg-red-700 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isUpdatingVerification ? (
|
||||
<span className="flex items-center justify-center">
|
||||
<svg
|
||||
className="animate-spin -ml-1 mr-2 h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
Processing...
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<svg
|
||||
className="w-4 h-4 inline mr-1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
Reject Verification
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{!verificationNote.trim() && (
|
||||
<p className="mt-2 text-xs text-[#666666]">
|
||||
* A note is required to reject verification
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Already Approved Message */}
|
||||
{user.agentProfile.verificationStatus === 'APPROVED' && (
|
||||
<div className="border-t border-[#e5e7eb] pt-6">
|
||||
<div className="flex items-center text-green-600">
|
||||
<svg className="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span className="font-medium">This agent is verified</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Verification History */}
|
||||
{verificationHistory.length > 0 && (
|
||||
<div className="border-t border-[#e5e7eb] pt-6">
|
||||
<h3 className="text-sm font-medium text-[#00293d] mb-3">Verification History</h3>
|
||||
<div className="space-y-3">
|
||||
{verificationHistory.map((entry) => (
|
||||
<div key={entry.id} className="flex items-start gap-3 p-3 bg-gray-50 rounded-lg">
|
||||
<div className={`w-2 h-2 rounded-full mt-1.5 flex-shrink-0 ${
|
||||
entry.status === 'APPROVED' ? 'bg-green-500' :
|
||||
entry.status === 'REJECTED' ? 'bg-red-500' :
|
||||
entry.status === 'PENDING_REVIEW' ? 'bg-yellow-500' : 'bg-gray-400'
|
||||
}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`px-2 py-0.5 text-xs font-semibold rounded-full ${
|
||||
entry.status === 'APPROVED' ? 'bg-green-100 text-green-800' :
|
||||
entry.status === 'REJECTED' ? 'bg-red-100 text-red-800' :
|
||||
entry.status === 'PENDING_REVIEW' ? 'bg-yellow-100 text-yellow-800' : 'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{entry.status.replace('_', ' ')}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{new Date(entry.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
</div>
|
||||
{entry.note && (
|
||||
<p className="text-sm text-gray-700 mt-1">{entry.note}</p>
|
||||
)}
|
||||
{entry.admin && (
|
||||
<p className="text-xs text-gray-500 mt-1">By: {entry.admin.email}</p>
|
||||
)}
|
||||
{entry.submittedData && (
|
||||
<details className="mt-2">
|
||||
<summary className="text-xs text-blue-600 cursor-pointer hover:text-blue-800">View Submitted Data</summary>
|
||||
<div className="mt-2 p-3 bg-white rounded border border-gray-200 text-xs space-y-1">
|
||||
{entry.submittedData.firstName && (
|
||||
<p><span className="font-medium text-gray-600">Name:</span> {entry.submittedData.firstName} {entry.submittedData.lastName}</p>
|
||||
)}
|
||||
{entry.submittedData.email && (
|
||||
<p><span className="font-medium text-gray-600">Email:</span> {entry.submittedData.email}</p>
|
||||
)}
|
||||
{entry.submittedData.phone && (
|
||||
<p><span className="font-medium text-gray-600">Phone:</span> {entry.submittedData.phone}</p>
|
||||
)}
|
||||
{entry.submittedData.agentType && (
|
||||
<p><span className="font-medium text-gray-600">Type:</span> {entry.submittedData.agentType}</p>
|
||||
)}
|
||||
{entry.submittedData.sections && Object.entries(entry.submittedData.sections).map(([sectionSlug, sectionData]: [string, any]) => (
|
||||
<div key={sectionSlug} className="mt-2">
|
||||
<p className="font-medium text-gray-700">{sectionData._name || sectionSlug}</p>
|
||||
{Object.entries(sectionData).filter(([k]) => k !== '_name').map(([fieldSlug, fieldData]: [string, any]) => (
|
||||
<p key={fieldSlug} className="ml-2 text-gray-600">
|
||||
<span className="font-medium">{fieldData.name || fieldSlug}:</span>{' '}
|
||||
{typeof fieldData.value === 'object' ? JSON.stringify(fieldData.value) : String(fieldData.value ?? '-')}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
<p className="text-gray-400 mt-1">Captured: {new Date(entry.submittedData.capturedAt).toLocaleString()}</p>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { usersService, User, getErrorMessage } from '@/services';
|
||||
import { usersService, User, getErrorMessage, uploadService } from '@/services';
|
||||
|
||||
type RoleFilter = 'ALL' | 'ADMIN' | 'USER' | 'AGENT';
|
||||
|
||||
@@ -21,7 +21,39 @@ export default function UsersPage() {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalUsers, setTotalUsers] = useState(0);
|
||||
const [activeFilter, setActiveFilter] = useState<RoleFilter>('ALL');
|
||||
const limit = 10;
|
||||
const [avatarUrls, setAvatarUrls] = useState<Record<string, string>>({});
|
||||
const limit = 50;
|
||||
|
||||
// Helper function to check if avatar is an S3 key
|
||||
const isS3Key = (avatar: string | null | undefined): boolean => {
|
||||
if (!avatar) return false;
|
||||
return !avatar.startsWith('http') && !avatar.startsWith('/');
|
||||
};
|
||||
|
||||
// Fetch presigned URLs for user avatars
|
||||
const fetchAvatarUrls = async (userList: User[]) => {
|
||||
const urls: Record<string, string> = {};
|
||||
|
||||
await Promise.all(
|
||||
userList.map(async (user) => {
|
||||
const avatar = user.profile?.avatar;
|
||||
if (avatar) {
|
||||
if (isS3Key(avatar)) {
|
||||
try {
|
||||
const presignedUrl = await uploadService.getPresignedDownloadUrl(avatar);
|
||||
urls[user.id] = presignedUrl;
|
||||
} catch (err) {
|
||||
console.error(`Failed to get avatar URL for user ${user.id}:`, err);
|
||||
}
|
||||
} else {
|
||||
urls[user.id] = avatar;
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
setAvatarUrls(urls);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
@@ -36,8 +68,12 @@ export default function UsersPage() {
|
||||
filters.role = activeFilter;
|
||||
}
|
||||
const response = await usersService.getUsers(filters);
|
||||
setUsers(response.users || []);
|
||||
const userList = response.users || [];
|
||||
setUsers(userList);
|
||||
setTotalUsers(response.total || 0);
|
||||
|
||||
// Fetch presigned URLs for avatars
|
||||
await fetchAvatarUrls(userList);
|
||||
} catch (err) {
|
||||
const errorMessage = getErrorMessage(err);
|
||||
setError(errorMessage);
|
||||
@@ -60,24 +96,24 @@ export default function UsersPage() {
|
||||
<div>
|
||||
{/* Page Title */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Users</h1>
|
||||
<p className="text-gray-500">Manage all registered users</p>
|
||||
<h1 className="text-2xl font-bold text-[#00293d] font-fractul">Users</h1>
|
||||
<p className="text-[#666666] font-serif">Manage all registered users</p>
|
||||
</div>
|
||||
|
||||
{/* Users Table */}
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<div className="bg-white rounded-xl shadow-sm border border-[#e5e7eb]">
|
||||
<div className="px-6 py-4 border-b border-[#e5e7eb]">
|
||||
<div className="flex justify-between items-center">
|
||||
{/* Filter Tabs */}
|
||||
<div className="flex space-x-1 bg-gray-100 rounded-lg p-1">
|
||||
<div className="flex space-x-1 bg-[#f5f9f8] rounded-lg p-1">
|
||||
{roleFilters.map((filter) => (
|
||||
<button
|
||||
key={filter.value}
|
||||
onClick={() => handleFilterChange(filter.value)}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-md transition-colors ${
|
||||
activeFilter === filter.value
|
||||
? 'bg-white text-gray-900 shadow-sm'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
? 'bg-white text-[#00293d] shadow-sm'
|
||||
: 'text-[#666666] hover:text-[#00293d]'
|
||||
}`}
|
||||
>
|
||||
{filter.label}
|
||||
@@ -86,7 +122,7 @@ export default function UsersPage() {
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchUsers}
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors flex items-center"
|
||||
className="px-4 py-2 bg-[#f5a623] hover:bg-[#e09620] text-white text-sm font-medium rounded-lg transition-colors flex items-center"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4 mr-2"
|
||||
@@ -115,12 +151,12 @@ export default function UsersPage() {
|
||||
<div className="overflow-x-auto">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[#f5a623]"></div>
|
||||
</div>
|
||||
) : users.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<svg
|
||||
className="mx-auto h-12 w-12 text-gray-400"
|
||||
className="mx-auto h-12 w-12 text-[#9ca3af]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -132,60 +168,64 @@ export default function UsersPage() {
|
||||
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="mt-2 text-gray-500">No users found</p>
|
||||
<p className="mt-2 text-[#666666]">No users found</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<table className="min-w-full divide-y divide-[#e5e7eb]">
|
||||
<thead className="bg-[#f5f9f8]">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-[#666666] uppercase tracking-wider">
|
||||
User
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-[#666666] uppercase tracking-wider">
|
||||
Role
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-[#666666] uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-[#666666] uppercase tracking-wider">
|
||||
Provider
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-[#666666] uppercase tracking-wider">
|
||||
Verified
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-[#666666] uppercase tracking-wider">
|
||||
Joined
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-[#666666] uppercase tracking-wider">
|
||||
Last Login
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
<tbody className="bg-white divide-y divide-[#e5e7eb]">
|
||||
{users.map((user) => (
|
||||
<tr key={user.id} className="hover:bg-gray-50">
|
||||
<tr
|
||||
key={user.id}
|
||||
className="hover:bg-[#f5f9f8] cursor-pointer"
|
||||
onClick={() => router.push(`/dashboard/users/${user.id}`)}
|
||||
>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0 h-10 w-10">
|
||||
{user.profile?.avatar ? (
|
||||
<img
|
||||
className="h-10 w-10 rounded-full object-cover"
|
||||
src={user.profile.avatar}
|
||||
alt=""
|
||||
/>
|
||||
) : (
|
||||
<div className="h-10 w-10 rounded-full bg-gray-200 flex items-center justify-center">
|
||||
<span className="text-gray-500 font-medium text-sm">
|
||||
<div className="h-10 w-10 rounded-full bg-[#e8f0ee] flex items-center justify-center relative overflow-hidden">
|
||||
<span className="text-[#666666] font-medium text-sm">
|
||||
{user.profile?.firstName?.[0] || user.email[0].toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
{avatarUrls[user.id] && (
|
||||
<img
|
||||
className="absolute inset-0 h-10 w-10 rounded-full object-cover"
|
||||
src={avatarUrls[user.id]}
|
||||
alt=""
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
<div className="text-sm font-medium text-[#00293d]">
|
||||
{user.profile?.firstName} {user.profile?.lastName}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">{user.email}</div>
|
||||
<div className="text-sm text-[#666666]">{user.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
@@ -195,8 +235,8 @@ export default function UsersPage() {
|
||||
user.role === 'ADMIN'
|
||||
? 'bg-red-100 text-red-800'
|
||||
: user.role === 'AGENT'
|
||||
? 'bg-purple-100 text-purple-800'
|
||||
: 'bg-blue-100 text-blue-800'
|
||||
? 'bg-[#fff7ed] text-[#f5a623]'
|
||||
: 'bg-[#e8f0ee] text-[#5ba4a4]'
|
||||
}`}
|
||||
>
|
||||
{user.role}
|
||||
@@ -217,7 +257,7 @@ export default function UsersPage() {
|
||||
{user.status === 'PENDING_VERIFICATION' ? 'PENDING' : user.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-[#666666]">
|
||||
{user.authProvider === 'LOCAL' ? 'Email' : user.authProvider}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
@@ -235,7 +275,7 @@ export default function UsersPage() {
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
className="w-5 h-5 text-gray-400"
|
||||
className="w-5 h-5 text-[#9ca3af]"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
@@ -247,10 +287,10 @@ export default function UsersPage() {
|
||||
</svg>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-[#666666]">
|
||||
{new Date(user.createdAt).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-[#666666]">
|
||||
{user.lastLoginAt
|
||||
? new Date(user.lastLoginAt).toLocaleDateString()
|
||||
: 'Never'}
|
||||
@@ -264,8 +304,8 @@ export default function UsersPage() {
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="px-6 py-4 border-t border-gray-200 flex items-center justify-between">
|
||||
<p className="text-sm text-gray-500">
|
||||
<div className="px-6 py-4 border-t border-[#e5e7eb] flex items-center justify-between">
|
||||
<p className="text-sm text-[#666666]">
|
||||
Showing {(currentPage - 1) * limit + 1} to{' '}
|
||||
{Math.min(currentPage * limit, totalUsers)} of {totalUsers} users
|
||||
</p>
|
||||
@@ -273,14 +313,14 @@ export default function UsersPage() {
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="px-3 py-1 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="px-3 py-1 border border-[#e5e7eb] rounded-md text-sm font-medium text-[#00293d] hover:bg-[#f5f9f8] disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="px-3 py-1 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="px-3 py-1 border border-[#e5e7eb] rounded-md text-sm font-medium text-[#00293d] hover:bg-[#f5f9f8] disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 102 KiB |
@@ -1,26 +1,70 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
/* RE-Quest Admin Design Tokens (matching web app) */
|
||||
--color-primary-dark: #00293d;
|
||||
--color-primary-light: #c4d9d4;
|
||||
--color-accent-orange: #f5a623;
|
||||
--color-accent-orange-hover: #e09620;
|
||||
--color-accent-teal: #5ba4a4;
|
||||
--color-background: #ffffff;
|
||||
--color-background-alt: #f5f9f8;
|
||||
--color-foreground: #171717;
|
||||
--color-text-gray: #666666;
|
||||
--color-text-muted: #9ca3af;
|
||||
--color-border: #e5e7eb;
|
||||
|
||||
/* Spacing */
|
||||
--spacing-section: 6rem;
|
||||
--spacing-section-sm: 4rem;
|
||||
|
||||
/* Border Radius */
|
||||
--radius-sm: 0.375rem;
|
||||
--radius-md: 0.5rem;
|
||||
--radius-lg: 1rem;
|
||||
--radius-xl: 1.5rem;
|
||||
--radius-2xl: 2rem;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-background: #ffffff;
|
||||
--color-foreground: #171717;
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
--font-serif: var(--font-source-serif-4);
|
||||
--font-fractul: var(--font-fractul);
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
background: var(--color-background);
|
||||
color: var(--color-foreground);
|
||||
font-family: var(--font-source-serif-4, Arial, Helvetica, sans-serif);
|
||||
}
|
||||
|
||||
/* Selection styling to match web */
|
||||
::selection {
|
||||
background-color: var(--color-primary-light);
|
||||
color: var(--color-primary-dark);
|
||||
}
|
||||
|
||||
/* Smooth scrolling */
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* Card hover effects */
|
||||
.card-hover {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.card-hover:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import localFont from "next/font/local";
|
||||
import "./globals.css";
|
||||
import { AuthProvider } from "@/context/AuthContext";
|
||||
|
||||
@@ -13,6 +14,110 @@ const geistMono = Geist_Mono({
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
// Local Fractul font (matching web app)
|
||||
const fractul = localFont({
|
||||
src: [
|
||||
{
|
||||
path: "../fonts/fractul/Fractul-Regular.ttf",
|
||||
weight: "400",
|
||||
style: "normal",
|
||||
},
|
||||
{
|
||||
path: "../fonts/fractul/Fractul-Italic.ttf",
|
||||
weight: "400",
|
||||
style: "italic",
|
||||
},
|
||||
{
|
||||
path: "../fonts/fractul/Fractul-Medium.ttf",
|
||||
weight: "500",
|
||||
style: "normal",
|
||||
},
|
||||
{
|
||||
path: "../fonts/fractul/Fractul-MediumItalic.ttf",
|
||||
weight: "500",
|
||||
style: "italic",
|
||||
},
|
||||
{
|
||||
path: "../fonts/fractul/Fractul-SemiBold.ttf",
|
||||
weight: "600",
|
||||
style: "normal",
|
||||
},
|
||||
{
|
||||
path: "../fonts/fractul/Fractul-SemiBoldItalic.ttf",
|
||||
weight: "600",
|
||||
style: "italic",
|
||||
},
|
||||
{
|
||||
path: "../fonts/fractul/Fractul-Bold.ttf",
|
||||
weight: "700",
|
||||
style: "normal",
|
||||
},
|
||||
{
|
||||
path: "../fonts/fractul/Fractul-BoldItalic.ttf",
|
||||
weight: "700",
|
||||
style: "italic",
|
||||
},
|
||||
{
|
||||
path: "../fonts/fractul/Fractul-ExtraBold.ttf",
|
||||
weight: "800",
|
||||
style: "normal",
|
||||
},
|
||||
{
|
||||
path: "../fonts/fractul/Fractul-ExtraBoldItalic.ttf",
|
||||
weight: "800",
|
||||
style: "italic",
|
||||
},
|
||||
],
|
||||
variable: "--font-fractul",
|
||||
});
|
||||
|
||||
// Local Source Serif 4 font (matching web app)
|
||||
const sourceSerif4 = localFont({
|
||||
src: [
|
||||
{
|
||||
path: "../fonts/Source_Serif_4/static/SourceSerif4-Regular.ttf",
|
||||
weight: "400",
|
||||
style: "normal",
|
||||
},
|
||||
{
|
||||
path: "../fonts/Source_Serif_4/static/SourceSerif4-Italic.ttf",
|
||||
weight: "400",
|
||||
style: "italic",
|
||||
},
|
||||
{
|
||||
path: "../fonts/Source_Serif_4/static/SourceSerif4-Medium.ttf",
|
||||
weight: "500",
|
||||
style: "normal",
|
||||
},
|
||||
{
|
||||
path: "../fonts/Source_Serif_4/static/SourceSerif4-MediumItalic.ttf",
|
||||
weight: "500",
|
||||
style: "italic",
|
||||
},
|
||||
{
|
||||
path: "../fonts/Source_Serif_4/static/SourceSerif4-SemiBold.ttf",
|
||||
weight: "600",
|
||||
style: "normal",
|
||||
},
|
||||
{
|
||||
path: "../fonts/Source_Serif_4/static/SourceSerif4-SemiBoldItalic.ttf",
|
||||
weight: "600",
|
||||
style: "italic",
|
||||
},
|
||||
{
|
||||
path: "../fonts/Source_Serif_4/static/SourceSerif4-Bold.ttf",
|
||||
weight: "700",
|
||||
style: "normal",
|
||||
},
|
||||
{
|
||||
path: "../fonts/Source_Serif_4/static/SourceSerif4-BoldItalic.ttf",
|
||||
weight: "700",
|
||||
style: "italic",
|
||||
},
|
||||
],
|
||||
variable: "--font-source-serif-4",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Re-Quest Admin",
|
||||
description: "Admin dashboard for Re-Quest Platform",
|
||||
@@ -26,7 +131,7 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
className={`${geistSans.variable} ${geistMono.variable} ${sourceSerif4.variable} ${fractul.variable} antialiased`}
|
||||
>
|
||||
<AuthProvider>{children}</AuthProvider>
|
||||
</body>
|
||||
|
||||
@@ -35,21 +35,21 @@ export default function LoginPage() {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
<div className="min-h-screen flex items-center justify-center bg-[#f5f9f8]">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[#f5a623]"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100">
|
||||
<div className="min-h-screen flex items-center justify-center bg-[#f5f9f8]">
|
||||
<div className="max-w-md w-full mx-4">
|
||||
<div className="bg-white rounded-lg shadow-lg p-8">
|
||||
<div className="bg-white rounded-2xl shadow-lg p-8 border border-[#e5e7eb]">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="mx-auto w-16 h-16 bg-blue-600 rounded-full flex items-center justify-center mb-4">
|
||||
<div className="mx-auto w-16 h-16 bg-[#00293d] rounded-2xl flex items-center justify-center mb-4 shadow-md">
|
||||
<svg
|
||||
className="w-8 h-8 text-white"
|
||||
className="w-8 h-8 text-[#f5a623]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -58,17 +58,17 @@ export default function LoginPage() {
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||||
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Re-Quest Admin</h1>
|
||||
<p className="text-gray-500 mt-2">Re-Quest Platform Administration</p>
|
||||
<h1 className="text-2xl font-bold text-[#00293d] font-fractul">Re-Quest Admin</h1>
|
||||
<p className="text-[#666666] mt-2 font-serif">Platform Administration</p>
|
||||
</div>
|
||||
|
||||
{/* Error Alert */}
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-xl">
|
||||
<div className="flex items-center">
|
||||
<svg
|
||||
className="w-5 h-5 text-red-500 mr-2"
|
||||
@@ -91,7 +91,7 @@ export default function LoginPage() {
|
||||
<div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium text-gray-700 mb-2"
|
||||
className="block text-sm font-medium text-[#00293d] mb-2 font-serif"
|
||||
>
|
||||
Email Address
|
||||
</label>
|
||||
@@ -101,15 +101,15 @@ export default function LoginPage() {
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-gray-900 placeholder:text-gray-400 bg-white"
|
||||
placeholder="admin@realestate.com"
|
||||
className="w-full px-4 py-3 border border-[#e5e7eb] rounded-xl focus:outline-none focus:border-[#f5a623] transition-colors text-gray-900 placeholder:text-gray-400 bg-white"
|
||||
placeholder="admin@re-quest.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium text-gray-700 mb-2"
|
||||
className="block text-sm font-medium text-[#00293d] mb-2 font-serif"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
@@ -119,7 +119,7 @@ export default function LoginPage() {
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-gray-900 placeholder:text-gray-400 bg-white"
|
||||
className="w-full px-4 py-3 border border-[#e5e7eb] rounded-xl focus:outline-none focus:border-[#f5a623] transition-colors text-gray-900 placeholder:text-gray-400 bg-white"
|
||||
placeholder="Enter your password"
|
||||
/>
|
||||
</div>
|
||||
@@ -127,7 +127,7 @@ export default function LoginPage() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full py-3 px-4 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-medium rounded-lg transition-colors flex items-center justify-center"
|
||||
className="w-full py-3 px-4 bg-[#f5a623] hover:bg-[#e09620] disabled:bg-[#f5a623]/60 text-white font-semibold rounded-xl transition-all duration-200 flex items-center justify-center shadow-sm hover:shadow-md font-fractul"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
@@ -160,7 +160,7 @@ export default function LoginPage() {
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-xs text-gray-500">
|
||||
<p className="text-xs text-[#9ca3af] font-serif">
|
||||
Only administrators can access this portal
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -19,8 +19,8 @@ export default function Home() {
|
||||
}, [isAuthenticated, isLoading, router]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
<div className="min-h-screen flex items-center justify-center bg-[#f5f9f8]">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[#f5a623]"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
101
src/components/SectionIcon.tsx
Normal file
101
src/components/SectionIcon.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
'use client';
|
||||
|
||||
interface SectionIconProps {
|
||||
name: string | null | undefined;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Map icon names to SVG paths
|
||||
const iconPaths: Record<string, string> = {
|
||||
// User/Person icons
|
||||
'user': 'M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z',
|
||||
'person': 'M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z',
|
||||
|
||||
// Contact icons
|
||||
'phone': 'M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z',
|
||||
'telephone': 'M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z',
|
||||
|
||||
// Location icons
|
||||
'map-pin': 'M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z M15 11a3 3 0 11-6 0 3 3 0 016 0z',
|
||||
'location': 'M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z M15 11a3 3 0 11-6 0 3 3 0 016 0z',
|
||||
'location_on': 'M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z M15 11a3 3 0 11-6 0 3 3 0 016 0z',
|
||||
|
||||
// Building icons
|
||||
'building': 'M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4',
|
||||
'office': 'M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4',
|
||||
|
||||
// House icons
|
||||
'home': 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6',
|
||||
'house': 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6',
|
||||
|
||||
// Work icons
|
||||
'briefcase': 'M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z',
|
||||
'work': 'M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z',
|
||||
|
||||
// Education icons
|
||||
'academic-cap': 'M12 14l9-5-9-5-9 5 9 5z M12 14l6.16-3.422a12.083 12.083 0 01.665 6.479A11.952 11.952 0 0012 20.055a11.952 11.952 0 00-6.824-2.998 12.078 12.078 0 01.665-6.479L12 14z M12 14l9-5-9-5-9 5 9 5zm0 0l6.16-3.422a12.083 12.083 0 01.665 6.479A11.952 11.952 0 0012 20.055a11.952 11.952 0 00-6.824-2.998 12.078 12.078 0 01.665-6.479L12 14zm-4 6v-7.5l4-2.222',
|
||||
'education': 'M12 14l9-5-9-5-9 5 9 5z M12 14l6.16-3.422a12.083 12.083 0 01.665 6.479A11.952 11.952 0 0012 20.055a11.952 11.952 0 00-6.824-2.998 12.078 12.078 0 01.665-6.479L12 14z',
|
||||
|
||||
// Bank icons
|
||||
'bank': 'M3 21h18M3 10h18M5 6l7-3 7 3M4 10v11M20 10v11M8 14v3M12 14v3M16 14v3',
|
||||
'library': 'M3 21h18M3 10h18M5 6l7-3 7 3M4 10v11M20 10v11M8 14v3M12 14v3M16 14v3',
|
||||
|
||||
// Experience icons
|
||||
'star': 'M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z',
|
||||
'experience': 'M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z',
|
||||
|
||||
// Document icons
|
||||
'document': 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z',
|
||||
'file': 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z',
|
||||
|
||||
// Mail icons
|
||||
'mail': 'M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z',
|
||||
'email': 'M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z',
|
||||
|
||||
// Globe icons
|
||||
'globe': 'M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9',
|
||||
'world': 'M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9',
|
||||
|
||||
// Settings/cog icons
|
||||
'cog': 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z',
|
||||
'settings': 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z',
|
||||
|
||||
// Key icons
|
||||
'key': 'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z',
|
||||
|
||||
// Info icons
|
||||
'info': 'M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
|
||||
'information': 'M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
|
||||
|
||||
// Default/fallback
|
||||
'default': 'M4 6h16M4 10h16M4 14h16M4 18h16',
|
||||
};
|
||||
|
||||
export default function SectionIcon({ name, className = 'w-5 h-5' }: SectionIconProps) {
|
||||
if (!name) return null;
|
||||
|
||||
const iconName = name.toLowerCase().replace(/\s+/g, '-');
|
||||
const path = iconPaths[iconName] || iconPaths['default'];
|
||||
|
||||
// Handle multi-path icons (like map-pin)
|
||||
const paths = path.split(' M').map((p, i) => i === 0 ? p : 'M' + p);
|
||||
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
{paths.map((p, i) => (
|
||||
<path
|
||||
key={i}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d={p}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { supportChatService } from '@/services/support-chat.service';
|
||||
import { adminSocketService } from '@/services/socket.service';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
@@ -32,17 +36,179 @@ const menuItems = [
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'Agent Types',
|
||||
href: '/dashboard/agent-types',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'Profile Sections',
|
||||
href: '/dashboard/profile-sections',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 6h16M4 10h16M4 14h16M4 18h16"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'Content Management',
|
||||
href: '/dashboard/cms',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'Subscriptions',
|
||||
href: '/dashboard/subscriptions',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'Subscription Plans',
|
||||
href: '/dashboard/subscription-plans',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'Contact Messages',
|
||||
href: '/dashboard/contact-messages',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'User Reports',
|
||||
href: '/dashboard/reports',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'Audit Log',
|
||||
href: '/dashboard/audit-log',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9h6m-6 4h6m-6-8h6"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'Support Chat',
|
||||
href: '/dashboard/support-chat',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export default function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
const { isSuperAdmin } = useAuth();
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
|
||||
const fetchUnreadCount = useCallback(async () => {
|
||||
try {
|
||||
const count = await supportChatService.getUnreadTotal();
|
||||
setUnreadCount(count);
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Initial fetch
|
||||
fetchUnreadCount();
|
||||
|
||||
// Connect WebSocket for real-time updates
|
||||
adminSocketService.connect();
|
||||
|
||||
const handleUnreadUpdate = (data: unknown) => {
|
||||
const payload = data as { unreadTotal: number };
|
||||
if (typeof payload?.unreadTotal === 'number') {
|
||||
setUnreadCount(payload.unreadTotal);
|
||||
}
|
||||
};
|
||||
|
||||
adminSocketService.on('support_unread_update', handleUnreadUpdate);
|
||||
|
||||
// Fallback polling every 30s (in case socket disconnects)
|
||||
const interval = setInterval(fetchUnreadCount, 30000);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
adminSocketService.off('support_unread_update', handleUnreadUpdate);
|
||||
};
|
||||
}, [fetchUnreadCount]);
|
||||
|
||||
return (
|
||||
<aside className="w-64 bg-gray-900 min-h-screen fixed left-0 top-0">
|
||||
<aside className="w-64 bg-[#00293d] min-h-screen fixed left-0 top-0">
|
||||
{/* Logo */}
|
||||
<div className="p-6 border-b border-gray-800">
|
||||
<div className="p-6 border-b border-[#003a57]">
|
||||
<div className="flex items-center">
|
||||
<div className="w-10 h-10 bg-blue-600 rounded-lg flex items-center justify-center mr-3">
|
||||
<div className="w-10 h-10 bg-[#f5a623] rounded-lg flex items-center justify-center mr-3 shadow-sm">
|
||||
<svg
|
||||
className="w-6 h-6 text-white"
|
||||
fill="none"
|
||||
@@ -58,35 +224,58 @@ export default function Sidebar() {
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-white">Re-Quest</h1>
|
||||
<p className="text-xs text-gray-400">Admin Panel</p>
|
||||
<h1 className="text-lg font-bold text-white font-fractul">Re-Quest</h1>
|
||||
<p className="text-xs text-[#e8f0ee]">Admin Panel</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="p-4">
|
||||
<ul className="space-y-2">
|
||||
<ul className="space-y-1">
|
||||
{menuItems.map((item) => {
|
||||
const isActive = pathname === item.href ||
|
||||
(item.href !== '/dashboard' && pathname.startsWith(item.href));
|
||||
const showBadge = item.name === 'Support Chat' && unreadCount > 0;
|
||||
|
||||
return (
|
||||
<li key={item.name}>
|
||||
<Link
|
||||
href={item.href}
|
||||
className={`flex items-center px-4 py-3 rounded-lg transition-colors ${
|
||||
className={`flex items-center px-4 py-3 rounded-lg transition-all duration-200 ${
|
||||
isActive
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-gray-300 hover:bg-gray-800 hover:text-white'
|
||||
? 'bg-[#f5a623] text-white shadow-sm'
|
||||
: 'text-[#c4d9d4] hover:bg-[#003a57] hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{item.icon}
|
||||
<span className="ml-3 font-medium">{item.name}</span>
|
||||
<span className="ml-3 font-medium font-serif text-sm">{item.name}</span>
|
||||
{showBadge && (
|
||||
<span className="ml-auto inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 text-xs font-bold text-white bg-red-500 rounded-full">
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{isSuperAdmin && (
|
||||
<li>
|
||||
<Link
|
||||
href="/dashboard/admin-users"
|
||||
className={`flex items-center px-4 py-3 rounded-lg transition-all duration-200 ${
|
||||
pathname === '/dashboard/admin-users'
|
||||
? 'bg-[#f5a623] text-white shadow-sm'
|
||||
: 'text-[#c4d9d4] hover:bg-[#003a57] hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
<span className="ml-3 font-medium font-serif text-sm">Admin Users</span>
|
||||
</Link>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
@@ -8,6 +8,7 @@ interface AuthContextType {
|
||||
user: AuthUser | null;
|
||||
isLoading: boolean;
|
||||
isAuthenticated: boolean;
|
||||
isSuperAdmin: boolean;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
error: string | null;
|
||||
@@ -25,7 +26,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
try {
|
||||
const storedUser = authService.getStoredUser();
|
||||
|
||||
if (storedUser && storedUser.role === 'ADMIN') {
|
||||
if (storedUser && (storedUser.role === 'ADMIN' || storedUser.role === 'SUPER_ADMIN')) {
|
||||
setUser(storedUser);
|
||||
} else if (storedUser) {
|
||||
// Not admin, clear storage
|
||||
@@ -47,6 +48,13 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
setError(null);
|
||||
try {
|
||||
const response = await authService.login({ email, password });
|
||||
|
||||
// Block non-admin users from admin panel
|
||||
if (response.user.role !== 'ADMIN' && response.user.role !== 'SUPER_ADMIN') {
|
||||
authService.clearAuth();
|
||||
throw new Error('Access denied. Only admin users can login here.');
|
||||
}
|
||||
|
||||
setUser(response.user);
|
||||
router.push('/dashboard');
|
||||
} catch (err) {
|
||||
@@ -71,6 +79,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
user,
|
||||
isLoading,
|
||||
isAuthenticated: !!user,
|
||||
isSuperAdmin: user?.role === 'SUPER_ADMIN',
|
||||
login,
|
||||
logout,
|
||||
error,
|
||||
|
||||
91
src/fonts/Source_Serif_4/OFL.txt
Normal file
91
src/fonts/Source_Serif_4/OFL.txt
Normal file
@@ -0,0 +1,91 @@
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
128
src/fonts/Source_Serif_4/README.txt
Normal file
128
src/fonts/Source_Serif_4/README.txt
Normal file
@@ -0,0 +1,128 @@
|
||||
Source Serif 4 Variable Font
|
||||
============================
|
||||
|
||||
This download contains Source Serif 4 as both variable fonts and static fonts.
|
||||
|
||||
Source Serif 4 is a variable font with these axes:
|
||||
opsz
|
||||
wght
|
||||
|
||||
This means all the styles are contained in these files:
|
||||
SourceSerif4-VariableFont_opsz,wght.ttf
|
||||
SourceSerif4-Italic-VariableFont_opsz,wght.ttf
|
||||
|
||||
If your app fully supports variable fonts, you can now pick intermediate styles
|
||||
that aren’t available as static fonts. Not all apps support variable fonts, and
|
||||
in those cases you can use the static font files for Source Serif 4:
|
||||
static/SourceSerif4-ExtraLight.ttf
|
||||
static/SourceSerif4-Light.ttf
|
||||
static/SourceSerif4-Regular.ttf
|
||||
static/SourceSerif4-Medium.ttf
|
||||
static/SourceSerif4-SemiBold.ttf
|
||||
static/SourceSerif4-Bold.ttf
|
||||
static/SourceSerif4-ExtraBold.ttf
|
||||
static/SourceSerif4-Black.ttf
|
||||
static/SourceSerif4_18pt-ExtraLight.ttf
|
||||
static/SourceSerif4_18pt-Light.ttf
|
||||
static/SourceSerif4_18pt-Regular.ttf
|
||||
static/SourceSerif4_18pt-Medium.ttf
|
||||
static/SourceSerif4_18pt-SemiBold.ttf
|
||||
static/SourceSerif4_18pt-Bold.ttf
|
||||
static/SourceSerif4_18pt-ExtraBold.ttf
|
||||
static/SourceSerif4_18pt-Black.ttf
|
||||
static/SourceSerif4_36pt-ExtraLight.ttf
|
||||
static/SourceSerif4_36pt-Light.ttf
|
||||
static/SourceSerif4_36pt-Regular.ttf
|
||||
static/SourceSerif4_36pt-Medium.ttf
|
||||
static/SourceSerif4_36pt-SemiBold.ttf
|
||||
static/SourceSerif4_36pt-Bold.ttf
|
||||
static/SourceSerif4_36pt-ExtraBold.ttf
|
||||
static/SourceSerif4_36pt-Black.ttf
|
||||
static/SourceSerif4_48pt-ExtraLight.ttf
|
||||
static/SourceSerif4_48pt-Light.ttf
|
||||
static/SourceSerif4_48pt-Regular.ttf
|
||||
static/SourceSerif4_48pt-Medium.ttf
|
||||
static/SourceSerif4_48pt-SemiBold.ttf
|
||||
static/SourceSerif4_48pt-Bold.ttf
|
||||
static/SourceSerif4_48pt-ExtraBold.ttf
|
||||
static/SourceSerif4_48pt-Black.ttf
|
||||
static/SourceSerif4-ExtraLightItalic.ttf
|
||||
static/SourceSerif4-LightItalic.ttf
|
||||
static/SourceSerif4-Italic.ttf
|
||||
static/SourceSerif4-MediumItalic.ttf
|
||||
static/SourceSerif4-SemiBoldItalic.ttf
|
||||
static/SourceSerif4-BoldItalic.ttf
|
||||
static/SourceSerif4-ExtraBoldItalic.ttf
|
||||
static/SourceSerif4-BlackItalic.ttf
|
||||
static/SourceSerif4_18pt-ExtraLightItalic.ttf
|
||||
static/SourceSerif4_18pt-LightItalic.ttf
|
||||
static/SourceSerif4_18pt-Italic.ttf
|
||||
static/SourceSerif4_18pt-MediumItalic.ttf
|
||||
static/SourceSerif4_18pt-SemiBoldItalic.ttf
|
||||
static/SourceSerif4_18pt-BoldItalic.ttf
|
||||
static/SourceSerif4_18pt-ExtraBoldItalic.ttf
|
||||
static/SourceSerif4_18pt-BlackItalic.ttf
|
||||
static/SourceSerif4_36pt-ExtraLightItalic.ttf
|
||||
static/SourceSerif4_36pt-LightItalic.ttf
|
||||
static/SourceSerif4_36pt-Italic.ttf
|
||||
static/SourceSerif4_36pt-MediumItalic.ttf
|
||||
static/SourceSerif4_36pt-SemiBoldItalic.ttf
|
||||
static/SourceSerif4_36pt-BoldItalic.ttf
|
||||
static/SourceSerif4_36pt-ExtraBoldItalic.ttf
|
||||
static/SourceSerif4_36pt-BlackItalic.ttf
|
||||
static/SourceSerif4_48pt-ExtraLightItalic.ttf
|
||||
static/SourceSerif4_48pt-LightItalic.ttf
|
||||
static/SourceSerif4_48pt-Italic.ttf
|
||||
static/SourceSerif4_48pt-MediumItalic.ttf
|
||||
static/SourceSerif4_48pt-SemiBoldItalic.ttf
|
||||
static/SourceSerif4_48pt-BoldItalic.ttf
|
||||
static/SourceSerif4_48pt-ExtraBoldItalic.ttf
|
||||
static/SourceSerif4_48pt-BlackItalic.ttf
|
||||
|
||||
Get started
|
||||
-----------
|
||||
|
||||
1. Install the font files you want to use
|
||||
|
||||
2. Use your app's font picker to view the font family and all the
|
||||
available styles
|
||||
|
||||
Learn more about variable fonts
|
||||
-------------------------------
|
||||
|
||||
https://developers.google.com/web/fundamentals/design-and-ux/typography/variable-fonts
|
||||
https://variablefonts.typenetwork.com
|
||||
https://medium.com/variable-fonts
|
||||
|
||||
In desktop apps
|
||||
|
||||
https://theblog.adobe.com/can-variable-fonts-illustrator-cc
|
||||
https://helpx.adobe.com/nz/photoshop/using/fonts.html#variable_fonts
|
||||
|
||||
Online
|
||||
|
||||
https://developers.google.com/fonts/docs/getting_started
|
||||
https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Fonts/Variable_Fonts_Guide
|
||||
https://developer.microsoft.com/en-us/microsoft-edge/testdrive/demos/variable-fonts
|
||||
|
||||
Installing fonts
|
||||
|
||||
MacOS: https://support.apple.com/en-us/HT201749
|
||||
Linux: https://www.google.com/search?q=how+to+install+a+font+on+gnu%2Blinux
|
||||
Windows: https://support.microsoft.com/en-us/help/314960/how-to-install-or-remove-a-font-in-windows
|
||||
|
||||
Android Apps
|
||||
|
||||
https://developers.google.com/fonts/docs/android
|
||||
https://developer.android.com/guide/topics/ui/look-and-feel/downloadable-fonts
|
||||
|
||||
License
|
||||
-------
|
||||
Please read the full license text (OFL.txt) to understand the permissions,
|
||||
restrictions and requirements for usage, redistribution, and modification.
|
||||
|
||||
You can use them in your products & projects – print or digital,
|
||||
commercial or otherwise.
|
||||
|
||||
This isn't legal advice, please consider consulting a lawyer and see the full
|
||||
license for all details.
|
||||
Binary file not shown.
BIN
src/fonts/Source_Serif_4/SourceSerif4-VariableFont_opsz,wght.ttf
Normal file
BIN
src/fonts/Source_Serif_4/SourceSerif4-VariableFont_opsz,wght.ttf
Normal file
Binary file not shown.
BIN
src/fonts/Source_Serif_4/static/SourceSerif4-Black.ttf
Normal file
BIN
src/fonts/Source_Serif_4/static/SourceSerif4-Black.ttf
Normal file
Binary file not shown.
BIN
src/fonts/Source_Serif_4/static/SourceSerif4-BlackItalic.ttf
Normal file
BIN
src/fonts/Source_Serif_4/static/SourceSerif4-BlackItalic.ttf
Normal file
Binary file not shown.
BIN
src/fonts/Source_Serif_4/static/SourceSerif4-Bold.ttf
Normal file
BIN
src/fonts/Source_Serif_4/static/SourceSerif4-Bold.ttf
Normal file
Binary file not shown.
BIN
src/fonts/Source_Serif_4/static/SourceSerif4-BoldItalic.ttf
Normal file
BIN
src/fonts/Source_Serif_4/static/SourceSerif4-BoldItalic.ttf
Normal file
Binary file not shown.
BIN
src/fonts/Source_Serif_4/static/SourceSerif4-ExtraBold.ttf
Normal file
BIN
src/fonts/Source_Serif_4/static/SourceSerif4-ExtraBold.ttf
Normal file
Binary file not shown.
BIN
src/fonts/Source_Serif_4/static/SourceSerif4-ExtraBoldItalic.ttf
Normal file
BIN
src/fonts/Source_Serif_4/static/SourceSerif4-ExtraBoldItalic.ttf
Normal file
Binary file not shown.
BIN
src/fonts/Source_Serif_4/static/SourceSerif4-ExtraLight.ttf
Normal file
BIN
src/fonts/Source_Serif_4/static/SourceSerif4-ExtraLight.ttf
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/fonts/Source_Serif_4/static/SourceSerif4-Italic.ttf
Normal file
BIN
src/fonts/Source_Serif_4/static/SourceSerif4-Italic.ttf
Normal file
Binary file not shown.
BIN
src/fonts/Source_Serif_4/static/SourceSerif4-Light.ttf
Normal file
BIN
src/fonts/Source_Serif_4/static/SourceSerif4-Light.ttf
Normal file
Binary file not shown.
BIN
src/fonts/Source_Serif_4/static/SourceSerif4-LightItalic.ttf
Normal file
BIN
src/fonts/Source_Serif_4/static/SourceSerif4-LightItalic.ttf
Normal file
Binary file not shown.
BIN
src/fonts/Source_Serif_4/static/SourceSerif4-Medium.ttf
Normal file
BIN
src/fonts/Source_Serif_4/static/SourceSerif4-Medium.ttf
Normal file
Binary file not shown.
BIN
src/fonts/Source_Serif_4/static/SourceSerif4-MediumItalic.ttf
Normal file
BIN
src/fonts/Source_Serif_4/static/SourceSerif4-MediumItalic.ttf
Normal file
Binary file not shown.
BIN
src/fonts/Source_Serif_4/static/SourceSerif4-Regular.ttf
Normal file
BIN
src/fonts/Source_Serif_4/static/SourceSerif4-Regular.ttf
Normal file
Binary file not shown.
BIN
src/fonts/Source_Serif_4/static/SourceSerif4-SemiBold.ttf
Normal file
BIN
src/fonts/Source_Serif_4/static/SourceSerif4-SemiBold.ttf
Normal file
Binary file not shown.
BIN
src/fonts/Source_Serif_4/static/SourceSerif4-SemiBoldItalic.ttf
Normal file
BIN
src/fonts/Source_Serif_4/static/SourceSerif4-SemiBoldItalic.ttf
Normal file
Binary file not shown.
BIN
src/fonts/Source_Serif_4/static/SourceSerif4_18pt-Black.ttf
Normal file
BIN
src/fonts/Source_Serif_4/static/SourceSerif4_18pt-Black.ttf
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/fonts/Source_Serif_4/static/SourceSerif4_18pt-Bold.ttf
Normal file
BIN
src/fonts/Source_Serif_4/static/SourceSerif4_18pt-Bold.ttf
Normal file
Binary file not shown.
BIN
src/fonts/Source_Serif_4/static/SourceSerif4_18pt-BoldItalic.ttf
Normal file
BIN
src/fonts/Source_Serif_4/static/SourceSerif4_18pt-BoldItalic.ttf
Normal file
Binary file not shown.
BIN
src/fonts/Source_Serif_4/static/SourceSerif4_18pt-ExtraBold.ttf
Normal file
BIN
src/fonts/Source_Serif_4/static/SourceSerif4_18pt-ExtraBold.ttf
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/fonts/Source_Serif_4/static/SourceSerif4_18pt-ExtraLight.ttf
Normal file
BIN
src/fonts/Source_Serif_4/static/SourceSerif4_18pt-ExtraLight.ttf
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/fonts/Source_Serif_4/static/SourceSerif4_18pt-Italic.ttf
Normal file
BIN
src/fonts/Source_Serif_4/static/SourceSerif4_18pt-Italic.ttf
Normal file
Binary file not shown.
BIN
src/fonts/Source_Serif_4/static/SourceSerif4_18pt-Light.ttf
Normal file
BIN
src/fonts/Source_Serif_4/static/SourceSerif4_18pt-Light.ttf
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/fonts/Source_Serif_4/static/SourceSerif4_18pt-Medium.ttf
Normal file
BIN
src/fonts/Source_Serif_4/static/SourceSerif4_18pt-Medium.ttf
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/fonts/Source_Serif_4/static/SourceSerif4_18pt-Regular.ttf
Normal file
BIN
src/fonts/Source_Serif_4/static/SourceSerif4_18pt-Regular.ttf
Normal file
Binary file not shown.
BIN
src/fonts/Source_Serif_4/static/SourceSerif4_18pt-SemiBold.ttf
Normal file
BIN
src/fonts/Source_Serif_4/static/SourceSerif4_18pt-SemiBold.ttf
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/fonts/Source_Serif_4/static/SourceSerif4_36pt-Black.ttf
Normal file
BIN
src/fonts/Source_Serif_4/static/SourceSerif4_36pt-Black.ttf
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/fonts/Source_Serif_4/static/SourceSerif4_36pt-Bold.ttf
Normal file
BIN
src/fonts/Source_Serif_4/static/SourceSerif4_36pt-Bold.ttf
Normal file
Binary file not shown.
BIN
src/fonts/Source_Serif_4/static/SourceSerif4_36pt-BoldItalic.ttf
Normal file
BIN
src/fonts/Source_Serif_4/static/SourceSerif4_36pt-BoldItalic.ttf
Normal file
Binary file not shown.
BIN
src/fonts/Source_Serif_4/static/SourceSerif4_36pt-ExtraBold.ttf
Normal file
BIN
src/fonts/Source_Serif_4/static/SourceSerif4_36pt-ExtraBold.ttf
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/fonts/Source_Serif_4/static/SourceSerif4_36pt-ExtraLight.ttf
Normal file
BIN
src/fonts/Source_Serif_4/static/SourceSerif4_36pt-ExtraLight.ttf
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/fonts/Source_Serif_4/static/SourceSerif4_36pt-Italic.ttf
Normal file
BIN
src/fonts/Source_Serif_4/static/SourceSerif4_36pt-Italic.ttf
Normal file
Binary file not shown.
BIN
src/fonts/Source_Serif_4/static/SourceSerif4_36pt-Light.ttf
Normal file
BIN
src/fonts/Source_Serif_4/static/SourceSerif4_36pt-Light.ttf
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/fonts/Source_Serif_4/static/SourceSerif4_36pt-Medium.ttf
Normal file
BIN
src/fonts/Source_Serif_4/static/SourceSerif4_36pt-Medium.ttf
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/fonts/Source_Serif_4/static/SourceSerif4_36pt-Regular.ttf
Normal file
BIN
src/fonts/Source_Serif_4/static/SourceSerif4_36pt-Regular.ttf
Normal file
Binary file not shown.
BIN
src/fonts/Source_Serif_4/static/SourceSerif4_36pt-SemiBold.ttf
Normal file
BIN
src/fonts/Source_Serif_4/static/SourceSerif4_36pt-SemiBold.ttf
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/fonts/Source_Serif_4/static/SourceSerif4_48pt-Black.ttf
Normal file
BIN
src/fonts/Source_Serif_4/static/SourceSerif4_48pt-Black.ttf
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/fonts/Source_Serif_4/static/SourceSerif4_48pt-Bold.ttf
Normal file
BIN
src/fonts/Source_Serif_4/static/SourceSerif4_48pt-Bold.ttf
Normal file
Binary file not shown.
BIN
src/fonts/Source_Serif_4/static/SourceSerif4_48pt-BoldItalic.ttf
Normal file
BIN
src/fonts/Source_Serif_4/static/SourceSerif4_48pt-BoldItalic.ttf
Normal file
Binary file not shown.
BIN
src/fonts/Source_Serif_4/static/SourceSerif4_48pt-ExtraBold.ttf
Normal file
BIN
src/fonts/Source_Serif_4/static/SourceSerif4_48pt-ExtraBold.ttf
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/fonts/Source_Serif_4/static/SourceSerif4_48pt-ExtraLight.ttf
Normal file
BIN
src/fonts/Source_Serif_4/static/SourceSerif4_48pt-ExtraLight.ttf
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/fonts/Source_Serif_4/static/SourceSerif4_48pt-Italic.ttf
Normal file
BIN
src/fonts/Source_Serif_4/static/SourceSerif4_48pt-Italic.ttf
Normal file
Binary file not shown.
BIN
src/fonts/Source_Serif_4/static/SourceSerif4_48pt-Light.ttf
Normal file
BIN
src/fonts/Source_Serif_4/static/SourceSerif4_48pt-Light.ttf
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/fonts/Source_Serif_4/static/SourceSerif4_48pt-Medium.ttf
Normal file
BIN
src/fonts/Source_Serif_4/static/SourceSerif4_48pt-Medium.ttf
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/fonts/Source_Serif_4/static/SourceSerif4_48pt-Regular.ttf
Normal file
BIN
src/fonts/Source_Serif_4/static/SourceSerif4_48pt-Regular.ttf
Normal file
Binary file not shown.
BIN
src/fonts/Source_Serif_4/static/SourceSerif4_48pt-SemiBold.ttf
Normal file
BIN
src/fonts/Source_Serif_4/static/SourceSerif4_48pt-SemiBold.ttf
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/fonts/fractul/Fractul-Black.ttf
Normal file
BIN
src/fonts/fractul/Fractul-Black.ttf
Normal file
Binary file not shown.
BIN
src/fonts/fractul/Fractul-BlackItalic.ttf
Normal file
BIN
src/fonts/fractul/Fractul-BlackItalic.ttf
Normal file
Binary file not shown.
BIN
src/fonts/fractul/Fractul-Bold.ttf
Normal file
BIN
src/fonts/fractul/Fractul-Bold.ttf
Normal file
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user