Admin.vue 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473
  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">
  7. <Header />
  8. <main class="container mx-auto px-4 pt-32 pb-20">
  9. <!-- 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">Management Center</span>
  13. <h1 class="font-display text-4xl font-bold">Admin <span class="text-gradient">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. 'flex items-center gap-2 px-6 py-2.5 rounded-xl text-sm font-bold transition-all',
  18. activeTab === tab.id ? 'bg-primary text-primary-foreground shadow-glow' : 'text-muted-foreground hover:bg-white/5 hover:text-foreground'
  19. ]">
  20. <component :is="tab.icon" class="w-4 h-4" />{{ tab.label }}
  21. </button>
  22. </div>
  23. </div>
  24. <!-- Search & actions -->
  25. <div class="flex flex-col sm:flex-row gap-4 mb-8">
  26. <div class="relative flex-1">
  27. <Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
  28. <input type="text" v-model="searchQuery" :placeholder="`Search ${activeTab}...`"
  29. class="w-full bg-card/50 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" />
  30. </div>
  31. <select v-if="activeTab === 'orders'" v-model="statusFilter"
  32. class="bg-card/50 border border-border/50 rounded-xl px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-primary/20 text-sm min-w-[140px]">
  33. <option value="all">All Statuses</option>
  34. <option v-for="s in Object.keys(STATUS_CONFIG)" :key="s" :value="s">{{ capitalize(s) }}</option>
  35. </select>
  36. <Button v-if="activeTab !== 'orders'" variant="hero" class="gap-2 sm:px-8" @click="showAddModal = true">
  37. <Plus class="w-4 h-4" />Add New
  38. </Button>
  39. </div>
  40. <!-- Loading -->
  41. <div v-if="isLoading" class="flex items-center justify-center py-20">
  42. <RefreshCw class="w-8 h-8 text-primary animate-spin" />
  43. </div>
  44. <!-- ORDERS -->
  45. <div v-else-if="activeTab === 'orders'" class="grid gap-6">
  46. <div v-for="order in filteredOrders" :key="order.id"
  47. class="group relative bg-card/40 backdrop-blur-md border border-border/50 rounded-3xl overflow-hidden hover:border-primary/30 transition-all duration-300">
  48. <div class="flex flex-col lg:flex-row divide-y lg:divide-y-0 lg:divide-x divide-border/50">
  49. <!-- Info -->
  50. <div class="p-6 lg:w-1/4">
  51. <div class="flex items-center justify-between mb-4">
  52. <span class="text-xs font-bold text-muted-foreground border border-border/50 rounded-full px-2 py-0.5 uppercase tracking-tighter">Order #{{ order.id }}</span>
  53. <span :class="`flex items-center gap-1.5 px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider ${statusColor(order.status)}`">
  54. <component :is="statusIcon(order.status)" class="w-3.5 h-3.5" />{{ order.status }}
  55. </span>
  56. </div>
  57. <div class="space-y-1">
  58. <h3 class="font-bold">{{ order.first_name }} {{ order.last_name }}</h3>
  59. <p class="text-sm text-muted-foreground truncate">{{ order.email }}</p>
  60. </div>
  61. <div class="mt-4 pt-4 border-t border-border/50 text-xs text-muted-foreground">{{ new Date(order.created_at).toLocaleString() }}</div>
  62. </div>
  63. <!-- Details -->
  64. <div class="p-6 lg:w-1/4 space-y-4">
  65. <div class="flex justify-between items-start">
  66. <div>
  67. <span class="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1 block">Selected Material</span>
  68. <div class="flex items-center gap-2">
  69. <Layers class="w-3.5 h-3.5 text-primary" />
  70. <p class="text-sm font-bold uppercase">{{ order.material_name || "unknown" }}</p>
  71. <span class="text-[10px] text-muted-foreground">(@ {{ order.material_price || "0.00" }})</span>
  72. </div>
  73. </div>
  74. <div class="text-right">
  75. <span class="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1 block">Quantity</span>
  76. <div class="flex items-center justify-end gap-1.5 px-2 py-1 bg-primary/10 rounded-lg text-primary font-bold">
  77. <Hash class="w-3 h-3" /><span class="text-sm">{{ order.quantity || 1 }}</span>
  78. </div>
  79. </div>
  80. </div>
  81. <div>
  82. <span class="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1 block">Shipping Address</span>
  83. <p class="text-xs text-muted-foreground line-clamp-2">{{ order.shipping_address }}</p>
  84. </div>
  85. <div v-if="order.notes" class="p-3 bg-background/50 border border-border/50 rounded-xl">
  86. <span class="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1 block">Project Notes</span>
  87. <p class="text-[11px] text-muted-foreground italic">"{{ order.notes }}"</p>
  88. </div>
  89. <div class="flex items-center gap-2">
  90. <ShieldCheck :class="`w-4 h-4 ${order.allow_portfolio ? 'text-emerald-500' : 'text-muted-foreground/30'}`" />
  91. <span class="text-xs">{{ order.allow_portfolio ? "Portfolio Allowed" : "No Portfolio" }}</span>
  92. </div>
  93. </div>
  94. <!-- Resources -->
  95. <div class="p-6 lg:w-1/4 space-y-6">
  96. <div class="flex items-center justify-between mb-3">
  97. <span class="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">Source Files ({{ order.files?.length || 0 }})</span>
  98. <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">
  99. <Plus class="w-3 h-3" />
  100. <input type="file" class="hidden" accept=".stl,.obj" @change="e => handleAttachFile(order.id, (e.target as HTMLInputElement).files?.[0])" />
  101. </label>
  102. </div>
  103. <!-- Model Link (if provided) -->
  104. <div v-if="order.model_link" class="mb-4 p-3 bg-blue-500/5 border border-blue-500/20 rounded-xl">
  105. <span class="text-[10px] font-bold uppercase tracking-widest text-blue-500/60 mb-1 block">External Model Link</span>
  106. <div class="flex items-center justify-between gap-2 overflow-hidden">
  107. <p class="text-[10px] text-muted-foreground truncate">{{ order.model_link }}</p>
  108. <a :href="order.model_link" target="_blank" class="text-blue-500 hover:underline"><ExternalLink class="w-3.5 h-3.5" /></a>
  109. </div>
  110. </div>
  111. <div class="grid gap-3">
  112. <div v-for="(f, i) in order.files" :key="i" class="relative group/file bg-background/30 border border-border/50 rounded-2xl overflow-hidden hover:border-primary/30 transition-all flex h-20">
  113. <!-- Preview -->
  114. <div class="w-20 bg-muted/20 flex items-center justify-center border-r border-border/50 overflow-hidden">
  115. <img v-if="f.preview_path" :src="`http://localhost:8000/${f.preview_path}`" class="w-full h-full object-contain p-1" />
  116. <FileBox v-else class="w-6 h-6 text-muted-foreground/30" />
  117. <div class="absolute inset-0 bg-primary/20 opacity-0 group-hover/file:opacity-100 transition-opacity flex items-center justify-center">
  118. <a :href="`http://localhost:8000/${f.file_path}`" target="_blank" class="bg-card w-8 h-8 rounded-full flex items-center justify-center shadow-lg"><Download class="w-4 h-4 text-primary" /></a>
  119. </div>
  120. </div>
  121. <!-- Info -->
  122. <div class="flex-1 p-3 flex flex-col justify-center min-w-0">
  123. <p class="text-[11px] font-bold truncate mb-1 pr-4">{{ f.filename }}</p>
  124. <div class="flex flex-wrap gap-2 items-center">
  125. <span class="text-[9px] text-muted-foreground uppercase opacity-60">{{ (f.file_size / 1024 / 1024).toFixed(1) }} MB</span>
  126. <div v-if="f.print_time || f.filament_g" class="flex gap-2 border-l border-border/50 pl-2">
  127. <span v-if="f.print_time" class="text-[9px] font-bold text-primary/80">⏱️ {{ f.print_time }}</span>
  128. <span v-if="f.filament_g" class="text-[9px] font-bold text-primary/80">⚖️ {{ f.filament_g.toFixed(1) }}g</span>
  129. </div>
  130. </div>
  131. </div>
  132. <!-- Quantity Badge -->
  133. <div class="absolute top-2 right-2 px-1.5 py-0.5 bg-background/80 backdrop-blur-md rounded-md border border-border/50 text-[10px] font-bold">x{{ f.quantity || 1 }}</div>
  134. </div>
  135. </div>
  136. <div class="pt-4 border-t border-border/50">
  137. <div class="flex items-center justify-between mb-3">
  138. <span class="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">Photo Report ({{ order.photos?.length || 0 }})</span>
  139. <label class="p-1.5 bg-primary/10 text-primary rounded-lg cursor-pointer hover:bg-primary hover:text-primary-foreground transition-all">
  140. <Plus class="w-3 h-3" />
  141. <input type="file" class="hidden" accept="image/*" @change="e => handleUploadPhoto(order.id, (e.target as HTMLInputElement).files?.[0])" />
  142. </label>
  143. </div>
  144. <div class="flex flex-wrap gap-2">
  145. <div v-for="(p, i) in order.photos" :key="i" class="relative group/img overflow-hidden rounded-lg border border-border/50 w-12 h-12 bg-background/50">
  146. <img :src="`http://localhost:8000/${p.file_path}`" class="w-full h-full object-cover" />
  147. <button @click="handleTogglePhotoPublic(p.id, p.is_public, order.allow_portfolio)"
  148. :class="`absolute top-0 right-0 p-0.5 rounded-bl-md bg-black/60 z-10 transition-colors ${p.is_public ? 'text-blue-400 hover:text-blue-300' : 'text-gray-400 hover:text-white'}`">
  149. <Eye v-if="p.is_public" class="w-2.5 h-2.5" /><EyeOff v-else class="w-2.5 h-2.5" />
  150. </button>
  151. <a :href="`http://localhost:8000/${p.file_path}`" target="_blank" class="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 group-hover/img:opacity-100 transition-opacity">
  152. <ExternalLink class="w-4 h-4 text-white" />
  153. </a>
  154. </div>
  155. <div v-if="!order.photos?.length" class="w-full py-4 border border-dashed border-border/50 rounded-xl flex flex-col items-center justify-center opacity-40">
  156. <ImageIcon class="w-4 h-4 mb-1" /><span class="text-[10px]">No photos yet</span>
  157. </div>
  158. </div>
  159. </div>
  160. </div>
  161. <!-- Pricing & Actions -->
  162. <div class="p-6 lg:w-1/4 bg-primary/5">
  163. <div class="grid grid-cols-2 gap-2 mb-6">
  164. <button v-for="s in Object.keys(STATUS_CONFIG)" :key="s"
  165. @click="handleUpdateStatus(order.id, s)"
  166. :class="`text-[9px] font-bold uppercase py-1.5 rounded-lg border transition-all ${order.status === s ? 'bg-primary text-primary-foreground border-primary' : 'bg-background hover:border-primary/30 border-border/50'}`">
  167. {{ s }}
  168. </button>
  169. </div>
  170. <div class="pt-4 border-t border-border/50">
  171. <div class="flex justify-between items-center mb-1">
  172. <span class="text-[10px] font-bold text-muted-foreground uppercase">Estimated</span>
  173. <span class="font-bold text-sm text-primary/80">{{ order.estimated_price }} EUR</span>
  174. </div>
  175. <div class="flex justify-between items-center">
  176. <span class="text-[10px] font-bold text-muted-foreground uppercase tracking-widest">Final Price</span>
  177. <button @click="editingPrice = { id: order.id, price: order.total_price?.toString() ?? '' }" class="text-[10px] text-primary hover:underline font-bold">Edit</button>
  178. </div>
  179. <div class="text-2xl font-display font-bold">{{ order.total_price || "---" }} <span class="text-xs">EUR</span></div>
  180. <button @click="toggleAdminChat(order.id)" class="mt-4 w-full flex items-center justify-center gap-2 py-2 rounded-xl border border-border/50 hover:border-primary/30 text-sm font-bold transition-all"
  181. :class="adminChatId === order.id ? 'bg-primary text-primary-foreground' : 'bg-background/50 text-muted-foreground hover:text-foreground'">
  182. <MessageCircle class="w-4 h-4" />{{ t("chat.open") }}
  183. </button>
  184. </div>
  185. </div>
  186. </div>
  187. <!-- Admin Chat Panel -->
  188. <div v-if="adminChatId === order.id" class="border-t border-border/50">
  189. <OrderChat :orderId="order.id" @close="adminChatId = null" closable />
  190. </div>
  191. </div>
  192. </div>
  193. <!-- MATERIALS -->
  194. <div v-else-if="activeTab === 'materials'" class="grid gap-4">
  195. <div v-for="m in materials" :key="m.id"
  196. class="p-6 bg-card/40 border border-border/50 rounded-2xl flex items-center justify-between group hover:border-primary/30 transition-all">
  197. <div class="flex items-center gap-4">
  198. <div :class="`p-3 rounded-xl bg-primary/10 text-primary ${!m.is_active && 'opacity-30 grayscale'}`"><Layers class="w-6 h-6" /></div>
  199. <div>
  200. <div class="flex items-center gap-2">
  201. <h4 class="font-bold">{{ m.name_en }} / {{ m.name_ru }}</h4>
  202. </div>
  203. <p class="text-xs text-muted-foreground truncate max-w-md">{{ m.desc_en }}</p>
  204. </div>
  205. </div>
  206. <div class="flex items-center gap-8">
  207. <div class="text-right">
  208. <p class="text-[10px] font-bold text-muted-foreground uppercase">Price / cm³</p>
  209. <p class="font-display font-bold text-lg">{{ m.price_per_cm3 }} EUR</p>
  210. </div>
  211. <div class="flex items-center gap-2">
  212. <button @click="editingMaterial = { ...m }" class="p-2 hover:bg-white/5 rounded-lg text-muted-foreground hover:text-primary transition-colors"><Edit2 class="w-4 h-4" /></button>
  213. <button @click="toggleMaterialActive(m)" :class="`p-2 rounded-lg transition-colors ${m.is_active ? 'text-emerald-500 hover:bg-emerald-500/10' : 'text-rose-500 hover:bg-rose-500/10'}`">
  214. <ToggleRight v-if="m.is_active" class="w-6 h-6" /><ToggleLeft v-else class="w-6 h-6" />
  215. </button>
  216. <button @click="handleDeleteMaterial(m.id, m.name_en)" class="p-2 hover:bg-rose-500/10 rounded-lg text-muted-foreground hover:text-rose-500 transition-colors"><Trash2 class="w-4 h-4" /></button>
  217. </div>
  218. </div>
  219. </div>
  220. </div>
  221. <!-- SERVICES -->
  222. <div v-else-if="activeTab === 'services'" class="grid gap-4">
  223. <div v-for="s in services" :key="s.id"
  224. class="p-6 bg-card/40 border border-border/50 rounded-2xl flex items-center justify-between group hover:border-primary/30 transition-all">
  225. <div class="flex items-center gap-4">
  226. <div :class="`p-3 rounded-xl bg-blue-500/10 text-blue-500 ${!s.is_active && 'opacity-30 grayscale'}`"><Database class="w-6 h-6" /></div>
  227. <div>
  228. <div class="flex items-center gap-2">
  229. <h4 class="font-bold">{{ t(s.name_key) }}</h4>
  230. <span class="text-[10px] px-2 py-0.5 rounded-full bg-white/5 text-muted-foreground">{{ s.tech_type }}</span>
  231. </div>
  232. <p class="text-xs text-muted-foreground truncate max-w-md">{{ t(s.description_key) }}</p>
  233. </div>
  234. </div>
  235. <div class="flex items-center gap-2">
  236. <button @click="editingService = { ...s }" class="p-2 hover:bg-white/5 rounded-lg text-muted-foreground hover:text-primary transition-colors"><Edit2 class="w-4 h-4" /></button>
  237. <button @click="toggleServiceActive(s)" :class="`p-2 rounded-lg transition-colors ${s.is_active ? 'text-emerald-500 hover:bg-emerald-500/10' : 'text-rose-500 hover:bg-rose-500/10'}`">
  238. <ToggleRight v-if="s.is_active" class="w-6 h-6" /><ToggleLeft v-else class="w-6 h-6" />
  239. </button>
  240. <button @click="handleDeleteService(s.id, t(s.name_key))" class="p-2 hover:bg-rose-500/10 rounded-lg text-muted-foreground hover:text-rose-500 transition-colors"><Trash2 class="w-4 h-4" /></button>
  241. </div>
  242. </div>
  243. </div>
  244. </main>
  245. <!-- ——— MODALS ——— -->
  246. <Teleport to="body">
  247. <!-- Price Modal -->
  248. <Transition enter-active-class="transition duration-150" enter-from-class="opacity-0" enter-to-class="opacity-100" leave-active-class="transition duration-100" leave-from-class="opacity-100" leave-to-class="opacity-0">
  249. <div v-if="editingPrice" class="fixed inset-0 z-[100] flex items-center justify-center p-4">
  250. <div class="absolute inset-0 bg-background/80 backdrop-blur-sm" @click="editingPrice = null" />
  251. <div class="relative w-full max-w-sm bg-card border border-border/50 rounded-3xl p-8 shadow-2xl">
  252. <h3 class="text-xl font-bold font-display mb-6">Update Final Price</h3>
  253. <div class="space-y-4">
  254. <input v-model="editingPrice.price" type="number" step="0.01"
  255. class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 font-bold" />
  256. <div class="flex gap-3">
  257. <Button variant="ghost" class="flex-1" @click="editingPrice = null">Cancel</Button>
  258. <Button variant="hero" class="flex-1" @click="handleUpdatePrice">Save Price</Button>
  259. </div>
  260. </div>
  261. </div>
  262. </div>
  263. </Transition>
  264. <!-- Material Modal -->
  265. <Transition enter-active-class="transition duration-150" enter-from-class="opacity-0" enter-to-class="opacity-100" leave-active-class="transition duration-100" leave-from-class="opacity-100" leave-to-class="opacity-0">
  266. <div v-if="editingMaterial || (showAddModal && activeTab === 'materials')" class="fixed inset-0 z-[100] flex items-center justify-center p-4">
  267. <div class="absolute inset-0 bg-background/80 backdrop-blur-sm" @click="closeModals" />
  268. <div class="relative w-full max-w-2xl bg-card border border-border/50 rounded-3xl p-8 shadow-2xl max-h-[90vh] overflow-y-auto">
  269. <h3 class="text-xl font-bold font-display mb-6">{{ editingMaterial?.id ? "Edit Material" : "Add New Material" }}</h3>
  270. <form @submit.prevent="handleSaveMaterial" class="space-y-6">
  271. <!-- Names -->
  272. <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
  273. <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">Name (EN)</label><input v-model="matForm.name_en" required placeholder="PLA" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3" /></div>
  274. <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">Name (RU)</label><input v-model="matForm.name_ru" required placeholder="ПЛА" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3" /></div>
  275. <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">Name (ME)</label><input v-model="matForm.name_me" required placeholder="PLA" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3" /></div>
  276. </div>
  277. <!-- Price & Status -->
  278. <div class="grid grid-cols-2 gap-4">
  279. <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">Price per cm³ (EUR)</label><input v-model.number="matForm.price_per_cm3" type="number" step="0.001" required placeholder="0.05" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3" /></div>
  280. <div class="flex items-center gap-2 pt-6"><input v-model="matForm.is_active" type="checkbox" id="mat_active" class="w-5 h-5 rounded border-border" /><label for="mat_active" class="text-sm font-bold">Active and Visible</label></div>
  281. </div>
  282. <!-- Descriptions -->
  283. <div class="space-y-4">
  284. <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">Description (EN)</label><textarea v-model="matForm.desc_en" required class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 min-h-[60px]" /></div>
  285. <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">Description (RU)</label><textarea v-model="matForm.desc_ru" required class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 min-h-[60px]" /></div>
  286. <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">Description (ME)</label><textarea v-model="matForm.desc_me" required class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 min-h-[60px]" /></div>
  287. </div>
  288. <div class="flex gap-3 pt-4"><Button type="button" variant="ghost" class="flex-1" @click="closeModals">Cancel</Button><Button type="submit" variant="hero" class="flex-1">Save Changes</Button></div>
  289. </form>
  290. </div>
  291. </div>
  292. </Transition>
  293. <!-- Service Modal -->
  294. <Transition enter-active-class="transition duration-150" enter-from-class="opacity-0" enter-to-class="opacity-100" leave-active-class="transition duration-100" leave-from-class="opacity-100" leave-to-class="opacity-0">
  295. <div v-if="editingService || (showAddModal && activeTab === 'services')" class="fixed inset-0 z-[100] flex items-center justify-center p-4">
  296. <div class="absolute inset-0 bg-background/80 backdrop-blur-sm" @click="closeModals" />
  297. <div class="relative w-full max-w-lg bg-card border border-border/50 rounded-3xl p-8 shadow-2xl">
  298. <h3 class="text-xl font-bold font-display mb-6">{{ editingService?.id ? "Edit Service" : "Add New Service" }}</h3>
  299. <form @submit.prevent="handleSaveService" class="space-y-4">
  300. <div class="grid grid-cols-2 gap-4">
  301. <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">Name Key (i18n)</label><input v-model="svcForm.name_key" required placeholder="svc_fused_layer" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3" /></div>
  302. <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">Tech Type</label><input v-model="svcForm.tech_type" placeholder="FDM" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3" /></div>
  303. </div>
  304. <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">Description Key</label><textarea v-model="svcForm.description_key" placeholder="svc_fdm_desc" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 min-h-[120px] resize-y" /></div>
  305. <div class="flex items-center gap-2 py-2"><input v-model="svcForm.is_active" type="checkbox" id="srv_active" class="w-5 h-5 rounded border-border" /><label for="srv_active" class="text-sm font-bold">Active and Visible</label></div>
  306. <div class="flex gap-3 pt-4"><Button type="button" variant="ghost" class="flex-1" @click="closeModals">Cancel</Button><Button type="submit" variant="hero" class="flex-1">Save Changes</Button></div>
  307. </form>
  308. </div>
  309. </div>
  310. </Transition>
  311. </Teleport>
  312. <Footer />
  313. </div>
  314. </template>
  315. <script setup lang="ts">
  316. import { ref, computed, watch, reactive, onMounted } from "vue";
  317. import { RouterLink, useRouter } from "vue-router";
  318. import { useI18n } from "vue-i18n";
  319. import { toast } from "vue-sonner";
  320. import { Package, Clock, CheckCircle2, Truck, XCircle, AlertCircle, FileText, ExternalLink, ShieldCheck, Eye, RefreshCw, Search, Filter, Layers, Settings, Plus, Edit2, Trash2, ToggleLeft, ToggleRight, Database, Image as ImageIcon, EyeOff, Hash, MessageCircle, FileBox, Download } from "lucide-vue-next";
  321. import Button from "@/components/ui/button.vue";
  322. import Header from "@/components/Header.vue";
  323. import Footer from "@/components/Footer.vue";
  324. import OrderChat from "@/components/OrderChat.vue";
  325. import { useAuthStore } from "@/stores/auth";
  326. import { adminGetOrders, adminUpdateOrder, adminGetMaterials, adminCreateMaterial, adminUpdateMaterial, adminDeleteMaterial, adminGetServices, adminCreateService, adminUpdateService, adminDeleteService, adminUploadOrderPhoto, adminUpdatePhotoStatus, adminAttachFile } from "@/lib/api";
  327. const { t } = useI18n();
  328. const router = useRouter();
  329. const authStore = useAuthStore();
  330. const STATUS_CONFIG: Record<string, { color: string; icon: any }> = {
  331. pending: { color: "text-amber-500 bg-amber-500/10", icon: Clock },
  332. processing: { color: "text-blue-500 bg-blue-500/10", icon: RefreshCw },
  333. shipped: { color: "text-purple-500 bg-purple-500/10", icon: Truck },
  334. completed: { color: "text-emerald-500 bg-emerald-500/10", icon: CheckCircle2 },
  335. cancelled: { color: "text-rose-500 bg-rose-500/10", icon: XCircle },
  336. };
  337. const statusColor = (s: string) => STATUS_CONFIG[s]?.color ?? "bg-muted text-muted-foreground";
  338. const statusIcon = (s: string) => STATUS_CONFIG[s]?.icon ?? AlertCircle;
  339. const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
  340. const adminChatId = ref<number | null>(null);
  341. function toggleAdminChat(orderId: number) {
  342. adminChatId.value = adminChatId.value === orderId ? null : orderId;
  343. }
  344. const tabs: { id: Tab; label: string; icon: any }[] = [
  345. { id: "orders", label: "Orders", icon: Package },
  346. { id: "materials", label: "Materials", icon: Layers },
  347. { id: "services", label: "Services", icon: Database },
  348. ];
  349. type Tab = "orders" | "materials" | "services";
  350. const activeTab = ref<Tab>("orders");
  351. const orders = ref<any[]>([]);
  352. const materials = ref<any[]>([]);
  353. const services = ref<any[]>([]);
  354. const isLoading = ref(true);
  355. const searchQuery = ref("");
  356. const statusFilter = ref("all");
  357. const editingPrice = ref<{ id: number; price: string } | null>(null);
  358. const editingMaterial = ref<any | null>(null);
  359. const editingService = ref<any | null>(null);
  360. const showAddModal = ref(false);
  361. const matForm = reactive({ name_en: "", name_ru: "", name_me: "", desc_en: "", desc_ru: "", desc_me: "", price_per_cm3: 0, is_active: true });
  362. const svcForm = reactive({ name_key: "", description_key: "", tech_type: "", is_active: true });
  363. const filteredOrders = computed(() => orders.value.filter(o => {
  364. const qs = searchQuery.value.toLowerCase();
  365. const matchSearch = o.email?.toLowerCase().includes(qs) || o.first_name?.toLowerCase().includes(qs) || o.last_name?.toLowerCase().includes(qs) || String(o.id).includes(qs);
  366. const matchStatus = statusFilter.value === "all" || o.status === statusFilter.value;
  367. return matchSearch && matchStatus;
  368. }));
  369. async function fetchData() {
  370. isLoading.value = true;
  371. try {
  372. if (activeTab.value === "orders") orders.value = await adminGetOrders();
  373. else if (activeTab.value === "materials") materials.value = await adminGetMaterials();
  374. else if (activeTab.value === "services") services.value = await adminGetServices();
  375. } catch { toast.error(`Failed to load ${activeTab.value}`); }
  376. finally { isLoading.value = false; }
  377. }
  378. watch(activeTab, fetchData, { immediate: false });
  379. onMounted(async () => {
  380. if (!authStore.user || authStore.user.role !== "admin") { router.push("/auth"); return; }
  381. fetchData();
  382. });
  383. async function handleUpdateStatus(id: number, status: string) {
  384. try { await adminUpdateOrder(id, { status }); toast.success(`Status → ${status}`); fetchData(); }
  385. catch { toast.error("Failed to update status"); }
  386. }
  387. async function handleUpdatePrice() {
  388. if (!editingPrice.value) return;
  389. const p = parseFloat(editingPrice.value.price);
  390. if (isNaN(p)) { toast.error("Invalid price"); return; }
  391. try { await adminUpdateOrder(editingPrice.value.id, { total_price: p }); toast.success("Price updated"); editingPrice.value = null; fetchData(); }
  392. catch { toast.error("Failed to update price"); }
  393. }
  394. async function handleTogglePhotoPublic(photoId: number, current: boolean, allowed: boolean) {
  395. if (!allowed && !current) { toast.error("User did not consent to portfolio"); return; }
  396. try { await adminUpdatePhotoStatus(photoId, { is_public: !current }); toast.success(`Photo is now ${!current ? "Public" : "Private"}`); fetchData(); }
  397. catch (e: any) { toast.error(e.message); }
  398. }
  399. async function handleUploadPhoto(orderId: number, file?: File) {
  400. if (!file) return;
  401. const order = orders.value.find(o => o.id === orderId);
  402. const fd = new FormData(); fd.append("file", file); fd.append("is_public", order?.allow_portfolio ? "true" : "false");
  403. try { await adminUploadOrderPhoto(orderId, fd); toast.success("Photo added"); fetchData(); }
  404. catch (e: any) { toast.error(e.message); }
  405. }
  406. async function handleAttachFile(orderId: number, file?: File) {
  407. if (!file) return;
  408. const fd = new FormData(); fd.append("file", file);
  409. try { await adminAttachFile(orderId, fd); toast.success("File attached and preview generated"); fetchData(); }
  410. catch (e: any) { toast.error(e.message); }
  411. }
  412. async function handleDeleteMaterial(id: number, name: string) {
  413. if (!window.confirm(`Delete material "${name}"?`)) return;
  414. try { await adminDeleteMaterial(id); toast.success("Deleted"); fetchData(); }
  415. catch { toast.error("Failed to delete"); }
  416. }
  417. async function handleDeleteService(id: number, name: string) {
  418. if (!window.confirm(`Delete service "${name}"?`)) return;
  419. try { await adminDeleteService(id); toast.success("Deleted"); fetchData(); }
  420. catch { toast.error("Failed to delete"); }
  421. }
  422. async function toggleMaterialActive(m: any) { await adminUpdateMaterial(m.id, { is_active: !m.is_active }); fetchData(); }
  423. async function toggleServiceActive(s: any) { await adminUpdateService(s.id, { is_active: !s.is_active }); fetchData(); }
  424. function openMaterialForm(m?: any) {
  425. if (m) { Object.assign(matForm, m); editingMaterial.value = m; }
  426. else { Object.assign(matForm, { name_en: "", name_ru: "", name_me: "", desc_en: "", desc_ru: "", desc_me: "", price_per_cm3: 0, is_active: true }); showAddModal.value = true; }
  427. }
  428. async function handleSaveMaterial() {
  429. try {
  430. if (editingMaterial.value?.id) { await adminUpdateMaterial(editingMaterial.value.id, { ...matForm }); toast.success("Material updated"); }
  431. else { await adminCreateMaterial({ ...matForm }); toast.success("Material created"); }
  432. closeModals(); fetchData();
  433. } catch { toast.error("Failed to save material"); }
  434. }
  435. async function handleSaveService() {
  436. try {
  437. if (editingService.value?.id) { await adminUpdateService(editingService.value.id, { ...svcForm }); toast.success("Service updated"); }
  438. else { await adminCreateService({ ...svcForm }); toast.success("Service created"); }
  439. closeModals(); fetchData();
  440. } catch { toast.error("Failed to save service"); }
  441. }
  442. function closeModals() { editingMaterial.value = null; editingService.value = null; showAddModal.value = false; editingPrice.value = null; }
  443. // Watch editing to sync form data
  444. watch(editingMaterial, m => { if (m) Object.assign(matForm, m); });
  445. watch(editingService, s => { if (s) Object.assign(svcForm, s); });
  446. </script>