|
@@ -11,8 +11,8 @@
|
|
|
<!-- Header -->
|
|
<!-- Header -->
|
|
|
<div class="flex flex-col md:flex-row md:items-end justify-between gap-6 mb-12">
|
|
<div class="flex flex-col md:flex-row md:items-end justify-between gap-6 mb-12">
|
|
|
<div>
|
|
<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>
|
|
|
|
|
|
|
+ <span class="text-primary font-display text-sm tracking-widest uppercase mb-2 block">{{ t("admin.managementCenter") }}</span>
|
|
|
|
|
+ <h1 class="font-display text-4xl font-bold">Admin <span class="text-gradient">{{ t("admin.dashboard") }}</span></h1>
|
|
|
</div>
|
|
</div>
|
|
|
<div class="flex bg-card/40 backdrop-blur-md border border-border/50 rounded-2xl p-1 gap-1">
|
|
<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="[
|
|
<button v-for="tab in tabs" :key="tab.id" @click="activeTab = tab.id" :class="[
|
|
@@ -28,16 +28,16 @@
|
|
|
<div class="flex flex-col sm:flex-row gap-4 mb-8">
|
|
<div class="flex flex-col sm:flex-row gap-4 mb-8">
|
|
|
<div class="relative flex-1">
|
|
<div class="relative flex-1">
|
|
|
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
|
<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}...`"
|
|
|
|
|
|
|
+ <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" />
|
|
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>
|
|
</div>
|
|
|
<select v-if="activeTab === 'orders'" v-model="statusFilter"
|
|
<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]">
|
|
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 value="all">{{ t("admin.allStatuses") }}</option>
|
|
|
<option v-for="s in Object.keys(STATUS_CONFIG)" :key="s" :value="s">{{ capitalize(s) }}</option>
|
|
<option v-for="s in Object.keys(STATUS_CONFIG)" :key="s" :value="s">{{ capitalize(s) }}</option>
|
|
|
</select>
|
|
</select>
|
|
|
<Button v-if="activeTab !== 'orders'" variant="hero" class="gap-2 sm:px-8" @click="handleAddNew">
|
|
<Button v-if="activeTab !== 'orders'" variant="hero" class="gap-2 sm:px-8" @click="handleAddNew">
|
|
|
- <Plus class="w-4 h-4" />Add New
|
|
|
|
|
|
|
+ <Plus class="w-4 h-4" />{{ t("admin.addNew") }}
|
|
|
</Button>
|
|
</Button>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
@@ -49,60 +49,96 @@
|
|
|
<!-- ORDERS -->
|
|
<!-- ORDERS -->
|
|
|
<div v-else-if="activeTab === 'orders'" class="grid gap-6">
|
|
<div v-else-if="activeTab === 'orders'" class="grid gap-6">
|
|
|
<div v-for="order in filteredOrders" :key="order.id"
|
|
<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">
|
|
|
|
|
|
|
+ @mouseenter="focusedOrderId = order.id"
|
|
|
|
|
+ @mouseleave="focusedOrderId = null"
|
|
|
|
|
+ :class="[
|
|
|
|
|
+ 'group relative bg-card/40 backdrop-blur-md border rounded-3xl overflow-hidden transition-all duration-300',
|
|
|
|
|
+ focusedOrderId === order.id ? 'border-primary ring-1 ring-primary/20 shadow-glow' : 'border-border/50'
|
|
|
|
|
+ ]">
|
|
|
|
|
+ <!-- Paste Indicator -->
|
|
|
|
|
+ <div v-if="focusedOrderId === order.id" class="absolute top-2 right-2 z-50 pointer-events-none animate-pulse">
|
|
|
|
|
+ <div class="px-2 py-1 bg-primary/20 backdrop-blur-md border border-primary/50 rounded-lg flex items-center gap-1.5 shadow-lg">
|
|
|
|
|
+ <span class="text-[9px] font-black text-primary uppercase tracking-widest">Ctrl+V — Photo Report</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
<div class="flex flex-col lg:flex-row divide-y lg:divide-y-0 lg:divide-x divide-border/50">
|
|
<div class="flex flex-col lg:flex-row divide-y lg:divide-y-0 lg:divide-x divide-border/50">
|
|
|
<!-- Info -->
|
|
<!-- Info -->
|
|
|
<div class="p-6 lg:w-1/4">
|
|
<div class="p-6 lg:w-1/4">
|
|
|
<div class="flex items-center justify-between mb-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="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)}`">
|
|
<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 }}
|
|
|
|
|
|
|
+ <component :is="statusIcon(order.status)" class="w-3.5 h-3.5" />{{ t('admin.statuses.' + order.status) }}
|
|
|
</span>
|
|
</span>
|
|
|
</div>
|
|
</div>
|
|
|
- <div class="space-y-1">
|
|
|
|
|
<div class="flex items-center gap-2">
|
|
<div class="flex items-center gap-2">
|
|
|
- <h3 class="font-bold">{{ order.first_name }} {{ order.last_name }}</h3>
|
|
|
|
|
|
|
+ <div class="flex flex-col">
|
|
|
|
|
+ <h3 class="font-bold leading-none">{{ order.first_name }} {{ order.last_name }}</h3>
|
|
|
|
|
+ <p class="text-[10px] text-muted-foreground mt-1">{{ order.email }}</p>
|
|
|
|
|
+ </div>
|
|
|
<span :title="order.is_online ? 'Online' : 'Offline'" :class="['w-2 h-2 rounded-full', order.is_online ? 'bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.8)]' : 'bg-muted-foreground/30']"></span>
|
|
<span :title="order.is_online ? 'Online' : 'Offline'" :class="['w-2 h-2 rounded-full', order.is_online ? 'bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.8)]' : 'bg-muted-foreground/30']"></span>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- Chat Permission Toggle -->
|
|
|
|
|
+ <button v-if="order.user_id"
|
|
|
|
|
+ @click="handleToggleUserChat(order.user_id, order.can_chat)"
|
|
|
|
|
+ class="ml-auto p-1.5 rounded-lg hover:bg-muted transition-colors group/chat-perm"
|
|
|
|
|
+ :title="order.can_chat ? 'Forbid Chat for this user' : 'Allow Chat for this user'">
|
|
|
|
|
+ <component :is="order.can_chat ? ToggleRight : ToggleLeft"
|
|
|
|
|
+ class="w-5 h-5 transition-transform group-hover/chat-perm:scale-110"
|
|
|
|
|
+ :class="order.can_chat ? 'text-emerald-500' : 'text-muted-foreground'" />
|
|
|
|
|
+ </button>
|
|
|
</div>
|
|
</div>
|
|
|
- <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 class="mt-4 pt-4 border-t border-border/50 text-xs text-muted-foreground">{{ new Date(order.created_at).toLocaleString() }}</div>
|
|
|
</div>
|
|
</div>
|
|
|
<!-- Details -->
|
|
<!-- Details -->
|
|
|
<div class="p-6 lg:w-1/4 space-y-4">
|
|
<div class="p-6 lg:w-1/4 space-y-4">
|
|
|
<div class="flex justify-between items-start">
|
|
<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 class="relative group/params">
|
|
|
|
|
+ <span class="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1 block">Material & Color</span>
|
|
|
|
|
+ <div class="flex flex-col gap-1">
|
|
|
|
|
+ <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>
|
|
|
|
|
+ <button @click="editingParams = { id: order.id, material_id: order.material_id, color_name: order.color_name || '', quantity: order.quantity || 1 }" class="p-1 hover:bg-primary/10 rounded-md text-primary transition-colors">
|
|
|
|
|
+ <Edit2 class="w-3 h-3" />
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="flex items-center gap-2">
|
|
|
|
|
+ <div class="w-3.5 h-3.5 rounded-full border border-border/50 bg-muted/20" :style="order.color_name ? { backgroundColor: order.color_name.toLowerCase() } : {}"></div>
|
|
|
|
|
+ <p class="text-xs font-medium text-muted-foreground">{{ order.color_name || t("admin.fields.defaultColor") }}</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- Original Snapshot Indicator -->
|
|
|
|
|
+ <div v-if="order.original_params" class="mt-2">
|
|
|
|
|
+ <button @click="showOriginalParams(order.original_params)" class="flex items-center gap-1.5 text-[9px] font-bold text-primary/60 hover:text-primary transition-colors mb-2">
|
|
|
|
|
+ <History class="w-3 h-3" /> {{ t("admin.actions.viewOriginal") }}
|
|
|
|
|
+ </button>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
<div class="text-right">
|
|
<div class="text-right">
|
|
|
- <span class="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1 block">Quantity</span>
|
|
|
|
|
|
|
+ <span class="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1 block">{{ t("admin.fields.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">
|
|
<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>
|
|
<Hash class="w-3 h-3" /><span class="text-sm">{{ order.quantity || 1 }}</span>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
<div>
|
|
<div>
|
|
|
- <span class="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1 block">Shipping Address</span>
|
|
|
|
|
|
|
+ <span class="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1 block">{{ t("admin.fields.shippingAddress") }}</span>
|
|
|
<p class="text-xs text-muted-foreground line-clamp-2">{{ order.shipping_address }}</p>
|
|
<p class="text-xs text-muted-foreground line-clamp-2">{{ order.shipping_address }}</p>
|
|
|
</div>
|
|
</div>
|
|
|
<div v-if="order.notes" class="p-3 bg-background/50 border border-border/50 rounded-xl">
|
|
<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>
|
|
|
|
|
|
|
+ <span class="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1 block">{{ t("admin.fields.projectNotes") }}</span>
|
|
|
<p class="text-[11px] text-muted-foreground italic">"{{ order.notes }}"</p>
|
|
<p class="text-[11px] text-muted-foreground italic">"{{ order.notes }}"</p>
|
|
|
</div>
|
|
</div>
|
|
|
<div class="flex items-center gap-2">
|
|
<div class="flex items-center gap-2">
|
|
|
<ShieldCheck :class="`w-4 h-4 ${order.allow_portfolio ? 'text-emerald-500' : 'text-muted-foreground/30'}`" />
|
|
<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>
|
|
|
|
|
|
|
+ <span class="text-xs">{{ order.allow_portfolio ? t("admin.fields.portfolioAllowed") : t("admin.fields.noPortfolio") }}</span>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
<!-- Resources -->
|
|
<!-- Resources -->
|
|
|
<div class="p-6 lg:w-1/4 space-y-6">
|
|
<div class="p-6 lg:w-1/4 space-y-6">
|
|
|
<div class="flex items-center justify-between mb-3">
|
|
<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>
|
|
|
|
|
|
|
+ <span class="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">{{ t("admin.fields.sourceFiles") }} ({{ 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">
|
|
<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" />
|
|
<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])" />
|
|
<input type="file" class="hidden" accept=".stl,.obj" @change="e => handleAttachFile(order.id, (e.target as HTMLInputElement).files?.[0])" />
|
|
@@ -110,7 +146,7 @@
|
|
|
</div>
|
|
</div>
|
|
|
<!-- Model Link (if provided) -->
|
|
<!-- 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">
|
|
<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>
|
|
|
|
|
|
|
+ <span class="text-[10px] font-bold uppercase tracking-widest text-blue-500/60 mb-1 block">{{ t("admin.fields.externalLink") }}</span>
|
|
|
<div class="flex items-center justify-between gap-2 overflow-hidden">
|
|
<div class="flex items-center justify-between gap-2 overflow-hidden">
|
|
|
<p class="text-[10px] text-muted-foreground truncate">{{ order.model_link }}</p>
|
|
<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>
|
|
<a :href="order.model_link" target="_blank" class="text-blue-500 hover:underline"><ExternalLink class="w-3.5 h-3.5" /></a>
|
|
@@ -140,7 +176,7 @@
|
|
|
<!-- Delete Actions & Quantity -->
|
|
<!-- Delete Actions & Quantity -->
|
|
|
<div class="absolute top-2 right-2 flex flex-col items-end gap-1">
|
|
<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>
|
|
<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="Delete attached file">
|
|
|
|
|
|
|
+ <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')">
|
|
|
<Trash2 class="w-2.5 h-2.5" />
|
|
<Trash2 class="w-2.5 h-2.5" />
|
|
|
</button>
|
|
</button>
|
|
|
</div>
|
|
</div>
|
|
@@ -148,7 +184,7 @@
|
|
|
</div>
|
|
</div>
|
|
|
<div class="pt-4 border-t border-border/50">
|
|
<div class="pt-4 border-t border-border/50">
|
|
|
<div class="flex items-center justify-between mb-3">
|
|
<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>
|
|
|
|
|
|
|
+ <span class="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">{{ t("admin.fields.photoReport") }} ({{ 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">
|
|
<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" />
|
|
<Plus class="w-3 h-3" />
|
|
|
<input type="file" class="hidden" accept="image/*" @change="e => handleUploadPhoto(order.id, (e.target as HTMLInputElement).files?.[0])" />
|
|
<input type="file" class="hidden" accept="image/*" @change="e => handleUploadPhoto(order.id, (e.target as HTMLInputElement).files?.[0])" />
|
|
@@ -161,12 +197,17 @@
|
|
|
: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'}`">
|
|
: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" />
|
|
<Eye v-if="p.is_public" class="w-2.5 h-2.5" /><EyeOff v-else class="w-2.5 h-2.5" />
|
|
|
</button>
|
|
</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 class="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 group-hover/img:opacity-100 transition-opacity gap-2">
|
|
|
|
|
+ <a :href="`http://localhost:8000/${p.file_path}`" target="_blank" class="w-7 h-7 bg-white/10 hover:bg-white/20 rounded-full flex items-center justify-center transition-colors">
|
|
|
|
|
+ <ExternalLink class="w-3.5 h-3.5 text-white" />
|
|
|
|
|
+ </a>
|
|
|
|
|
+ <button @click="handleDeletePhoto(p.id)" class="w-7 h-7 bg-rose-500/20 hover:bg-rose-500/40 rounded-full flex items-center justify-center transition-colors">
|
|
|
|
|
+ <Trash2 class="w-3.5 h-3.5 text-white" />
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
</div>
|
|
</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">
|
|
<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>
|
|
|
|
|
|
|
+ <ImageIcon class="w-4 h-4 mb-1" /><span class="text-[10px]">{{ t("admin.fields.noPhotos") }}</span>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
@@ -175,7 +216,7 @@
|
|
|
<div class="p-6 lg:w-1/4 bg-primary/5">
|
|
<div class="p-6 lg:w-1/4 bg-primary/5">
|
|
|
<div class="flex items-center gap-1.5 mb-3">
|
|
<div class="flex items-center gap-1.5 mb-3">
|
|
|
<input type="checkbox" :id="`notify-${order.id}`" v-model="notifyStatusMap[order.id]" class="w-3.5 h-3.5 rounded border-border" />
|
|
<input type="checkbox" :id="`notify-${order.id}`" v-model="notifyStatusMap[order.id]" class="w-3.5 h-3.5 rounded border-border" />
|
|
|
- <label :id="`notify-${order.id}`" class="text-[9px] font-bold uppercase text-muted-foreground cursor-pointer">Notify User</label>
|
|
|
|
|
|
|
+ <label :for="`notify-${order.id}`" class="text-[9px] font-bold uppercase text-muted-foreground cursor-pointer">{{ t("admin.fields.notifyUser") }}</label>
|
|
|
</div>
|
|
</div>
|
|
|
<div class="grid grid-cols-2 gap-2 mb-6">
|
|
<div class="grid grid-cols-2 gap-2 mb-6">
|
|
|
<button v-for="s in Object.keys(STATUS_CONFIG)" :key="s"
|
|
<button v-for="s in Object.keys(STATUS_CONFIG)" :key="s"
|
|
@@ -186,18 +227,18 @@
|
|
|
</div>
|
|
</div>
|
|
|
<div class="pt-4 border-t border-border/50">
|
|
<div class="pt-4 border-t border-border/50">
|
|
|
<div class="flex justify-between items-center mb-1">
|
|
<div class="flex justify-between items-center mb-1">
|
|
|
- <span class="text-[10px] font-bold text-muted-foreground uppercase">Estimated</span>
|
|
|
|
|
|
|
+ <span class="text-[10px] font-bold text-muted-foreground uppercase">{{ t("admin.fields.estimated") }}</span>
|
|
|
<span class="font-bold text-sm text-primary/80">{{ order.estimated_price }} EUR</span>
|
|
<span class="font-bold text-sm text-primary/80">{{ order.estimated_price }} EUR</span>
|
|
|
</div>
|
|
</div>
|
|
|
<div class="flex justify-between items-center">
|
|
<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>
|
|
|
|
|
|
|
+ <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>
|
|
|
</div>
|
|
</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 || "---" }} <span class="text-xs">EUR</span></div>
|
|
|
|
|
|
|
|
<a v-if="order.invoice_path" :href="`http://localhost:8000/${order.invoice_path}`" target="_blank"
|
|
<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-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">
|
|
|
- <FileText class="w-4 h-4" /> Print Uplatnica
|
|
|
|
|
|
|
+ <FileText class="w-4 h-4" /> {{ t("admin.actions.printInvoice") }}
|
|
|
</a>
|
|
</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 ? 'mt-2' : 'mt-4', adminChatId === order.id ? 'bg-primary text-primary-foreground' : 'bg-background/50 text-muted-foreground hover:text-foreground']">
|
|
@@ -229,7 +270,7 @@
|
|
|
</div>
|
|
</div>
|
|
|
<div class="flex items-center gap-8">
|
|
<div class="flex items-center gap-8">
|
|
|
<div class="text-right">
|
|
<div class="text-right">
|
|
|
- <p class="text-[10px] font-bold text-muted-foreground uppercase">Price / cm³</p>
|
|
|
|
|
|
|
+ <p class="text-[10px] font-bold text-muted-foreground uppercase">{{ t("admin.fields.pricePerCm3") }}</p>
|
|
|
<p class="font-display font-bold text-lg">{{ m.price_per_cm3 }} EUR</p>
|
|
<p class="font-display font-bold text-lg">{{ m.price_per_cm3 }} EUR</p>
|
|
|
</div>
|
|
</div>
|
|
|
<div class="flex items-center gap-2">
|
|
<div class="flex items-center gap-2">
|
|
@@ -266,6 +307,83 @@
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
+ <!-- USERS -->
|
|
|
|
|
+ <div v-else-if="activeTab === 'users'" class="space-y-6">
|
|
|
|
|
+ <div class="flex items-center gap-4 bg-card/40 p-4 rounded-2xl border border-border/50">
|
|
|
|
|
+ <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 v-model="userSearch" :placeholder="t('admin.searchUsersPlaceholder')" class="w-full bg-background/50 border border-border/50 rounded-xl pl-10 pr-4 py-2 text-sm focus:ring-2 ring-primary/20 outline-none" />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="text-xs font-bold text-muted-foreground uppercase">{{ t("admin.total") }}: {{ usersResult.total }} ({{ usersResult.users?.length }})</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div v-if="!isLoading && usersResult.users?.length === 0" class="flex flex-col items-center justify-center py-20 bg-card/40 border border-border/50 rounded-3xl">
|
|
|
|
|
+ <Users class="w-12 h-12 text-muted-foreground/20 mb-4" />
|
|
|
|
|
+ <p class="text-sm text-muted-foreground">{{ t("admin.fields.noUsers") }}</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div v-else class="bg-card/40 backdrop-blur-md border border-border/50 rounded-3xl overflow-hidden shadow-xl">
|
|
|
|
|
+ <div class="overflow-x-auto">
|
|
|
|
|
+ <table class="w-full text-left border-collapse">
|
|
|
|
|
+ <thead>
|
|
|
|
|
+ <tr class="bg-muted/30 border-b border-border/50">
|
|
|
|
|
+ <th class="p-4 text-[10px] font-bold uppercase tracking-wider">{{ t("admin.labels.user") }}</th>
|
|
|
|
|
+ <th class="p-4 text-[10px] font-bold uppercase tracking-wider">{{ t("admin.labels.contact") }}</th>
|
|
|
|
|
+ <th class="p-4 text-[10px] font-bold uppercase tracking-wider">{{ t("admin.labels.role") }}</th>
|
|
|
|
|
+ <th class="p-4 text-[10px] font-bold uppercase tracking-wider text-center">{{ t("admin.labels.chat") }}</th>
|
|
|
|
|
+ <th class="p-4 text-[10px] font-bold uppercase tracking-wider">{{ t("admin.labels.registered") }}</th>
|
|
|
|
|
+ <th class="p-4 text-[10px] font-bold uppercase tracking-wider text-right">{{ t("admin.labels.actions") }}</th>
|
|
|
|
|
+ </tr>
|
|
|
|
|
+ </thead>
|
|
|
|
|
+ <tbody class="divide-y divide-border/30">
|
|
|
|
|
+ <tr v-for="u in usersResult.users" :key="u.id" class="hover:bg-primary/5 transition-colors group/row">
|
|
|
|
|
+ <td class="p-4">
|
|
|
|
|
+ <div class="flex flex-col">
|
|
|
|
|
+ <span class="text-sm font-bold">{{ u.first_name }} {{ u.last_name }}</span>
|
|
|
|
|
+ <span class="text-[10px] text-muted-foreground font-mono">ID: {{ u.id }}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </td>
|
|
|
|
|
+ <td class="p-4">
|
|
|
|
|
+ <div class="flex flex-col text-xs">
|
|
|
|
|
+ <span class="font-medium">{{ u.email }}</span>
|
|
|
|
|
+ <span class="text-muted-foreground">{{ u.phone }}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </td>
|
|
|
|
|
+ <td class="p-4">
|
|
|
|
|
+ <span :class="`px-2 py-0.5 rounded-full text-[10px] font-bold uppercase ${u.role === 'admin' ? 'bg-rose-500/10 text-rose-500 border border-rose-500/20' : 'bg-muted text-muted-foreground border border-border/50'}`">{{ u.role }}</span>
|
|
|
|
|
+ </td>
|
|
|
|
|
+ <td class="p-4 text-center">
|
|
|
|
|
+ <button @click="handleToggleUserChat(u.id, u.can_chat)" class="inline-flex">
|
|
|
|
|
+ <component :is="u.can_chat ? ToggleRight : ToggleLeft" class="w-6 h-6" :class="u.can_chat ? 'text-emerald-500' : 'text-muted-foreground'" />
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </td>
|
|
|
|
|
+ <td class="p-4 text-xs text-muted-foreground">
|
|
|
|
|
+ {{ new Date(u.created_at).toLocaleDateString() }}
|
|
|
|
|
+ </td>
|
|
|
|
|
+ <td class="p-4 text-right">
|
|
|
|
|
+ <div class="flex items-center justify-end gap-2">
|
|
|
|
|
+ <button @click="handleUpdateUserRole(u.id, u.role === 'admin' ? 'user' : 'admin')"
|
|
|
|
|
+ class="p-2 rounded-lg hover:bg-muted text-muted-foreground hover:text-primary transition-all"
|
|
|
|
|
+ :title="t('admin.actions.toggleAdminRole')">
|
|
|
|
|
+ <ShieldCheck class="w-4 h-4" />
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </td>
|
|
|
|
|
+ </tr>
|
|
|
|
|
+ </tbody>
|
|
|
|
|
+ </table>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- Pagination -->
|
|
|
|
|
+ <div v-if="usersResult.total > 50" class="flex items-center justify-center gap-2 py-4">
|
|
|
|
|
+ <button v-for="p in Math.ceil(usersResult.total / 50)" :key="p"
|
|
|
|
|
+ @click="userPage = p"
|
|
|
|
|
+ :class="['w-8 h-8 rounded-lg font-bold text-xs transition-all', userPage === p ? 'bg-primary text-white' : 'bg-card border border-border/50 text-muted-foreground hover:border-primary/50']">
|
|
|
|
|
+ {{ p }}
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
|
|
|
<!-- POSTS -->
|
|
<!-- POSTS -->
|
|
|
<div v-else-if="activeTab === 'posts'" class="grid gap-4">
|
|
<div v-else-if="activeTab === 'posts'" class="grid gap-4">
|
|
@@ -294,6 +412,45 @@
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- PORTFOLIO -->
|
|
|
|
|
+ <div v-else-if="activeTab === 'portfolio'" class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
|
|
|
|
+ <div v-for="pi in portfolioItems" :key="pi.id" class="group relative aspect-square bg-card/40 border border-border/50 rounded-2xl overflow-hidden hover:border-primary/30 transition-all">
|
|
|
|
|
+ <img :src="`http://localhost:8000/${pi.file_path}`" class="w-full h-full object-cover" />
|
|
|
|
|
+
|
|
|
|
|
+ <!-- Status Overlay -->
|
|
|
|
|
+ <div class="absolute top-2 left-2 flex gap-1">
|
|
|
|
|
+ <span :class="`px-2 py-0.5 rounded-full text-[8px] font-bold uppercase ${pi.is_public ? 'bg-emerald-500 text-white shadow-glow' : 'bg-black/40 text-white/40'}`">
|
|
|
|
|
+ {{ pi.is_public ? 'Public' : 'Private' }}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ <span v-if="!pi.allow_portfolio" class="px-2 py-0.5 rounded-full text-[8px] font-bold uppercase bg-rose-500 text-white">
|
|
|
|
|
+ No Consent
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- Actions Overlay -->
|
|
|
|
|
+ <div class="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col items-center justify-center p-4 gap-3">
|
|
|
|
|
+ <div class="text-center mb-1">
|
|
|
|
|
+ <p class="text-[9px] font-bold text-white uppercase tracking-widest opacity-60">Order #{{ pi.order_id }}</p>
|
|
|
|
|
+ <p class="text-xs font-bold text-white truncate max-w-full">{{ pi.first_name }} {{ pi.last_name }}</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="flex items-center gap-2">
|
|
|
|
|
+ <button @click="handleTogglePhotoPublic(pi.id, pi.is_public, pi.allow_portfolio)"
|
|
|
|
|
+ :class="`p-2 rounded-xl transition-all ${pi.is_public ? 'bg-emerald-500 text-white' : 'bg-white/10 text-white hover:bg-white/20'}`"
|
|
|
|
|
+ :title="pi.is_public ? 'Make Private' : 'Make Public'">
|
|
|
|
|
+ <Eye v-if="pi.is_public" class="w-4 h-4" /><EyeOff v-else class="w-4 h-4" />
|
|
|
|
|
+ </button>
|
|
|
|
|
+ <a :href="`http://localhost:8000/${pi.file_path}`" target="_blank" class="p-2 bg-white/10 text-white hover:bg-white/20 rounded-xl transition-all">
|
|
|
|
|
+ <ExternalLink class="w-4 h-4" />
|
|
|
|
|
+ </a>
|
|
|
|
|
+ <button @click="handleDeletePhoto(pi.id)" class="p-2 bg-rose-500/20 text-rose-500 hover:bg-rose-500 hover:text-white rounded-xl transition-all">
|
|
|
|
|
+ <Trash2 class="w-4 h-4" />
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <p class="text-[9px] font-bold text-primary uppercase mt-1">{{ pi.material_name }}</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
</main>
|
|
</main>
|
|
|
|
|
|
|
|
<!-- ——— MODALS ——— -->
|
|
<!-- ——— MODALS ——— -->
|
|
@@ -303,19 +460,77 @@
|
|
|
<div v-if="editingPrice" class="fixed inset-0 z-[100] flex items-center justify-center p-4">
|
|
<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="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">
|
|
<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>
|
|
|
|
|
|
|
+ <h3 class="text-xl font-bold font-display mb-6">{{ t("admin.fields.updateFinalPrice") }}</h3>
|
|
|
<div class="space-y-4">
|
|
<div class="space-y-4">
|
|
|
<input v-model="editingPrice.price" type="number" step="0.01"
|
|
<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" />
|
|
class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 font-bold" />
|
|
|
<div class="flex gap-3">
|
|
<div class="flex gap-3">
|
|
|
<Button variant="ghost" class="flex-1" @click="editingPrice = null">Cancel</Button>
|
|
<Button variant="ghost" class="flex-1" @click="editingPrice = null">Cancel</Button>
|
|
|
- <Button variant="hero" class="flex-1" @click="handleUpdatePrice">Save Price</Button>
|
|
|
|
|
|
|
+ <Button variant="hero" class="flex-1" @click="handleUpdatePrice">{{ t("admin.actions.savePrice") }}</Button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </Transition>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- Params Modal (Material & Color) -->
|
|
|
|
|
+ <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="editingParams" 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="editingParams = 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.modals.changeParams") }}</h3>
|
|
|
|
|
+ <div class="space-y-4">
|
|
|
|
|
+ <div class="space-y-1">
|
|
|
|
|
+ <label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.selectMaterialStrict") }}</label>
|
|
|
|
|
+ <select v-model="editingParams.material_id"
|
|
|
|
|
+ class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 font-bold">
|
|
|
|
|
+ <option v-for="m in materials" :key="m.id" :value="m.id">{{ m['name_' + locale] || m.name_en }} (@ {{ m.price_per_cm3 }})</option>
|
|
|
|
|
+ </select>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="space-y-1" v-if="selectedMatColors.length">
|
|
|
|
|
+ <label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.selectColorStrict") }}</label>
|
|
|
|
|
+ <select v-model="editingParams.color_name"
|
|
|
|
|
+ class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 font-bold">
|
|
|
|
|
+ <option v-for="c in selectedMatColors" :key="c" :value="c">{{ c }}</option>
|
|
|
|
|
+ </select>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="space-y-1" v-else>
|
|
|
|
|
+ <label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.customColorDirInfo") }}</label>
|
|
|
|
|
+ <input v-model="editingParams.color_name" type="text" :placeholder="t('admin.fields.customColorPlaceholder')"
|
|
|
|
|
+ class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 font-bold" />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="space-y-1">
|
|
|
|
|
+ <label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.quantity") }}</label>
|
|
|
|
|
+ <input v-model.number="editingParams.quantity" type="number" min="1"
|
|
|
|
|
+ class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 font-bold" />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <p class="text-[10px] text-muted-foreground italic">{{ t("admin.fields.strictSelectionInfo") }}</p>
|
|
|
|
|
+ <div class="flex gap-3 pt-2">
|
|
|
|
|
+ <Button variant="ghost" class="flex-1" @click="editingParams = null">{{ t("admin.actions.cancel") }}</Button>
|
|
|
|
|
+ <Button variant="hero" class="flex-1" @click="handleUpdateParams">{{ t("admin.actions.saveChanges") }}</Button>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
</Transition>
|
|
</Transition>
|
|
|
|
|
|
|
|
|
|
+ <!-- Original Params Snapshot Viewer -->
|
|
|
|
|
+ <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="viewingOriginal" 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="viewingOriginal = null" />
|
|
|
|
|
+ <div class="relative w-full max-w-md bg-card border border-border/50 rounded-3xl p-8 shadow-2xl overflow-hidden">
|
|
|
|
|
+ <div class="flex items-center justify-between mb-6">
|
|
|
|
|
+ <h3 class="text-xl font-bold font-display">{{ t("admin.fields.originalSnapshot") }}</h3>
|
|
|
|
|
+ <button @click="viewingOriginal = null" class="p-2 hover:bg-muted rounded-full transition-colors"><X class="w-5 h-5" /></button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="bg-muted/30 rounded-2xl p-4 font-mono text-xs max-h-[60vh] overflow-y-auto">
|
|
|
|
|
+ <pre>{{ JSON.stringify(viewingOriginal, null, 2) }}</pre>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <p class="mt-4 text-[11px] text-muted-foreground">These are the parameters recorded at the moment of order submission.</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </Transition>
|
|
|
|
|
+
|
|
|
<!-- Material Modal -->
|
|
<!-- 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">
|
|
<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 v-if="editingMaterial || (showAddModal && activeTab === 'materials')" class="fixed inset-0 z-[100] flex items-center justify-center p-4">
|
|
@@ -325,24 +540,49 @@
|
|
|
<form @submit.prevent="handleSaveMaterial" class="space-y-6">
|
|
<form @submit.prevent="handleSaveMaterial" class="space-y-6">
|
|
|
<!-- Names -->
|
|
<!-- Names -->
|
|
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
<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 (UA)</label><input v-model="matForm.name_ua" 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 class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.name") }} (EN)</label><input v-model="matForm.name_en" required placeholder="PLA Standard" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 text-sm" /></div>
|
|
|
|
|
+ <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.name") }} (RU)</label><input v-model="matForm.name_ru" required placeholder="PLA Стандарт" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 text-sm" /></div>
|
|
|
|
|
+ <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.name") }} (UA)</label><input v-model="matForm.name_ua" required placeholder="PLA Стандарт" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 text-sm" /></div>
|
|
|
|
|
+ <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.name") }} (ME)</label><input v-model="matForm.name_me" required placeholder="PLA Standard" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 text-sm" /></div>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
<!-- Price & Status -->
|
|
<!-- Price & Status -->
|
|
|
<div class="grid grid-cols-2 gap-4">
|
|
<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 class="space-y-1">
|
|
|
|
|
+ <label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.price") }}</label>
|
|
|
|
|
+ <input v-model.number="matForm.price_per_cm3" type="number" step="0.01" required class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 text-sm" />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <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">{{ t("admin.fields.active") }}</label></div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- Available Colors -->
|
|
|
|
|
+ <div class="space-y-1">
|
|
|
|
|
+ <label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.colors") }}</label>
|
|
|
|
|
+ <div class="flex flex-wrap gap-2 p-3 bg-background border border-border/50 rounded-xl min-h-[50px]">
|
|
|
|
|
+ <div v-for="(c, idx) in matForm.available_colors" :key="idx" class="group/color flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-primary/5 text-primary border border-primary/20 hover:border-primary/40 transition-all">
|
|
|
|
|
+ <div class="w-2 h-2 rounded-full border border-primary/30" :style="{ backgroundColor: c.toLowerCase() }"></div>
|
|
|
|
|
+ <span class="text-xs font-bold capitalize">{{ c }}</span>
|
|
|
|
|
+ <button type="button" @click="removeColor(idx)" class="ml-1 opacity-40 hover:opacity-100 hover:text-rose-500 transition-all">
|
|
|
|
|
+ <X class="w-3 h-3" />
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <input
|
|
|
|
|
+ v-model="newColor"
|
|
|
|
|
+ @keydown.enter.prevent="addColor"
|
|
|
|
|
+ @keydown.comma.prevent="addColor"
|
|
|
|
|
+ placeholder="Add color..."
|
|
|
|
|
+ class="flex-1 min-w-[100px] bg-transparent border-none focus:ring-0 text-sm font-medium p-1"
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <p class="text-[10px] text-muted-foreground italic px-1">Press Enter or use comma to add a color. Type "Blue" or hex codes like "#0000ff".</p>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
<!-- Descriptions -->
|
|
<!-- Descriptions -->
|
|
|
<div class="space-y-4">
|
|
<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 (UA)</label><textarea v-model="matForm.desc_ua" 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 class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.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">{{ t("admin.fields.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">{{ t("admin.fields.description") }} (UA)</label><textarea v-model="matForm.desc_ua" 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">{{ 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>
|
|
</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>
|
|
<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>
|
|
@@ -356,25 +596,25 @@
|
|
|
<div v-if="editingService || (showAddModal && activeTab === 'services')" class="fixed inset-0 z-[100] flex items-center justify-center p-4">
|
|
<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="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">
|
|
<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>
|
|
|
|
|
|
|
+ <h3 class="text-xl font-bold font-display mb-6">{{ editingService?.id ? t("admin.modals.editService") : t("admin.modals.createService") }}</h3>
|
|
|
<form @submit.prevent="handleSaveService" class="space-y-6">
|
|
<form @submit.prevent="handleSaveService" class="space-y-6">
|
|
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
<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="svcForm.name_en" required placeholder="FDM Printing" 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="svcForm.name_ru" required placeholder="FDM Печать" 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 (UA)</label><input v-model="svcForm.name_ua" required placeholder="FDM Друк" 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="svcForm.name_me" required placeholder="FDM Štampa" 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">{{ t("admin.fields.name") }} (EN)</label><input v-model="svcForm.name_en" required placeholder="FDM Printing" 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">{{ t("admin.fields.name") }} (RU)</label><input v-model="svcForm.name_ru" required placeholder="FDM Печать" 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">{{ t("admin.fields.name") }} (UA)</label><input v-model="svcForm.name_ua" required placeholder="FDM Друк" 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">{{ t("admin.fields.name") }} (ME)</label><input v-model="svcForm.name_me" required placeholder="FDM Štampa" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3" /></div>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
<div class="grid grid-cols-2 gap-4">
|
|
<div class="grid grid-cols-2 gap-4">
|
|
|
- <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 class="flex items-center gap-2 pt-6"><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="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.techType") }}</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 class="flex items-center gap-2 pt-6"><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">{{ t("admin.fields.active") }}</label></div>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
<div class="space-y-4">
|
|
<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="svcForm.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="svcForm.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 (UA)</label><textarea v-model="svcForm.desc_ua" 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="svcForm.desc_me" 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">{{ t("admin.fields.description") }} (EN)</label><textarea v-model="svcForm.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">{{ t("admin.fields.description") }} (RU)</label><textarea v-model="svcForm.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">{{ t("admin.fields.description") }} (UA)</label><textarea v-model="svcForm.desc_ua" 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">{{ t("admin.fields.description") }} (ME)</label><textarea v-model="svcForm.desc_me" required class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 min-h-[60px]" /></div>
|
|
|
</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>
|
|
<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>
|
|
@@ -388,44 +628,44 @@
|
|
|
<div v-if="editingPost || (showAddModal && activeTab === 'posts')" class="fixed inset-0 z-[100] flex items-center justify-center p-4">
|
|
<div v-if="editingPost || (showAddModal && activeTab === 'posts')" 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="absolute inset-0 bg-background/80 backdrop-blur-sm" @click="closeModals" />
|
|
|
<div class="relative w-full max-w-4xl bg-card border border-border/50 rounded-3xl p-8 shadow-2xl max-h-[90vh] overflow-y-auto">
|
|
<div class="relative w-full max-w-4xl 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">{{ editingPost?.id ? "Edit Blog Post" : "Create New Post" }}</h3>
|
|
|
|
|
|
|
+ <h3 class="text-xl font-bold font-display mb-6">{{ editingPost?.id ? t("admin.modals.editPost") : t("admin.modals.createPost") }}</h3>
|
|
|
<form @submit.prevent="handleSavePost" class="space-y-6">
|
|
<form @submit.prevent="handleSavePost" class="space-y-6">
|
|
|
|
|
|
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
|
<div class="space-y-1">
|
|
<div class="space-y-1">
|
|
|
- <label class="text-[10px] font-bold uppercase ml-1">Slug (URL)</label>
|
|
|
|
|
|
|
+ <label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.slug") }}</label>
|
|
|
<input v-model="postForm.slug" required placeholder="my-new-post" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3" />
|
|
<input v-model="postForm.slug" required placeholder="my-new-post" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3" />
|
|
|
</div>
|
|
</div>
|
|
|
<div class="space-y-1">
|
|
<div class="space-y-1">
|
|
|
- <label class="text-[10px] font-bold uppercase ml-1">Category</label>
|
|
|
|
|
|
|
+ <label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.category") }}</label>
|
|
|
<input v-model="postForm.category" required placeholder="Technology" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3" />
|
|
<input v-model="postForm.category" required placeholder="Technology" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3" />
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
<div class="space-y-1">
|
|
<div class="space-y-1">
|
|
|
- <label class="text-[10px] font-bold uppercase ml-1">Image URL</label>
|
|
|
|
|
|
|
+ <label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.imageUrl") }}</label>
|
|
|
<input v-model="postForm.image_url" placeholder="https://ex.com/img.jpg" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3" />
|
|
<input v-model="postForm.image_url" placeholder="https://ex.com/img.jpg" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3" />
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
<!-- Titles -->
|
|
<!-- Titles -->
|
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
|
- <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">Title (EN)</label><input v-model="postForm.title_en" required 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">Title (ME)</label><input v-model="postForm.title_me" 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">Title (RU)</label><input v-model="postForm.title_ru" 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">Title (UA)</label><input v-model="postForm.title_ua" 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">{{ t("admin.fields.title") }} (EN)</label><input v-model="postForm.title_en" required 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">{{ t("admin.fields.title") }} (ME)</label><input v-model="postForm.title_me" 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">{{ t("admin.fields.title") }} (RU)</label><input v-model="postForm.title_ru" 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">{{ t("admin.fields.title") }} (UA)</label><input v-model="postForm.title_ua" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3" /></div>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
<!-- Excerpts -->
|
|
<!-- Excerpts -->
|
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
|
- <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">Excerpt (EN)</label><textarea v-model="postForm.excerpt_en" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 min-h-[80px]" /></div>
|
|
|
|
|
- <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">Excerpt (ME)</label><textarea v-model="postForm.excerpt_me" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 min-h-[80px]" /></div>
|
|
|
|
|
- <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">Excerpt (RU)</label><textarea v-model="postForm.excerpt_ru" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 min-h-[80px]" /></div>
|
|
|
|
|
- <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">Excerpt (UA)</label><textarea v-model="postForm.excerpt_ua" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 min-h-[80px]" /></div>
|
|
|
|
|
|
|
+ <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.excerpt") }} (EN)</label><textarea v-model="postForm.excerpt_en" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 min-h-[80px]" /></div>
|
|
|
|
|
+ <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.excerpt") }} (ME)</label><textarea v-model="postForm.excerpt_me" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 min-h-[80px]" /></div>
|
|
|
|
|
+ <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.excerpt") }} (RU)</label><textarea v-model="postForm.excerpt_ru" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 min-h-[80px]" /></div>
|
|
|
|
|
+ <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.excerpt") }} (UA)</label><textarea v-model="postForm.excerpt_ua" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 min-h-[80px]" /></div>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
<!-- Content -->
|
|
<!-- Content -->
|
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
|
- <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">Content (EN)</label><textarea v-model="postForm.content_en" required class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 min-h-[200px]" /></div>
|
|
|
|
|
|
|
+ <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.content") }} (EN)</label><textarea v-model="postForm.content_en" required class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 min-h-[200px]" /></div>
|
|
|
<div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">Content (ME)</label><textarea v-model="postForm.content_me" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 min-h-[200px]" /></div>
|
|
<div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">Content (ME)</label><textarea v-model="postForm.content_me" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 min-h-[200px]" /></div>
|
|
|
<div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">Content (RU)</label><textarea v-model="postForm.content_ru" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 min-h-[200px]" /></div>
|
|
<div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">Content (RU)</label><textarea v-model="postForm.content_ru" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 min-h-[200px]" /></div>
|
|
|
<div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">Content (UA)</label><textarea v-model="postForm.content_ua" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 min-h-[200px]" /></div>
|
|
<div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">Content (UA)</label><textarea v-model="postForm.content_ua" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 min-h-[200px]" /></div>
|
|
@@ -433,12 +673,44 @@
|
|
|
|
|
|
|
|
<div class="flex items-center gap-2">
|
|
<div class="flex items-center gap-2">
|
|
|
<input v-model="postForm.is_published" type="checkbox" id="post_published" class="w-5 h-5 rounded border-border" />
|
|
<input v-model="postForm.is_published" type="checkbox" id="post_published" class="w-5 h-5 rounded border-border" />
|
|
|
- <label for="post_published" class="text-sm font-bold">Publish immediately</label>
|
|
|
|
|
|
|
+ <label for="post_published" class="text-sm font-bold">{{ t("admin.fields.publishImmediately") }}</label>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
<div class="flex gap-3 pt-4">
|
|
<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 Post</Button>
|
|
|
|
|
|
|
+ <Button type="button" variant="ghost" class="flex-1" @click="closeModals">{{ t("admin.actions.cancel") }}</Button>
|
|
|
|
|
+ <Button type="submit" variant="hero" class="flex-1">{{ t("admin.actions.save") }}</Button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </form>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </Transition>
|
|
|
|
|
+ <!-- User 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="showAddModal && activeTab === 'users'" 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-md bg-card border border-border/50 rounded-3xl p-8 shadow-2xl">
|
|
|
|
|
+ <h3 class="text-xl font-bold font-display mb-6">{{ t("admin.modals.createUser") }}</h3>
|
|
|
|
|
+ <form @submit.prevent="handleSaveUser" class="space-y-4">
|
|
|
|
|
+ <div class="space-y-1">
|
|
|
|
|
+ <label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.email") }}</label>
|
|
|
|
|
+ <input v-model="userForm.email" type="email" required class="w-full bg-background border border-border/50 rounded-xl px-4 py-3" />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="space-y-1">
|
|
|
|
|
+ <label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.password") }}</label>
|
|
|
|
|
+ <input v-model="userForm.password" type="password" required class="w-full bg-background border border-border/50 rounded-xl px-4 py-3" />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="grid grid-cols-2 gap-4">
|
|
|
|
|
+ <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.firstName") }}</label><input v-model="userForm.first_name" required class="w-full bg-background border border-border/50 rounded-xl px-4 py-3" /></div>
|
|
|
|
|
+ <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.lastName") }}</label><input v-model="userForm.last_name" required class="w-full bg-background border border-border/50 rounded-xl px-4 py-3" /></div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="space-y-1">
|
|
|
|
|
+ <label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.phone") }}</label>
|
|
|
|
|
+ <input v-model="userForm.phone" required class="w-full bg-background border border-border/50 rounded-xl px-4 py-3" />
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div class="flex gap-3 pt-4">
|
|
|
|
|
+ <Button type="button" variant="ghost" class="flex-1" @click="closeModals">{{ t("admin.actions.cancel") }}</Button>
|
|
|
|
|
+ <Button type="submit" variant="hero" class="flex-1">{{ t("admin.actions.create") }}</Button>
|
|
|
</div>
|
|
</div>
|
|
|
</form>
|
|
</form>
|
|
|
</div>
|
|
</div>
|
|
@@ -451,11 +723,11 @@
|
|
|
</template>
|
|
</template>
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
|
<script setup lang="ts">
|
|
|
-import { ref, computed, watch, reactive, onMounted } from "vue";
|
|
|
|
|
|
|
+import { ref, computed, watch, reactive, onMounted, onUnmounted } from "vue";
|
|
|
import { RouterLink, useRouter } from "vue-router";
|
|
import { RouterLink, useRouter } from "vue-router";
|
|
|
import { useI18n } from "vue-i18n";
|
|
import { useI18n } from "vue-i18n";
|
|
|
import { toast } from "vue-sonner";
|
|
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 } 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 } from "lucide-vue-next";
|
|
|
import Button from "@/components/ui/button.vue";
|
|
import Button from "@/components/ui/button.vue";
|
|
|
import Header from "@/components/Header.vue";
|
|
import Header from "@/components/Header.vue";
|
|
|
import Footer from "@/components/Footer.vue";
|
|
import Footer from "@/components/Footer.vue";
|
|
@@ -464,10 +736,10 @@ import { useAuthStore } from "@/stores/auth";
|
|
|
import {
|
|
import {
|
|
|
adminGetOrders, adminUpdateOrder, adminGetMaterials, adminCreateMaterial, adminUpdateMaterial, adminDeleteMaterial,
|
|
adminGetOrders, adminUpdateOrder, adminGetMaterials, adminCreateMaterial, adminUpdateMaterial, adminDeleteMaterial,
|
|
|
adminGetServices, adminCreateService, adminUpdateService, adminDeleteService, adminUploadOrderPhoto,
|
|
adminGetServices, adminCreateService, adminUpdateService, adminDeleteService, adminUploadOrderPhoto,
|
|
|
- adminUpdatePhotoStatus, adminAttachFile, adminDeleteFile, getBlogPosts, adminCreatePost, adminUpdatePost, adminDeletePost
|
|
|
|
|
|
|
+ adminUpdatePhotoStatus, adminDeletePhoto, adminGetAllPhotos, adminAttachFile, adminDeleteFile, getBlogPosts, adminCreatePost, adminUpdatePost, adminDeletePost, adminUpdateUser, adminGetUsers, adminCreateUser
|
|
|
} from "@/lib/api";
|
|
} from "@/lib/api";
|
|
|
|
|
|
|
|
-const { t } = useI18n();
|
|
|
|
|
|
|
+const { t, locale } = useI18n();
|
|
|
const router = useRouter();
|
|
const router = useRouter();
|
|
|
const authStore = useAuthStore();
|
|
const authStore = useAuthStore();
|
|
|
|
|
|
|
@@ -488,29 +760,40 @@ function toggleAdminChat(orderId: number) {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
const tabs: { id: Tab; label: string; icon: any }[] = [
|
|
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 },
|
|
|
|
|
- { id: "posts", label: "Blog", icon: Newspaper },
|
|
|
|
|
|
|
+ { id: "orders", label: t("admin.tabs.orders"), icon: Package },
|
|
|
|
|
+ { id: "materials", label: t("admin.tabs.materials"), icon: Layers },
|
|
|
|
|
+ { id: "services", label: t("admin.tabs.services"), icon: Database },
|
|
|
|
|
+ { id: "portfolio", label: t("admin.tabs.portfolio"), icon: ImageIcon },
|
|
|
|
|
+ { id: "users", label: t("admin.tabs.users"), icon: Users },
|
|
|
|
|
+ { id: "posts", label: t("admin.tabs.posts"), icon: Newspaper },
|
|
|
];
|
|
];
|
|
|
|
|
|
|
|
-type Tab = "orders" | "materials" | "services" | "posts";
|
|
|
|
|
|
|
+type Tab = "orders" | "materials" | "services" | "posts" | "users" | "portfolio";
|
|
|
const activeTab = ref<Tab>("orders");
|
|
const activeTab = ref<Tab>("orders");
|
|
|
const orders = ref<any[]>([]);
|
|
const orders = ref<any[]>([]);
|
|
|
const materials = ref<any[]>([]);
|
|
const materials = ref<any[]>([]);
|
|
|
const services = ref<any[]>([]);
|
|
const services = ref<any[]>([]);
|
|
|
const posts = ref<any[]>([]);
|
|
const posts = ref<any[]>([]);
|
|
|
|
|
+const portfolioItems = ref<any[]>([]);
|
|
|
|
|
+
|
|
|
|
|
+const usersResult = ref({ users: [] as any[], total: 0, page: 1, size: 50 });
|
|
|
|
|
+const userSearch = ref("");
|
|
|
|
|
+const userPage = ref(1);
|
|
|
const isLoading = ref(true);
|
|
const isLoading = ref(true);
|
|
|
const searchQuery = ref("");
|
|
const searchQuery = ref("");
|
|
|
const statusFilter = ref("all");
|
|
const statusFilter = ref("all");
|
|
|
const editingPrice = ref<{ id: number; price: string } | null>(null);
|
|
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);
|
|
|
|
|
+const viewingOriginal = ref<any | null>(null);
|
|
|
const editingMaterial = ref<any | null>(null);
|
|
const editingMaterial = ref<any | null>(null);
|
|
|
const editingService = ref<any | null>(null);
|
|
const editingService = ref<any | null>(null);
|
|
|
const editingPost = ref<any | null>(null);
|
|
const editingPost = ref<any | null>(null);
|
|
|
const showAddModal = ref(false);
|
|
const showAddModal = ref(false);
|
|
|
const notifyStatusMap = ref<Record<number, boolean>>({});
|
|
const notifyStatusMap = ref<Record<number, boolean>>({});
|
|
|
|
|
|
|
|
-const matForm = reactive({ name_en: "", name_ru: "", name_ua: "", name_me: "", desc_en: "", desc_ru: "", desc_ua: "", desc_me: "", price_per_cm3: 0, is_active: true });
|
|
|
|
|
|
|
+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 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 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({
|
|
const postForm = reactive({
|
|
|
slug: "",
|
|
slug: "",
|
|
@@ -520,6 +803,14 @@ const postForm = reactive({
|
|
|
category: "Technology", image_url: "", is_published: true
|
|
category: "Technology", image_url: "", is_published: true
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
|
|
+const userForm = reactive({
|
|
|
|
|
+ email: "",
|
|
|
|
|
+ password: "",
|
|
|
|
|
+ first_name: "",
|
|
|
|
|
+ last_name: "",
|
|
|
|
|
+ phone: ""
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
const filteredOrders = computed(() => orders.value.filter(o => {
|
|
const filteredOrders = computed(() => orders.value.filter(o => {
|
|
|
const qs = searchQuery.value.toLowerCase();
|
|
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 matchSearch = o.email?.toLowerCase().includes(qs) || o.first_name?.toLowerCase().includes(qs) || o.last_name?.toLowerCase().includes(qs) || String(o.id).includes(qs);
|
|
@@ -527,75 +818,162 @@ const filteredOrders = computed(() => orders.value.filter(o => {
|
|
|
return matchSearch && matchStatus;
|
|
return matchSearch && matchStatus;
|
|
|
}));
|
|
}));
|
|
|
|
|
|
|
|
|
|
+const selectedMatColors = computed(() => {
|
|
|
|
|
+ if (!editingParams.value?.material_id) return [];
|
|
|
|
|
+ const mat = materials.value.find(m => m.id === editingParams.value?.material_id);
|
|
|
|
|
+ return mat?.available_colors || [];
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+function addColor() {
|
|
|
|
|
+ const val = newColor.value.trim();
|
|
|
|
|
+ if (!val) return;
|
|
|
|
|
+ if (!matForm.available_colors) matForm.available_colors = [];
|
|
|
|
|
+ if (!matForm.available_colors.includes(val)) {
|
|
|
|
|
+ matForm.available_colors.push(val);
|
|
|
|
|
+ }
|
|
|
|
|
+ newColor.value = "";
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function removeColor(index: number) {
|
|
|
|
|
+ matForm.available_colors.splice(index, 1);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
async function fetchData() {
|
|
async function fetchData() {
|
|
|
isLoading.value = true;
|
|
isLoading.value = true;
|
|
|
try {
|
|
try {
|
|
|
- if (activeTab.value === "orders") {
|
|
|
|
|
|
|
+ const currentTab = activeTab.value;
|
|
|
|
|
+ if (currentTab === "orders") {
|
|
|
orders.value = await adminGetOrders();
|
|
orders.value = await adminGetOrders();
|
|
|
|
|
+ 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; });
|
|
|
}
|
|
}
|
|
|
- else if (activeTab.value === "materials") materials.value = await adminGetMaterials();
|
|
|
|
|
- else if (activeTab.value === "services") services.value = await adminGetServices();
|
|
|
|
|
- else if (activeTab.value === "posts") posts.value = await getBlogPosts(false);
|
|
|
|
|
- } catch { toast.error(`Failed to load ${activeTab.value}`); }
|
|
|
|
|
|
|
+ else if (currentTab === "materials") materials.value = await adminGetMaterials();
|
|
|
|
|
+ else if (currentTab === "services") services.value = await adminGetServices();
|
|
|
|
|
+ else if (currentTab === "posts") posts.value = await getBlogPosts(false);
|
|
|
|
|
+ else if (currentTab === "portfolio") portfolioItems.value = await adminGetAllPhotos();
|
|
|
|
|
+ else if (currentTab === "users") await fetchUsers();
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ console.error(err);
|
|
|
|
|
+ toast.error(t("admin.toasts.loadError", { tab: activeTab.value }));
|
|
|
|
|
+ }
|
|
|
finally { isLoading.value = false; }
|
|
finally { isLoading.value = false; }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+async function fetchUsers() {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const res = await adminGetUsers(userPage.value, 50, userSearch.value);
|
|
|
|
|
+ usersResult.value = res;
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ toast.error(t("admin.toasts.loadError", { tab: "users" }));
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+watch([userPage, userSearch], () => {
|
|
|
|
|
+ if (activeTab.value === 'users') fetchUsers();
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
watch(activeTab, fetchData, { immediate: false });
|
|
watch(activeTab, fetchData, { immediate: false });
|
|
|
onMounted(async () => {
|
|
onMounted(async () => {
|
|
|
if (!authStore.user || authStore.user.role !== "admin") { router.push("/auth"); return; }
|
|
if (!authStore.user || authStore.user.role !== "admin") { router.push("/auth"); return; }
|
|
|
fetchData();
|
|
fetchData();
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
|
|
+function handlePaste(event: ClipboardEvent) {
|
|
|
|
|
+ const active = document.activeElement;
|
|
|
|
|
+ if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')) return;
|
|
|
|
|
+ if (!focusedOrderId.value) return;
|
|
|
|
|
+ const items = event.clipboardData?.items;
|
|
|
|
|
+ if (!items) return;
|
|
|
|
|
+ for (const item of items) {
|
|
|
|
|
+ if (item.type.indexOf('image') !== -1) {
|
|
|
|
|
+ const blob = item.getAsFile();
|
|
|
|
|
+ if (blob) handleUploadPhoto(focusedOrderId.value, blob);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+onMounted(() => {
|
|
|
|
|
+ window.addEventListener('paste', handlePaste);
|
|
|
|
|
+});
|
|
|
|
|
+onUnmounted(() => {
|
|
|
|
|
+ window.removeEventListener('paste', handlePaste);
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
async function handleUpdateStatus(id: number, status: string) {
|
|
async function handleUpdateStatus(id: number, status: string) {
|
|
|
const notify = notifyStatusMap.value[id] ?? true; // True by default
|
|
const notify = notifyStatusMap.value[id] ?? true; // True by default
|
|
|
- try { await adminUpdateOrder(id, { status, send_notification: notify }); toast.success(`Status → ${status}`); fetchData(); }
|
|
|
|
|
- catch { toast.error("Failed to update status"); }
|
|
|
|
|
|
|
+ try { await adminUpdateOrder(id, { status, send_notification: notify }); toast.success(t("admin.toasts.statusUpdated", { status })); fetchData(); }
|
|
|
|
|
+ catch { toast.error(t("admin.toasts.genericError")); }
|
|
|
}
|
|
}
|
|
|
async function handleUpdatePrice() {
|
|
async function handleUpdatePrice() {
|
|
|
if (!editingPrice.value) return;
|
|
if (!editingPrice.value) return;
|
|
|
const p = parseFloat(editingPrice.value.price);
|
|
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"); }
|
|
|
|
|
|
|
+ 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 handleUpdateParams() {
|
|
|
|
|
+ if (!editingParams.value) return;
|
|
|
|
|
+ try {
|
|
|
|
|
+ await adminUpdateOrder(editingParams.value.id, {
|
|
|
|
|
+ material_id: editingParams.value.material_id,
|
|
|
|
|
+ color_name: editingParams.value.color_name,
|
|
|
|
|
+ quantity: editingParams.value.quantity
|
|
|
|
|
+ });
|
|
|
|
|
+ toast.success(t("admin.toasts.paramsUpdated"));
|
|
|
|
|
+ editingParams.value = null;
|
|
|
|
|
+ fetchData();
|
|
|
|
|
+ } catch { toast.error(t("admin.toasts.genericError")); }
|
|
|
|
|
+}
|
|
|
|
|
+function showOriginalParams(snapshotStr: string) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ viewingOriginal.value = JSON.parse(snapshotStr);
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ toast.error(t("admin.toasts.genericError"));
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
async function handleTogglePhotoPublic(photoId: number, current: boolean, allowed: boolean) {
|
|
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); }
|
|
|
|
|
|
|
+ if (!allowed && !current) { toast.error(t("admin.toasts.noConsent")); return; }
|
|
|
|
|
+ try { await adminUpdatePhotoStatus(photoId, { is_public: !current }); toast.success(t("admin.toasts.statusUpdated")); fetchData(); }
|
|
|
|
|
+ catch (e: any) { toast.error(e.message || t("admin.toasts.genericError")); }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function handleDeletePhoto(photoId: number) {
|
|
|
|
|
+ if (!window.confirm(t("admin.questions.deletePhoto"))) return;
|
|
|
|
|
+ try { await adminDeletePhoto(photoId); toast.success(t("admin.toasts.photoDeleted")); fetchData(); }
|
|
|
|
|
+ catch { toast.error(t("admin.toasts.genericError")); }
|
|
|
}
|
|
}
|
|
|
async function handleUploadPhoto(orderId: number, file?: File) {
|
|
async function handleUploadPhoto(orderId: number, file?: File) {
|
|
|
if (!file) return;
|
|
if (!file) return;
|
|
|
const order = orders.value.find(o => o.id === orderId);
|
|
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");
|
|
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(); }
|
|
|
|
|
|
|
+ try { await adminUploadOrderPhoto(orderId, fd); toast.success(t("admin.toasts.photoAdded")); fetchData(); }
|
|
|
catch (e: any) { toast.error(e.message); }
|
|
catch (e: any) { toast.error(e.message); }
|
|
|
}
|
|
}
|
|
|
async function handleAttachFile(orderId: number, file?: File) {
|
|
async function handleAttachFile(orderId: number, file?: File) {
|
|
|
if (!file) return;
|
|
if (!file) return;
|
|
|
const fd = new FormData(); fd.append("file", file);
|
|
const fd = new FormData(); fd.append("file", file);
|
|
|
- try { await adminAttachFile(orderId, fd); toast.success("File attached and preview generated"); fetchData(); }
|
|
|
|
|
|
|
+ try { await adminAttachFile(orderId, fd); toast.success(t("admin.toasts.fileAttached")); fetchData(); }
|
|
|
catch (e: any) { toast.error(e.message); }
|
|
catch (e: any) { toast.error(e.message); }
|
|
|
}
|
|
}
|
|
|
async function handleDeleteFile(orderId: number, fileId: number, filename: string) {
|
|
async function handleDeleteFile(orderId: number, fileId: number, filename: string) {
|
|
|
if (!window.confirm(`Delete attached file "${filename}"?`)) return;
|
|
if (!window.confirm(`Delete attached file "${filename}"?`)) return;
|
|
|
- try { await adminDeleteFile(orderId, fileId); toast.success("File deleted successfully"); fetchData(); }
|
|
|
|
|
- catch { toast.error("Failed to delete file"); }
|
|
|
|
|
|
|
+ try { await adminDeleteFile(orderId, fileId); toast.success(t("admin.toasts.fileDeleted")); fetchData(); }
|
|
|
|
|
+ catch { toast.error(t("admin.toasts.genericError")); }
|
|
|
}
|
|
}
|
|
|
async function handleDeleteMaterial(id: number, name: string) {
|
|
async function handleDeleteMaterial(id: number, name: string) {
|
|
|
if (!window.confirm(`Delete material "${name}"?`)) return;
|
|
if (!window.confirm(`Delete material "${name}"?`)) return;
|
|
|
- try { await adminDeleteMaterial(id); toast.success("Deleted"); fetchData(); }
|
|
|
|
|
- catch { toast.error("Failed to delete"); }
|
|
|
|
|
|
|
+ try { await adminDeleteMaterial(id); toast.success(t("admin.toasts.materialDeleted")); fetchData(); }
|
|
|
|
|
+ catch { toast.error(t("admin.toasts.genericError")); }
|
|
|
}
|
|
}
|
|
|
async function handleDeleteService(id: number, name: string) {
|
|
async function handleDeleteService(id: number, name: string) {
|
|
|
if (!window.confirm(`Delete service "${name}"?`)) return;
|
|
if (!window.confirm(`Delete service "${name}"?`)) return;
|
|
|
- try { await adminDeleteService(id); toast.success("Deleted"); fetchData(); }
|
|
|
|
|
- catch { toast.error("Failed to delete"); }
|
|
|
|
|
|
|
+ try { await adminDeleteService(id); toast.success(t("admin.toasts.serviceDeleted")); fetchData(); }
|
|
|
|
|
+ catch { toast.error(t("admin.toasts.genericError")); }
|
|
|
}
|
|
}
|
|
|
async function handleDeletePost(id: number, title: string) {
|
|
async function handleDeletePost(id: number, title: string) {
|
|
|
if (!window.confirm(`Delete post "${title}"?`)) return;
|
|
if (!window.confirm(`Delete post "${title}"?`)) return;
|
|
|
- try { await adminDeletePost(id); toast.success("Post deleted"); fetchData(); }
|
|
|
|
|
- catch { toast.error("Failed to delete post"); }
|
|
|
|
|
|
|
+ try { await adminDeletePost(id); toast.success(t("admin.toasts.postDeleted")); fetchData(); }
|
|
|
|
|
+ catch { toast.error(t("admin.toasts.genericError")); }
|
|
|
}
|
|
}
|
|
|
async function toggleMaterialActive(m: any) { await adminUpdateMaterial(m.id, { is_active: !m.is_active }); fetchData(); }
|
|
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(); }
|
|
async function toggleServiceActive(s: any) { await adminUpdateService(s.id, { is_active: !s.is_active }); fetchData(); }
|
|
@@ -603,7 +981,8 @@ async function togglePostActive(p: any) { await adminUpdatePost(p.id, { .
|
|
|
|
|
|
|
|
function handleAddNew() {
|
|
function handleAddNew() {
|
|
|
if (activeTab.value === 'materials') {
|
|
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, is_active: true });
|
|
|
|
|
|
|
+ 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 });
|
|
|
|
|
+ newColor.value = "";
|
|
|
editingMaterial.value = null;
|
|
editingMaterial.value = null;
|
|
|
} else if (activeTab.value === 'services') {
|
|
} else if (activeTab.value === 'services') {
|
|
|
Object.assign(svcForm, { name_en: "", name_ru: "", name_ua: "", name_me: "", desc_en: "", desc_ru: "", desc_ua: "", desc_me: "", tech_type: "", is_active: true });
|
|
Object.assign(svcForm, { name_en: "", name_ru: "", name_ua: "", name_me: "", desc_en: "", desc_ru: "", desc_ua: "", desc_me: "", tech_type: "", is_active: true });
|
|
@@ -611,35 +990,73 @@ function handleAddNew() {
|
|
|
} else if (activeTab.value === 'posts') {
|
|
} else if (activeTab.value === 'posts') {
|
|
|
Object.assign(postForm, { slug: "", title_en: "", title_me: "", title_ru: "", title_ua: "", excerpt_en: "", excerpt_me: "", excerpt_ru: "", excerpt_ua: "", content_en: "", content_me: "", content_ru: "", content_ua: "", category: "Technology", image_url: "", is_published: true });
|
|
Object.assign(postForm, { slug: "", title_en: "", title_me: "", title_ru: "", title_ua: "", excerpt_en: "", excerpt_me: "", excerpt_ru: "", excerpt_ua: "", content_en: "", content_me: "", content_ru: "", content_ua: "", category: "Technology", image_url: "", is_published: true });
|
|
|
editingPost.value = null;
|
|
editingPost.value = null;
|
|
|
|
|
+ } else if (activeTab.value === 'users') {
|
|
|
|
|
+ Object.assign(userForm, { email: "", password: "", first_name: "", last_name: "", phone: "" });
|
|
|
}
|
|
}
|
|
|
showAddModal.value = true;
|
|
showAddModal.value = true;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
async function handleSaveMaterial() {
|
|
async function handleSaveMaterial() {
|
|
|
try {
|
|
try {
|
|
|
- if (editingMaterial.value?.id) { await adminUpdateMaterial(editingMaterial.value.id, { ...matForm }); toast.success("Material updated"); }
|
|
|
|
|
- else { await adminCreateMaterial({ ...matForm }); toast.success("Material created"); }
|
|
|
|
|
|
|
+ if (editingMaterial.value?.id) { await adminUpdateMaterial(editingMaterial.value.id, { ...matForm }); toast.success(t("admin.toasts.materialSaved")); }
|
|
|
|
|
+ else { await adminCreateMaterial({ ...matForm }); toast.success(t("admin.toasts.materialSaved")); }
|
|
|
closeModals(); fetchData();
|
|
closeModals(); fetchData();
|
|
|
- } catch { toast.error("Failed to save material"); }
|
|
|
|
|
|
|
+ } catch { toast.error(t("admin.toasts.genericError")); }
|
|
|
}
|
|
}
|
|
|
async function handleSaveService() {
|
|
async function handleSaveService() {
|
|
|
try {
|
|
try {
|
|
|
- if (editingService.value?.id) { await adminUpdateService(editingService.value.id, { ...svcForm }); toast.success("Service updated"); }
|
|
|
|
|
- else { await adminCreateService({ ...svcForm }); toast.success("Service created"); }
|
|
|
|
|
|
|
+ if (editingService.value?.id) { await adminUpdateService(editingService.value.id, { ...svcForm }); toast.success(t("admin.toasts.serviceSaved")); }
|
|
|
|
|
+ else { await adminCreateService({ ...svcForm }); toast.success(t("admin.toasts.serviceSaved")); }
|
|
|
closeModals(); fetchData();
|
|
closeModals(); fetchData();
|
|
|
- } catch { toast.error("Failed to save service"); }
|
|
|
|
|
|
|
+ } catch { toast.error(t("admin.toasts.genericError")); }
|
|
|
}
|
|
}
|
|
|
async function handleSavePost() {
|
|
async function handleSavePost() {
|
|
|
try {
|
|
try {
|
|
|
- if (editingPost.value?.id) { await adminUpdatePost(editingPost.value.id, { ...postForm }); toast.success("Post updated"); }
|
|
|
|
|
- else { await adminCreatePost({ ...postForm }); toast.success("Post created"); }
|
|
|
|
|
|
|
+ if (editingPost.value?.id) { await adminUpdatePost(editingPost.value.id, { ...postForm }); toast.success(t("admin.toasts.postSaved")); }
|
|
|
|
|
+ else { await adminCreatePost({ ...postForm }); toast.success(t("admin.toasts.postSaved")); }
|
|
|
closeModals(); fetchData();
|
|
closeModals(); fetchData();
|
|
|
- } catch { toast.error("Failed to save post"); }
|
|
|
|
|
|
|
+ } catch { toast.error(t("admin.toasts.genericError")); }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function handleSaveUser() {
|
|
|
|
|
+ try {
|
|
|
|
|
+ await adminCreateUser({ ...userForm });
|
|
|
|
|
+ toast.success(t("admin.toasts.userCreated"));
|
|
|
|
|
+ closeModals(); fetchUsers();
|
|
|
|
|
+ } catch (err: any) {
|
|
|
|
|
+ toast.error(err.message || t("admin.toasts.genericError"));
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-function closeModals() { editingMaterial.value = null; editingService.value = null; editingPost.value = null; showAddModal.value = false; editingPrice.value = null; }
|
|
|
|
|
|
|
+async function handleToggleUserChat(userId: number, current: boolean) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ await adminUpdateUser(userId, { can_chat: !current });
|
|
|
|
|
+ toast.success(t(`admin.toasts.chat${!current ? "Enabled" : "Disabled"}`, { id: userId }));
|
|
|
|
|
+ if (activeTab.value === 'users') fetchUsers();
|
|
|
|
|
+ else fetchData();
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ toast.error(t("admin.toasts.genericError"));
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
-watch(editingMaterial, m => { if (m) Object.assign(matForm, m); });
|
|
|
|
|
|
|
+async function handleUpdateUserRole(userId: number, role: string) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ await adminUpdateUser(userId, { role });
|
|
|
|
|
+ toast.success(t("admin.toasts.roleUpdated", { id: userId, role }));
|
|
|
|
|
+ if (activeTab.value === 'users') fetchUsers();
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ toast.error(t("admin.toasts.genericError"));
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function closeModals() { editingMaterial.value = null; editingService.value = null; editingPost.value = null; showAddModal.value = false; editingPrice.value = null; editingParams.value = null; viewingOriginal.value = null; }
|
|
|
|
|
+
|
|
|
|
|
+watch(editingMaterial, m => {
|
|
|
|
|
+ if (m) {
|
|
|
|
|
+ Object.assign(matForm, m);
|
|
|
|
|
+ newColor.value = "";
|
|
|
|
|
+ }
|
|
|
|
|
+});
|
|
|
watch(editingService, s => { if (s) Object.assign(svcForm, s); });
|
|
watch(editingService, s => { if (s) Object.assign(svcForm, s); });
|
|
|
watch(editingPost, p => { if (p) Object.assign(postForm, p); });
|
|
watch(editingPost, p => { if (p) Object.assign(postForm, p); });
|
|
|
</script>
|
|
</script>
|