|
@@ -19,7 +19,7 @@
|
|
|
'flex items-center gap-2 px-6 py-2.5 rounded-xl text-sm font-bold transition-all',
|
|
'flex items-center gap-2 px-6 py-2.5 rounded-xl text-sm font-bold transition-all',
|
|
|
activeTab === tab.id ? 'bg-primary text-primary-foreground shadow-glow' : 'text-muted-foreground hover:bg-white/5 hover:text-foreground'
|
|
activeTab === tab.id ? 'bg-primary text-primary-foreground shadow-glow' : 'text-muted-foreground hover:bg-white/5 hover:text-foreground'
|
|
|
]">
|
|
]">
|
|
|
- <component :is="tab.icon" class="w-4 h-4" />{{ tab.label }}
|
|
|
|
|
|
|
+ <component :is="tab.icon" class="w-4 h-4" />{{ t('admin.tabs.' + tab.id) }}
|
|
|
</button>
|
|
</button>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
@@ -81,7 +81,7 @@
|
|
|
<button v-if="order.user_id"
|
|
<button v-if="order.user_id"
|
|
|
@click="handleToggleUserChat(order.user_id, order.can_chat)"
|
|
@click="handleToggleUserChat(order.user_id, order.can_chat)"
|
|
|
class="ml-auto p-1.5 rounded-lg hover:bg-muted transition-colors group/chat-perm"
|
|
class="ml-auto p-1.5 rounded-lg hover:bg-muted transition-colors group/chat-perm"
|
|
|
- :title="order.can_chat ? 'Forbid Chat for this user' : 'Allow Chat for this user'">
|
|
|
|
|
|
|
+ :title="order.can_chat ? t('admin.actions.forbidChat') : t('admin.actions.allowChat')">
|
|
|
<component :is="order.can_chat ? ToggleRight : ToggleLeft"
|
|
<component :is="order.can_chat ? ToggleRight : ToggleLeft"
|
|
|
class="w-5 h-5 transition-transform group-hover/chat-perm:scale-110"
|
|
class="w-5 h-5 transition-transform group-hover/chat-perm:scale-110"
|
|
|
:class="order.can_chat ? 'text-emerald-500' : 'text-muted-foreground'" />
|
|
:class="order.can_chat ? 'text-emerald-500' : 'text-muted-foreground'" />
|
|
@@ -331,6 +331,7 @@
|
|
|
<th class="p-4 text-[10px] font-bold uppercase tracking-wider">{{ t("admin.labels.contact") }}</th>
|
|
<th class="p-4 text-[10px] font-bold uppercase tracking-wider">{{ t("admin.labels.contact") }}</th>
|
|
|
<th class="p-4 text-[10px] font-bold uppercase tracking-wider">{{ t("admin.labels.role") }}</th>
|
|
<th class="p-4 text-[10px] font-bold uppercase tracking-wider">{{ t("admin.labels.role") }}</th>
|
|
|
<th class="p-4 text-[10px] font-bold uppercase tracking-wider text-center">{{ t("admin.labels.chat") }}</th>
|
|
<th class="p-4 text-[10px] font-bold uppercase tracking-wider text-center">{{ t("admin.labels.chat") }}</th>
|
|
|
|
|
+ <th class="p-4 text-[10px] font-bold uppercase tracking-wider text-center">{{ t("admin.fields.active") }}</th>
|
|
|
<th class="p-4 text-[10px] font-bold uppercase tracking-wider">{{ t("admin.labels.registered") }}</th>
|
|
<th class="p-4 text-[10px] font-bold uppercase tracking-wider">{{ t("admin.labels.registered") }}</th>
|
|
|
<th class="p-4 text-[10px] font-bold uppercase tracking-wider text-right">{{ t("admin.labels.actions") }}</th>
|
|
<th class="p-4 text-[10px] font-bold uppercase tracking-wider text-right">{{ t("admin.labels.actions") }}</th>
|
|
|
</tr>
|
|
</tr>
|
|
@@ -357,6 +358,11 @@
|
|
|
<component :is="u.can_chat ? ToggleRight : ToggleLeft" class="w-6 h-6" :class="u.can_chat ? 'text-emerald-500' : 'text-muted-foreground'" />
|
|
<component :is="u.can_chat ? ToggleRight : ToggleLeft" class="w-6 h-6" :class="u.can_chat ? 'text-emerald-500' : 'text-muted-foreground'" />
|
|
|
</button>
|
|
</button>
|
|
|
</td>
|
|
</td>
|
|
|
|
|
+ <td class="p-4 text-center">
|
|
|
|
|
+ <button @click="handleToggleUserActive(u.id, u.is_active)" class="inline-flex" :title="u.is_active ? t('admin.actions.suspendAccount') : t('admin.actions.activateAccount')">
|
|
|
|
|
+ <component :is="u.is_active ? ToggleRight : ToggleLeft" class="w-6 h-6" :class="u.is_active ? 'text-emerald-500' : 'text-rose-500'" />
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </td>
|
|
|
<td class="p-4 text-xs text-muted-foreground">
|
|
<td class="p-4 text-xs text-muted-foreground">
|
|
|
{{ new Date(u.created_at).toLocaleDateString() }}
|
|
{{ new Date(u.created_at).toLocaleDateString() }}
|
|
|
</td>
|
|
</td>
|
|
@@ -437,7 +443,7 @@
|
|
|
<div class="flex items-center gap-2">
|
|
<div class="flex items-center gap-2">
|
|
|
<button @click="handleTogglePhotoPublic(pi.id, pi.is_public, pi.allow_portfolio)"
|
|
<button @click="handleTogglePhotoPublic(pi.id, pi.is_public, pi.allow_portfolio)"
|
|
|
:class="`p-2 rounded-xl transition-all ${pi.is_public ? 'bg-emerald-500 text-white' : 'bg-white/10 text-white hover:bg-white/20'}`"
|
|
:class="`p-2 rounded-xl transition-all ${pi.is_public ? 'bg-emerald-500 text-white' : 'bg-white/10 text-white hover:bg-white/20'}`"
|
|
|
- :title="pi.is_public ? 'Make Private' : 'Make Public'">
|
|
|
|
|
|
|
+ :title="pi.is_public ? t('admin.actions.makePrivate') : t('admin.actions.makePublic')">
|
|
|
<Eye v-if="pi.is_public" class="w-4 h-4" /><EyeOff v-else class="w-4 h-4" />
|
|
<Eye v-if="pi.is_public" class="w-4 h-4" /><EyeOff v-else class="w-4 h-4" />
|
|
|
</button>
|
|
</button>
|
|
|
<a :href="`http://localhost:8000/${pi.file_path}`" target="_blank" class="p-2 bg-white/10 text-white hover:bg-white/20 rounded-xl transition-all">
|
|
<a :href="`http://localhost:8000/${pi.file_path}`" target="_blank" class="p-2 bg-white/10 text-white hover:bg-white/20 rounded-xl transition-all">
|
|
@@ -451,6 +457,60 @@
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- AUDIT LOGS -->
|
|
|
|
|
+ <div v-else-if="activeTab === 'audit'" class="space-y-4">
|
|
|
|
|
+ <div class="bg-card/40 border border-border/50 rounded-2xl overflow-hidden shadow-sm">
|
|
|
|
|
+ <div class="overflow-x-auto">
|
|
|
|
|
+ <table class="w-full text-left border-collapse">
|
|
|
|
|
+ <thead>
|
|
|
|
|
+ <tr class="bg-muted/30 border-b border-border/50">
|
|
|
|
|
+ <th class="p-4 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">{{ t("admin.fields.timestamp") }}</th>
|
|
|
|
|
+ <th class="p-4 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">{{ t("admin.fields.user") }}</th>
|
|
|
|
|
+ <th class="p-4 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">{{ t("admin.fields.action") }}</th>
|
|
|
|
|
+ <th class="p-4 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">{{ t("admin.fields.target") }}</th>
|
|
|
|
|
+ <th class="p-4 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">{{ t("admin.fields.details") }}</th>
|
|
|
|
|
+ </tr>
|
|
|
|
|
+ </thead>
|
|
|
|
|
+ <tbody class="divide-y divide-border/30">
|
|
|
|
|
+ <tr v-for="log in auditLogs" :key="log.id" class="hover:bg-muted/20 transition-colors">
|
|
|
|
|
+ <td class="p-4 text-[11px] font-medium whitespace-nowrap opacity-60">
|
|
|
|
|
+ {{ new Date(log.created_at).toLocaleString() }}
|
|
|
|
|
+ </td>
|
|
|
|
|
+ <td class="p-4">
|
|
|
|
|
+ <div class="text-[11px] font-bold truncate max-w-[150px]">{{ log.user_email || 'System' }}</div>
|
|
|
|
|
+ <div class="text-[9px] opacity-40">{{ log.ip_address }}</div>
|
|
|
|
|
+ </td>
|
|
|
|
|
+ <td class="p-4">
|
|
|
|
|
+ <span class="px-2 py-0.5 rounded-full text-[9px] font-bold uppercase bg-primary/10 text-primary border border-primary/20">
|
|
|
|
|
+ {{ log.action }}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </td>
|
|
|
|
|
+ <td class="p-4 whitespace-nowrap">
|
|
|
|
|
+ <span v-if="log.target_type" class="text-[10px] font-bold text-muted-foreground">
|
|
|
|
|
+ {{ log.target_type }} #{{ log.target_id }}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </td>
|
|
|
|
|
+ <td class="p-4">
|
|
|
|
|
+ <div class="text-[11px] max-w-[300px] truncate opacity-80" :title="log.details">
|
|
|
|
|
+ {{ log.details }}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </td>
|
|
|
|
|
+ </tr>
|
|
|
|
|
+ </tbody>
|
|
|
|
|
+ </table>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- Pagination -->
|
|
|
|
|
+ <div v-if="auditTotal > 50" class="flex items-center justify-center gap-2 py-4">
|
|
|
|
|
+ <button v-for="p in Math.ceil(auditTotal / 50)" :key="p"
|
|
|
|
|
+ @click="auditPage = p"
|
|
|
|
|
+ :class="['w-8 h-8 rounded-lg font-bold text-xs transition-all', auditPage === p ? 'bg-primary text-white' : 'bg-card border border-border/50 text-muted-foreground hover:border-primary/50']">
|
|
|
|
|
+ {{ p }}
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
</main>
|
|
</main>
|
|
|
|
|
|
|
|
<!-- ——— MODALS ——— -->
|
|
<!-- ——— MODALS ——— -->
|
|
@@ -736,7 +796,7 @@ import { useAuthStore } from "@/stores/auth";
|
|
|
import {
|
|
import {
|
|
|
adminGetOrders, adminUpdateOrder, adminGetMaterials, adminCreateMaterial, adminUpdateMaterial, adminDeleteMaterial,
|
|
adminGetOrders, adminUpdateOrder, adminGetMaterials, adminCreateMaterial, adminUpdateMaterial, adminDeleteMaterial,
|
|
|
adminGetServices, adminCreateService, adminUpdateService, adminDeleteService, adminUploadOrderPhoto,
|
|
adminGetServices, adminCreateService, adminUpdateService, adminDeleteService, adminUploadOrderPhoto,
|
|
|
- adminUpdatePhotoStatus, adminDeletePhoto, adminGetAllPhotos, adminAttachFile, adminDeleteFile, getBlogPosts, adminCreatePost, adminUpdatePost, adminDeletePost, adminUpdateUser, adminGetUsers, adminCreateUser
|
|
|
|
|
|
|
+ adminUpdatePhotoStatus, adminDeletePhoto, adminGetAllPhotos, adminAttachFile, adminDeleteFile, getBlogPosts, adminCreatePost, adminUpdatePost, adminDeletePost, adminUpdateUser, adminGetUsers, adminCreateUser, adminGetAuditLogs
|
|
|
} from "@/lib/api";
|
|
} from "@/lib/api";
|
|
|
|
|
|
|
|
const { t, locale } = useI18n();
|
|
const { t, locale } = useI18n();
|
|
@@ -768,22 +828,36 @@ async function toggleAdminChat(orderId: number) {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-const tabs: { id: Tab; label: string; icon: any }[] = [
|
|
|
|
|
- { id: "orders", label: t("admin.tabs.orders"), icon: Package },
|
|
|
|
|
- { id: "materials", label: t("admin.tabs.materials"), icon: Layers },
|
|
|
|
|
- { id: "services", label: t("admin.tabs.services"), icon: Database },
|
|
|
|
|
- { id: "portfolio", label: t("admin.tabs.portfolio"), icon: ImageIcon },
|
|
|
|
|
- { id: "users", label: t("admin.tabs.users"), icon: Users },
|
|
|
|
|
- { id: "posts", label: t("admin.tabs.posts"), icon: Newspaper },
|
|
|
|
|
|
|
+const tabs: { id: Tab; icon: any }[] = [
|
|
|
|
|
+ { id: "orders", icon: Package },
|
|
|
|
|
+ { id: "materials", icon: Layers },
|
|
|
|
|
+ { id: "services", icon: Database },
|
|
|
|
|
+ { id: "portfolio", icon: ImageIcon },
|
|
|
|
|
+ { id: "users", icon: Users },
|
|
|
|
|
+ { id: "posts", icon: Newspaper },
|
|
|
|
|
+ { id: "audit", icon: History },
|
|
|
];
|
|
];
|
|
|
|
|
|
|
|
-type Tab = "orders" | "materials" | "services" | "posts" | "users" | "portfolio";
|
|
|
|
|
|
|
+type Tab = "orders" | "materials" | "services" | "posts" | "users" | "portfolio" | "audit";
|
|
|
const activeTab = ref<Tab>("orders");
|
|
const activeTab = ref<Tab>("orders");
|
|
|
const orders = ref<any[]>([]);
|
|
const orders = ref<any[]>([]);
|
|
|
const materials = ref<any[]>([]);
|
|
const materials = ref<any[]>([]);
|
|
|
const services = ref<any[]>([]);
|
|
const services = ref<any[]>([]);
|
|
|
const posts = ref<any[]>([]);
|
|
const posts = ref<any[]>([]);
|
|
|
const portfolioItems = ref<any[]>([]);
|
|
const portfolioItems = ref<any[]>([]);
|
|
|
|
|
+const auditLogs = ref<any[]>([]);
|
|
|
|
|
+const auditTotal = ref(0);
|
|
|
|
|
+const auditPage = ref(1);
|
|
|
|
|
+
|
|
|
|
|
+async function fetchAuditLogs() {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const res = await adminGetAuditLogs(auditPage.value);
|
|
|
|
|
+ auditLogs.value = res.logs;
|
|
|
|
|
+ auditTotal.value = res.total;
|
|
|
|
|
+ } catch (err: any) {
|
|
|
|
|
+ toast.error(err.message);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
const usersResult = ref({ users: [] as any[], total: 0, page: 1, size: 50 });
|
|
const usersResult = ref({ users: [] as any[], total: 0, page: 1, size: 50 });
|
|
|
const userSearch = ref("");
|
|
const userSearch = ref("");
|
|
@@ -861,6 +935,7 @@ async function fetchData() {
|
|
|
else if (currentTab === "posts") posts.value = await getBlogPosts(false);
|
|
else if (currentTab === "posts") posts.value = await getBlogPosts(false);
|
|
|
else if (currentTab === "portfolio") portfolioItems.value = await adminGetAllPhotos();
|
|
else if (currentTab === "portfolio") portfolioItems.value = await adminGetAllPhotos();
|
|
|
else if (currentTab === "users") await fetchUsers();
|
|
else if (currentTab === "users") await fetchUsers();
|
|
|
|
|
+ else if (currentTab === "audit") await fetchAuditLogs();
|
|
|
} catch (err) {
|
|
} catch (err) {
|
|
|
console.error(err);
|
|
console.error(err);
|
|
|
toast.error(t("admin.toasts.loadError", { tab: activeTab.value }));
|
|
toast.error(t("admin.toasts.loadError", { tab: activeTab.value }));
|
|
@@ -881,6 +956,10 @@ watch([userPage, userSearch], () => {
|
|
|
if (activeTab.value === 'users') fetchUsers();
|
|
if (activeTab.value === 'users') fetchUsers();
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
|
|
+watch(auditPage, () => {
|
|
|
|
|
+ if (activeTab.value === 'audit') fetchAuditLogs();
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
watch(activeTab, fetchData, { immediate: false });
|
|
watch(activeTab, fetchData, { immediate: false });
|
|
|
onMounted(async () => {
|
|
onMounted(async () => {
|
|
|
if (!authStore.user || authStore.user.role !== "admin") { router.push("/auth"); return; }
|
|
if (!authStore.user || authStore.user.role !== "admin") { router.push("/auth"); return; }
|
|
@@ -1048,6 +1127,17 @@ async function handleToggleUserChat(userId: number, current: boolean) {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+async function handleToggleUserActive(userId: number, current: boolean) {
|
|
|
|
|
+ if (!window.confirm(`Are you sure you want to ${current ? 'suspend' : 'activate'} this user account?`)) return;
|
|
|
|
|
+ try {
|
|
|
|
|
+ await adminUpdateUser(userId, { is_active: !current });
|
|
|
|
|
+ toast.success(t("admin.toasts.statusUpdated"));
|
|
|
|
|
+ if (activeTab.value === 'users') fetchUsers();
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ toast.error(t("admin.toasts.genericError"));
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
async function handleUpdateUserRole(userId: number, role: string) {
|
|
async function handleUpdateUserRole(userId: number, role: string) {
|
|
|
try {
|
|
try {
|
|
|
await adminUpdateUser(userId, { role });
|
|
await adminUpdateUser(userId, { role });
|