WarehouseSection.vue 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300
  1. <template>
  2. <div class="space-y-6">
  3. <!-- Action Bar -->
  4. <div class="flex justify-between items-center bg-card/30 p-4 rounded-2xl border border-border/50">
  5. <div class="flex items-center gap-3">
  6. <Package class="w-5 h-5 text-primary" />
  7. <h2 class="text-sm font-black uppercase tracking-widest">{{ t("admin.tabs.warehouse") }}</h2>
  8. </div>
  9. <button @click="showAddModal = true" class="flex items-center gap-2 bg-primary text-white px-4 py-2 rounded-xl text-xs font-bold hover:shadow-glow transition-all">
  10. <Plus class="w-4 h-4" />
  11. {{ t("admin.actions.add") }}
  12. </button>
  13. </div>
  14. <!-- Stock Table -->
  15. <div v-if="stock.length === 0" class="flex flex-col items-center justify-center py-20 bg-card/40 border border-border/50 rounded-3xl opacity-50">
  16. <PackageOpen class="w-12 h-12 text-muted-foreground/20 mb-4" />
  17. <p class="text-sm text-muted-foreground">{{ t("admin.warehouse.noStockFound") }}</p>
  18. </div>
  19. <div v-else class="bg-card/30 border border-border/50 rounded-3xl overflow-hidden shadow-xl">
  20. <div class="overflow-x-auto">
  21. <table class="w-full text-left border-collapse">
  22. <thead>
  23. <tr class="bg-muted/30 border-b border-border/50 text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
  24. <th class="p-4">{{ t("admin.fields.material") }}</th>
  25. <th class="p-4">{{ t("admin.fields.colors") }}</th>
  26. <th class="p-4 text-center">{{ t("admin.fields.unitMass") }}</th>
  27. <th class="p-4 text-center">{{ t("admin.fields.unitsCount") }}</th>
  28. <th class="p-4 text-center">{{ t("admin.fields.quantity") }} (kg)</th>
  29. <th class="p-4 text-center">{{ t("admin.fields.status") }}</th>
  30. <th class="p-4 text-right">{{ t("admin.labels.actions") }}</th>
  31. </tr>
  32. </thead>
  33. <tbody class="divide-y divide-border/20">
  34. <tr v-for="item in stock" :key="item.id" class="hover:bg-white/5 transition-colors group">
  35. <td class="p-4">
  36. <div class="flex flex-col">
  37. <span class="text-sm font-bold">{{ item.material_name_en }}</span>
  38. <span class="text-[10px] text-muted-foreground font-mono opacity-50">#ID {{ item.material_id }}</span>
  39. </div>
  40. </td>
  41. <td class="p-4">
  42. <div class="flex items-center gap-2">
  43. <div class="w-3 h-3 rounded-full border border-border/50" :style="{ backgroundColor: item.color_name.toLowerCase() }"></div>
  44. <span class="text-xs font-semibold">{{ item.color_name }}</span>
  45. </div>
  46. </td>
  47. <td class="p-4 text-center">
  48. <span class="text-xs font-mono">{{ item.unit_mass }}</span>
  49. </td>
  50. <td class="p-4 text-center">
  51. <div class="flex items-center justify-center gap-2">
  52. <span class="text-xs font-bold">{{ item.units_count }}</span>
  53. <button v-if="item.units_count > 0" @click="handleDeduct(item)" class="p-1 hover:bg-rose-500/10 rounded-md text-rose-500 transition-colors" title="Deduct 1 unit">
  54. <Minus class="w-3 h-3" />
  55. </button>
  56. </div>
  57. </td>
  58. <td class="p-4 text-center">
  59. <span class="text-sm font-mono font-bold" :class="item.quantity <= 0.1 ? 'text-rose-500' : 'text-emerald-500'">
  60. {{ item.quantity }}
  61. </span>
  62. </td>
  63. <td class="p-4 text-center">
  64. <button @click="toggleStatus(item)"
  65. :class="['px-3 py-1 rounded-full text-[9px] font-black uppercase tracking-widest border transition-all',
  66. item.is_active ? 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20 hover:bg-emerald-500/20' : 'bg-rose-500/10 text-rose-500 border-rose-500/20 hover:bg-rose-500/20'
  67. ]">
  68. {{ item.is_active ? t('admin.labels.current') : t('admin.labels.noFiles') /* repurposed noFiles as inactive for now */ }}
  69. </button>
  70. </td>
  71. <td class="p-4 text-right">
  72. <div class="flex justify-end gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
  73. <button @click="handleEdit(item)" class="p-2 hover:bg-primary/10 rounded-lg text-primary transition-colors"><Edit2 class="w-4 h-4" /></button>
  74. <button @click="handleDelete(item.id)" class="p-2 hover:bg-rose-500/10 rounded-lg text-rose-500 transition-colors"><Trash2 class="w-4 h-4" /></button>
  75. </div>
  76. </td>
  77. </tr>
  78. </tbody>
  79. </table>
  80. </div>
  81. </div>
  82. <!-- Pagination (Placeholder) -->
  83. <div v-if="total > 50" class="flex items-center justify-center gap-2 py-4">
  84. <button v-for="p in Math.ceil(total / 50)" :key="p"
  85. @click="$emit('update-page', p)"
  86. :class="['w-8 h-8 rounded-lg font-bold text-xs transition-all', currentPage === p ? 'bg-primary text-white shadow-glow' : 'bg-card border border-border/50 text-muted-foreground hover:border-primary/50']">
  87. {{ p }}
  88. </button>
  89. </div>
  90. <!-- Add/Edit Modal -->
  91. <div v-if="showAddModal" class="fixed inset-0 z-[10000] flex items-center justify-center p-4">
  92. <div class="absolute inset-0 bg-background/95 backdrop-blur-xl" @click="closeModal" />
  93. <div class="relative w-full max-w-md bg-card border border-primary/20 rounded-3xl p-8 shadow-2xl">
  94. <h3 class="text-xl font-black font-display text-gradient mb-6">
  95. {{ editingId ? t('admin.modals.editStock') : t('admin.modals.addStock') }}
  96. </h3>
  97. <form v-if="materials && materials.length > 0" @submit.prevent="handleSubmit" class="space-y-4">
  98. <div class="space-y-1">
  99. <label class="text-[10px] font-black uppercase text-muted-foreground ml-1 tracking-widest">{{ t('admin.fields.material') }}</label>
  100. <select v-model="form.material_id" required class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 text-sm focus:ring-2 ring-primary/20 outline-none appearance-none">
  101. <option value="0" disabled>{{ t('admin.actions.select') || 'Select material...' }}</option>
  102. <option v-for="m in materials" :key="m.id" :value="m.id">{{ m.name_en || m.name_ru }}</option>
  103. </select>
  104. </div>
  105. <div class="space-y-1">
  106. <label class="text-[10px] font-black uppercase text-muted-foreground ml-1 tracking-widest">{{ t('admin.fields.color') }}</label>
  107. <input v-model="form.color_name" required placeholder="Ex: Black, Red, Gold..." class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 text-sm focus:ring-2 ring-primary/20 outline-none" />
  108. </div>
  109. <div class="grid grid-cols-2 gap-4">
  110. <div class="space-y-1">
  111. <label class="text-[10px] font-black uppercase text-muted-foreground ml-1 tracking-widest">{{ t('admin.fields.unitMass') }}</label>
  112. <input v-model.number="form.unit_mass" type="number" step="0.001" required class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 text-sm focus:ring-2 ring-primary/20 outline-none" />
  113. </div>
  114. <div class="space-y-1">
  115. <label class="text-[10px] font-black uppercase text-muted-foreground ml-1 tracking-widest">{{ t('admin.fields.unitsCount') }}</label>
  116. <input v-model.number="form.units_count" type="number" required class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 text-sm focus:ring-2 ring-primary/20 outline-none" />
  117. </div>
  118. </div>
  119. <div class="grid grid-cols-2 gap-4">
  120. <div class="space-y-1">
  121. <label class="text-[10px] font-black uppercase text-muted-foreground ml-1 tracking-widest">{{ t('admin.fields.quantity') }} (kg)</label>
  122. <input v-model.number="form.quantity" type="number" step="0.001" required class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 text-sm focus:ring-2 ring-primary/20 outline-none" />
  123. </div>
  124. <div class="space-y-1 flex flex-col justify-end pb-2">
  125. <label class="flex items-center gap-2 cursor-pointer group">
  126. <input type="checkbox" v-model="form.is_active" class="hidden" />
  127. <div class="w-5 h-5 rounded border border-border/50 flex items-center justify-center group-hover:border-primary transition-colors" :class="form.is_active ? 'bg-primary border-primary' : ''">
  128. <Check v-if="form.is_active" class="w-3 h-3 text-white" />
  129. </div>
  130. <span class="text-[10px] font-black uppercase tracking-wider text-muted-foreground">{{ t('admin.fields.active') || 'Active' }}</span>
  131. </label>
  132. </div>
  133. </div>
  134. <div class="flex gap-4 pt-4">
  135. <button type="button" @click="closeModal" class="flex-1 px-4 py-3 rounded-xl border border-border/50 hover:bg-white/5 transition-colors text-xs font-black uppercase tracking-widest">
  136. {{ t('admin.actions.cancel') }}
  137. </button>
  138. <button type="submit" class="flex-2 bg-primary text-white px-8 py-3 rounded-xl text-xs font-black uppercase tracking-widest hover:shadow-glow transition-all">
  139. {{ editingId ? t('admin.actions.save') : t('admin.actions.add') }}
  140. </button>
  141. </div>
  142. </form>
  143. <div v-else class="text-center py-10">
  144. <PackageOpen class="w-12 h-12 text-muted-foreground/20 mx-auto mb-4" />
  145. <p class="text-xs text-muted-foreground uppercase font-black tracking-widest mb-6">
  146. {{ t('admin.warehouse.noMaterials') }}
  147. </p>
  148. <button @click="closeModal" class="px-6 py-2 bg-primary text-white rounded-xl text-[10px] font-black uppercase tracking-widest">
  149. {{ t('admin.actions.cancel') }}
  150. </button>
  151. </div>
  152. </div>
  153. </div>
  154. </div>
  155. </template>
  156. <script setup lang="ts">
  157. import { ref, reactive, onMounted } from "vue";
  158. import { useI18n } from "vue-i18n";
  159. import { Package, Plus, PackageOpen, Check, Edit2, Trash2, Minus } from "lucide-vue-next";
  160. import { toast } from "vue-sonner";
  161. import {
  162. adminGetWarehouseStock,
  163. adminAddWarehouseStock,
  164. adminUpdateWarehouseStock,
  165. adminDeleteWarehouseStock
  166. } from "@/lib/api";
  167. const { t } = useI18n();
  168. const props = defineProps<{
  169. materials: any[];
  170. }>();
  171. const stock = ref<any[]>([]);
  172. const total = ref(0);
  173. const currentPage = ref(1);
  174. const showAddModal = ref(false);
  175. const editingId = ref<number | null>(null);
  176. const form = reactive({
  177. material_id: 0,
  178. color_name: "",
  179. quantity: 1.0,
  180. unit_mass: 1.0,
  181. units_count: 1,
  182. is_active: true,
  183. notes: ""
  184. });
  185. watch(() => [form.unit_mass, form.units_count], ([m, c]) => {
  186. if (!editingId.value) {
  187. form.quantity = Number((m * c).toFixed(3));
  188. }
  189. });
  190. onMounted(() => {
  191. fetchStock();
  192. if (props.materials.length > 0) {
  193. form.material_id = props.materials[0].id;
  194. }
  195. });
  196. async function fetchStock() {
  197. try {
  198. const res = await adminGetWarehouseStock(currentPage.value);
  199. stock.value = res.stock;
  200. total.value = res.total;
  201. } catch (err: any) {
  202. toast.error(t('admin.toasts.loadError'));
  203. }
  204. }
  205. async function handleDeduct(item: any) {
  206. if (item.units_count <= 0) return;
  207. const newCount = item.units_count - 1;
  208. const newTotal = Number((Math.max(0, item.quantity - item.unit_mass)).toFixed(3));
  209. try {
  210. await adminUpdateWarehouseStock(item.id, {
  211. units_count: newCount,
  212. quantity: newTotal
  213. });
  214. item.units_count = newCount;
  215. item.quantity = newTotal;
  216. toast.success(t('admin.toasts.materialSaved'));
  217. } catch (err: any) {
  218. toast.error(err.message);
  219. }
  220. }
  221. async function toggleStatus(item: any) {
  222. try {
  223. await adminUpdateWarehouseStock(item.id, { is_active: !item.is_active });
  224. item.is_active = !item.is_active;
  225. toast.success(t('admin.toasts.statusUpdated'));
  226. } catch (err: any) {
  227. toast.error(err.message);
  228. }
  229. }
  230. function handleEdit(item: any) {
  231. editingId.value = item.id;
  232. Object.assign(form, {
  233. material_id: item.material_id,
  234. color_name: item.color_name,
  235. quantity: item.quantity,
  236. unit_mass: item.unit_mass,
  237. units_count: item.units_count,
  238. is_active: item.is_active,
  239. notes: item.notes || ""
  240. });
  241. showAddModal.value = true;
  242. }
  243. async function handleDelete(id: number) {
  244. if (!confirm(t('admin.questions.deletePhoto'))) return;
  245. try {
  246. await adminDeleteWarehouseStock(id);
  247. toast.success(t('admin.toasts.materialDeleted'));
  248. fetchStock();
  249. } catch (err: any) {
  250. toast.error(err.message);
  251. }
  252. }
  253. function closeModal() {
  254. showAddModal.value = false;
  255. editingId.value = null;
  256. form.color_name = "";
  257. form.quantity = 1;
  258. form.is_active = true;
  259. }
  260. async function handleSubmit() {
  261. try {
  262. if (editingId.value) {
  263. await adminUpdateWarehouseStock(editingId.value, form);
  264. toast.success(t('admin.toasts.materialSaved'));
  265. } else {
  266. await adminAddWarehouseStock(form);
  267. toast.success(t('admin.toasts.materialSaved'));
  268. }
  269. closeModal();
  270. fetchStock();
  271. } catch (err: any) {
  272. toast.error(err.message);
  273. }
  274. }
  275. </script>