소스 검색

Merge branch 'feature/GINC-GAB-CB007-fluxo-produtos' of Softpar/sfp_vue_franchisor_ginastica_cerebro into development

Gabriel Alves 1 개월 전
부모
커밋
45088b93b3

+ 34 - 0
src/api/product.js

@@ -1,6 +1,40 @@
 import api from "src/api";
 import api from "src/api";
 
 
+export const getProducts = async (params) => {
+  const { data } = await api.get("/product", { params });
+  return data.payload;
+};
+
 export const getProductsForSelect = async () => {
 export const getProductsForSelect = async () => {
   const { data } = await api.get("/product/all/select");
   const { data } = await api.get("/product/all/select");
   return data.payload;
   return data.payload;
 };
 };
+
+export const createProduct = async (payload) => {
+  const { data } = await api.post("/product", payload);
+  return data.payload;
+};
+
+export const updateProduct = async (id, payload) => {
+  const { data } = await api.put(`/product/${id}`, payload);
+  return data.payload;
+};
+
+export const deleteProduct = async (id) => {
+  const { data } = await api.delete(`/product/${id}`);
+  return data;
+};
+
+export const adjustProductStock = async (id, payload) => {
+  const { data } = await api.patch(`/product/${id}/stock`, payload);
+  return data.payload;
+};
+
+export const importProducts = async (file) => {
+  const formData = new FormData();
+  formData.append("file", file);
+  const { data } = await api.post("/product/import", formData, {
+    headers: { "Content-Type": "multipart/form-data" },
+  });
+  return data.payload;
+};

+ 1 - 1
src/components/layout/LeftMenuLayout.vue

@@ -328,7 +328,7 @@ onMounted(() => {
 }
 }
 
 
 .q-drawer-container {
 .q-drawer-container {
-  background-image: url("images/background-opacity.png");
+  background-image: url("/images/background-opacity.png");
   background-position: 15%;
   background-position: 15%;
 }
 }
 </style>
 </style>

+ 117 - 0
src/pages/products/ProductsPage.vue

@@ -0,0 +1,117 @@
+<template>
+  <div>
+    <DefaultHeaderPage title="Produtos" :show-filter-icon="false" />
+
+    <div class="q-px-sm">
+      <CustomTabComponent
+        v-model:active-tab="currentTab"
+        :tabs="tabs"
+        class="q-mb-sm"
+      />
+      <div class="row items-center justify-between q-my-md" style="gap: 12px">
+        <q-input
+          v-model="search"
+          dense
+          outlined
+          bg-color="white"
+          placeholder="Buscar produto..."
+          style="width: 480px"
+        >
+          <template #prepend>
+            <q-icon name="mdi-magnify" />
+          </template>
+        </q-input>
+        <div class="row items-center" style="gap: 8px">
+          <q-pagination
+            v-model="currentPage"
+            :max="totalPages"
+            :max-pages="5"
+            boundary-numbers
+            direction-links
+            color="primary"
+            active-color="primary"
+          />
+          <q-btn
+            v-if="currentTab === 'products'"
+            color="primary-2"
+            style="height: 40px; width: 40px; min-width: 40px"
+            @click="handleAdd"
+          >
+            <q-icon name="mdi-plus" color="white" size="20px" />
+          </q-btn>
+
+          <q-btn
+            v-if="currentTab === 'orders'"
+            label="Importar"
+            color="primary-2"
+            icon="mdi-file-import-outline"
+            style="height: 40px"
+            @click="handleImport"
+          />
+        </div>
+      </div>
+
+      <div v-show="currentTab === 'products'">
+        <ProductsTab ref="productsTabRef" />
+      </div>
+
+      <div v-show="currentTab === 'stock'">
+        <StockTab ref="stockTabRef" />
+      </div>
+
+      <div v-show="currentTab === 'orders'">
+        <OrdersTab ref="ordersTabRef" />
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { defineAsyncComponent, ref, shallowRef, watch } from "vue";
+import { useQuasar } from "quasar";
+
+import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
+import CustomTabComponent from "src/components/shared/CustomTabComponent.vue";
+import AddEditProductDialog from "./components/AddEditProductDialog.vue";
+import ImportOrderDialog from "./components/ImportOrderDialog.vue";
+
+const ProductsTab = defineAsyncComponent(() => import("./tabs/ProductsTab.vue"));
+const StockTab = defineAsyncComponent(() => import("./tabs/StockTab.vue"));
+const OrdersTab = defineAsyncComponent(() => import("./tabs/OrdersTab.vue"));
+
+const currentTab = ref("products");
+const search = ref("");
+const currentPage = ref(1);
+const totalPages = ref(1);
+const productsTabRef = shallowRef(null);
+const stockTabRef = shallowRef(null);
+const ordersTabRef = shallowRef(null);
+
+watch(currentTab, (tab) => {
+  if (tab === "products") productsTabRef.value?.fetchProducts();
+  if (tab === "stock")    stockTabRef.value?.refresh();
+  if (tab === "orders")   ordersTabRef.value?.refresh();
+});
+
+const tabs = [
+  { name: "products", label: "Produtos" },
+  { name: "stock", label: "Estoque" },
+  { name: "orders", label: "Pedidos" },
+];
+
+const $q = useQuasar();
+
+const handleAdd = () => {
+  $q.dialog({ component: AddEditProductDialog }).onOk(() => {
+    productsTabRef.value?.fetchProducts();
+  });
+};
+
+const handleImport = () => {
+  $q.dialog({ component: ImportOrderDialog }).onOk(() => {
+    ordersTabRef.value?.refresh();
+    productsTabRef.value?.fetchProducts();
+    stockTabRef.value?.refresh();
+  });
+};
+</script>

+ 134 - 0
src/pages/products/components/AddEditProductDialog.vue

@@ -0,0 +1,134 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <div style="width: 100%; max-width: 600px">
+      <q-card class="overflow-hidden" style="width: 100%">
+        <DefaultDialogHeader
+          :title="isEdit ? 'Editar Produto' : 'Novo Produto'"
+          @close="onDialogCancel"
+        />
+
+        <q-form ref="formRef" @submit="onOKClick">
+          <q-card-section class="q-pt-sm">
+            <div class="text-h6 q-mb-sm">Produto</div>
+
+            <div class="row q-col-gutter-sm">
+              <DefaultInput
+                v-model="form.name"
+                label="Nome do Produto"
+                class="col-12"
+              />
+
+              <DefaultInput
+                v-model="form.description"
+                label="Descrição"
+                class="col-12"
+                type="textarea"
+                autogrow
+              />
+
+              <DefaultCurrencyInput
+                v-model="form.unitPrice"
+                label="Valor Unitário"
+                class="col-4"
+              />
+
+              <DefaultInput
+                v-model="form.quantity"
+                label="Quantidade"
+                class="col-4"
+                type="number"
+                min="0"
+              />
+
+              <DefaultInput
+                :model-value="totalValueFormatted"
+                label="Valor Total do Produto"
+                class="col-4"
+                disable
+                readonly
+              />
+            </div>
+          </q-card-section>
+
+          <q-card-actions align="right" class="q-px-md q-pb-md">
+            <q-btn
+              label="Cancelar"
+              outline
+              color="primary-2"
+              @click="onDialogCancel"
+            />
+            <q-btn
+              :label="isEdit ? 'Salvar Alterações' : 'Cadastrar Produto'"
+              color="primary-2"
+              type="submit"
+              :loading="loading"
+            />
+          </q-card-actions>
+        </q-form>
+      </q-card>
+    </div>
+  </q-dialog>
+</template>
+
+<script setup>
+import { ref, computed } from "vue";
+import { useDialogPluginComponent, useQuasar } from "quasar";
+import { createProduct, updateProduct } from "src/api/product";
+import { formatToBRLCurrency } from "src/helpers/utils";
+
+import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+import DefaultInput from "src/components/defaults/DefaultInput.vue";
+import DefaultCurrencyInput from "src/components/defaults/DefaultCurrencyInput.vue";
+
+const props = defineProps({
+  product: {
+    type: Object,
+    default: null,
+  },
+});
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const $q = useQuasar();
+
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
+  useDialogPluginComponent();
+
+const formRef = ref(null);
+const loading = ref(false);
+
+const isEdit = computed(() => !!props.product);
+
+const form = ref({
+  name:        props.product?.name        ?? "",
+  description: props.product?.description ?? "",
+  unitPrice:   props.product?.price_sale  ?? 0,
+  quantity:    props.product?.quantity    ?? 0,
+});
+
+const totalValue = computed(() => form.value.unitPrice * form.value.quantity);
+const totalValueFormatted = computed(() => formatToBRLCurrency(totalValue.value));
+
+const onOKClick = async () => {
+  loading.value = true;
+  try {
+    const payload = {
+      name:        form.value.name,
+      description: form.value.description,
+      price_sale:  form.value.unitPrice,
+      quantity:    Number(form.value.quantity),
+    };
+    const product = isEdit.value
+      ? await updateProduct(props.product.id, payload)
+      : await createProduct(payload);
+    onDialogOK(product);
+  } catch {
+    $q.notify({
+      type: "negative",
+      message: isEdit.value ? "Erro ao editar produto." : "Erro ao cadastrar produto.",
+    });
+  } finally {
+    loading.value = false;
+  }
+};
+</script>

+ 168 - 0
src/pages/products/components/AddEditStockProductDialog.vue

@@ -0,0 +1,168 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <div style="width: 100%; max-width: 560px">
+      <q-card class="overflow-hidden" style="width: 100%">
+        <DefaultDialogHeader title="Ajustar Estoque" @close="onDialogCancel" />
+
+        <q-form @submit="onOKClick">
+          <q-card-section class="q-pt-sm">
+            <div class="text-h6 q-mb-sm">Produto</div>
+
+            <div class="row q-col-gutter-sm q-mb-md">
+              <DefaultInput
+                :model-value="product.name"
+                label="Nome do Produto"
+                class="col-12"
+                disable
+                readonly
+              />
+
+              <DefaultInput
+                :model-value="product.description"
+                label="Descrição"
+                class="col-12"
+                type="textarea"
+                autogrow
+                disable
+                readonly
+              />
+
+              <DefaultInput
+                :model-value="formatToBRLCurrency(product.price_sale)"
+                label="Valor Unitário"
+                class="col-6"
+                disable
+                readonly
+              />
+
+              <DefaultInput
+                :model-value="String(product.quantity)"
+                label="Estoque Atual"
+                class="col-6"
+                disable
+                readonly
+              />
+            </div>
+
+            <q-separator class="q-mb-md" />
+
+            <div class="text-h6 q-mb-sm">Movimentação</div>
+
+            <div class="row q-col-gutter-sm">
+              <DefaultSelect
+                v-model="form.type"
+                label="Tipo"
+                class="col-6"
+                :options="typeOptions"
+                emit-value
+                map-options
+              />
+
+              <DefaultInput
+                v-model="form.quantity"
+                label="Quantidade"
+                class="col-6"
+                type="number"
+                min="1"
+                :rules="[quantityRule]"
+              />
+            </div>
+
+            <div v-if="stockError" class="text-negative text-caption q-mt-sm">
+              {{ stockError }}
+            </div>
+          </q-card-section>
+
+          <q-card-actions align="right" class="q-px-md q-pb-md">
+            <q-btn
+              label="Cancelar"
+              outline
+              color="primary-2"
+              @click="onDialogCancel"
+            />
+            <q-btn
+              label="Confirmar"
+              color="primary-2"
+              type="submit"
+              :loading="loading"
+            />
+          </q-card-actions>
+        </q-form>
+      </q-card>
+    </div>
+  </q-dialog>
+</template>
+
+<script setup>
+import { ref, watch } from "vue";
+import { useDialogPluginComponent, useQuasar } from "quasar";
+import { adjustProductStock } from "src/api/product";
+import { formatToBRLCurrency } from "src/helpers/utils";
+
+import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+import DefaultInput from "src/components/defaults/DefaultInput.vue";
+import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
+
+const props = defineProps({
+  product: {
+    type: Object,
+    required: true,
+  },
+});
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const $q = useQuasar();
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
+  useDialogPluginComponent();
+
+const loading = ref(false);
+const stockError = ref(null);
+
+const typeOptions = [
+  { label: "Entrada", value: "entrada" },
+  { label: "Saída", value: "saida" },
+];
+
+const form = ref({
+  type:     "entrada",
+  quantity: 1,
+});
+
+watch(() => [form.value.type, form.value.quantity], () => {
+  stockError.value = null;
+});
+
+const quantityRule = (val) => {
+  const qty = Number(val);
+  if (!qty || qty < 1) return "Informe uma quantidade válida.";
+  if (form.value.type === "saida" && qty > props.product.quantity) {
+    return `Estoque insuficiente. Disponível: ${props.product.quantity}`;
+  }
+  return true;
+};
+
+const onOKClick = async () => {
+  const qty = Number(form.value.quantity);
+
+  if (form.value.type === "saida" && qty > props.product.quantity) {
+    stockError.value = `Estoque insuficiente. Disponível: ${props.product.quantity}`;
+    return;
+  }
+
+  loading.value = true;
+  try {
+    const updated = await adjustProductStock(props.product.id, {
+      type:     form.value.type,
+      quantity: qty,
+    });
+    onDialogOK(updated);
+  } catch (err) {
+    const msg = err?.response?.data?.message ?? "Erro ao ajustar estoque.";
+    stockError.value = msg;
+    $q.notify({ type: "negative", message: msg });
+  } finally {
+    loading.value = false;
+  }
+};
+</script>

+ 115 - 0
src/pages/products/components/AddProductDialog.vue

@@ -0,0 +1,115 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <div style="width: 100%; max-width: 600px">
+      <q-card class="overflow-hidden" style="width: 100%">
+        <DefaultDialogHeader title="Novo Produto" @close="onDialogCancel" />
+
+        <q-form ref="formRef" @submit="onOKClick">
+          <q-card-section class="q-pt-sm">
+            <div class="text-h6 q-mb-sm">Produto</div>
+
+            <div class="row q-col-gutter-sm">
+              <DefaultInput
+                v-model="form.name"
+                label="Nome do Produto"
+                class="col-12"
+              />
+
+              <DefaultInput
+                v-model="form.description"
+                label="Descrição"
+                class="col-12"
+                type="textarea"
+                autogrow
+              />
+
+              <DefaultCurrencyInput
+                v-model="form.unitPrice"
+                label="Valor Unitário"
+                class="col-4"
+              />
+
+              <DefaultInput
+                v-model="form.quantity"
+                label="Quantidade"
+                class="col-4"
+                type="number"
+                min="0"
+              />
+
+              <DefaultInput
+                :model-value="totalValueFormatted"
+                label="Valor Total do Produto"
+                class="col-4"
+                disable
+                readonly
+              />
+            </div>
+          </q-card-section>
+
+          <q-card-actions align="right" class="q-px-md q-pb-md">
+            <q-btn
+              label="Cancelar"
+              outline
+              color="primary-2"
+              @click="onDialogCancel"
+            />
+            <q-btn
+              label="Cadastrar Produto"
+              color="primary-2"
+              type="submit"
+              :loading="loading"
+            />
+          </q-card-actions>
+        </q-form>
+      </q-card>
+    </div>
+  </q-dialog>
+</template>
+
+<script setup>
+import { ref, computed } from "vue";
+import { useDialogPluginComponent, useQuasar } from "quasar";
+import { createProduct } from "src/api/product";
+import { formatToBRLCurrency } from "src/helpers/utils";
+
+import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+import DefaultInput from "src/components/defaults/DefaultInput.vue";
+import DefaultCurrencyInput from "src/components/defaults/DefaultCurrencyInput.vue";
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const $q = useQuasar();
+
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
+  useDialogPluginComponent();
+
+const formRef = ref(null);
+const loading = ref(false);
+
+const form = ref({
+  name: "",
+  description: "",
+  unitPrice: 0,
+  quantity: 0,
+});
+
+const totalValue = computed(() => form.value.unitPrice * form.value.quantity);
+const totalValueFormatted = computed(() => formatToBRLCurrency(totalValue.value));
+
+const onOKClick = async () => {
+  loading.value = true;
+  try {
+    const product = await createProduct({
+      name:        form.value.name,
+      description: form.value.description,
+      price_sale:  form.value.unitPrice,
+    });
+    onDialogOK(product);
+  } catch {
+    $q.notify({ type: "negative", message: "Erro ao cadastrar produto." });
+  } finally {
+    loading.value = false;
+  }
+};
+</script>

+ 120 - 0
src/pages/products/components/ImportOrderDialog.vue

@@ -0,0 +1,120 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <div style="width: 100%; max-width: 680px">
+      <q-card class="overflow-hidden" style="width: 100%">
+        <DefaultDialogHeader title="Importar Pedido" @close="onDialogCancel" />
+
+        <q-card-section class="q-pt-sm">
+          <div class="row items-center q-col-gutter-sm">
+            <div class="col">
+              <q-file
+                v-model="selectedFile"
+                outlined
+                dense
+                accept=".xlsx,.xls"
+                label="Pesquisar arquivo Excel..."
+                @update:model-value="importedRows = []"
+              >
+                <template #prepend>
+                  <q-icon name="mdi-file-excel-outline" color="secondary" />
+                </template>
+              </q-file>
+            </div>
+
+            <div class="col-auto">
+              <q-btn
+                label="Importar"
+                color="primary-2"
+                :loading="loading"
+                :disable="!selectedFile"
+                style="height: 40px"
+                @click="handleImport"
+              />
+            </div>
+          </div>
+
+          <template v-if="importedRows.length > 0">
+            <q-separator class="q-my-md" />
+
+            <q-table
+              flat
+              dense
+              :rows="importedRows"
+              :columns="columns"
+              row-key="name"
+              hide-bottom
+              :pagination="{ rowsPerPage: 0 }"
+            >
+              <template #body-cell-price_sale="{ row }">
+                <q-td>{{ formatToBRLCurrency(row.price_sale) }}</q-td>
+              </template>
+              <template #body-cell-subtotal="{ row }">
+                <q-td>{{ formatToBRLCurrency(row.price_sale * row.quantity) }}</q-td>
+              </template>
+            </q-table>
+
+            <div class="row justify-end q-mt-sm q-pr-sm">
+              <span class="text-body2">
+                Total:
+                <strong>{{ formatToBRLCurrency(total) }}</strong>
+              </span>
+            </div>
+          </template>
+        </q-card-section>
+
+        <q-card-actions align="right" class="q-px-md q-pb-md">
+          <q-btn
+            label="Fechar"
+            outline
+            color="primary-2"
+            @click="importedRows.length > 0 ? onDialogOK() : onDialogCancel()"
+          />
+        </q-card-actions>
+      </q-card>
+    </div>
+  </q-dialog>
+</template>
+
+<script setup>
+import { ref, computed } from "vue";
+import { useDialogPluginComponent, useQuasar } from "quasar";
+import { importProducts } from "src/api/product";
+import { formatToBRLCurrency } from "src/helpers/utils";
+
+import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const $q = useQuasar();
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
+  useDialogPluginComponent();
+
+const selectedFile = ref(null);
+const loading = ref(false);
+const importedRows = ref([]);
+
+const columns = [
+  { name: "name",      label: "Descrição", field: "name",      align: "left" },
+  { name: "quantity",  label: "Qtde",      field: "quantity",  align: "center" },
+  { name: "price_sale", label: "Preço",   field: "price_sale", align: "right" },
+  { name: "subtotal",  label: "Subtotal",  field: "subtotal",  align: "right" },
+];
+
+const total = computed(() =>
+  importedRows.value.reduce((sum, r) => sum + r.price_sale * r.quantity, 0)
+);
+
+const handleImport = async () => {
+  if (!selectedFile.value) return;
+
+  loading.value = true;
+  try {
+    importedRows.value = await importProducts(selectedFile.value);
+  } catch (err) {
+    const msg = err?.response?.data?.message ?? "Erro ao importar arquivo.";
+    $q.notify({ type: "negative", message: msg });
+  } finally {
+    loading.value = false;
+  }
+};
+</script>

+ 102 - 0
src/pages/products/components/ProductCard.vue

@@ -0,0 +1,102 @@
+<template>
+  <q-card flat bordered class="product-card">
+    <q-expansion-item v-model="expanded" hide-expand-icon>
+      <template #header>
+        <div class="row items-center justify-between full-width q-px-xs q-py-sm" style="gap: 8px">
+          <span class="text-subtitle2 text-weight-bold text-primary">
+            {{ title }}
+          </span>
+          <div class="row items-center" style="gap: 8px">
+            <q-badge
+              v-if="!expanded"
+              color="secondary"
+              text-color="black"
+              :label="currentStock"
+              class="q-px-lg q-py-sm"
+            />
+            <q-icon
+              :name="expanded ? 'mdi-chevron-up' : 'mdi-chevron-down'"
+              size="24px"
+              color="secondary"
+            />
+          </div>
+        </div>
+      </template>
+
+      <q-separator />
+
+      <q-card-section class="q-px-md q-py-sm">
+        <div class="column" style="gap: 8px">
+          <span class="text-caption text-foreground ellipsis-2-lines">
+            {{ description }}
+          </span>
+          <span class="text-body2">
+            Valor: {{ formatToBRLCurrency(unitPrice) }}
+          </span>
+          <div class="row items-center justify-between">
+            <span class="text-body2">Estoque</span>
+            <div class="column items-end" style="gap: 2px">
+              <q-badge color="secondary" text-color="black" :label="currentStock" class="q-px-lg q-py-sm" />
+              <span class="text-body2 text-weight-bold">{{ formatToBRLCurrency(totalValue) }}</span>
+            </div>
+          </div>
+          <slot name="details" />
+          <div class="row justify-end" style="gap: 4px; margin-top: 4px">
+            <q-btn flat dense round icon="mdi-pencil" color="primary" size="sm" @click.stop="$emit('edit')" />
+            <q-btn flat dense round icon="mdi-delete" color="negative" size="sm" @click.stop="$emit('delete')" />
+          </div>
+        </div>
+      </q-card-section>
+    </q-expansion-item>
+  </q-card>
+</template>
+
+<script setup>
+import { ref } from "vue";
+import { formatToBRLCurrency } from "src/helpers/utils";
+
+const expanded = ref(false);
+
+defineEmits(["edit", "delete"]);
+
+defineProps({
+  title: {
+    type: String,
+    default: "titulo",
+  },
+  description: {
+    type: String,
+    default: "descricao do produto",
+  },
+  unitPrice: {
+    type: Number,
+    default: 0,
+  },
+  currentStock: {
+    type: Number,
+    default: 0,
+  },
+  totalValue: {
+    type: Number,
+    default: 0,
+  },
+});
+</script>
+
+<style scoped>
+.product-card {
+  border-radius: 12px;
+  transition: box-shadow 0.2s;
+}
+
+.product-card:hover {
+  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
+}
+
+.ellipsis-2-lines {
+  display: -webkit-box;
+
+  -webkit-box-orient: vertical;
+  overflow: hidden;
+}
+</style>

+ 44 - 0
src/pages/products/tabs/OrdersTab.vue

@@ -0,0 +1,44 @@
+<template>
+  <DefaultTable
+    ref="tableRef"
+    :columns="columns"
+    :api-call="getProducts"
+    :show-search-field="false"
+    description="produtos"
+  />
+</template>
+
+<script setup>
+import { ref } from "vue";
+import { formatToBRLCurrency } from "src/helpers/utils";
+import { getProducts } from "src/api/product";
+import DefaultTable from "src/components/defaults/DefaultTable.vue";
+
+const tableRef = ref(null);
+
+const columns = [
+  {
+    name:     "name",
+    label:    "Produto",
+    field:    "name",
+    align:    "left",
+    sortable: true,
+  },
+  {
+    name:    "price_sale",
+    label:   "Preço",
+    field:   "price_sale",
+    align:   "left",
+    format:  (val) => formatToBRLCurrency(val),
+  },
+  {
+    name:     "quantity",
+    label:    "Estoque Atual",
+    field:    "quantity",
+    align:    "center",
+    sortable: true,
+  },
+];
+
+defineExpose({ refresh: () => tableRef.value?.refresh() });
+</script>

+ 83 - 0
src/pages/products/tabs/ProductsTab.vue

@@ -0,0 +1,83 @@
+<template>
+  <div class="column" style="gap: 8px">
+    <div v-if="loading" class="row justify-center q-pa-lg">
+      <q-spinner color="primary" size="32px" />
+    </div>
+
+    <template v-else>
+      <ProductCard
+        v-for="product in products"
+        :key="product.id"
+        :title="product.name"
+        :description="product.description"
+        :unit-price="product.price_sale"
+        :current-stock="product.quantity"
+        :total-value="product.price_sale * product.quantity"
+        @edit="handleEdit(product)"
+        @delete="handleDelete(product)"
+      />
+
+      <div v-if="products.length === 0" class="text-center text-grey q-pa-lg">
+        Nenhum produto cadastrado.
+      </div>
+    </template>
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted } from "vue";
+import { useQuasar } from "quasar";
+import ProductCard from "../components/ProductCard.vue";
+import AddEditProductDialog from "../components/AddEditProductDialog.vue";
+import { getProducts, deleteProduct } from "src/api/product";
+
+const $q = useQuasar();
+
+const products = ref([]);
+const loading = ref(false);
+
+const fetchProducts = async () => {
+  loading.value = true;
+  try {
+    products.value = await getProducts();
+  } catch {
+    $q.notify({ type: "negative", message: "Erro ao carregar produtos." });
+  } finally {
+    loading.value = false;
+  }
+};
+
+onMounted(fetchProducts);
+
+defineExpose({ fetchProducts });
+
+const handleEdit = (product) => {
+  $q.dialog({
+    component: AddEditProductDialog,
+    componentProps: { product },
+  }).onOk((updated) => {
+    const index = products.value.findIndex((p) => p.id === product.id);
+    if (index !== -1) {
+      products.value[index] = updated;
+    }
+  });
+};
+
+const handleDelete = (product) => {
+  $q.dialog({
+    title: "Excluir Produto",
+    message: `Deseja excluir o produto "${product.name}"?`,
+    cancel: { label: "Cancelar", flat: true, color: "primary" },
+    ok: { label: "Excluir", color: "negative" },
+    persistent: true,
+  }).onOk(async () => {
+    try {
+      await deleteProduct(product.id);
+      products.value = products.value.filter((p) => p.id !== product.id);
+      $q.notify({ type: "positive", message: "Produto excluído com sucesso." });
+    } catch {
+      $q.notify({ type: "negative", message: "Erro ao excluir produto." });
+    }
+  });
+};
+</script>

+ 75 - 0
src/pages/products/tabs/StockTab.vue

@@ -0,0 +1,75 @@
+<template>
+  <DefaultTable
+    ref="tableRef"
+    :columns="columns"
+    :api-call="getProducts"
+    :show-search-field="false"
+    description="produtos"
+  >
+    <template #body-cell-actions="{ row }">
+      <q-btn
+        flat
+        dense
+        round
+        icon="mdi-file-edit-outline"
+        color="primary"
+        size="sm"
+        @click.stop="handleEdit(row)"
+      />
+    </template>
+  </DefaultTable>
+</template>
+
+<script setup>
+import { ref } from "vue";
+import { useQuasar } from "quasar";
+import { formatToBRLCurrency } from "src/helpers/utils";
+import { getProducts } from "src/api/product";
+
+import DefaultTable from "src/components/defaults/DefaultTable.vue";
+import AddEditStockProductDialog from "../components/AddEditStockProductDialog.vue";
+
+const $q = useQuasar();
+const tableRef = ref(null);
+
+const columns = [
+  {
+    name:     "name",
+    label:    "Produto",
+    field:    "name",
+    align:    "left",
+    sortable: true,
+  },
+  {
+    name:    "price_sale",
+    label:   "Preço",
+    field:   "price_sale",
+    align:   "left",
+    format:  (val) => formatToBRLCurrency(val),
+  },
+  {
+    name:     "quantity",
+    label:    "Estoque Atual",
+    field:    "quantity",
+    align:    "center",
+    sortable: true,
+  },
+  {
+    name:  "actions",
+    label: "Ações",
+    field: "actions",
+    align: "center",
+  },
+];
+
+defineExpose({ refresh: () => tableRef.value?.refresh() });
+
+const handleEdit = (product) => {
+  $q.dialog({
+    component: AddEditStockProductDialog,
+    componentProps: { product },
+  }).onOk(() => {
+    tableRef.value?.refresh();
+  });
+};
+</script>

+ 24 - 0
src/router/routes/product.route.js

@@ -0,0 +1,24 @@
+export default [
+  {
+    path: "/products",
+    name: "ProductsPage",
+    component: () => import("pages/products/ProductsPage.vue"),
+    meta: {
+      title: {
+        value: "Produtos",
+        translate: false,
+      },
+      requireAuth: true,
+      breadcrumbs: [
+        {
+          name: "DashboardPage",
+          title: "Dashboard",
+        },
+        {
+          name: "ProductsPage",
+          title: "Produtos",
+        },
+      ],
+    },
+  },
+];

+ 21 - 12
src/stores/navigation.js

@@ -6,22 +6,13 @@ export const navigationStore = defineStore("navigation", () => {
   const navigationStructure = Object.freeze([
   const navigationStructure = Object.freeze([
     {
     {
       type: "single",
       type: "single",
-      title: "ui.navigation.dashboard",
+      title: "Dashboard",
       name: "DashboardPage",
       name: "DashboardPage",
       icon: "mdi-poll",
       icon: "mdi-poll",
       disable: false,
       disable: false,
       permission: false,
       permission: false,
       permissionScope: "dashboard",
       permissionScope: "dashboard",
     },
     },
-    {
-      type: "single",
-      title: "Usuários",
-      name: "UserPage",
-      icon: "mdi-account-multiple-outline",
-      disable: false,
-      permission: false,
-      permissionScope: "dashboard",
-    },
     {
     {
       type: "single",
       type: "single",
       title: "Franqueados",
       title: "Franqueados",
@@ -29,7 +20,7 @@ export const navigationStore = defineStore("navigation", () => {
       icon: "mdi-home-variant-outline",
       icon: "mdi-home-variant-outline",
       disable: false,
       disable: false,
       permission: false,
       permission: false,
-      permissionScope: "dashboard"
+      permissionScope: "dashboard",
     },
     },
     {
     {
       type: "single",
       type: "single",
@@ -40,6 +31,15 @@ export const navigationStore = defineStore("navigation", () => {
       permission: false,
       permission: false,
       permissionScope: "dashboard",
       permissionScope: "dashboard",
     },
     },
+    {
+      type: "single",
+      title: "Produtos",
+      name: "ProductsPage",
+      icon: "mdi-cart-outline",
+      disable: false,
+      permission: false,
+      permissionScope: "dashboard",
+    },
     {
     {
       type: "single",
       type: "single",
       title: "TBR",
       title: "TBR",
@@ -47,7 +47,16 @@ export const navigationStore = defineStore("navigation", () => {
       icon: "mdi-database-outline",
       icon: "mdi-database-outline",
       disable: false,
       disable: false,
       permission: false,
       permission: false,
-      permissionScope: "dashboard"
+      permissionScope: "dashboard",
+    },
+    {
+      type: "single",
+      title: "Cadastros",
+      name: "UserPage",
+      icon: "mdi-account-multiple-outline",
+      disable: false,
+      permission: false,
+      permissionScope: "dashboard",
     },
     },
   ]);
   ]);