Admin.vue 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600
  1. <template>
  2. <div v-if="authStore.isLoading" />
  3. <div v-else-if="!authStore.user || authStore.user.role !== 'admin'">
  4. <RouterLink to="/auth" /><!-- redirect handled in onMounted -->
  5. </div>
  6. <div v-else class="min-h-screen bg-background text-foreground">
  7. <Header />
  8. <main class="container mx-auto px-4 pt-32 pb-20">
  9. <!-- DEPLOY INDICATOR -->
  10. <div class="bg-rose-600 text-white text-[10px] py-1 text-center font-bold uppercase tracking-widest mb-4 rounded-lg">
  11. DEPLOY VERIFIED: 12:50 CEST
  12. </div>
  13. <!-- Admin Header -->
  14. <div class="flex flex-col md:flex-row md:items-end justify-between gap-6 mb-12">
  15. <div>
  16. <span class="text-primary font-display text-sm tracking-widest uppercase mb-2 block">{{ t("admin.managementCenter") }}</span>
  17. <h1 class="font-display text-4xl font-bold">Admin <span class="text-gradient">{{ t("admin.dashboard") }}</span></h1>
  18. </div>
  19. <div class="flex bg-card/40 backdrop-blur-md border border-border/50 rounded-2xl p-1 gap-1">
  20. <button v-for="tab in tabs" :key="tab.id" @click="activeTab = tab.id" :class="[
  21. 'px-6 py-2.5 rounded-xl text-sm font-bold transition-all whitespace-nowrap',
  22. activeTab === tab.id ? 'bg-primary text-primary-foreground shadow-glow' : 'text-muted-foreground hover:bg-white/5 hover:text-foreground'
  23. ]">
  24. {{ t('admin.tabs.' + tab.id) }}
  25. </button>
  26. </div>
  27. </div>
  28. <!-- Global Search & Quick Actions -->
  29. <div class="flex flex-col gap-4 mb-8">
  30. <div class="flex flex-col sm:flex-row gap-4">
  31. <div class="relative flex-1">
  32. <Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
  33. <input type="text" v-model="searchQuery" :placeholder="t('admin.searchPlaceholder', { tab: activeTab })"
  34. class="w-full bg-card/40 border border-border/50 rounded-xl pl-10 pr-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-primary/20 transition-all text-sm" />
  35. </div>
  36. <Button v-if="activeTab !== 'orders' && activeTab !== 'audit'" variant="hero" class="gap-2 sm:px-8" @click="handleAddNew">
  37. <Plus class="w-4 h-4" />{{ t("admin.addNew") }}
  38. </Button>
  39. </div>
  40. </div>
  41. <!-- Content Loader -->
  42. <div v-if="isLoading" class="flex items-center justify-center py-20">
  43. <RefreshCw class="w-8 h-8 text-primary animate-spin" />
  44. </div>
  45. <!-- Modular Sections -->
  46. <div v-else>
  47. <OrdersSection
  48. v-if="activeTab === 'orders'"
  49. :orders="orders"
  50. :statusConfig="STATUS_CONFIG"
  51. :resourcesBaseUrl="RESOURCES_BASE_URL"
  52. :adminChatId="adminChatId"
  53. :notifyStatusMap="notifyStatusMap"
  54. :fiscalFormMap="fiscalFormMap"
  55. :searchQuery="searchQuery"
  56. @update-status="handleUpdateStatus"
  57. @delete-order="handleDeleteOrder"
  58. @attach-file="handleAttachFile"
  59. @upload-photo="handleUploadPhoto"
  60. @delete-file="handleDeleteFile"
  61. @delete-photo="handleDeletePhoto"
  62. @toggle-photo-public="handleTogglePhotoPublic"
  63. @approve-review="handleApproveReview"
  64. @open-chat="toggleAdminChat"
  65. @close-chat="adminChatId = null"
  66. @update-notify="(id, val) => notifyStatusMap[id] = val"
  67. @update-fiscal="handleUpdateFiscal"
  68. @edit-order="handleEditOrder"
  69. />
  70. <MaterialsSection
  71. v-if="activeTab === 'materials'"
  72. :materials="materials"
  73. :searchQuery="searchQuery"
  74. @edit="m => { editingMaterial = m; Object.assign(matForm, m); showAddModal = true; }"
  75. @delete="handleDeleteMaterial"
  76. @toggle-active="toggleMaterialActive"
  77. />
  78. <ServicesSection
  79. v-if="activeTab === 'services'"
  80. :services="services"
  81. :searchQuery="searchQuery"
  82. @edit="s => { editingService = s; Object.assign(svcForm, s); showAddModal = true; }"
  83. @delete="handleDeleteService"
  84. @toggle-active="toggleServiceActive"
  85. />
  86. <UsersSection
  87. v-if="activeTab === 'users'"
  88. :users="usersResult.users"
  89. :total="usersResult.total"
  90. :currentPage="userPage"
  91. v-model:searchQuery="userSearch"
  92. @toggle-chat="handleToggleUserChat"
  93. @toggle-active="handleToggleUserActive"
  94. @reset-password="handleResetPassword"
  95. @toggle-role="handleUpdateUserRole"
  96. @update-page="p => { userPage = p; fetchUsers(); }"
  97. />
  98. <PostsSection
  99. v-if="activeTab === 'posts'"
  100. :posts="posts"
  101. :searchQuery="searchQuery"
  102. @edit="p => { editingPost = p; Object.assign(postForm, p); showAddModal = true; }"
  103. @delete="handleDeletePost"
  104. @toggle-publish="togglePostActive"
  105. />
  106. <PortfolioSection
  107. v-if="activeTab === 'portfolio'"
  108. :portfolioItems="portfolioItems"
  109. :resourcesBaseUrl="RESOURCES_BASE_URL"
  110. @delete="handleDeletePhoto"
  111. />
  112. <AuditSection
  113. v-if="activeTab === 'audit'"
  114. :auditLogs="auditLogs"
  115. :total="auditTotal"
  116. :currentPage="auditPage"
  117. @update-page="p => { auditPage = p; fetchAuditLogs(); }"
  118. />
  119. <ReviewsSection
  120. v-if="activeTab === 'reviews'"
  121. />
  122. </div>
  123. </main>
  124. <!-- Global Modals -->
  125. <div v-if="showAddModal" class="fixed inset-0 z-[99999] flex items-center justify-center p-4">
  126. <div class="absolute inset-0 bg-background/80 backdrop-blur-sm" @click="closeModals" />
  127. <div class="relative w-full max-w-lg bg-card border border-border/50 rounded-3xl p-8 shadow-2xl overflow-y-auto max-h-[90vh]">
  128. <h3 class="text-xl font-bold font-display mb-6">{{ editingMaterial || editingService || editingPost || editingOrder ? t('admin.actions.edit') : t("admin.addNew") }}</h3>
  129. <!-- Order Edit Form -->
  130. <form v-if="editingOrder" @submit.prevent="handleSaveOrder" class="space-y-4">
  131. <!-- Form items... no changes there -->
  132. <div class="grid grid-cols-2 gap-4">
  133. <div class="space-y-1">
  134. <label class="text-[10px] font-bold uppercase ml-1">{{ t("auth.fields.firstName") }}</label>
  135. <input v-model="orderForm.first_name" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 text-sm" />
  136. </div>
  137. <div class="space-y-1">
  138. <label class="text-[10px] font-bold uppercase ml-1">{{ t("auth.fields.lastName") }}</label>
  139. <input v-model="orderForm.last_name" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 text-sm" />
  140. </div>
  141. </div>
  142. <div class="grid grid-cols-2 gap-4">
  143. <div class="space-y-1">
  144. <label class="text-[10px] font-bold uppercase ml-1">{{ t("auth.fields.email") }}</label>
  145. <input v-model="orderForm.email" type="email" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 text-sm" />
  146. </div>
  147. <div class="space-y-1">
  148. <label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.phone") }}</label>
  149. <input v-model="orderForm.phone" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 text-sm" />
  150. </div>
  151. </div>
  152. <div class="space-y-1">
  153. <label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.address") }}</label>
  154. <input v-model="orderForm.shipping_address" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 text-sm" />
  155. </div>
  156. <div class="space-y-1">
  157. <label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.projectNotes") }}</label>
  158. <textarea v-model="orderForm.notes" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 text-sm h-20" />
  159. </div>
  160. <div class="grid grid-cols-2 gap-4 pt-4 border-t border-border/10">
  161. <div class="space-y-1">
  162. <label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.totalPrice") }} (EUR)</label>
  163. <input v-model.number="orderForm.total_price" type="number" step="0.01" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 text-sm" />
  164. </div>
  165. <div class="space-y-1">
  166. <label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.quantity") }}</label>
  167. <input v-model.number="orderForm.quantity" type="number" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 text-sm" />
  168. </div>
  169. </div>
  170. <div class="grid grid-cols-2 gap-4">
  171. <div class="space-y-1">
  172. <label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.material") }}</label>
  173. <input v-model="orderForm.material_name" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 text-sm" />
  174. </div>
  175. <div class="space-y-1">
  176. <label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.colors") }}</label>
  177. <input v-model="orderForm.color_name" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 text-sm" />
  178. </div>
  179. </div>
  180. <!-- Review Edit fallback -->
  181. <div v-if="orderForm.review_text" class="pt-4 border-t border-border/10 space-y-4">
  182. <div class="space-y-1">
  183. <label class="text-[10px] font-bold uppercase ml-1">Review Text</label>
  184. <textarea v-model="orderForm.review_text" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 text-sm h-20" />
  185. </div>
  186. <div class="space-y-1">
  187. <label class="text-[10px] font-bold uppercase ml-1">Rating (1-5)</label>
  188. <input v-model.number="orderForm.rating" type="number" min="1" max="5" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 text-sm" />
  189. </div>
  190. </div>
  191. <div class="flex gap-3 pt-6 border-t border-border/10">
  192. <Button type="button" variant="ghost" class="flex-1" @click="closeModals">{{ t("admin.actions.cancel") }}</Button>
  193. <Button type="submit" variant="hero" class="flex-1">{{ t("admin.actions.save") }}</Button>
  194. </div>
  195. </form>
  196. <!-- Material Modal Form -->
  197. <form v-if="activeTab === 'materials'" @submit.prevent="handleSaveMaterial" class="space-y-4">
  198. <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.nameEn") }}</label><input v-model="matForm.name_en" required class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 text-sm" /></div>
  199. <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.nameRu") }}</label><input v-model="matForm.name_ru" required class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 text-sm" /></div>
  200. <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.nameUa") }}</label><input v-model="matForm.name_ua" required class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 text-sm" /></div>
  201. <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.pricePerCm3") }}</label><input v-model.number="matForm.price_per_cm3" type="number" step="0.01" required class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 text-sm" /></div>
  202. <div class="space-y-1">
  203. <label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.colors") }}</label>
  204. <div class="flex gap-2">
  205. <input v-model="newColor" @keydown.enter.prevent="addColor" class="flex-1 bg-background border border-border/50 rounded-xl px-4 py-2 text-sm" placeholder="e.g. Red" />
  206. <Button type="button" variant="hero" @click="addColor">Add</Button>
  207. </div>
  208. <div class="flex flex-wrap gap-2 mt-3">
  209. <span v-for="(c, idx) in matForm.available_colors" :key="idx" class="px-2 py-1 bg-primary/10 text-primary rounded-lg text-xs font-bold border border-primary/20 flex items-center gap-2">
  210. {{ c }} <X class="w-3 h-3 cursor-pointer hover:text-rose-500" @click="removeColor(idx)" />
  211. </span>
  212. </div>
  213. </div>
  214. <div class="flex gap-3 pt-6 border-t border-border/10">
  215. <Button type="button" variant="ghost" class="flex-1" @click="closeModals">{{ t("admin.actions.cancel") }}</Button>
  216. <Button type="submit" variant="hero" class="flex-1">{{ t("admin.actions.save") }}</Button>
  217. </div>
  218. </form>
  219. <!-- Service Modal Form -->
  220. <form v-if="activeTab === 'services'" @submit.prevent="handleSaveService" class="space-y-4">
  221. <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.nameEn") }}</label><input v-model="svcForm.name_en" required class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 text-sm" /></div>
  222. <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.nameRu") }}</label><input v-model="svcForm.name_ru" required class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 text-sm" /></div>
  223. <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.techType") }}</label><input v-model="svcForm.tech_type" required class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 text-sm" /></div>
  224. <div class="flex gap-3 pt-6 border-t border-border/10">
  225. <Button type="button" variant="ghost" class="flex-1" @click="closeModals">{{ t("admin.actions.cancel") }}</Button>
  226. <Button type="submit" variant="hero" class="flex-1">{{ t("admin.actions.save") }}</Button>
  227. </div>
  228. </form>
  229. <!-- User Creation Form -->
  230. <form v-if="activeTab === 'users'" @submit.prevent="handleSaveUser" class="space-y-4">
  231. <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.email") }}</label><input v-model="userForm.email" type="email" required class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 text-sm" /></div>
  232. <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.password") }}</label><input v-model="userForm.password" type="password" required class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 text-sm" /></div>
  233. <div class="grid grid-cols-2 gap-4">
  234. <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.firstName") }}</label><input v-model="userForm.first_name" required class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 text-sm" /></div>
  235. <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.lastName") }}</label><input v-model="userForm.last_name" required class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 text-sm" /></div>
  236. </div>
  237. <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.phone") }}</label><input v-model="userForm.phone" required class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 text-sm" /></div>
  238. <div class="flex gap-3 pt-6 border-t border-border/10">
  239. <Button type="button" variant="ghost" class="flex-1" @click="closeModals">{{ t("admin.actions.cancel") }}</Button>
  240. <Button type="submit" variant="hero" class="flex-1">{{ t("admin.actions.save") }}</Button>
  241. </div>
  242. </form>
  243. </div>
  244. </div>
  245. <Footer />
  246. </div>
  247. </template>
  248. <script setup lang="ts">
  249. import { ref, watch, reactive, onMounted, onUnmounted } from "vue";
  250. import { useRouter, useRoute, RouterLink } from "vue-router";
  251. import { useI18n } from "vue-i18n";
  252. import { loadAdminTranslations } from "@/i18n";
  253. import { toast } from "vue-sonner";
  254. // Icons
  255. import {
  256. Package, Clock, RefreshCw, Search, Layers, Plus, Database,
  257. Newspaper, History, X, Users, ImageIcon, Truck, CheckCircle2, XCircle
  258. } from "lucide-vue-next";
  259. // UI Components
  260. import Button from "@/components/ui/button.vue";
  261. import Header from "@/components/Header.vue";
  262. import Footer from "@/components/Footer.vue";
  263. // Admin Sections
  264. import OrdersSection from "@/components/admin/OrdersSection.vue";
  265. import MaterialsSection from "@/components/admin/MaterialsSection.vue";
  266. import ServicesSection from "@/components/admin/ServicesSection.vue";
  267. import UsersSection from "@/components/admin/UsersSection.vue";
  268. import PostsSection from "@/components/admin/PostsSection.vue";
  269. import PortfolioSection from "@/components/admin/PortfolioSection.vue";
  270. import AuditSection from "@/components/admin/AuditSection.vue";
  271. import ReviewsSection from "@/components/admin/ReviewsSection.vue";
  272. // API & Stores
  273. import { useAuthStore } from "@/stores/auth";
  274. import {
  275. adminGetOrders, adminUpdateOrder, adminGetMaterials, adminUpdateMaterial,
  276. adminDeleteMaterial, adminCreateMaterial, adminGetServices, adminUpdateService,
  277. adminDeleteService, adminCreateService, adminUploadOrderPhoto, adminUpdatePhotoStatus,
  278. adminDeletePhoto, adminGetAllPhotos, adminAttachFile, adminDeleteFile, adminDeleteOrder,
  279. getBlogPosts, adminUpdatePost, adminDeletePost, adminCreatePost,
  280. adminGetUsers, adminUpdateUser, adminCreateUser,
  281. adminGetAuditLogs, approveOrderReview, RESOURCES_BASE_URL
  282. } from "@/lib/api";
  283. const { t } = useI18n();
  284. const router = useRouter();
  285. const route = useRoute();
  286. const authStore = useAuthStore();
  287. // Status Configuration shared with OrdersSection
  288. const STATUS_CONFIG: Record<string, { color: string; icon: any }> = {
  289. pending: { color: "text-amber-500 bg-amber-500/10", icon: Clock },
  290. processing: { color: "text-blue-500 bg-blue-500/10", icon: RefreshCw },
  291. shipped: { color: "text-purple-500 bg-purple-500/10", icon: Truck },
  292. completed: { color: "text-emerald-500 bg-emerald-500/10", icon: CheckCircle2 },
  293. cancelled: { color: "text-rose-500 bg-rose-500/10", icon: XCircle },
  294. };
  295. // State
  296. const isLoading = ref(true);
  297. const searchQuery = ref("");
  298. const adminChatId = ref<any>(null);
  299. // Records
  300. const orders = ref<any[]>([]);
  301. const materials = ref<any[]>([]);
  302. const services = ref<any[]>([]);
  303. const posts = ref<any[]>([]);
  304. const portfolioItems = ref<any[]>([]);
  305. const auditLogs = ref<any[]>([]);
  306. const auditTotal = ref(0);
  307. const auditPage = ref(1);
  308. const usersResult = ref({ users: [] as any[], total: 0, page: 1, size: 50 });
  309. const userSearch = ref("");
  310. const userPage = ref(1);
  311. // Maps for reactive UI state
  312. const notifyStatusMap = ref<Record<number, boolean>>({});
  313. const fiscalFormMap = ref<Record<number, { fiscal_qr_url: string; ikof: string; jikr: string }>>({});
  314. const tabs: { id: Tab }[] = [
  315. { id: "orders" },
  316. { id: "materials" },
  317. { id: "services" },
  318. { id: "portfolio" },
  319. { id: "reviews" },
  320. { id: "users" },
  321. { id: "posts" },
  322. { id: "audit" },
  323. ];
  324. type Tab = "orders" | "materials" | "services" | "posts" | "users" | "portfolio" | "audit" | "reviews";
  325. function getValidTab(val: any): Tab {
  326. const t = val?.toString();
  327. return ["orders", "materials", "services", "posts", "users", "portfolio", "audit", "reviews"].includes(t) ? (t as Tab) : "orders";
  328. }
  329. const activeTab = ref<Tab>(getValidTab(route.query.tab));
  330. // Watchers
  331. watch(activeTab, (newTab) => {
  332. if (route.query.tab !== newTab) {
  333. router.replace({ query: { ...route.query, tab: newTab } });
  334. }
  335. fetchData();
  336. });
  337. watch([searchQuery], () => {
  338. if (activeTab.value === 'orders') debouncedFetchData();
  339. else fetchData();
  340. });
  341. // Modals State & Forms
  342. const showAddModal = ref(false);
  343. const editingMaterial = ref<any | null>(null);
  344. const editingService = ref<any | null>(null);
  345. const editingPost = ref<any | null>(null);
  346. const editingOrder = ref<any | null>(null);
  347. const matForm = reactive({ name_en: "", name_ru: "", name_ua: "", name_me: "", desc_en: "", desc_ru: "", desc_ua: "", desc_me: "", price_per_cm3: 0, available_colors: [] as string[], is_active: true });
  348. const svcForm = reactive({ name_en: "", name_ru: "", name_ua: "", name_me: "", desc_en: "", desc_ru: "", desc_ua: "", desc_me: "", tech_type: "", is_active: true });
  349. const postForm = reactive({ slug: "", title_en: "", title_me: "", title_ru: "", title_ua: "", category: "Technology", image_url: "", is_published: true });
  350. const userForm = reactive({ email: "", password: "", first_name: "", last_name: "", phone: "" });
  351. const orderForm = reactive({ total_price: 0, material_name: "", color_name: "", quantity: 1, first_name: "", last_name: "", email: "", phone: "", shipping_address: "", notes: "", review_text: "", rating: 0 });
  352. const handleEditOrder = (order: any) => {
  353. toast.info("Opening order edit for #" + order.id);
  354. console.log("CLICK DETECTED: Order #", order.id);
  355. editingOrder.value = order;
  356. Object.assign(orderForm, {
  357. total_price: order.invoice_amount || 0,
  358. material_name: order.material_name || "",
  359. color_name: order.color_name || "",
  360. quantity: order.quantity || 1,
  361. first_name: order.first_name || "",
  362. last_name: order.last_name || "",
  363. email: order.email || "",
  364. phone: order.phone || "",
  365. shipping_address: order.shipping_address || "",
  366. notes: order.notes || "",
  367. review_text: order.review_text || "",
  368. rating: order.rating || 0
  369. });
  370. showAddModal.value = true;
  371. };
  372. const newColor = ref("");
  373. // Fetching Logic
  374. let fetchTimeout: any = null;
  375. function debouncedFetchData() {
  376. clearTimeout(fetchTimeout);
  377. fetchTimeout = setTimeout(fetchData, 400);
  378. }
  379. async function fetchData() {
  380. isLoading.value = true;
  381. try {
  382. const tab = activeTab.value;
  383. if (tab === "orders") {
  384. orders.value = await adminGetOrders({ search: searchQuery.value });
  385. orders.value.forEach(o => {
  386. if (notifyStatusMap.value[o.id] === undefined) notifyStatusMap.value[o.id] = true;
  387. if (!fiscalFormMap.value[o.id]) fiscalFormMap.value[o.id] = { fiscal_qr_url: o.fiscal_qr_url || "", ikof: o.ikof || "", jikr: o.jikr || "" };
  388. });
  389. }
  390. else if (tab === "materials") materials.value = await adminGetMaterials();
  391. else if (tab === "services") services.value = await adminGetServices();
  392. else if (tab === "posts") posts.value = await getBlogPosts(false);
  393. else if (tab === "portfolio") portfolioItems.value = await adminGetAllPhotos();
  394. else if (tab === "users") await fetchUsers();
  395. else if (tab === "audit") await fetchAuditLogs();
  396. } catch (err: any) {
  397. toast.error(err.message || "Failed to load data");
  398. } finally {
  399. isLoading.value = false;
  400. }
  401. }
  402. async function fetchUsers() {
  403. usersResult.value = await adminGetUsers(userPage.value, 50, userSearch.value);
  404. }
  405. async function fetchAuditLogs() {
  406. const res = await adminGetAuditLogs(auditPage.value);
  407. auditLogs.value = res.logs; auditTotal.value = res.total;
  408. }
  409. // Global Handlers
  410. const toggleAdminChat = (id: number) => adminChatId.value = adminChatId.value === id ? null : id;
  411. const handleUpdateStatus = async (id: number, status: string) => {
  412. try {
  413. await adminUpdateOrder(id, { status, send_notification: notifyStatusMap.value[id] });
  414. toast.success("Status updated"); fetchData();
  415. } catch (err: any) { toast.error(err.message); }
  416. };
  417. const handleUpdateFiscal = async (id: number, data: any) => {
  418. try {
  419. await adminUpdateOrder(id, data); toast.success("Fiscal data saved"); fetchData();
  420. } catch (err: any) { toast.error(err.message); }
  421. };
  422. const handleApproveReview = async (id: number) => {
  423. try {
  424. await approveOrderReview(id); toast.success("Review approved"); fetchData();
  425. } catch (err: any) { toast.error(err.message); }
  426. };
  427. const handleDeleteOrder = async (id: number) => {
  428. if (confirm(`Delete Order #${id}?`)) {
  429. try { await adminDeleteOrder(id); toast.success("Order deleted"); fetchData(); }
  430. catch (err: any) { toast.error(err.message); }
  431. }
  432. };
  433. const handleAttachFile = async (id: number, file?: File) => {
  434. if (!file) return;
  435. try {
  436. const fd = new FormData(); fd.append("file", file);
  437. await adminAttachFile(id, fd); toast.success("File attached"); fetchData();
  438. } catch (err: any) { toast.error(err.message); }
  439. };
  440. const handleUploadPhoto = async (id: number, file?: File) => {
  441. if (!file) return;
  442. try {
  443. const fd = new FormData(); fd.append("file", file);
  444. await adminUploadOrderPhoto(id, fd); toast.success("Photo added"); fetchData();
  445. } catch (err: any) { toast.error(err.message); }
  446. };
  447. const handleDeleteFile = async (id: number, fid: number, fname: string) => {
  448. if (confirm(`Delete ${fname}?`)) {
  449. try { await adminDeleteFile(id, fid); fetchData(); }
  450. catch (err: any) { toast.error(err.message); }
  451. }
  452. };
  453. const handleDeletePhoto = async (id: number) => {
  454. if (confirm(`Delete photo?`)) {
  455. try { await adminDeletePhoto(id); fetchData(); }
  456. catch (err: any) { toast.error(err.message); }
  457. }
  458. };
  459. const handleTogglePhotoPublic = async (id: number, current: boolean) => {
  460. try { await adminUpdatePhotoStatus(id, { is_public: !current }); fetchData(); }
  461. catch (err: any) { toast.error(err.message); }
  462. };
  463. const toggleMaterialActive = async (m: any) => { try { await adminUpdateMaterial(m.id, { is_active: !m.is_active }); fetchData(); } catch (err: any) { toast.error(err.message); } };
  464. const toggleServiceActive = async (s: any) => { try { await adminUpdateService(s.id, { is_active: !s.is_active }); fetchData(); } catch (err: any) { toast.error(err.message); } };
  465. const togglePostActive = async (p: any) => { try { await adminUpdatePost(p.id, { ...p, is_published: !p.is_published }); fetchData(); } catch (err: any) { toast.error(err.message); } };
  466. const handleDeleteMaterial = async (id: number, name: string) => { if (confirm(`Delete ${name}?`)) { try { await adminDeleteMaterial(id); fetchData(); } catch (err: any) { toast.error(err.message); } } };
  467. const handleDeleteService = async (id: number, name: string) => { if (confirm(`Delete ${name}?`)) { try { await adminDeleteService(id); fetchData(); } catch (err: any) { toast.error(err.message); } } };
  468. const handleDeletePost = async (id: number, title: string) => { if (confirm(`Delete ${title}?`)) { try { await adminDeletePost(id); fetchData(); } catch (err: any) { toast.error(err.message); } } };
  469. const handleToggleUserChat = async (id: number, current: boolean) => { try { await adminUpdateUser(id, { can_chat: !current }); fetchUsers(); } catch (err: any) { toast.error(err.message); } };
  470. const handleToggleUserActive = async (id: number, current: boolean) => { if (confirm(`Toggle active?`)) { try { await adminUpdateUser(id, { is_active: !current }); fetchUsers(); } catch (err: any) { toast.error(err.message); } } };
  471. const handleUpdateUserRole = async (id: number, role: string) => { try { await adminUpdateUser(id, { role }); fetchUsers(); } catch (err: any) { toast.error(err.message); } };
  472. const handleResetPassword = async (id: number) => {
  473. const p = prompt("New password:"); if (p) { try { await adminUpdateUser(id, { password: p }); toast.success("Updated"); } catch (err: any) { toast.error(err.message); } }
  474. };
  475. // Modal Actions
  476. const handleAddNew = () => { closeModals(); showAddModal.value = true; };
  477. const closeModals = () => {
  478. showAddModal.value = false; editingMaterial.value = null; editingService.value = null; editingPost.value = null; editingOrder.value = null;
  479. Object.assign(matForm, { name_en: "", name_ru: "", name_me: "", name_ua: "", desc_en: "", desc_ru: "", desc_ua: "", desc_me: "", price_per_cm3: 0, available_colors: [], is_active: true });
  480. Object.assign(svcForm, { name_en: "", name_ru: "", name_me: "", name_ua: "", desc_en: "", desc_ru: "", desc_ua: "", desc_me: "", tech_type: "", is_active: true });
  481. Object.assign(userForm, { email: "", password: "", first_name: "", last_name: "", phone: "" });
  482. Object.assign(postForm, { slug: "", title_en: "", title_me: "", title_ru: "", title_ua: "", category: "Technology", image_url: "", is_published: true });
  483. Object.assign(orderForm, { total_price: 0, material_name: "", color_name: "", quantity: 1, first_name: "", last_name: "", email: "", phone: "", shipping_address: "", notes: "", review_text: "", rating: 0 });
  484. };
  485. function addColor() { if (newColor.value) { matForm.available_colors.push(newColor.value); newColor.value = ""; } }
  486. function removeColor(idx: number) { matForm.available_colors.splice(idx, 1); }
  487. async function handleSaveMaterial() {
  488. try {
  489. if (editingMaterial.value) await adminUpdateMaterial(editingMaterial.value.id, matForm);
  490. else await adminCreateMaterial(matForm);
  491. closeModals(); fetchData();
  492. } catch (err: any) { toast.error(err.message); }
  493. }
  494. async function handleSaveService() {
  495. try {
  496. if (editingService.value) await adminUpdateService(editingService.value.id, svcForm);
  497. else await adminCreateService(svcForm);
  498. closeModals(); fetchData();
  499. } catch (err: any) { toast.error(err.message); }
  500. }
  501. async function handleSavePost() {
  502. try {
  503. if (editingPost.value) await adminUpdatePost(editingPost.value.id, postForm);
  504. else await adminCreatePost(postForm);
  505. closeModals(); fetchData();
  506. } catch (err: any) { toast.error(err.message); }
  507. }
  508. async function handleSaveOrder() {
  509. if (!editingOrder.value) return;
  510. try {
  511. await adminUpdateOrder(editingOrder.value.id, orderForm);
  512. closeModals(); fetchData();
  513. toast.success("Order updated");
  514. } catch (err: any) { toast.error(err.message); }
  515. }
  516. async function handleSaveUser() { try { await adminCreateUser(userForm); closeModals(); fetchUsers(); } catch (err: any) { toast.error(err.message); } }
  517. // Lifecycle
  518. onMounted(async () => {
  519. if (!authStore.user || authStore.user.role !== "admin") { router.push("/auth"); return; }
  520. await loadAdminTranslations();
  521. fetchData();
  522. window.addEventListener('paste', handlePaste);
  523. });
  524. onUnmounted(() => {
  525. window.removeEventListener('paste', handlePaste);
  526. });
  527. async function handlePaste(event: ClipboardEvent) {
  528. const active = document.activeElement;
  529. if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')) return;
  530. }
  531. </script>