Admin.vue 37 KB

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