OrderCard.vue 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276
  1. <template>
  2. <div
  3. @mouseenter="$emit('focus', order.id)"
  4. @mouseleave="$emit('focus', null)"
  5. :class="[
  6. 'group relative bg-card/40 backdrop-blur-md border rounded-3xl overflow-hidden transition-all duration-300',
  7. isFocused ? 'border-primary ring-1 ring-primary/20 shadow-glow' : 'border-border/50'
  8. ]"
  9. >
  10. <!-- Paste Indicator -->
  11. <div v-if="isFocused" class="absolute top-2 right-2 z-50 pointer-events-none animate-pulse">
  12. <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">
  13. <span class="text-[9px] font-black text-primary uppercase tracking-widest">Ctrl+V — Photo Report</span>
  14. </div>
  15. </div>
  16. <div class="flex flex-col lg:flex-row divide-y lg:divide-y-0 lg:divide-x divide-border/50">
  17. <!-- Info Column -->
  18. <div class="p-6 lg:w-1/2">
  19. <div class="flex items-center justify-between mb-4">
  20. <span class="text-xl font-black text-foreground bg-primary/10 px-3 py-1 rounded-xl tracking-tight">#{{ order.id }}</span>
  21. <span :class="['flex items-center gap-1.5 px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider', statusColor]">
  22. <component :is="statusIcon" class="w-3.5 h-3.5" />
  23. {{ t("statuses." + order.status) }}
  24. </span>
  25. </div>
  26. <div class="flex items-center gap-2">
  27. <div class="flex flex-col">
  28. <h3 class="font-bold leading-none">{{ order.first_name }} {{ order.last_name }}</h3>
  29. <div v-if="order.is_company" class="mt-1 flex flex-col gap-0.5">
  30. <span class="text-[9px] font-bold uppercase py-0.5 px-1.5 bg-primary text-primary-foreground rounded-md w-fit">{{ t("auth.fields.company") }}</span>
  31. <p class="text-[10px] font-bold text-primary truncate max-w-[150px]">{{ order.company_name }}</p>
  32. <p class="text-[8px] text-muted-foreground font-mono">PIB: {{ order.company_pib }}</p>
  33. </div>
  34. <p v-else class="text-[10px] text-muted-foreground mt-1">{{ order.email }}</p>
  35. </div>
  36. </div>
  37. <div class="mt-8 grid grid-cols-2 gap-x-8 gap-y-4 border-t border-border/10 pt-6">
  38. <div v-if="order.phone" class="space-y-1">
  39. <span class="text-[9px] font-bold uppercase text-muted-foreground tracking-widest">{{ t("admin.fields.phone") }}</span>
  40. <p class="text-xs font-medium">{{ order.phone }}</p>
  41. </div>
  42. <div class="space-y-1">
  43. <span class="text-[9px] font-bold uppercase text-muted-foreground tracking-widest">{{ t("admin.labels.registered") }}</span>
  44. <p class="text-xs font-medium">{{ formatDate(order.created_at) }}</p>
  45. </div>
  46. <div class="space-y-1">
  47. <span class="text-[9px] font-bold uppercase text-muted-foreground tracking-widest">{{ t("admin.fields.address") }}</span>
  48. <p class="text-xs font-medium leading-relaxed">{{ order.shipping_address || '—' }}</p>
  49. </div>
  50. <div class="space-y-1">
  51. <span class="text-[9px] font-bold uppercase text-muted-foreground tracking-widest">{{ t("admin.fields.deliveryType") }}</span>
  52. <p class="text-xs font-medium">{{ order.delivery_type === 'cargo' ? t("admin.fields.cargo") : t("admin.fields.pickup") }}</p>
  53. </div>
  54. </div>
  55. <div v-if="order.notes" class="mt-8 p-4 bg-primary/5 border border-primary/10 rounded-2xl italic">
  56. <span class="text-[9px] font-bold uppercase text-primary/60 mb-2 block tracking-widest">{{ t("admin.fields.projectNotes") }}</span>
  57. <p class="text-[11px] leading-relaxed">"{{ order.notes }}"</p>
  58. </div>
  59. </div>
  60. <!-- Resources Column -->
  61. <div class="p-6 lg:w-1/4 border-x border-border/50">
  62. <div class="flex items-center justify-between mb-4">
  63. <span class="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">{{ t("admin.fields.sourceFiles") }} ({{ order.files?.length || 0 }})</span>
  64. <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">
  65. <Plus class="w-3.5 h-3.5" />
  66. <input type="file" class="hidden" accept=".stl,.obj" @change="e => $emit('attach-file', order.id, (e.target as HTMLInputElement).files?.[0])" />
  67. </label>
  68. </div>
  69. <div v-if="order.model_link" class="mb-6 p-3 bg-blue-500/5 border border-blue-500/20 rounded-xl">
  70. <span class="text-[9px] font-bold uppercase text-blue-500/60 mb-1 block">{{ t("admin.fields.externalLink") }}</span>
  71. <div class="flex items-center justify-between gap-2 overflow-hidden">
  72. <p class="text-[10px] text-muted-foreground truncate">{{ order.model_link }}</p>
  73. <a :href="order.model_link" target="_blank" class="text-blue-500 hover:underline"><ExternalLink class="w-3.5 h-3.5" /></a>
  74. </div>
  75. </div>
  76. <div class="space-y-3">
  77. <template v-for="(f, i) in order.files" :key="f.id || i">
  78. <div v-if="f.id" class="relative group/file bg-background/30 border border-border/50 rounded-2xl overflow-hidden hover:border-primary/30 transition-all flex h-20">
  79. <div class="w-20 bg-muted/20 flex items-center justify-center border-r border-border/50 overflow-hidden">
  80. <img v-if="f.preview_path" :src="`${resourcesBaseUrl}/${f.preview_path}`" class="w-full h-full object-contain p-1" />
  81. <FileBox v-else class="w-6 h-6 text-muted-foreground/30" />
  82. <div class="absolute inset-0 bg-primary/20 opacity-0 group-hover/file:opacity-100 transition-opacity flex items-center justify-center">
  83. <a :href="`${resourcesBaseUrl}/${f.file_path}`" target="_blank" class="bg-card w-8 h-8 rounded-full flex items-center justify-center shadow-lg"><Download class="w-4 h-4 text-primary" /></a>
  84. </div>
  85. </div>
  86. <div class="flex-1 p-3 flex flex-col justify-center min-w-0">
  87. <p class="text-[11px] font-bold truncate mb-1 pr-8">{{ f.filename }}</p>
  88. <div class="flex flex-wrap gap-2 items-center">
  89. <span v-if="f.file_size" class="text-[9px] text-muted-foreground uppercase opacity-60">{{ (f.file_size / 1024 / 1024).toFixed(1) }} MB</span>
  90. <div v-if="f.print_time || f.filament_g" class="flex gap-2 border-l border-border/50 pl-2">
  91. <span v-if="f.print_time" class="text-[9px] font-bold text-primary/80">⏱️ {{ f.print_time }}</span>
  92. <span v-if="f.filament_g" class="text-[9px] font-bold text-primary/80">⚖️ {{ f.filament_g.toFixed(1) }}g</span>
  93. </div>
  94. </div>
  95. </div>
  96. <div class="absolute top-2 right-2 flex flex-col items-end gap-1">
  97. <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>
  98. <button @click.prevent="$emit('delete-file', order.id, f.id, f.filename)" class="p-1 bg-background/80 backdrop-blur-md border border-border/50 text-rose-500 hover:bg-rose-500 hover:text-white rounded-md transition-colors shadow-sm">
  99. <Trash2 class="w-2.5 h-2.5" />
  100. </button>
  101. </div>
  102. </div>
  103. </template>
  104. </div>
  105. <div class="mt-8 pt-6 border-t border-border/50">
  106. <div class="flex items-center justify-between mb-4">
  107. <span class="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">{{ t("admin.fields.photoReport") }} ({{ order.photos?.length || 0 }})</span>
  108. <label class="p-1.5 bg-primary/10 text-primary rounded-lg cursor-pointer hover:bg-primary hover:text-primary-foreground transition-all">
  109. <Plus class="w-3.5 h-3.5" />
  110. <input type="file" class="hidden" accept="image/*" @change="e => $emit('upload-photo', order.id, (e.target as HTMLInputElement).files?.[0])" />
  111. </label>
  112. </div>
  113. <div class="flex flex-wrap gap-2">
  114. <div v-for="(p, i) in order.photos" :key="i" class="relative group/img overflow-hidden rounded-lg border border-border/50 w-12 h-12 bg-background/50">
  115. <img :src="`${resourcesBaseUrl}/${p.file_path}`" class="w-full h-full object-cover" />
  116. <button @click="$emit('toggle-photo-public', p.id, p.is_public)" :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'}`">
  117. <Eye v-if="p.is_public" class="w-2.5 h-2.5" /><EyeOff v-else class="w-2.5 h-2.5" />
  118. </button>
  119. <div class="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 group-hover/img:opacity-100 transition-opacity gap-2">
  120. <a :href="`${resourcesBaseUrl}/${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">
  121. <ExternalLink class="w-3.5 h-3.5 text-white" />
  122. </a>
  123. <button @click="$emit('delete-photo', 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">
  124. <Trash2 class="w-3.5 h-3.5 text-white" />
  125. </button>
  126. </div>
  127. </div>
  128. <div v-if="!order.photos?.length" class="w-full py-6 border border-dashed border-border/50 rounded-2xl flex flex-col items-center justify-center opacity-40">
  129. <ImageIcon class="w-6 h-6 mb-2" /><span class="text-[10px] font-bold uppercase tracking-tighter">{{ t("admin.fields.noPhotos") }}</span>
  130. </div>
  131. </div>
  132. </div>
  133. </div>
  134. <!-- Pricing & Actions Column -->
  135. <div class="p-6 lg:w-1/4 bg-primary/5">
  136. <div class="flex items-center gap-2 mb-6">
  137. <input type="checkbox" :id="`notify-${order.id}`" v-model="internalNotify" @change="$emit('update-notify', order.id, internalNotify)" class="w-4 h-4 rounded border-border" />
  138. <label :for="`notify-${order.id}`" class="text-[10px] font-bold uppercase text-muted-foreground cursor-pointer">{{ t("admin.fields.notifyUser") }}</label>
  139. </div>
  140. <div class="grid grid-cols-2 gap-2 mb-8">
  141. <button v-for="s in statusOptions" :key="s"
  142. @click="$emit('update-status', order.id, s)"
  143. :class="`text-[9px] font-bold uppercase py-2 rounded-xl border transition-all ${order.status === s ? 'bg-primary text-primary-foreground border-primary shadow-glow' : 'bg-background hover:border-primary/30 border-border/50'}`">
  144. {{ t("statuses." + s) }}
  145. </button>
  146. </div>
  147. <div class="space-y-4 mb-8">
  148. <div class="flex justify-between items-baseline">
  149. <span class="text-[10px] font-bold uppercase text-muted-foreground">{{ t("admin.fields.totalPrice") }}</span>
  150. <p class="text-2xl font-black font-display text-primary">{{ order.invoice_amount || 0 }} EUR</p>
  151. </div>
  152. <div class="pt-4 border-t border-primary/10 space-y-3">
  153. <div class="flex items-center justify-between text-[10px] font-bold uppercase text-muted-foreground tracking-tighter">
  154. <span>{{ t("admin.labels.fiscalization") }}</span>
  155. <span :class="order.fiscal_jikr ? 'text-emerald-500' : 'text-rose-500' ">{{ order.fiscal_jikr ? t("admin.fields.active") : t("admin.fields.notActive") }}</span>
  156. </div>
  157. <div class="grid grid-cols-2 gap-3">
  158. <div class="space-y-1">
  159. <label class="text-[9px] font-bold uppercase text-muted-foreground ml-1">IKOF</label>
  160. <input v-model="fiscalData.ikof" @change="$emit('update-fiscal', order.id, fiscalData)" class="w-full bg-background border border-border/50 rounded-lg px-2 py-1.5 text-[10px] font-mono outline-none" />
  161. </div>
  162. <div class="space-y-1">
  163. <label class="text-[9px] font-bold uppercase text-muted-foreground ml-1">JIKR</label>
  164. <input v-model="fiscalData.jikr" @change="$emit('update-fiscal', order.id, fiscalData)" class="w-full bg-background border border-border/50 rounded-lg px-2 py-1.5 text-[10px] font-mono outline-none" />
  165. </div>
  166. </div>
  167. </div>
  168. </div>
  169. <div class="mt-auto space-y-3">
  170. <button @click="$emit('edit-order', order)" class="w-full py-2 bg-red-600 text-white font-bold rounded-xl mb-2">!!! DEBUG EDIT ORDER !!!</button>
  171. <Button variant="outline" class="w-full gap-2 rounded-2xl h-12 border-primary/20 text-primary hover:bg-primary/10" @click="$emit('edit-order', order)">
  172. <Edit2 class="w-4 h-4" />{{ t("admin.actions.edit") }}
  173. </Button>
  174. <Button variant="hero" class="w-full gap-2 rounded-2xl h-12" @click="$emit('open-chat', order.id)">
  175. <MessageCircle class="w-4 h-4" />{{ t("admin.actions.chatWithClient") }}
  176. </Button>
  177. <div class="pt-4 border-t border-rose-500/10">
  178. <button @click="$emit('delete-order', order.id)" class="w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-rose-500/5 hover:bg-rose-500 text-rose-500 hover:text-white border border-transparent font-bold transition-all text-xs group">
  179. <Trash2 class="w-4 h-4 transition-transform group-hover:scale-110" /> {{ t("admin.actions.deleteOrder") }}
  180. </button>
  181. </div>
  182. </div>
  183. </div>
  184. </div>
  185. <!-- Feedback Row -->
  186. <div v-if="order.review_text" class="p-6 bg-amber-500/5 border-t border-border/50 flex flex-col sm:flex-row items-center justify-between gap-6 relative z-10">
  187. <div class="flex items-start gap-4 flex-1">
  188. <div class="pt-1">
  189. <div class="flex gap-0.5">
  190. <Star v-for="i in 5" :key="i" class="w-4 h-4" :class="order.rating >= i ? 'text-yellow-500 fill-yellow-500' : 'text-muted-foreground/30'" />
  191. </div>
  192. </div>
  193. <div>
  194. <p class="text-xs font-bold text-foreground italic leading-relaxed">"{{ order.review_text }}"</p>
  195. <div class="flex items-center gap-3 mt-2">
  196. <span class="text-[9px] font-black uppercase tracking-widest text-muted-foreground shadow-sm">Customer Feedback</span>
  197. <span v-if="order.review_approved" class="flex items-center gap-1.5 text-[9px] font-black uppercase tracking-widest text-emerald-500">
  198. <CheckCircle2 class="w-3.5 h-3.5" /> Approved
  199. </span>
  200. <span v-else class="flex items-center gap-1.5 text-[9px] font-black uppercase tracking-widest text-amber-500">
  201. <Clock class="w-3.5 h-3.5" /> Pending
  202. </span>
  203. </div>
  204. </div>
  205. </div>
  206. <Button v-if="!order.review_approved" variant="hero" class="whitespace-nowrap rounded-xl" @click="$emit('approve-review', order.id)">
  207. Approve Entry
  208. </Button>
  209. </div>
  210. <!-- Chat Panel -->
  211. <div v-if="isAdminChatOpen" :id="'admin-chat-' + order.id" class="border-t border-border/50">
  212. <OrderChat :orderId="order.id" @close="$emit('close-chat')" closable />
  213. </div>
  214. </div>
  215. </template>
  216. <script setup lang="ts">
  217. import { ref, watch, reactive } from "vue";
  218. import { useI18n } from "vue-i18n";
  219. import {
  220. Clock, CheckCircle2, Truck, XCircle, AlertCircle, RefreshCw,
  221. FileText, ExternalLink, ShieldCheck, Eye, Trash2, ImageIcon,
  222. EyeOff, MessageCircle, FileBox, Download, Star, Plus, Edit2
  223. } from "lucide-vue-next";
  224. import Button from "@/components/ui/button.vue";
  225. import OrderChat from "@/components/OrderChat.vue";
  226. const { t } = useI18n();
  227. const props = defineProps<{
  228. order: any;
  229. statusConfig: Record<string, any>;
  230. resourcesBaseUrl: string;
  231. isFocused: boolean;
  232. isAdminChatOpen: boolean;
  233. notifyStatus: boolean;
  234. fiscalData: { fiscal_qr_url: string; ikof: string; jikr: string };
  235. adminChatId?: any;
  236. }>();
  237. const emit = defineEmits([
  238. 'focus', 'update-status', 'delete-order', 'attach-file', 'upload-photo',
  239. 'delete-file', 'delete-photo', 'toggle-photo-public', 'approve-review',
  240. 'open-chat', 'close-chat', 'update-notify', 'update-fiscal', 'edit-order'
  241. ]);
  242. const internalNotify = ref(props.notifyStatus);
  243. const fiscalData = reactive({ ...props.fiscalData });
  244. watch(() => props.notifyStatus, (val) => { internalNotify.value = val; });
  245. watch(() => props.fiscalData, (val) => { Object.assign(fiscalData, val); }, { deep: true });
  246. const statusOptions = Object.keys(props.statusConfig);
  247. const statusColor = props.statusConfig[props.order.status]?.color || "bg-muted text-muted-foreground";
  248. const statusIcon = props.statusConfig[props.order.status]?.icon || Clock;
  249. const formatDate = (date: string) => {
  250. return new Date(date).toLocaleDateString();
  251. };
  252. </script>