| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473 |
- <template>
- <div v-if="authStore.isLoading" />
- <div v-else-if="!authStore.user || authStore.user.role !== 'admin'">
- <RouterLink to="/auth" /><!-- redirect handled in onMounted -->
- </div>
- <div v-else class="min-h-screen bg-background">
- <Header />
- <main class="container mx-auto px-4 pt-32 pb-20">
- <!-- Header -->
- <div class="flex flex-col md:flex-row md:items-end justify-between gap-6 mb-12">
- <div>
- <span class="text-primary font-display text-sm tracking-widest uppercase mb-2 block">Management Center</span>
- <h1 class="font-display text-4xl font-bold">Admin <span class="text-gradient">Dashboard</span></h1>
- </div>
- <div class="flex bg-card/40 backdrop-blur-md border border-border/50 rounded-2xl p-1 gap-1">
- <button v-for="tab in tabs" :key="tab.id" @click="activeTab = tab.id" :class="[
- 'flex items-center gap-2 px-6 py-2.5 rounded-xl text-sm font-bold transition-all',
- activeTab === tab.id ? 'bg-primary text-primary-foreground shadow-glow' : 'text-muted-foreground hover:bg-white/5 hover:text-foreground'
- ]">
- <component :is="tab.icon" class="w-4 h-4" />{{ tab.label }}
- </button>
- </div>
- </div>
- <!-- Search & actions -->
- <div class="flex flex-col sm:flex-row gap-4 mb-8">
- <div class="relative flex-1">
- <Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
- <input type="text" v-model="searchQuery" :placeholder="`Search ${activeTab}...`"
- 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" />
- </div>
- <select v-if="activeTab === 'orders'" v-model="statusFilter"
- 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]">
- <option value="all">All Statuses</option>
- <option v-for="s in Object.keys(STATUS_CONFIG)" :key="s" :value="s">{{ capitalize(s) }}</option>
- </select>
- <Button v-if="activeTab !== 'orders'" variant="hero" class="gap-2 sm:px-8" @click="showAddModal = true">
- <Plus class="w-4 h-4" />Add New
- </Button>
- </div>
- <!-- Loading -->
- <div v-if="isLoading" class="flex items-center justify-center py-20">
- <RefreshCw class="w-8 h-8 text-primary animate-spin" />
- </div>
- <!-- ORDERS -->
- <div v-else-if="activeTab === 'orders'" class="grid gap-6">
- <div v-for="order in filteredOrders" :key="order.id"
- 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">
- <div class="flex flex-col lg:flex-row divide-y lg:divide-y-0 lg:divide-x divide-border/50">
- <!-- Info -->
- <div class="p-6 lg:w-1/4">
- <div class="flex items-center justify-between mb-4">
- <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>
- <span :class="`flex items-center gap-1.5 px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider ${statusColor(order.status)}`">
- <component :is="statusIcon(order.status)" class="w-3.5 h-3.5" />{{ order.status }}
- </span>
- </div>
- <div class="space-y-1">
- <h3 class="font-bold">{{ order.first_name }} {{ order.last_name }}</h3>
- <p class="text-sm text-muted-foreground truncate">{{ order.email }}</p>
- </div>
- <div class="mt-4 pt-4 border-t border-border/50 text-xs text-muted-foreground">{{ new Date(order.created_at).toLocaleString() }}</div>
- </div>
- <!-- Details -->
- <div class="p-6 lg:w-1/4 space-y-4">
- <div class="flex justify-between items-start">
- <div>
- <span class="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1 block">Selected Material</span>
- <div class="flex items-center gap-2">
- <Layers class="w-3.5 h-3.5 text-primary" />
- <p class="text-sm font-bold uppercase">{{ order.material_name || "unknown" }}</p>
- <span class="text-[10px] text-muted-foreground">(@ {{ order.material_price || "0.00" }})</span>
- </div>
- </div>
- <div class="text-right">
- <span class="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1 block">Quantity</span>
- <div class="flex items-center justify-end gap-1.5 px-2 py-1 bg-primary/10 rounded-lg text-primary font-bold">
- <Hash class="w-3 h-3" /><span class="text-sm">{{ order.quantity || 1 }}</span>
- </div>
- </div>
- </div>
- <div>
- <span class="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1 block">Shipping Address</span>
- <p class="text-xs text-muted-foreground line-clamp-2">{{ order.shipping_address }}</p>
- </div>
- <div v-if="order.notes" class="p-3 bg-background/50 border border-border/50 rounded-xl">
- <span class="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1 block">Project Notes</span>
- <p class="text-[11px] text-muted-foreground italic">"{{ order.notes }}"</p>
- </div>
- <div class="flex items-center gap-2">
- <ShieldCheck :class="`w-4 h-4 ${order.allow_portfolio ? 'text-emerald-500' : 'text-muted-foreground/30'}`" />
- <span class="text-xs">{{ order.allow_portfolio ? "Portfolio Allowed" : "No Portfolio" }}</span>
- </div>
- </div>
- <!-- Resources -->
- <div class="p-6 lg:w-1/4 space-y-6">
- <div class="flex items-center justify-between mb-3">
- <span class="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">Source Files ({{ order.files?.length || 0 }})</span>
- <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">
- <Plus class="w-3 h-3" />
- <input type="file" class="hidden" accept=".stl,.obj" @change="e => handleAttachFile(order.id, (e.target as HTMLInputElement).files?.[0])" />
- </label>
- </div>
- <!-- Model Link (if provided) -->
- <div v-if="order.model_link" class="mb-4 p-3 bg-blue-500/5 border border-blue-500/20 rounded-xl">
- <span class="text-[10px] font-bold uppercase tracking-widest text-blue-500/60 mb-1 block">External Model Link</span>
- <div class="flex items-center justify-between gap-2 overflow-hidden">
- <p class="text-[10px] text-muted-foreground truncate">{{ order.model_link }}</p>
- <a :href="order.model_link" target="_blank" class="text-blue-500 hover:underline"><ExternalLink class="w-3.5 h-3.5" /></a>
- </div>
- </div>
- <div class="grid gap-3">
- <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">
- <!-- Preview -->
- <div class="w-20 bg-muted/20 flex items-center justify-center border-r border-border/50 overflow-hidden">
- <img v-if="f.preview_path" :src="`http://localhost:8000/${f.preview_path}`" class="w-full h-full object-contain p-1" />
- <FileBox v-else class="w-6 h-6 text-muted-foreground/30" />
- <div class="absolute inset-0 bg-primary/20 opacity-0 group-hover/file:opacity-100 transition-opacity flex items-center justify-center">
- <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>
- </div>
- </div>
- <!-- Info -->
- <div class="flex-1 p-3 flex flex-col justify-center min-w-0">
- <p class="text-[11px] font-bold truncate mb-1 pr-4">{{ f.filename }}</p>
- <div class="flex flex-wrap gap-2 items-center">
- <span class="text-[9px] text-muted-foreground uppercase opacity-60">{{ (f.file_size / 1024 / 1024).toFixed(1) }} MB</span>
- <div v-if="f.print_time || f.filament_g" class="flex gap-2 border-l border-border/50 pl-2">
- <span v-if="f.print_time" class="text-[9px] font-bold text-primary/80">⏱️ {{ f.print_time }}</span>
- <span v-if="f.filament_g" class="text-[9px] font-bold text-primary/80">⚖️ {{ f.filament_g.toFixed(1) }}g</span>
- </div>
- </div>
- </div>
- <!-- Quantity Badge -->
- <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>
- </div>
- </div>
- <div class="pt-4 border-t border-border/50">
- <div class="flex items-center justify-between mb-3">
- <span class="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">Photo Report ({{ order.photos?.length || 0 }})</span>
- <label class="p-1.5 bg-primary/10 text-primary rounded-lg cursor-pointer hover:bg-primary hover:text-primary-foreground transition-all">
- <Plus class="w-3 h-3" />
- <input type="file" class="hidden" accept="image/*" @change="e => handleUploadPhoto(order.id, (e.target as HTMLInputElement).files?.[0])" />
- </label>
- </div>
- <div class="flex flex-wrap gap-2">
- <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">
- <img :src="`http://localhost:8000/${p.file_path}`" class="w-full h-full object-cover" />
- <button @click="handleTogglePhotoPublic(p.id, p.is_public, order.allow_portfolio)"
- :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'}`">
- <Eye v-if="p.is_public" class="w-2.5 h-2.5" /><EyeOff v-else class="w-2.5 h-2.5" />
- </button>
- <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">
- <ExternalLink class="w-4 h-4 text-white" />
- </a>
- </div>
- <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">
- <ImageIcon class="w-4 h-4 mb-1" /><span class="text-[10px]">No photos yet</span>
- </div>
- </div>
- </div>
- </div>
- <!-- Pricing & Actions -->
- <div class="p-6 lg:w-1/4 bg-primary/5">
- <div class="grid grid-cols-2 gap-2 mb-6">
- <button v-for="s in Object.keys(STATUS_CONFIG)" :key="s"
- @click="handleUpdateStatus(order.id, s)"
- :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'}`">
- {{ s }}
- </button>
- </div>
- <div class="pt-4 border-t border-border/50">
- <div class="flex justify-between items-center mb-1">
- <span class="text-[10px] font-bold text-muted-foreground uppercase">Estimated</span>
- <span class="font-bold text-sm text-primary/80">{{ order.estimated_price }} EUR</span>
- </div>
- <div class="flex justify-between items-center">
- <span class="text-[10px] font-bold text-muted-foreground uppercase tracking-widest">Final Price</span>
- <button @click="editingPrice = { id: order.id, price: order.total_price?.toString() ?? '' }" class="text-[10px] text-primary hover:underline font-bold">Edit</button>
- </div>
- <div class="text-2xl font-display font-bold">{{ order.total_price || "---" }} <span class="text-xs">EUR</span></div>
- <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"
- :class="adminChatId === order.id ? 'bg-primary text-primary-foreground' : 'bg-background/50 text-muted-foreground hover:text-foreground'">
- <MessageCircle class="w-4 h-4" />{{ t("chat.open") }}
- </button>
- </div>
- </div>
- </div>
- <!-- Admin Chat Panel -->
- <div v-if="adminChatId === order.id" class="border-t border-border/50">
- <OrderChat :orderId="order.id" @close="adminChatId = null" closable />
- </div>
- </div>
- </div>
- <!-- MATERIALS -->
- <div v-else-if="activeTab === 'materials'" class="grid gap-4">
- <div v-for="m in materials" :key="m.id"
- class="p-6 bg-card/40 border border-border/50 rounded-2xl flex items-center justify-between group hover:border-primary/30 transition-all">
- <div class="flex items-center gap-4">
- <div :class="`p-3 rounded-xl bg-primary/10 text-primary ${!m.is_active && 'opacity-30 grayscale'}`"><Layers class="w-6 h-6" /></div>
- <div>
- <div class="flex items-center gap-2">
- <h4 class="font-bold">{{ m.name_en }} / {{ m.name_ru }}</h4>
- </div>
- <p class="text-xs text-muted-foreground truncate max-w-md">{{ m.desc_en }}</p>
- </div>
- </div>
- <div class="flex items-center gap-8">
- <div class="text-right">
- <p class="text-[10px] font-bold text-muted-foreground uppercase">Price / cm³</p>
- <p class="font-display font-bold text-lg">{{ m.price_per_cm3 }} EUR</p>
- </div>
- <div class="flex items-center gap-2">
- <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>
- <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'}`">
- <ToggleRight v-if="m.is_active" class="w-6 h-6" /><ToggleLeft v-else class="w-6 h-6" />
- </button>
- <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>
- </div>
- </div>
- </div>
- </div>
- <!-- SERVICES -->
- <div v-else-if="activeTab === 'services'" class="grid gap-4">
- <div v-for="s in services" :key="s.id"
- class="p-6 bg-card/40 border border-border/50 rounded-2xl flex items-center justify-between group hover:border-primary/30 transition-all">
- <div class="flex items-center gap-4">
- <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>
- <div>
- <div class="flex items-center gap-2">
- <h4 class="font-bold">{{ t(s.name_key) }}</h4>
- <span class="text-[10px] px-2 py-0.5 rounded-full bg-white/5 text-muted-foreground">{{ s.tech_type }}</span>
- </div>
- <p class="text-xs text-muted-foreground truncate max-w-md">{{ t(s.description_key) }}</p>
- </div>
- </div>
- <div class="flex items-center gap-2">
- <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>
- <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'}`">
- <ToggleRight v-if="s.is_active" class="w-6 h-6" /><ToggleLeft v-else class="w-6 h-6" />
- </button>
- <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>
- </div>
- </div>
- </div>
- </main>
- <!-- ——— MODALS ——— -->
- <Teleport to="body">
- <!-- Price Modal -->
- <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">
- <div v-if="editingPrice" class="fixed inset-0 z-[100] flex items-center justify-center p-4">
- <div class="absolute inset-0 bg-background/80 backdrop-blur-sm" @click="editingPrice = null" />
- <div class="relative w-full max-w-sm bg-card border border-border/50 rounded-3xl p-8 shadow-2xl">
- <h3 class="text-xl font-bold font-display mb-6">Update Final Price</h3>
- <div class="space-y-4">
- <input v-model="editingPrice.price" type="number" step="0.01"
- class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 font-bold" />
- <div class="flex gap-3">
- <Button variant="ghost" class="flex-1" @click="editingPrice = null">Cancel</Button>
- <Button variant="hero" class="flex-1" @click="handleUpdatePrice">Save Price</Button>
- </div>
- </div>
- </div>
- </div>
- </Transition>
- <!-- Material Modal -->
- <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">
- <div v-if="editingMaterial || (showAddModal && activeTab === 'materials')" class="fixed inset-0 z-[100] flex items-center justify-center p-4">
- <div class="absolute inset-0 bg-background/80 backdrop-blur-sm" @click="closeModals" />
- <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">
- <h3 class="text-xl font-bold font-display mb-6">{{ editingMaterial?.id ? "Edit Material" : "Add New Material" }}</h3>
- <form @submit.prevent="handleSaveMaterial" class="space-y-6">
- <!-- Names -->
- <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
- <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>
- <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>
- <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>
- </div>
-
- <!-- Price & Status -->
- <div class="grid grid-cols-2 gap-4">
- <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>
- <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>
- </div>
- <!-- Descriptions -->
- <div class="space-y-4">
- <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>
- <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>
- <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>
- </div>
- <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>
- </form>
- </div>
- </div>
- </Transition>
- <!-- Service Modal -->
- <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">
- <div v-if="editingService || (showAddModal && activeTab === 'services')" class="fixed inset-0 z-[100] flex items-center justify-center p-4">
- <div class="absolute inset-0 bg-background/80 backdrop-blur-sm" @click="closeModals" />
- <div class="relative w-full max-w-lg bg-card border border-border/50 rounded-3xl p-8 shadow-2xl">
- <h3 class="text-xl font-bold font-display mb-6">{{ editingService?.id ? "Edit Service" : "Add New Service" }}</h3>
- <form @submit.prevent="handleSaveService" class="space-y-4">
- <div class="grid grid-cols-2 gap-4">
- <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>
- <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>
- </div>
- <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>
- <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>
- <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>
- </form>
- </div>
- </div>
- </Transition>
- </Teleport>
- <Footer />
- </div>
- </template>
- <script setup lang="ts">
- import { ref, computed, watch, reactive, onMounted } from "vue";
- import { RouterLink, useRouter } from "vue-router";
- import { useI18n } from "vue-i18n";
- import { toast } from "vue-sonner";
- 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";
- import Button from "@/components/ui/button.vue";
- import Header from "@/components/Header.vue";
- import Footer from "@/components/Footer.vue";
- import OrderChat from "@/components/OrderChat.vue";
- import { useAuthStore } from "@/stores/auth";
- import { adminGetOrders, adminUpdateOrder, adminGetMaterials, adminCreateMaterial, adminUpdateMaterial, adminDeleteMaterial, adminGetServices, adminCreateService, adminUpdateService, adminDeleteService, adminUploadOrderPhoto, adminUpdatePhotoStatus, adminAttachFile } from "@/lib/api";
- const { t } = useI18n();
- const router = useRouter();
- const authStore = useAuthStore();
- const STATUS_CONFIG: Record<string, { color: string; icon: any }> = {
- pending: { color: "text-amber-500 bg-amber-500/10", icon: Clock },
- processing: { color: "text-blue-500 bg-blue-500/10", icon: RefreshCw },
- shipped: { color: "text-purple-500 bg-purple-500/10", icon: Truck },
- completed: { color: "text-emerald-500 bg-emerald-500/10", icon: CheckCircle2 },
- cancelled: { color: "text-rose-500 bg-rose-500/10", icon: XCircle },
- };
- const statusColor = (s: string) => STATUS_CONFIG[s]?.color ?? "bg-muted text-muted-foreground";
- const statusIcon = (s: string) => STATUS_CONFIG[s]?.icon ?? AlertCircle;
- const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
- const adminChatId = ref<number | null>(null);
- function toggleAdminChat(orderId: number) {
- adminChatId.value = adminChatId.value === orderId ? null : orderId;
- }
- const tabs: { id: Tab; label: string; icon: any }[] = [
- { id: "orders", label: "Orders", icon: Package },
- { id: "materials", label: "Materials", icon: Layers },
- { id: "services", label: "Services", icon: Database },
- ];
- type Tab = "orders" | "materials" | "services";
- const activeTab = ref<Tab>("orders");
- const orders = ref<any[]>([]);
- const materials = ref<any[]>([]);
- const services = ref<any[]>([]);
- const isLoading = ref(true);
- const searchQuery = ref("");
- const statusFilter = ref("all");
- const editingPrice = ref<{ id: number; price: string } | null>(null);
- const editingMaterial = ref<any | null>(null);
- const editingService = ref<any | null>(null);
- const showAddModal = ref(false);
- const matForm = reactive({ name_en: "", name_ru: "", name_me: "", desc_en: "", desc_ru: "", desc_me: "", price_per_cm3: 0, is_active: true });
- const svcForm = reactive({ name_key: "", description_key: "", tech_type: "", is_active: true });
- const filteredOrders = computed(() => orders.value.filter(o => {
- const qs = searchQuery.value.toLowerCase();
- const matchSearch = o.email?.toLowerCase().includes(qs) || o.first_name?.toLowerCase().includes(qs) || o.last_name?.toLowerCase().includes(qs) || String(o.id).includes(qs);
- const matchStatus = statusFilter.value === "all" || o.status === statusFilter.value;
- return matchSearch && matchStatus;
- }));
- async function fetchData() {
- isLoading.value = true;
- try {
- if (activeTab.value === "orders") orders.value = await adminGetOrders();
- else if (activeTab.value === "materials") materials.value = await adminGetMaterials();
- else if (activeTab.value === "services") services.value = await adminGetServices();
- } catch { toast.error(`Failed to load ${activeTab.value}`); }
- finally { isLoading.value = false; }
- }
- watch(activeTab, fetchData, { immediate: false });
- onMounted(async () => {
- if (!authStore.user || authStore.user.role !== "admin") { router.push("/auth"); return; }
- fetchData();
- });
- async function handleUpdateStatus(id: number, status: string) {
- try { await adminUpdateOrder(id, { status }); toast.success(`Status → ${status}`); fetchData(); }
- catch { toast.error("Failed to update status"); }
- }
- async function handleUpdatePrice() {
- if (!editingPrice.value) return;
- const p = parseFloat(editingPrice.value.price);
- if (isNaN(p)) { toast.error("Invalid price"); return; }
- try { await adminUpdateOrder(editingPrice.value.id, { total_price: p }); toast.success("Price updated"); editingPrice.value = null; fetchData(); }
- catch { toast.error("Failed to update price"); }
- }
- async function handleTogglePhotoPublic(photoId: number, current: boolean, allowed: boolean) {
- if (!allowed && !current) { toast.error("User did not consent to portfolio"); return; }
- try { await adminUpdatePhotoStatus(photoId, { is_public: !current }); toast.success(`Photo is now ${!current ? "Public" : "Private"}`); fetchData(); }
- catch (e: any) { toast.error(e.message); }
- }
- async function handleUploadPhoto(orderId: number, file?: File) {
- if (!file) return;
- const order = orders.value.find(o => o.id === orderId);
- const fd = new FormData(); fd.append("file", file); fd.append("is_public", order?.allow_portfolio ? "true" : "false");
- try { await adminUploadOrderPhoto(orderId, fd); toast.success("Photo added"); fetchData(); }
- catch (e: any) { toast.error(e.message); }
- }
- async function handleAttachFile(orderId: number, file?: File) {
- if (!file) return;
- const fd = new FormData(); fd.append("file", file);
- try { await adminAttachFile(orderId, fd); toast.success("File attached and preview generated"); fetchData(); }
- catch (e: any) { toast.error(e.message); }
- }
- async function handleDeleteMaterial(id: number, name: string) {
- if (!window.confirm(`Delete material "${name}"?`)) return;
- try { await adminDeleteMaterial(id); toast.success("Deleted"); fetchData(); }
- catch { toast.error("Failed to delete"); }
- }
- async function handleDeleteService(id: number, name: string) {
- if (!window.confirm(`Delete service "${name}"?`)) return;
- try { await adminDeleteService(id); toast.success("Deleted"); fetchData(); }
- catch { toast.error("Failed to delete"); }
- }
- async function toggleMaterialActive(m: any) { await adminUpdateMaterial(m.id, { is_active: !m.is_active }); fetchData(); }
- async function toggleServiceActive(s: any) { await adminUpdateService(s.id, { is_active: !s.is_active }); fetchData(); }
- function openMaterialForm(m?: any) {
- if (m) { Object.assign(matForm, m); editingMaterial.value = m; }
- 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; }
- }
- async function handleSaveMaterial() {
- try {
- if (editingMaterial.value?.id) { await adminUpdateMaterial(editingMaterial.value.id, { ...matForm }); toast.success("Material updated"); }
- else { await adminCreateMaterial({ ...matForm }); toast.success("Material created"); }
- closeModals(); fetchData();
- } catch { toast.error("Failed to save material"); }
- }
- async function handleSaveService() {
- try {
- if (editingService.value?.id) { await adminUpdateService(editingService.value.id, { ...svcForm }); toast.success("Service updated"); }
- else { await adminCreateService({ ...svcForm }); toast.success("Service created"); }
- closeModals(); fetchData();
- } catch { toast.error("Failed to save service"); }
- }
- function closeModals() { editingMaterial.value = null; editingService.value = null; showAddModal.value = false; editingPrice.value = null; }
- // Watch editing to sync form data
- watch(editingMaterial, m => { if (m) Object.assign(matForm, m); });
- watch(editingService, s => { if (s) Object.assign(svcForm, s); });
- </script>
|