Admin.vue 44 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820
  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' && activeTab !== 'warehouse'" 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 || isTranslationsLoading" 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. @refresh="fetchData"
  108. />
  109. <AuditSection
  110. v-if="activeTab === 'audit'"
  111. :auditLogs="auditLogs"
  112. :total="auditTotal"
  113. :currentPage="auditPage"
  114. @update-page="p => { auditPage = p; fetchAuditLogs(); }"
  115. />
  116. <ReviewsSection
  117. v-if="activeTab === 'reviews'"
  118. />
  119. <WarehouseSection
  120. v-if="activeTab === 'warehouse'"
  121. :materials="materials"
  122. />
  123. </div>
  124. </main>
  125. <!-- DEDICATED ORDER EDIT MODAL -->
  126. <div v-if="showOrderEditModal" class="fixed inset-0 z-[99999] flex items-center justify-center p-4" @keydown.esc="closeModals">
  127. <div class="absolute inset-0 bg-background/90 backdrop-blur-md" @click="showOrderEditModal = false" />
  128. <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]">
  129. <div class="flex justify-between items-center mb-6">
  130. <h3 class="text-2xl font-black font-display text-gradient">{{ t('admin.labels.editOrder') }} #{{ editingOrder?.id }}</h3>
  131. <button @click="showOrderEditModal = false" class="p-2 hover:bg-white/5 rounded-full transition-colors"><X class="w-6 h-6" /></button>
  132. </div>
  133. <form v-if="editingOrder" @submit.prevent="handleSaveOrder" class="space-y-6">
  134. <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
  135. <div class="space-y-4">
  136. <div class="space-y-1">
  137. <label class="text-[10px] font-bold uppercase text-muted-foreground ml-1">{{ t("admin.fields.firstName") }}</label>
  138. <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" />
  139. </div>
  140. <div class="space-y-1">
  141. <label class="text-[10px] font-bold uppercase text-muted-foreground ml-1">{{ t("admin.fields.lastName") }}</label>
  142. <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" />
  143. </div>
  144. <div class="space-y-1">
  145. <label class="text-[10px] font-bold uppercase text-muted-foreground ml-1">{{ t("admin.fields.email") }}</label>
  146. <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" />
  147. </div>
  148. <div class="space-y-1">
  149. <label class="text-[10px] font-bold uppercase text-muted-foreground ml-1">{{ t("admin.fields.phone") }}</label>
  150. <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" />
  151. </div>
  152. </div>
  153. <div class="space-y-4">
  154. <div class="space-y-1">
  155. <label class="text-[10px] font-bold uppercase text-muted-foreground ml-1">{{ t("admin.fields.shippingAddress") }}</label>
  156. <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" />
  157. </div>
  158. <div class="space-y-1">
  159. <label class="text-[10px] font-bold uppercase text-muted-foreground ml-1">{{ t("admin.fields.projectNotes") }}</label>
  160. <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" />
  161. </div>
  162. </div>
  163. </div>
  164. <div class="p-6 bg-primary/5 rounded-2xl border border-primary/10 grid grid-cols-1 md:grid-cols-2 gap-6">
  165. <div class="space-y-1">
  166. <label class="text-[10px] font-bold uppercase text-primary/60 ml-1">{{ t("admin.fields.totalPrice") }} (EUR)</label>
  167. <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-2.5 text-sm font-black text-primary" />
  168. </div>
  169. <div class="space-y-1">
  170. <label class="text-[10px] font-bold uppercase text-primary/60 ml-1">{{ t("admin.fields.quantity") }} ({{ t('orders.labels.quantity') }})</label>
  171. <input v-model.number="orderForm.quantity" type="number" class="w-full bg-background border border-border/50 rounded-xl px-4 py-2.5 text-sm font-bold" />
  172. </div>
  173. </div>
  174. <div class="p-6 bg-muted/20 rounded-2xl border border-border/10 grid grid-cols-1 md:grid-cols-2 gap-6">
  175. <div class="space-y-1">
  176. <label class="text-[10px] font-bold uppercase text-primary/60 ml-1">{{ t("admin.fields.material") }}</label>
  177. <select v-model="orderForm.material_name" class="w-full bg-background border border-border/50 rounded-xl px-3 py-2 text-sm">
  178. <option value="">{{ t("admin.fields.material") }}...</option>
  179. <option v-for="m in activeMaterials" :key="m.id" :value="m.name_en">{{ m.name_en }} ({{ m.name_ru }})</option>
  180. </select>
  181. </div>
  182. <div class="space-y-1">
  183. <label class="text-[10px] font-bold uppercase text-primary/60 ml-1">{{ t("admin.fields.colors") }}</label>
  184. <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">
  185. <option value="">{{ t("admin.fields.colors") }}...</option>
  186. <option v-for="c in materialColors" :key="c" :value="c">{{ c }}</option>
  187. <option v-if="orderForm.color_name && !materialColors.includes(orderForm.color_name)" :value="orderForm.color_name">{{ orderForm.color_name }} ({{ t('admin.labels.current') }})</option>
  188. </select>
  189. </div>
  190. </div>
  191. <!-- Review Feedback -->
  192. <div v-if="orderForm.review_text" class="p-4 bg-amber-500/5 border border-amber-500/20 rounded-2xl space-y-3">
  193. <div class="flex justify-between items-center">
  194. <label class="text-[10px] font-bold uppercase text-amber-500 ml-1">{{ t('admin.labels.reviewContent') }}</label>
  195. <div class="flex gap-1">
  196. <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'" />
  197. </div>
  198. </div>
  199. <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" />
  200. </div>
  201. <!-- Management Sections (Files & Photos) -->
  202. <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
  203. <!-- Files Section -->
  204. <div class="p-4 bg-muted/30 rounded-2xl border border-border/20 space-y-4">
  205. <div class="flex items-center justify-between">
  206. <span class="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">{{ t("admin.fields.sourceFiles") }}</span>
  207. <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">
  208. <Plus class="w-3.5 h-3.5" />
  209. <input type="file" class="hidden" multiple @change="e => handleAttachFiles(editingOrder.id, (e.target as HTMLInputElement).files)" />
  210. </label>
  211. </div>
  212. <div class="space-y-2 max-h-[200px] overflow-y-auto pr-1 custom-scrollbar">
  213. <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] gap-2">
  214. <span class="truncate flex-1 font-medium">{{ f.filename }}</span>
  215. <div class="flex items-center gap-2">
  216. <div class="flex items-center bg-muted/50 rounded-lg px-2 py-1 border border-border/50">
  217. <span class="text-[9px] font-bold text-muted-foreground mr-1">COPIES:</span>
  218. <input
  219. type="number"
  220. v-model.number="f.quantity"
  221. @change="adminUpdateFile(editingOrder.id, f.id, { quantity: f.quantity })"
  222. class="w-8 bg-transparent text-center font-black outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
  223. />
  224. </div>
  225. <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>
  226. <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>
  227. </div>
  228. </div>
  229. <p v-if="!editingOrder?.files?.length" class="text-[10px] text-muted-foreground italic text-center py-4">{{ t('admin.labels.noFiles') }}</p>
  230. </div>
  231. </div>
  232. <!-- Photos Section -->
  233. <div class="p-4 bg-muted/30 rounded-2xl border border-border/20 space-y-4">
  234. <div class="flex items-center justify-between">
  235. <span class="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">{{ t("admin.fields.photoReport") }}</span>
  236. <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">
  237. <Plus class="w-3.5 h-3.5" />
  238. <input type="file" class="hidden" accept="image/*" @change="e => handleUploadPhoto(editingOrder.id, (e.target as HTMLInputElement).files?.[0])" />
  239. </label>
  240. </div>
  241. <div class="flex flex-wrap gap-2 max-h-[200px] overflow-y-auto pr-1">
  242. <div v-for="p in (editingOrder?.photos || [])" :key="p.id" class="relative group">
  243. <img :src="`${RESOURCES_BASE_URL}/${p.file_path}`" class="w-12 h-12 object-cover rounded-lg border border-border/50 shadow-sm" />
  244. <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">
  245. <X class="w-2.5 h-2.5" />
  246. </button>
  247. </div>
  248. <p v-if="!editingOrder?.photos?.length" class="text-[10px] text-muted-foreground italic text-center py-4 w-full">{{ t('admin.labels.noPhotos') }}</p>
  249. </div>
  250. </div>
  251. </div>
  252. <div class="flex gap-4 pt-4">
  253. <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>
  254. <Button type="submit" variant="hero" class="flex-[2] rounded-2xl h-12 shadow-glow">{{ t("admin.actions.save") }}</Button>
  255. </div>
  256. </form>
  257. </div>
  258. </div>
  259. <!-- Global Modals -->
  260. <div v-if="showAddModal" class="fixed inset-0 z-[99999] flex items-center justify-center p-4" @keydown.esc="closeModals">
  261. <div class="absolute inset-0 bg-background/80 backdrop-blur-sm" @click="closeModals" />
  262. <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]">
  263. <h3 class="text-xl font-bold font-display mb-6">{{ editingMaterial || editingService || editingPost ? t('admin.actions.edit') : t("admin.addNew") }}</h3>
  264. <!-- Material Modal Form -->
  265. <form v-if="activeTab === 'materials'" @submit.prevent="handleSaveMaterial" class="space-y-4">
  266. <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
  267. <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>
  268. <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>
  269. <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.nameMe") }}</label><input v-model="matForm.name_me" required class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 text-sm" /></div>
  270. <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>
  271. </div>
  272. <div class="space-y-1">
  273. <label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.technology") }}</label>
  274. <select v-model="matForm.service_id" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 text-sm">
  275. <option :value="null">{{ t("admin.fields.selectTechnology") }}...</option>
  276. <option v-for="s in services" :key="s.id" :value="s.id">{{ s.name_en }} ({{ s.tech_type }})</option>
  277. </select>
  278. </div>
  279. <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
  280. <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.longDescEn") }}</label><textarea v-model="matForm.long_desc_en" class="w-full bg-background border border-border/50 rounded-xl px-4 py-2 text-xs h-20 resize-none" /></div>
  281. <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.longDescRu") }}</label><textarea v-model="matForm.long_desc_ru" class="w-full bg-background border border-border/50 rounded-xl px-4 py-2 text-xs h-20 resize-none" /></div>
  282. <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.longDescMe") }}</label><textarea v-model="matForm.long_desc_me" class="w-full bg-background border border-border/50 rounded-xl px-4 py-2 text-xs h-20 resize-none" /></div>
  283. <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.longDescUa") }}</label><textarea v-model="matForm.long_desc_ua" class="w-full bg-background border border-border/50 rounded-xl px-4 py-2 text-xs h-20 resize-none" /></div>
  284. </div>
  285. <div class="grid grid-cols-2 gap-4">
  286. <div class="space-y-1">
  287. <label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.pricePerCm3") }}</label>
  288. <input v-model.number="matForm.price_per_cm3" type="number" step="any" required class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 text-sm" />
  289. </div>
  290. <div class="space-y-1">
  291. <label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.pricePerHour") }} ({{ t("admin.fields.override") }})</label>
  292. <input v-model.number="matForm.price_per_hour" type="number" step="any" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 text-sm" />
  293. </div>
  294. <div class="space-y-1">
  295. <label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.speedCoeff") }}</label>
  296. <input v-model.number="matForm.speed_coeff" type="number" step="0.01" required class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 text-sm" />
  297. </div>
  298. </div>
  299. <div class="space-y-1">
  300. <label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.colors") }}</label>
  301. <div class="flex gap-2">
  302. <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" />
  303. <Button type="button" variant="hero" @click="addColor">{{ t('admin.actions.add') }}</Button>
  304. </div>
  305. <div class="flex flex-wrap gap-2 mt-3">
  306. <div v-for="(c, idx) in matForm.available_colors" :key="idx" class="flex items-center gap-2 bg-background/50 border border-border/50 px-3 py-1.5 rounded-xl text-xs group transition-all hover:border-primary/30">
  307. <div class="w-3 h-3 rounded-full border border-white/10 shadow-sm" :style="{ backgroundColor: c.toLowerCase() }"></div>
  308. <span class="font-medium">{{ c }}</span>
  309. <button type="button" @click="removeColor(idx)" class="text-muted-foreground hover:text-rose-500 transition-colors ml-1 opacity-0 group-hover:opacity-100"><X class="w-3 h-3" /></button>
  310. </div>
  311. </div>
  312. </div>
  313. <div class="flex gap-3 pt-6 border-t border-border/10">
  314. <Button type="button" variant="ghost" class="flex-1" @click="closeModals">{{ t("admin.actions.cancel") }}</Button>
  315. <Button type="submit" variant="hero" class="flex-1">{{ t("admin.actions.save") }}</Button>
  316. </div>
  317. </form>
  318. <!-- Service Modal Form -->
  319. <form v-if="activeTab === 'services'" @submit.prevent="handleSaveService" class="space-y-4">
  320. <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>
  321. <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>
  322. <div class="grid grid-cols-2 gap-4">
  323. <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>
  324. <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.pricePerHour") }}</label><input v-model.number="svcForm.price_per_hour" type="number" step="any" required class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 text-sm" /></div>
  325. </div>
  326. <div class="flex gap-3 pt-6 border-t border-border/10">
  327. <Button type="button" variant="ghost" class="flex-1" @click="closeModals">{{ t("admin.actions.cancel") }}</Button>
  328. <Button type="submit" variant="hero" class="flex-1">{{ t("admin.actions.save") }}</Button>
  329. </div>
  330. </form>
  331. <!-- User Creation Form -->
  332. <form v-if="activeTab === 'users'" @submit.prevent="handleSaveUser" class="space-y-4">
  333. <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>
  334. <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>
  335. <div class="grid grid-cols-2 gap-4">
  336. <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>
  337. <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>
  338. </div>
  339. <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>
  340. <div class="flex gap-3 pt-6 border-t border-border/10">
  341. <Button type="button" variant="ghost" class="flex-1" @click="closeModals">{{ t("admin.actions.cancel") }}</Button>
  342. <Button type="submit" variant="hero" class="flex-1">{{ t("admin.actions.save") }}</Button>
  343. </div>
  344. </form>
  345. </div>
  346. </div>
  347. <Footer />
  348. </div>
  349. </template>
  350. <script setup lang="ts">
  351. import { ref, watch, reactive, computed, onMounted, onUnmounted } from "vue";
  352. import { useRouter, useRoute, RouterLink } from "vue-router";
  353. import { useI18n } from "vue-i18n";
  354. import i18n, { loadAdminTranslations } from "@/i18n";
  355. import { toast } from "vue-sonner";
  356. // Icons
  357. import {
  358. Package, Clock, RefreshCw, Search, Layers, Plus, Database,
  359. Newspaper, History, X, Users, ImageIcon, Truck, CheckCircle2, XCircle, Star,
  360. Trash2, MessageCircle, Edit2
  361. } from "lucide-vue-next";
  362. // UI Components
  363. import Button from "@/components/ui/button.vue";
  364. import Header from "@/components/Header.vue";
  365. import Footer from "@/components/Footer.vue";
  366. // Admin Sections
  367. import OrdersSection from "@/components/admin/OrdersSection.vue";
  368. import MaterialsSection from "@/components/admin/MaterialsSection.vue";
  369. import ServicesSection from "@/components/admin/ServicesSection.vue";
  370. import UsersSection from "@/components/admin/UsersSection.vue";
  371. import PostsSection from "@/components/admin/PostsSection.vue";
  372. import PortfolioSection from "@/components/admin/PortfolioSection.vue";
  373. import AuditSection from "@/components/admin/AuditSection.vue";
  374. import ReviewsSection from "@/components/admin/ReviewsSection.vue";
  375. import WarehouseSection from "@/components/admin/WarehouseSection.vue";
  376. // API & Stores
  377. import { useAuthStore } from "@/stores/auth";
  378. import {
  379. adminGetOrders, adminUpdateOrder, adminGetMaterials, adminUpdateMaterial,
  380. adminDeleteMaterial, adminCreateMaterial, adminGetServices, adminUpdateService,
  381. adminDeleteService, adminCreateService, adminUploadOrderPhoto, adminUpdatePhotoStatus,
  382. adminDeletePhoto, adminGetAllPhotos, adminAttachFile, adminUpdateFile, adminDeleteFile, adminDeleteOrder,
  383. getBlogPosts, adminUpdatePost, adminDeletePost, adminCreatePost,
  384. adminGetUsers, adminUpdateUser, adminCreateUser,
  385. adminGetAuditLogs, approveOrderReview, RESOURCES_BASE_URL
  386. } from "@/lib/api";
  387. const { t } = useI18n();
  388. const router = useRouter();
  389. const route = useRoute();
  390. const authStore = useAuthStore();
  391. // Status Configuration shared with OrdersSection
  392. const STATUS_CONFIG: Record<string, { color: string; icon: any }> = {
  393. pending: { color: "text-amber-500 bg-amber-500/10", icon: Clock },
  394. processing: { color: "text-blue-500 bg-blue-500/10", icon: RefreshCw },
  395. shipped: { color: "text-purple-500 bg-purple-500/10", icon: Truck },
  396. completed: { color: "text-emerald-500 bg-emerald-500/10", icon: CheckCircle2 },
  397. cancelled: { color: "text-rose-500 bg-rose-500/10", icon: XCircle },
  398. };
  399. // State
  400. const isLoading = ref(true);
  401. const isTranslationsLoading = ref(false);
  402. const searchQuery = ref("");
  403. const adminChatId = ref<any>(null);
  404. // Records
  405. const orders = ref<any[]>([]);
  406. const materials = ref<any[]>([]);
  407. const services = ref<any[]>([]);
  408. const posts = ref<any[]>([]);
  409. const portfolioItems = ref<any[]>([]);
  410. const auditLogs = ref<any[]>([]);
  411. const auditTotal = ref(0);
  412. const auditPage = ref(1);
  413. const usersResult = ref({ users: [] as any[], total: 0, page: 1, size: 50 });
  414. const userSearch = ref("");
  415. const userPage = ref(1);
  416. // Maps for reactive UI state
  417. const notifyStatusMap = ref<Record<number, boolean>>({});
  418. const fiscalFormMap = ref<Record<number, { fiscal_qr_url: string; ikof: string; jikr: string }>>({});
  419. const tabs: { id: Tab }[] = [
  420. { id: "orders" },
  421. { id: "materials" },
  422. { id: "services" },
  423. { id: "portfolio" },
  424. { id: "reviews" },
  425. { id: "users" },
  426. { id: "posts" },
  427. { id: "audit" },
  428. { id: "warehouse" },
  429. ];
  430. type Tab = "orders" | "materials" | "services" | "posts" | "users" | "portfolio" | "audit" | "reviews" | "warehouse";
  431. function getValidTab(val: any): Tab {
  432. const t = val?.toString();
  433. return ["orders", "materials", "services", "posts", "users", "portfolio", "audit", "reviews", "warehouse"].includes(t) ? (t as Tab) : "orders";
  434. }
  435. const activeTab = ref<Tab>(getValidTab(route.query.tab));
  436. // Watchers
  437. watch(activeTab, (newTab) => {
  438. if (route.query.tab !== newTab) {
  439. router.replace({ query: { ...route.query, tab: newTab } });
  440. }
  441. fetchData();
  442. });
  443. // React to language changes
  444. watch(() => (i18n.global as any).locale.value, async (newLang) => {
  445. isTranslationsLoading.value = true;
  446. await loadAdminTranslations(newLang);
  447. isTranslationsLoading.value = false;
  448. }, { immediate: true });
  449. watch([searchQuery], () => {
  450. if (activeTab.value === 'orders') debouncedFetchData();
  451. else fetchData();
  452. });
  453. // Modals State & Forms
  454. const showAddModal = ref(false);
  455. const showOrderEditModal = ref(false);
  456. const editingMaterial = ref<any | null>(null);
  457. const editingService = ref<any | null>(null);
  458. const editingPost = ref<any | null>(null);
  459. const editingOrder = ref<any | null>(null);
  460. const matForm = reactive({
  461. name_en: "", name_ru: "", name_ua: "", name_me: "",
  462. desc_en: "", desc_ru: "", desc_ua: "", desc_me: "",
  463. long_desc_en: "", long_desc_ru: "", long_desc_ua: "", long_desc_me: "",
  464. service_id: null as number | null,
  465. price_per_cm3: 0, price_per_hour: 0, speed_coeff: 1.0, available_colors: [] as string[], is_active: true
  466. });
  467. const svcForm = reactive({ name_en: "", name_ru: "", name_ua: "", name_me: "", desc_en: "", desc_ru: "", desc_ua: "", desc_me: "", tech_type: "", price_per_hour: 0, is_active: true });
  468. const postForm = reactive({ slug: "", title_en: "", title_me: "", title_ru: "", title_ua: "", category: "Technology", image_url: "", is_published: true });
  469. const userForm = reactive({ email: "", password: "", first_name: "", last_name: "", phone: "" });
  470. 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 });
  471. const handleEditOrder = (order: any) => {
  472. editingOrder.value = order;
  473. Object.assign(orderForm, {
  474. total_price: Number(order.invoice_amount || 0),
  475. material_name: order.material_name || "",
  476. color_name: order.color_name || "",
  477. quantity: order.quantity || 1,
  478. first_name: order.first_name || "",
  479. last_name: order.last_name || "",
  480. email: order.email || "",
  481. phone: order.phone || "",
  482. shipping_address: order.shipping_address || "",
  483. notes: order.notes || "",
  484. review_text: order.review_text || "",
  485. rating: order.rating || 0
  486. });
  487. showOrderEditModal.value = true;
  488. };
  489. const handleEditOrderEvent = (e: any) => {
  490. if (e.detail) handleEditOrder(e.detail);
  491. };
  492. const handleGlobalEsc = (e: KeyboardEvent) => {
  493. if (e.key === 'Escape') {
  494. closeModals();
  495. }
  496. };
  497. onUnmounted(() => {
  498. window.removeEventListener('admin-edit-order', handleEditOrderEvent);
  499. window.removeEventListener('keydown', handleGlobalEsc);
  500. });
  501. const newColor = ref("");
  502. const activeMaterials = computed(() => materials.value.filter((m: any) => m.is_active || m.is_active === 1));
  503. const selectedMaterialObj = computed(() => activeMaterials.value.find((m: any) => m.name_en === orderForm.material_name || m.name_ru === orderForm.material_name));
  504. const materialColors = computed(() => {
  505. if (!selectedMaterialObj.value) return [];
  506. try {
  507. return Array.isArray(selectedMaterialObj.value.available_colors)
  508. ? selectedMaterialObj.value.available_colors
  509. : JSON.parse(selectedMaterialObj.value.available_colors || "[]");
  510. } catch { return []; }
  511. });
  512. // Fetching Logic
  513. let fetchTimeout: any = null;
  514. function debouncedFetchData() {
  515. clearTimeout(fetchTimeout);
  516. fetchTimeout = setTimeout(fetchData, 400);
  517. }
  518. async function fetchData() {
  519. isLoading.value = true;
  520. try {
  521. const tab = activeTab.value;
  522. if (tab === "orders") {
  523. orders.value = await adminGetOrders({ search: searchQuery.value });
  524. orders.value.forEach(o => {
  525. if (notifyStatusMap.value[o.id] === undefined) notifyStatusMap.value[o.id] = true;
  526. if (!fiscalFormMap.value[o.id]) fiscalFormMap.value[o.id] = { fiscal_qr_url: o.fiscal_qr_url || "", ikof: o.ikof || "", jikr: o.jikr || "" };
  527. });
  528. }
  529. else if (tab === "materials") materials.value = await adminGetMaterials();
  530. else if (tab === "services") services.value = await adminGetServices();
  531. else if (tab === "posts") posts.value = await getBlogPosts(false);
  532. else if (tab === "portfolio") portfolioItems.value = await adminGetAllPhotos();
  533. else if (tab === "users") await fetchUsers();
  534. else if (tab === "audit") await fetchAuditLogs();
  535. else if (tab === "warehouse") {
  536. // Stock is handled inside WarehouseSection.vue onMounted,
  537. // but we need materials list here too
  538. materials.value = await adminGetMaterials();
  539. }
  540. } catch (err: any) {
  541. toast.error(err.message || "Failed to load data");
  542. } finally {
  543. isLoading.value = false;
  544. }
  545. }
  546. async function fetchUsers() {
  547. usersResult.value = await adminGetUsers(userPage.value, 50, userSearch.value);
  548. }
  549. async function fetchAuditLogs() {
  550. const res = await adminGetAuditLogs(auditPage.value);
  551. auditLogs.value = res.logs; auditTotal.value = res.total;
  552. }
  553. // Global Handlers
  554. const toggleAdminChat = (id: number) => adminChatId.value = adminChatId.value === id ? null : id;
  555. const handleUpdateStatus = async (id: number, status: string) => {
  556. try {
  557. await adminUpdateOrder(id, { status, send_notification: notifyStatusMap.value[id] });
  558. toast.success("Status updated"); fetchData();
  559. } catch (err: any) { toast.error(err.message); }
  560. };
  561. const handleUpdateFiscal = async (id: number, data: any) => {
  562. try {
  563. await adminUpdateOrder(id, data); toast.success("Fiscal data saved"); fetchData();
  564. } catch (err: any) { toast.error(err.message); }
  565. };
  566. const handleApproveReview = async (id: number) => {
  567. try {
  568. await approveOrderReview(id); toast.success("Review approved"); fetchData();
  569. } catch (err: any) { toast.error(err.message); }
  570. };
  571. const handleDeleteOrder = async (id: number) => {
  572. if (confirm(`Delete Order #${id}?`)) {
  573. try { await adminDeleteOrder(id); toast.success("Order deleted"); fetchData(); }
  574. catch (err: any) { toast.error(err.message); }
  575. }
  576. };
  577. const handleAttachFiles = async (orderId: number, files: FileList | File | null) => {
  578. if (!files || !orderId) {
  579. console.error("DEBUG: handleAttachFiles called with missing data:", { orderId, files });
  580. return;
  581. }
  582. const id = orderId;
  583. const fileArray = files instanceof FileList ? Array.from(files) : [files];
  584. const toastId = toast.loading(`Uploading ${fileArray.length} file(s)...`);
  585. try {
  586. // Perform parallel uploads for speed
  587. await Promise.all(fileArray.map(async (file) => {
  588. console.log(`DEBUG: Starting upload for: ${file.name} to order: ${id}`);
  589. const fd = new FormData();
  590. fd.append("file", file);
  591. return adminAttachFile(id, fd);
  592. }));
  593. toast.success(`${fileArray.length} file(s) attached successfully`, { id: toastId });
  594. await fetchData();
  595. // Re-sync editingOrder if open to show new files immediately
  596. if (editingOrder.value?.id === id) {
  597. const updatedOrder = orders.value.find(o => o.id === id);
  598. if (updatedOrder) editingOrder.value = updatedOrder;
  599. }
  600. } catch (err: any) {
  601. console.error("DEBUG: handleAttachFiles failed:", err);
  602. toast.error(`Failed to attach files: ${err.message}`, { id: toastId });
  603. }
  604. };
  605. const handleUploadPhoto = async (id: number, file?: File) => {
  606. if (!file) return;
  607. try {
  608. const fd = new FormData(); fd.append("file", file);
  609. await adminUploadOrderPhoto(id, fd); toast.success("Photo added"); fetchData();
  610. } catch (err: any) { toast.error(err.message); }
  611. };
  612. const handleDeleteFile = async (id: number, fid: number, fname: string) => {
  613. if (confirm(`Delete ${fname}?`)) {
  614. try { await adminDeleteFile(id, fid); fetchData(); }
  615. catch (err: any) { toast.error(err.message); }
  616. }
  617. };
  618. const handleDeletePhoto = async (photoId: number) => {
  619. if (confirm(`Delete photo?`)) {
  620. try {
  621. await adminDeletePhoto(photoId);
  622. await fetchData();
  623. if (editingOrder.value) {
  624. const updated = orders.value.find(o => o.id === editingOrder.value?.id);
  625. if (updated) editingOrder.value = updated;
  626. }
  627. }
  628. catch (err: any) { toast.error(err.message); }
  629. }
  630. };
  631. const handleTogglePhotoPublic = async (id: number, current: boolean) => {
  632. try { await adminUpdatePhotoStatus(id, { is_public: !current }); fetchData(); }
  633. catch (err: any) { toast.error(err.message); }
  634. };
  635. const toggleMaterialActive = async (m: any) => { try { await adminUpdateMaterial(m.id, { is_active: !m.is_active }); fetchData(); } catch (err: any) { toast.error(err.message); } };
  636. const toggleServiceActive = async (s: any) => { try { await adminUpdateService(s.id, { is_active: !s.is_active }); fetchData(); } catch (err: any) { toast.error(err.message); } };
  637. const togglePostActive = async (p: any) => { try { await adminUpdatePost(p.id, { ...p, is_published: !p.is_published }); fetchData(); } catch (err: any) { toast.error(err.message); } };
  638. const handleDeleteMaterial = async (id: number, name: string) => { if (confirm(`Delete ${name}?`)) { try { await adminDeleteMaterial(id); fetchData(); } catch (err: any) { toast.error(err.message); } } };
  639. const handleDeleteService = async (id: number, name: string) => { if (confirm(`Delete ${name}?`)) { try { await adminDeleteService(id); fetchData(); } catch (err: any) { toast.error(err.message); } } };
  640. const handleDeletePost = async (id: number, title: string) => { if (confirm(`Delete ${title}?`)) { try { await adminDeletePost(id); fetchData(); } catch (err: any) { toast.error(err.message); } } };
  641. const handleToggleUserChat = async (id: number, current: boolean) => { try { await adminUpdateUser(id, { can_chat: !current }); fetchUsers(); } catch (err: any) { toast.error(err.message); } };
  642. 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); } } };
  643. const handleUpdateUserRole = async (id: number, role: string) => { try { await adminUpdateUser(id, { role }); fetchUsers(); } catch (err: any) { toast.error(err.message); } };
  644. const handleResetPassword = async (id: number) => {
  645. const p = prompt("New password:"); if (p) { try { await adminUpdateUser(id, { password: p }); toast.success("Updated"); } catch (err: any) { toast.error(err.message); } }
  646. };
  647. // Modal Actions
  648. const handleAddNew = () => { closeModals(); showAddModal.value = true; };
  649. function closeModals() {
  650. showAddModal.value = false;
  651. showOrderEditModal.value = false;
  652. adminChatId.value = null;
  653. editingMaterial.value = null;
  654. editingService.value = null;
  655. editingPost.value = null;
  656. editingOrder.value = null;
  657. Object.assign(matForm, {
  658. name_en: "", name_ru: "", name_me: "", name_ua: "",
  659. desc_en: "", desc_ru: "", desc_ua: "", desc_me: "",
  660. long_desc_en: "", long_desc_ru: "", long_desc_ua: "", long_desc_me: "",
  661. service_id: null,
  662. price_per_cm3: 0, price_per_hour: 0, speed_coeff: 1.0, available_colors: [], is_active: true
  663. });
  664. Object.assign(svcForm, { name_en: "", name_ru: "", name_me: "", name_ua: "", desc_en: "", desc_ru: "", desc_ua: "", desc_me: "", tech_type: "", price_per_hour: 0, is_active: true });
  665. Object.assign(userForm, { email: "", password: "", first_name: "", last_name: "", phone: "" });
  666. Object.assign(postForm, { slug: "", title_en: "", title_me: "", title_ru: "", title_ua: "", category: "Technology", image_url: "", is_published: true });
  667. 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 });
  668. }
  669. function addColor() { if (newColor.value) { matForm.available_colors.push(newColor.value); newColor.value = ""; } }
  670. function removeColor(idx: number) { matForm.available_colors.splice(idx, 1); }
  671. async function handleSaveMaterial() {
  672. try {
  673. if (editingMaterial.value) await adminUpdateMaterial(editingMaterial.value.id, matForm);
  674. else await adminCreateMaterial(matForm);
  675. closeModals(); fetchData();
  676. } catch (err: any) { toast.error(err.message); }
  677. }
  678. async function handleSaveService() {
  679. try {
  680. if (editingService.value) await adminUpdateService(editingService.value.id, svcForm);
  681. else await adminCreateService(svcForm);
  682. closeModals(); fetchData();
  683. } catch (err: any) { toast.error(err.message); }
  684. }
  685. async function handleSavePost() {
  686. try {
  687. if (editingPost.value) await adminUpdatePost(editingPost.value.id, postForm);
  688. else await adminCreatePost(postForm);
  689. closeModals(); fetchData();
  690. } catch (err: any) { toast.error(err.message); }
  691. }
  692. async function handleSaveOrder() {
  693. const order = editingOrder.value;
  694. if (!order) return;
  695. try {
  696. await adminUpdateOrder(order.id, orderForm);
  697. // Optimistic local update with safety checks
  698. if (editingOrder.value && editingOrder.value.id === order.id) {
  699. editingOrder.value.invoice_amount = orderForm.total_price;
  700. }
  701. const idx = orders.value.findIndex(o => o.id === order.id);
  702. if (idx !== -1) {
  703. orders.value[idx].invoice_amount = orderForm.total_price;
  704. orders.value[idx].quantity = orderForm.quantity;
  705. }
  706. closeModals();
  707. toast.success("Order updated");
  708. // Refresh to be sure
  709. setTimeout(fetchData, 300);
  710. } catch (err: any) {
  711. console.error("Save order error:", err);
  712. toast.error(err.message || "Failed to save order");
  713. }
  714. }
  715. async function handleSaveUser() { try { await adminCreateUser(userForm); closeModals(); fetchUsers(); } catch (err: any) { toast.error(err.message); } }
  716. // Lifecycle
  717. onMounted(async () => {
  718. if (!authStore.user || authStore.user.role !== "admin") { router.push("/auth"); return; }
  719. window.addEventListener('admin-edit-order', handleEditOrderEvent);
  720. window.addEventListener('keydown', handleGlobalEsc);
  721. await loadAdminTranslations();
  722. fetchData();
  723. // Ensure materials are loaded for order edit selectors
  724. adminGetMaterials().then(res => materials.value = res).catch(() => {});
  725. });
  726. </script>