|
|
@@ -25,20 +25,39 @@
|
|
|
</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="t('admin.searchPlaceholder', { tab: 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 class="flex flex-col gap-4 mb-8">
|
|
|
+ <div class="flex flex-col sm:flex-row gap-4">
|
|
|
+ <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="t('admin.searchPlaceholder', { tab: 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>
|
|
|
+ <Button v-if="activeTab !== 'orders' && activeTab !== 'audit'" variant="hero" class="gap-2 sm:px-8" @click="handleAddNew">
|
|
|
+ <Plus class="w-4 h-4" />{{ t("admin.addNew") }}
|
|
|
+ </Button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- Filter bar for orders -->
|
|
|
+ <div v-if="activeTab === 'orders'" class="flex flex-wrap items-center gap-4 bg-card/30 p-4 rounded-2xl border border-border/50">
|
|
|
+ <div class="flex items-center gap-2">
|
|
|
+ <Filter class="w-4 h-4 text-muted-foreground" />
|
|
|
+ <span class="text-xs font-bold uppercase tracking-widest text-muted-foreground">{{ t("admin.filters") }}</span>
|
|
|
+ </div>
|
|
|
+ <select v-model="statusFilter"
|
|
|
+ class="bg-background border border-border/50 rounded-xl px-4 py-2 focus:outline-none focus:ring-2 focus:ring-primary/20 text-xs min-w-[120px]">
|
|
|
+ <option value="all">{{ t("admin.allStatuses") }}</option>
|
|
|
+ <option v-for="s in Object.keys(STATUS_CONFIG)" :key="s" :value="s">{{ t("statuses." + s) }}</option>
|
|
|
+ </select>
|
|
|
+ <div class="flex items-center gap-2 bg-background border border-border/50 rounded-xl px-3 py-1.5">
|
|
|
+ <span class="text-[10px] font-bold text-muted-foreground uppercase mr-1">{{ t("admin.from") }}</span>
|
|
|
+ <input type="date" v-model="dateFrom" class="bg-transparent border-none text-xs focus:ring-0 p-0" />
|
|
|
+ </div>
|
|
|
+ <div class="flex items-center gap-2 bg-background border border-border/50 rounded-xl px-3 py-1.5">
|
|
|
+ <span class="text-[10px] font-bold text-muted-foreground uppercase mr-1">{{ t("admin.to") }}</span>
|
|
|
+ <input type="date" v-model="dateTo" class="bg-transparent border-none text-xs focus:ring-0 p-0" />
|
|
|
+ </div>
|
|
|
+ <button @click="resetFilters" class="text-xs text-muted-foreground hover:text-primary transition-colors underline ml-auto">{{ t("admin.reset") }}</button>
|
|
|
</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">{{ t("admin.allStatuses") }}</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="handleAddNew">
|
|
|
- <Plus class="w-4 h-4" />{{ t("admin.addNew") }}
|
|
|
- </Button>
|
|
|
</div>
|
|
|
|
|
|
<!-- Loading -->
|
|
|
@@ -67,7 +86,7 @@
|
|
|
<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">{{ t("common.orderId", { id: 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" />{{ t('admin.statuses.' + order.status) }}
|
|
|
+ <component :is="statusIcon(order.status)" class="w-3.5 h-3.5" />{{ t("statuses." + order.status) }}
|
|
|
</span>
|
|
|
</div>
|
|
|
<div class="flex items-center gap-2">
|
|
|
@@ -120,6 +139,12 @@
|
|
|
<History class="w-3 h-3" /> {{ t("admin.actions.viewOriginal") }}
|
|
|
</button>
|
|
|
</div>
|
|
|
+
|
|
|
+ <!-- Manual Fiscal Indicator -->
|
|
|
+ <div v-if="order.fiscal_qr_url" class="mt-1 flex items-center gap-1.5 px-2 py-0.5 rounded-md bg-emerald-500/10 text-emerald-600 border border-emerald-500/20 w-fit">
|
|
|
+ <CheckCircle2 class="w-3 h-3" />
|
|
|
+ <span class="text-[9px] font-bold uppercase">{{ t("admin.fields.fiscalized") }}</span>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
<div class="text-right">
|
|
|
<span class="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1 block">{{ t("admin.fields.quantity") }}</span>
|
|
|
@@ -159,7 +184,8 @@
|
|
|
</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">
|
|
|
+ <template v-for="(f, i) in order.files" :key="f.id || i">
|
|
|
+ <div v-if="f.id" 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" />
|
|
|
@@ -172,7 +198,7 @@
|
|
|
<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>
|
|
|
+ <span v-if="f.file_size" 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>
|
|
|
@@ -182,12 +208,13 @@
|
|
|
<!-- Delete Actions & Quantity -->
|
|
|
<div class="absolute top-2 right-2 flex flex-col items-end gap-1">
|
|
|
<div class="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>
|
|
|
- <button @click.prevent="handleDeleteFile(order.id, f.file_id, f.filename)" class="p-1 bg-background/80 backdrop-blur-md border border-border/50 text-rose-500 hover:bg-rose-500 hover:text-white rounded-md transition-colors shadow-sm" :title="t('admin.actions.deleteFile')">
|
|
|
+ <button @click.prevent="handleDeleteFile(order.id, f.id, f.filename)" class="p-1 bg-background/80 backdrop-blur-md border border-border/50 text-rose-500 hover:bg-rose-500 hover:text-white rounded-md transition-colors shadow-sm" :title="t('admin.actions.deleteFile')">
|
|
|
<Trash2 class="w-2.5 h-2.5" />
|
|
|
</button>
|
|
|
</div>
|
|
|
</div>
|
|
|
- </div>
|
|
|
+ </template>
|
|
|
+ </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">{{ t("admin.fields.photoReport") }} ({{ order.photos?.length || 0 }})</span>
|
|
|
@@ -228,7 +255,7 @@
|
|
|
<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 }}
|
|
|
+ {{ t("statuses." + s) }}
|
|
|
</button>
|
|
|
</div>
|
|
|
<div class="pt-4 border-t border-border/50">
|
|
|
@@ -238,19 +265,65 @@
|
|
|
</div>
|
|
|
<div class="flex justify-between items-center">
|
|
|
<span class="text-[10px] font-bold text-muted-foreground uppercase tracking-widest">{{ t("admin.fields.finalPrice") }}</span>
|
|
|
- <button @click="editingPrice = { id: order.id, price: order.total_price?.toString() ?? '' }" class="text-[10px] text-primary hover:underline font-bold">{{ t("admin.actions.edit") }}</button>
|
|
|
+ <button @click="openItemsModal(order.id)" class="text-[10px] text-primary hover:underline font-bold">{{ t("admin.actions.edit") }}</button>
|
|
|
</div>
|
|
|
- <div class="text-2xl font-display font-bold">{{ order.total_price || "---" }} <span class="text-xs">EUR</span></div>
|
|
|
+ <div class="text-2xl font-display font-bold">{{ (order.total_price || 0) }} <span class="text-xs">EUR</span></div>
|
|
|
|
|
|
+ <a v-if="order.proforma_path" :href="`http://localhost:8000/${order.proforma_path}`" target="_blank"
|
|
|
+ class="mt-4 w-full flex items-center justify-center gap-2 py-2 rounded-xl bg-orange-500/10 hover:bg-orange-500/20 text-orange-600 border border-orange-500/20 font-bold transition-all text-sm">
|
|
|
+ <FileText class="w-4 h-4" /> {{ t("admin.actions.printProforma") }}
|
|
|
+ </a>
|
|
|
+
|
|
|
<a v-if="order.invoice_path" :href="`http://localhost:8000/${order.invoice_path}`" target="_blank"
|
|
|
- class="mt-4 w-full flex items-center justify-center gap-2 py-2 rounded-xl bg-primary/10 hover:bg-primary/20 text-primary border border-primary/20 font-bold transition-all text-sm">
|
|
|
+ class="mt-2 w-full flex items-center justify-center gap-2 py-2 rounded-xl bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-600 border border-emerald-500/20 font-bold transition-all text-sm">
|
|
|
<FileText class="w-4 h-4" /> {{ t("admin.actions.printInvoice") }}
|
|
|
</a>
|
|
|
|
|
|
- <button @click="toggleAdminChat(order.id)" :class="['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 relative', order.invoice_path ? 'mt-2' : 'mt-4', adminChatId === order.id ? 'bg-primary text-primary-foreground' : 'bg-background/50 text-muted-foreground hover:text-foreground']">
|
|
|
+ <button @click="toggleAdminChat(order.id)" :class="['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 relative', (order.invoice_path || order.proforma_path) ? 'mt-2' : 'mt-4', adminChatId === order.id ? 'bg-primary text-primary-foreground' : 'bg-background/50 text-muted-foreground hover:text-foreground']">
|
|
|
<span v-if="order.unread_count > 0" class="absolute -top-2 -right-2 bg-red-500 text-white text-[10px] w-5 h-5 flex items-center justify-center rounded-full shadow-sm animate-pulse z-10">{{ order.unread_count }}</span>
|
|
|
<MessageCircle class="w-4 h-4" />{{ t("chat.open") }}
|
|
|
</button>
|
|
|
+
|
|
|
+ <!-- Fiscalization Data Entry -->
|
|
|
+ <div class="mt-6 pt-6 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">{{ t("admin.fields.fiscalization") }}</span>
|
|
|
+ <button v-if="order.status === 'shipped' || order.invoice_path" @click="handleForceGenerateInvoice(order.id)" class="text-[9px] text-primary hover:underline font-bold uppercase">
|
|
|
+ {{ t("admin.actions.regenerateInvoice") }}
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ <div class="space-y-3">
|
|
|
+ <div class="space-y-1">
|
|
|
+ <label class="text-[9px] font-bold uppercase text-muted-foreground ml-1">EFI Verification URL</label>
|
|
|
+ <div class="flex gap-2">
|
|
|
+ <input v-model="fiscalFormMap[order.id].fiscal_qr_url"
|
|
|
+ class="flex-1 min-w-0 bg-background border border-border/50 rounded-lg px-3 py-1.5 text-[10px] font-mono focus:ring-1 ring-primary/30 outline-none"
|
|
|
+ placeholder="https://efi.porezi.me/verify/..." />
|
|
|
+ <button @click="handleUpdateFiscal(order.id)" class="p-1.5 bg-primary/10 text-primary hover:bg-primary hover:text-primary-foreground rounded-lg transition-all">
|
|
|
+ <Save class="w-3.5 h-3.5" />
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="grid grid-cols-2 gap-2">
|
|
|
+ <div class="space-y-1">
|
|
|
+ <label class="text-[9px] font-bold uppercase text-muted-foreground ml-1">IKOF</label>
|
|
|
+ <input v-model="fiscalFormMap[order.id].ikof" class="w-full bg-background border border-border/50 rounded-lg px-2 py-1.5 text-[10px] font-mono outline-none" />
|
|
|
+ </div>
|
|
|
+ <div class="space-y-1">
|
|
|
+ <label class="text-[9px] font-bold uppercase text-muted-foreground ml-1">JIKR</label>
|
|
|
+ <input v-model="fiscalFormMap[order.id].jikr" class="w-full bg-background border border-border/50 rounded-lg px-2 py-1.5 text-[10px] font-mono outline-none" />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- Danger Zone -->
|
|
|
+ <div class="mt-8 pt-6 border-t border-rose-500/10 text-center">
|
|
|
+ <button @click="handleDeleteOrder(order.id)"
|
|
|
+ class="w-full flex items-center justify-center gap-2 py-2 rounded-xl bg-rose-500/5 hover:bg-rose-500/10 text-rose-500/60 hover:text-rose-500 border border-transparent hover:border-rose-500/20 font-bold transition-all text-xs group">
|
|
|
+ <Trash2 class="w-3.5 h-3.5 transition-transform group-hover:scale-110" /> {{ t("admin.actions.deleteOrder") }}
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
@@ -525,20 +598,51 @@
|
|
|
|
|
|
<!-- ——— MODALS ——— -->
|
|
|
<Teleport to="body">
|
|
|
- <!-- Price Modal -->
|
|
|
+ <!-- Order Items / Specification 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">{{ t("admin.fields.updateFinalPrice") }}</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">{{ t("admin.actions.savePrice") }}</Button>
|
|
|
+ <div v-if="editingItems" 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="editingItems = null" />
|
|
|
+ <div class="relative w-full max-w-2xl bg-card border border-border/50 rounded-3xl p-8 shadow-2xl maxHeight-[90vh] flex flex-col">
|
|
|
+ <h3 class="text-xl font-bold font-display mb-6">Order Specification & Pricing</h3>
|
|
|
+
|
|
|
+ <div class="flex-1 overflow-y-auto space-y-4 mb-6 pr-2">
|
|
|
+ <div v-for="(item, idx) in editingItems.items" :key="idx" class="grid grid-cols-[1fr,80px,100px,40px] gap-3 items-end">
|
|
|
+ <div class="space-y-1">
|
|
|
+ <label v-if="idx===0" class="text-[10px] font-bold uppercase ml-1 opacity-50">Description</label>
|
|
|
+ <input v-model="item.description" type="text" placeholder="Item description" class="w-full bg-background border border-border/50 rounded-xl px-3 py-2 text-sm" />
|
|
|
+ </div>
|
|
|
+ <div class="space-y-1">
|
|
|
+ <label v-if="idx===0" class="text-[10px] font-bold uppercase ml-1 opacity-50">Qty</label>
|
|
|
+ <input v-model.number="item.quantity" type="number" class="w-full bg-background border border-border/50 rounded-xl px-2 py-2 text-sm text-center" />
|
|
|
+ </div>
|
|
|
+ <div class="space-y-1">
|
|
|
+ <label v-if="idx===0" class="text-[10px] font-bold uppercase ml-1 opacity-50">Unit Price</label>
|
|
|
+ <div class="relative">
|
|
|
+ <input v-model.number="item.unit_price" type="number" step="0.01" class="w-full bg-background border border-border/50 rounded-xl pl-6 pr-2 py-2 text-sm text-right" />
|
|
|
+ <span class="absolute left-2 top-1/2 -translate-y-1/2 text-[10px] opacity-30">€</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <button @click="removeItemRow(idx)" class="p-2 text-rose-500 hover:bg-rose-500/10 rounded-lg transition-colors mb-0.5">
|
|
|
+ <Trash2 class="w-4 h-4" />
|
|
|
+ </button>
|
|
|
</div>
|
|
|
+
|
|
|
+ <button @click="addItemRow" class="w-full py-3 border border-dashed border-border/50 rounded-xl text-primary text-xs font-bold hover:bg-primary/5 transition-all">
|
|
|
+ + Add Custom Item
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="flex items-center justify-between pt-6 border-t border-border/50">
|
|
|
+ <div>
|
|
|
+ <span class="text-[10px] uppercase font-bold text-muted-foreground block">Total Amount</span>
|
|
|
+ <span class="text-2xl font-bold font-display">{{ editingItems.items.reduce((sum, i) => (sum || 0) + ((i.quantity || 0) * (i.unit_price || 0)), 0).toFixed(2) }} EUR</span>
|
|
|
+ </div>
|
|
|
+ <div class="flex gap-3">
|
|
|
+ <Button variant="ghost" @click="editingItems = null">Cancel</Button>
|
|
|
+ <Button variant="hero" @click="handleSaveItems">Save Specification</Button>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
+
|
|
|
</div>
|
|
|
</div>
|
|
|
</Transition>
|
|
|
@@ -655,6 +759,14 @@
|
|
|
<div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.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>
|
|
|
|
|
|
+ <!-- Big Descriptions -->
|
|
|
+ <div class="space-y-4 pt-4 border-t border-border/30">
|
|
|
+ <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">Full Site Description (EN)</label><textarea v-model="matForm.long_desc_en" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 min-h-[120px]" /></div>
|
|
|
+ <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">Полное описание для сайта (RU)</label><textarea v-model="matForm.long_desc_ru" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 min-h-[120px]" /></div>
|
|
|
+ <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">Повний опис для сайту (UA)</label><textarea v-model="matForm.long_desc_ua" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 min-h-[120px]" /></div>
|
|
|
+ <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">Dugi opis za sajt (ME)</label><textarea v-model="matForm.long_desc_me" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 min-h-[120px]" /></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>
|
|
|
@@ -794,10 +906,11 @@
|
|
|
|
|
|
<script setup lang="ts">
|
|
|
import { ref, computed, watch, reactive, onMounted, onUnmounted, nextTick } from "vue";
|
|
|
-import { RouterLink, useRouter } from "vue-router";
|
|
|
+import { RouterLink, useRouter, useRoute } from "vue-router";
|
|
|
import { useI18n } from "vue-i18n";
|
|
|
+import { loadAdminTranslations } from "@/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, Newspaper, History, X, Users } from "lucide-vue-next";
|
|
|
+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, Newspaper, History, X, Users, Save } from "lucide-vue-next";
|
|
|
import Button from "@/components/ui/button.vue";
|
|
|
import Header from "@/components/Header.vue";
|
|
|
import Footer from "@/components/Footer.vue";
|
|
|
@@ -806,11 +919,13 @@ import { useAuthStore } from "@/stores/auth";
|
|
|
import {
|
|
|
adminGetOrders, adminUpdateOrder, adminGetMaterials, adminCreateMaterial, adminUpdateMaterial, adminDeleteMaterial,
|
|
|
adminGetServices, adminCreateService, adminUpdateService, adminDeleteService, adminUploadOrderPhoto,
|
|
|
- adminUpdatePhotoStatus, adminDeletePhoto, adminGetAllPhotos, adminAttachFile, adminDeleteFile, getBlogPosts, adminCreatePost, adminUpdatePost, adminDeletePost, adminUpdateUser, adminGetUsers, adminCreateUser, adminGetAuditLogs
|
|
|
+ adminUpdatePhotoStatus, adminDeletePhoto, adminGetAllPhotos, adminAttachFile, adminDeleteFile, adminDeleteOrder, getBlogPosts, adminCreatePost, adminUpdatePost, adminDeletePost, adminUpdateUser, adminGetUsers, adminCreateUser, adminGetAuditLogs,
|
|
|
+ adminGetOrderItems, adminUpdateOrderItems
|
|
|
} from "@/lib/api";
|
|
|
|
|
|
const { t, locale } = useI18n();
|
|
|
const router = useRouter();
|
|
|
+const route = useRoute();
|
|
|
const authStore = useAuthStore();
|
|
|
|
|
|
const STATUS_CONFIG: Record<string, { color: string; icon: any }> = {
|
|
|
@@ -830,6 +945,11 @@ async function toggleAdminChat(orderId: number) {
|
|
|
adminChatId.value = isOpening ? orderId : null;
|
|
|
|
|
|
if (isOpening) {
|
|
|
+ // Instantly clear local unread count for UI snappiness
|
|
|
+ const order = orders.value.find(o => o.id === orderId);
|
|
|
+ if (order) order.unread_count = 0;
|
|
|
+ authStore.refreshUnreadCount();
|
|
|
+
|
|
|
await nextTick();
|
|
|
const el = document.getElementById(`admin-chat-${orderId}`);
|
|
|
if (el) {
|
|
|
@@ -849,7 +969,26 @@ const tabs: { id: Tab; icon: any }[] = [
|
|
|
];
|
|
|
|
|
|
type Tab = "orders" | "materials" | "services" | "posts" | "users" | "portfolio" | "audit";
|
|
|
-const activeTab = ref<Tab>("orders");
|
|
|
+
|
|
|
+const getValidTab = (val: any): Tab => {
|
|
|
+ const t = val?.toString();
|
|
|
+ return ["orders", "materials", "services", "posts", "users", "portfolio", "audit"].includes(t) ? t : "orders";
|
|
|
+};
|
|
|
+
|
|
|
+const activeTab = ref<Tab>(getValidTab(route.query.tab));
|
|
|
+
|
|
|
+watch(activeTab, (newTab) => {
|
|
|
+ if (route.query.tab !== newTab) {
|
|
|
+ router.replace({ query: { ...route.query, tab: newTab } });
|
|
|
+ }
|
|
|
+});
|
|
|
+
|
|
|
+watch(() => route.query.tab, (newTab) => {
|
|
|
+ const valid = getValidTab(newTab);
|
|
|
+ if (valid !== activeTab.value) {
|
|
|
+ activeTab.value = valid;
|
|
|
+ }
|
|
|
+});
|
|
|
const orders = ref<any[]>([]);
|
|
|
const materials = ref<any[]>([]);
|
|
|
const services = ref<any[]>([]);
|
|
|
@@ -875,6 +1014,16 @@ const userPage = ref(1);
|
|
|
const isLoading = ref(true);
|
|
|
const searchQuery = ref("");
|
|
|
const statusFilter = ref("all");
|
|
|
+const dateFrom = ref("");
|
|
|
+const dateTo = ref("");
|
|
|
+
|
|
|
+function resetFilters() {
|
|
|
+ searchQuery.value = "";
|
|
|
+ statusFilter.value = "all";
|
|
|
+ dateFrom.value = "";
|
|
|
+ dateTo.value = "";
|
|
|
+}
|
|
|
+
|
|
|
const editingPrice = ref<{ id: number; price: string } | null>(null);
|
|
|
const editingParams = ref<{ id: number; material_id: number; color_name: string; quantity: number } | null>(null);
|
|
|
const focusedOrderId = ref<number | null>(null);
|
|
|
@@ -884,8 +1033,9 @@ const editingService = ref<any | null>(null);
|
|
|
const editingPost = ref<any | null>(null);
|
|
|
const showAddModal = ref(false);
|
|
|
const notifyStatusMap = ref<Record<number, boolean>>({});
|
|
|
+const fiscalFormMap = ref<Record<number, { fiscal_qr_url: string; ikof: string; jikr: string }>>({});
|
|
|
|
|
|
-const matForm = reactive({ name_en: "", name_ru: "", name_ua: "", name_me: "", desc_en: "", desc_ru: "", desc_ua: "", desc_me: "", price_per_cm3: 0, available_colors: [] as string[], is_active: true });
|
|
|
+const matForm = reactive({ name_en: "", name_ru: "", name_ua: "", name_me: "", desc_en: "", desc_ru: "", desc_ua: "", desc_me: "", long_desc_en: "", long_desc_ru: "", long_desc_ua: "", long_desc_me: "", price_per_cm3: 0, available_colors: [] as string[], is_active: true });
|
|
|
const newColor = ref("");
|
|
|
const svcForm = reactive({ name_en: "", name_ru: "", name_ua: "", name_me: "", desc_en: "", desc_ru: "", desc_ua: "", desc_me: "", tech_type: "", is_active: true });
|
|
|
const postForm = reactive({
|
|
|
@@ -904,12 +1054,12 @@ const userForm = reactive({
|
|
|
phone: ""
|
|
|
});
|
|
|
|
|
|
-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;
|
|
|
-}));
|
|
|
+const filteredOrders = computed(() => {
|
|
|
+ if (activeTab.value !== 'orders') return [];
|
|
|
+ // Since we now use server-side filtering, we just return orders.value
|
|
|
+ // But we can still do a final local filter if the API didn't cover something
|
|
|
+ return orders.value;
|
|
|
+});
|
|
|
|
|
|
const selectedMatColors = computed(() => {
|
|
|
if (!editingParams.value?.material_id) return [];
|
|
|
@@ -936,9 +1086,23 @@ async function fetchData() {
|
|
|
try {
|
|
|
const currentTab = activeTab.value;
|
|
|
if (currentTab === "orders") {
|
|
|
- orders.value = await adminGetOrders();
|
|
|
+ orders.value = await adminGetOrders({
|
|
|
+ search: searchQuery.value,
|
|
|
+ status: statusFilter.value,
|
|
|
+ date_from: dateFrom.value,
|
|
|
+ date_to: dateTo.value
|
|
|
+ });
|
|
|
materials.value = await adminGetMaterials(); // Needed for changing order params
|
|
|
- orders.value.forEach(o => { if (notifyStatusMap.value[o.id] === undefined) notifyStatusMap.value[o.id] = true; });
|
|
|
+ orders.value.forEach(o => {
|
|
|
+ if (notifyStatusMap.value[o.id] === undefined) notifyStatusMap.value[o.id] = true;
|
|
|
+ if (!fiscalFormMap.value[o.id]) {
|
|
|
+ fiscalFormMap.value[o.id] = {
|
|
|
+ fiscal_qr_url: o.fiscal_qr_url || "",
|
|
|
+ ikof: o.ikof || "",
|
|
|
+ jikr: o.jikr || ""
|
|
|
+ };
|
|
|
+ }
|
|
|
+ });
|
|
|
}
|
|
|
else if (currentTab === "materials") materials.value = await adminGetMaterials();
|
|
|
else if (currentTab === "services") services.value = await adminGetServices();
|
|
|
@@ -966,6 +1130,20 @@ watch([userPage, userSearch], () => {
|
|
|
if (activeTab.value === 'users') fetchUsers();
|
|
|
});
|
|
|
|
|
|
+watch([searchQuery, statusFilter, dateFrom, dateTo], () => {
|
|
|
+ if (activeTab.value === 'orders') {
|
|
|
+ debouncedFetchOrders();
|
|
|
+ }
|
|
|
+});
|
|
|
+
|
|
|
+let fetchTimeout: any = null;
|
|
|
+function debouncedFetchOrders() {
|
|
|
+ clearTimeout(fetchTimeout);
|
|
|
+ fetchTimeout = setTimeout(() => {
|
|
|
+ fetchData();
|
|
|
+ }, 400);
|
|
|
+}
|
|
|
+
|
|
|
watch(auditPage, () => {
|
|
|
if (activeTab.value === 'audit') fetchAuditLogs();
|
|
|
});
|
|
|
@@ -973,6 +1151,7 @@ watch(auditPage, () => {
|
|
|
watch(activeTab, fetchData, { immediate: false });
|
|
|
onMounted(async () => {
|
|
|
if (!authStore.user || authStore.user.role !== "admin") { router.push("/auth"); return; }
|
|
|
+ await loadAdminTranslations();
|
|
|
fetchData();
|
|
|
});
|
|
|
|
|
|
@@ -990,24 +1169,96 @@ function handlePaste(event: ClipboardEvent) {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+function handleOrderReadEvent(event: any) {
|
|
|
+ const orderId = event.detail?.order_id;
|
|
|
+ if (orderId) {
|
|
|
+ const order = orders.value.find(o => o.id === orderId);
|
|
|
+ if (order) order.unread_count = 0;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
onMounted(() => {
|
|
|
window.addEventListener('paste', handlePaste);
|
|
|
+ window.addEventListener('radionica:order_read', handleOrderReadEvent);
|
|
|
});
|
|
|
onUnmounted(() => {
|
|
|
window.removeEventListener('paste', handlePaste);
|
|
|
+ window.removeEventListener('radionica:order_read', handleOrderReadEvent);
|
|
|
});
|
|
|
|
|
|
async function handleUpdateStatus(id: number, status: string) {
|
|
|
const notify = notifyStatusMap.value[id] ?? true; // True by default
|
|
|
- try { await adminUpdateOrder(id, { status, send_notification: notify }); toast.success(t("admin.toasts.statusUpdated", { status })); fetchData(); }
|
|
|
+ try {
|
|
|
+ await adminUpdateOrder(id, { status, send_notification: notify });
|
|
|
+ toast.success(t("admin.toasts.statusUpdated", { status }));
|
|
|
+
|
|
|
+ if (status === 'shipped') {
|
|
|
+ toast.info(t("admin.toasts.invoiceReminder"), { duration: 6000 });
|
|
|
+ }
|
|
|
+
|
|
|
+ fetchData();
|
|
|
+ }
|
|
|
catch { toast.error(t("admin.toasts.genericError")); }
|
|
|
}
|
|
|
-async function handleUpdatePrice() {
|
|
|
- if (!editingPrice.value) return;
|
|
|
- const p = parseFloat(editingPrice.value.price);
|
|
|
- if (isNaN(p)) { toast.error(t("admin.toasts.genericError")); return; }
|
|
|
- try { await adminUpdateOrder(editingPrice.value.id, { total_price: p }); toast.success(t("admin.toasts.priceUpdated")); editingPrice.value = null; fetchData(); }
|
|
|
- catch { toast.error(t("admin.toasts.genericError")); }
|
|
|
+
|
|
|
+async function handleUpdateFiscal(orderId: number) {
|
|
|
+ const form = fiscalFormMap.value[orderId];
|
|
|
+ if (!form) return;
|
|
|
+ try {
|
|
|
+ await adminUpdateOrder(orderId, {
|
|
|
+ fiscal_qr_url: form.fiscal_qr_url,
|
|
|
+ ikof: form.ikof,
|
|
|
+ jikr: form.jikr
|
|
|
+ });
|
|
|
+ toast.success(t("admin.toasts.fiscalUpdated"));
|
|
|
+ fetchData();
|
|
|
+ } catch (err: any) {
|
|
|
+ toast.error(err.message || t("admin.toasts.genericError"));
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function handleForceGenerateInvoice(orderId: number) {
|
|
|
+ try {
|
|
|
+ // Regenerating invoice by re-triggering 'shipped' logic on the backend
|
|
|
+ await adminUpdateOrder(orderId, { status: 'shipped', send_notification: false });
|
|
|
+ toast.success(t("admin.toasts.invoiceRegenerated"));
|
|
|
+ fetchData();
|
|
|
+ } catch (err: any) {
|
|
|
+ toast.error(err.message || t("admin.toasts.genericError"));
|
|
|
+ }
|
|
|
+}
|
|
|
+const editingItems = ref<{ order_id: number; items: any[] } | null>(null);
|
|
|
+
|
|
|
+async function openItemsModal(orderId: number) {
|
|
|
+ try {
|
|
|
+ const items = await adminGetOrderItems(orderId);
|
|
|
+ editingItems.value = {
|
|
|
+ order_id: orderId,
|
|
|
+ items: items.length > 0 ? items.map((i:any) => ({ ...i })) : [{ description: '3D Printing Service', quantity: 1, unit_price: 0 }]
|
|
|
+ };
|
|
|
+ } catch (err) {
|
|
|
+ toast.error("Failed to fetch items");
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function addItemRow() {
|
|
|
+ editingItems.value?.items.push({ description: '', quantity: 1, unit_price: 0 });
|
|
|
+}
|
|
|
+
|
|
|
+function removeItemRow(idx: number) {
|
|
|
+ editingItems.value?.items.splice(idx, 1);
|
|
|
+}
|
|
|
+
|
|
|
+async function handleSaveItems() {
|
|
|
+ if (!editingItems.value) return;
|
|
|
+ try {
|
|
|
+ await adminUpdateOrderItems(editingItems.value.order_id, editingItems.value.items);
|
|
|
+ toast.success(t("admin.toasts.priceUpdated"));
|
|
|
+ editingItems.value = null;
|
|
|
+ fetchData();
|
|
|
+ } catch (err: any) {
|
|
|
+ toast.error(err.message || t("admin.toasts.genericError"));
|
|
|
+ }
|
|
|
}
|
|
|
async function handleUpdateParams() {
|
|
|
if (!editingParams.value) return;
|
|
|
@@ -1053,10 +1304,25 @@ async function handleAttachFile(orderId: number, file?: File) {
|
|
|
try { await adminAttachFile(orderId, fd); toast.success(t("admin.toasts.fileAttached")); fetchData(); }
|
|
|
catch (e: any) { toast.error(e.message); }
|
|
|
}
|
|
|
-async function handleDeleteFile(orderId: number, fileId: number, filename: string) {
|
|
|
- if (!window.confirm(`Delete attached file "${filename}"?`)) return;
|
|
|
- try { await adminDeleteFile(orderId, fileId); toast.success(t("admin.toasts.fileDeleted")); fetchData(); }
|
|
|
- catch { toast.error(t("admin.toasts.genericError")); }
|
|
|
+async function handleDeleteFile(orderId: number, file_id: number, filename: string) {
|
|
|
+ if (!confirm(t("admin.questions.deleteFile", { filename }))) return;
|
|
|
+ try {
|
|
|
+ await adminDeleteFile(orderId, file_id);
|
|
|
+ toast.success(t("admin.toasts.fileDeleted"));
|
|
|
+ fetchData();
|
|
|
+ } catch (err: any) {
|
|
|
+ toast.error(err.message);
|
|
|
+ }
|
|
|
+}
|
|
|
+async function handleDeleteOrder(orderId: number) {
|
|
|
+ if (!confirm(`DANGER: Are you sure you want to PERMANENTLY delete Order #${orderId}? This will remove all files, messages, and photos. This action cannot be undone.`)) return;
|
|
|
+ try {
|
|
|
+ await adminDeleteOrder(orderId);
|
|
|
+ toast.success(`Order #${orderId} deleted perfectly.`);
|
|
|
+ fetchData();
|
|
|
+ } catch (err: any) {
|
|
|
+ toast.error(err.message);
|
|
|
+ }
|
|
|
}
|
|
|
async function handleDeleteMaterial(id: number, name: string) {
|
|
|
if (!window.confirm(`Delete material "${name}"?`)) return;
|
|
|
@@ -1079,7 +1345,7 @@ async function togglePostActive(p: any) { await adminUpdatePost(p.id, { .
|
|
|
|
|
|
function handleAddNew() {
|
|
|
if (activeTab.value === 'materials') {
|
|
|
- Object.assign(matForm, { name_en: "", name_ru: "", name_ua: "", name_me: "", desc_en: "", desc_ru: "", desc_ua: "", desc_me: "", price_per_cm3: 0, available_colors: [], is_active: true });
|
|
|
+ Object.assign(matForm, { name_en: "", name_ru: "", name_ua: "", name_me: "", desc_en: "", desc_ru: "", desc_ua: "", desc_me: "", long_desc_en: "", long_desc_ru: "", long_desc_ua: "", long_desc_me: "", price_per_cm3: 0, available_colors: [], is_active: true });
|
|
|
newColor.value = "";
|
|
|
editingMaterial.value = null;
|
|
|
} else if (activeTab.value === 'services') {
|
|
|
@@ -1158,14 +1424,14 @@ async function handleUpdateUserRole(userId: number, role: string) {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-function closeModals() { editingMaterial.value = null; editingService.value = null; editingPost.value = null; showAddModal.value = false; editingPrice.value = null; editingParams.value = null; viewingOriginal.value = null; }
|
|
|
+function closeModals() { editingMaterial.value = null; editingService.value = null; editingPost.value = null; showAddModal.value = false; editingItems.value = null; editingParams.value = null; viewingOriginal.value = null; }
|
|
|
|
|
|
watch(editingMaterial, m => {
|
|
|
if (m) {
|
|
|
- Object.assign(matForm, m);
|
|
|
+ Object.assign(matForm, { ...m, is_active: !!m.is_active });
|
|
|
newColor.value = "";
|
|
|
}
|
|
|
});
|
|
|
-watch(editingService, s => { if (s) Object.assign(svcForm, s); });
|
|
|
+watch(editingService, s => { if (s) Object.assign(svcForm, { ...s, is_active: !!s.is_active }); });
|
|
|
watch(editingPost, p => { if (p) Object.assign(postForm, p); });
|
|
|
</script>
|