From 5d2b49aa7afe09961a2d09617277a48838aff8cf Mon Sep 17 00:00:00 2001
From: Saeid Doroudi <doroudi@outlook.com>
Date: Sun, 14 Jan 2024 21:05:09 +0330
Subject: [PATCH] feat: :sparkles: as a admin user I want to manage customers
 Fixes #47

---
 package.json                                  |   1 +
 pnpm-lock.yaml                                |   7 +
 src/auto-imports.d.ts                         |   3 +
 src/common/api/api-service.ts                 |   2 +-
 src/common/filters/date.filter.ts             |  19 ++
 src/components.d.ts                           |   1 +
 .../Customers/CustomerManagement.vue          | 166 ++++++++++++++++++
 src/components/Sidebar.vue                    |  12 +-
 src/main.ts                                   |  11 +-
 ...account.handlers.ts => account.handler.ts} |   2 +-
 src/mocks/handlers/customer.handler.ts        |  52 ++++++
 src/models/Customer.ts                        |  19 ++
 src/pages/Customers/index.vue                 |   7 +-
 src/services/customer.service.ts              |  14 ++
 src/store/account.store.ts                    |   5 +-
 src/store/customer.store.ts                   |  38 ++++
 16 files changed, 347 insertions(+), 12 deletions(-)
 create mode 100644 src/common/filters/date.filter.ts
 create mode 100644 src/components/Customers/CustomerManagement.vue
 rename src/mocks/handlers/{account.handlers.ts => account.handler.ts} (90%)
 create mode 100644 src/mocks/handlers/customer.handler.ts
 create mode 100644 src/models/Customer.ts
 create mode 100644 src/services/customer.service.ts
 create mode 100644 src/store/customer.store.ts

diff --git a/package.json b/package.json
index 7a464c2..ce7b0fa 100644
--- a/package.json
+++ b/package.json
@@ -22,6 +22,7 @@
     "@vueuse/head": "^1.3.1",
     "apexcharts": "^3.44.2",
     "axios": "^0.27.2",
+    "moment": "^2.30.1",
     "nprogress": "^0.2.0",
     "pinia": "^2.1.7",
     "vue": "^3.3.11",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 9cfef91..5a0d18d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -26,6 +26,9 @@ dependencies:
   axios:
     specifier: ^0.27.2
     version: 0.27.2
+  moment:
+    specifier: ^2.30.1
+    version: 2.30.1
   nprogress:
     specifier: ^0.2.0
     version: 0.2.0
@@ -7912,6 +7915,10 @@ packages:
       ufo: 1.3.2
     dev: true
 
+  /moment@2.30.1:
+    resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==}
+    dev: false
+
   /mrmime@1.0.1:
     resolution: {integrity: sha512-hzzEagAgDyoU1Q6yg5uI+AorQgdvMCur3FcKf7NhMKWsaYg+RnbTyHRa/9IlLF9rf455MOCtcqqrQQ83pPP7Uw==}
     engines: {node: '>=10'}
diff --git a/src/auto-imports.d.ts b/src/auto-imports.d.ts
index 07be639..03da606 100644
--- a/src/auto-imports.d.ts
+++ b/src/auto-imports.d.ts
@@ -154,6 +154,7 @@ declare global {
   const useCssVar: typeof import('@vueuse/core')['useCssVar']
   const useCssVars: typeof import('vue')['useCssVars']
   const useCurrentElement: typeof import('@vueuse/core')['useCurrentElement']
+  const useCustomerStore: typeof import('./store/customer')['useCustomerStore']
   const useCycleList: typeof import('@vueuse/core')['useCycleList']
   const useDark: typeof import('@vueuse/core')['useDark']
   const useDateFormat: typeof import('@vueuse/core')['useDateFormat']
@@ -470,6 +471,7 @@ declare module 'vue' {
     readonly useCssVar: UnwrapRef<typeof import('@vueuse/core')['useCssVar']>
     readonly useCssVars: UnwrapRef<typeof import('vue')['useCssVars']>
     readonly useCurrentElement: UnwrapRef<typeof import('@vueuse/core')['useCurrentElement']>
+    readonly useCustomerStore: UnwrapRef<typeof import('./store/customer.store')['useCustomerStore']>
     readonly useCycleList: UnwrapRef<typeof import('@vueuse/core')['useCycleList']>
     readonly useDark: UnwrapRef<typeof import('@vueuse/core')['useDark']>
     readonly useDateFormat: UnwrapRef<typeof import('@vueuse/core')['useDateFormat']>
@@ -779,6 +781,7 @@ declare module '@vue/runtime-core' {
     readonly useCssVar: UnwrapRef<typeof import('@vueuse/core')['useCssVar']>
     readonly useCssVars: UnwrapRef<typeof import('vue')['useCssVars']>
     readonly useCurrentElement: UnwrapRef<typeof import('@vueuse/core')['useCurrentElement']>
+    readonly useCustomerStore: UnwrapRef<typeof import('./store/customer.store')['useCustomerStore']>
     readonly useCycleList: UnwrapRef<typeof import('@vueuse/core')['useCycleList']>
     readonly useDark: UnwrapRef<typeof import('@vueuse/core')['useDark']>
     readonly useDateFormat: UnwrapRef<typeof import('@vueuse/core')['useDateFormat']>
diff --git a/src/common/api/api-service.ts b/src/common/api/api-service.ts
index cbd4661..a0e0219 100644
--- a/src/common/api/api-service.ts
+++ b/src/common/api/api-service.ts
@@ -36,7 +36,7 @@ export class ApiService {
   }
 
   async post<T>(url: string, data: any): Promise<T> {
-    return this.httpClient.post<T>(`${this.apiBase}/${url}`, data)
+    const response = this.httpClient.post<T>(`${this.apiBase}/${url}`, data)
     return response.data
   }
 
diff --git a/src/common/filters/date.filter.ts b/src/common/filters/date.filter.ts
new file mode 100644
index 0000000..f9dbb2c
--- /dev/null
+++ b/src/common/filters/date.filter.ts
@@ -0,0 +1,19 @@
+import moment from 'moment'
+
+const formattedDate = function (value: string, format = 'DD, MMM-YYYY, HH:mm') {
+  if (value === null)
+    return value
+  moment.locale('en')
+  return moment.utc(value).local().format(format)
+}
+const shortDate = function (value: string) {
+  moment.locale('en')
+  return moment.utc(value).local().format('YYYY/MM')
+}
+const friendlyTime = function (value: string) {
+  if (value === null)
+    return value
+  return moment(value).local().fromNow()
+}
+
+export default { formattedDate, shortDate, friendlyTime }
diff --git a/src/components.d.ts b/src/components.d.ts
index 8f41a70..a31b312 100644
--- a/src/components.d.ts
+++ b/src/components.d.ts
@@ -17,6 +17,7 @@ declare module 'vue' {
     CreateCategory: typeof import('./components/Category/CreateCategory.vue')['default']
     CreateColor: typeof import('./components/Color/CreateColor.vue')['default']
     CreateProduct: typeof import('./components/Products/CreateProduct.vue')['default']
+    CustomerManagement: typeof import('./components/Customers/CustomerManagement.vue')['default']
     DashboardCard: typeof import('./components/DashboardCard.vue')['default']
     DonutChart: typeof import('./components/DonutChart.vue')['default']
     Editor: typeof import('./components/Editor.vue')['default']
diff --git a/src/components/Customers/CustomerManagement.vue b/src/components/Customers/CustomerManagement.vue
new file mode 100644
index 0000000..215f5bd
--- /dev/null
+++ b/src/components/Customers/CustomerManagement.vue
@@ -0,0 +1,166 @@
+<script setup lang='ts'>
+import { getCurrentInstance } from 'vue'
+import { type DataTableColumns, NButton, NIcon, NSpace, NText } from 'naive-ui/es/components'
+import type { RowData } from 'naive-ui/es/data-table/src/interface'
+import {
+  Delete24Regular as DeleteIcon,
+  Edit24Regular as EditIcon,
+  Add24Filled as PlusIcon,
+} from '@vicons/fluent'
+import { storeToRefs } from 'pinia'
+import { useDialog, useMessage } from 'naive-ui'
+
+const { t } = useI18n()
+const store = useCustomerStore()
+const { customers, isLoading } = storeToRefs(store)
+const dialog = useDialog()
+const message = useMessage()
+const router = useRouter()
+
+const { proxy } = getCurrentInstance()
+
+onMounted(getItems)
+const columns: DataTableColumns<RowData> = [
+  {
+    type: 'selection',
+  },
+  {
+    title: 'NAME',
+    key: 'name',
+    render: row =>
+      h(NSpace, {}, {
+        default: () => [
+          h(NText, {}, { default: () => `${row.firstName} ${row.lastName}` }),
+        ],
+      }),
+  },
+  {
+    title: 'Join Date',
+    key: 'join-date',
+    render(row) {
+      return h(NText,
+        {}, {
+          default: () => proxy.$filters.friendlyTime(row.joinDate),
+        })
+    },
+  },
+  {
+    title: 'Phone',
+    key: 'phone',
+    render(row) {
+      return [
+        h(NText, {}, { default: () => row.mobile }),
+      ]
+    },
+  },
+  {
+    title: 'Email',
+    key: 'email',
+    render(row) {
+      return h(NText,
+        {}, {
+          default: () => row.email,
+        })
+    },
+  },
+  {
+    title: 'Orders Count',
+    key: 'ordersCount',
+  },
+  {
+    title: 'Actions',
+    key: 'actions',
+    width: 110,
+    render(row) {
+      return [
+        h(
+          NButton,
+          {
+            size: 'medium',
+            renderIcon: renderIcon(EditIcon),
+            quaternary: true,
+            circle: true,
+            class: 'mr-2',
+            onClick: () => { },
+          },
+        ),
+        h(
+          NButton,
+          {
+            size: 'medium',
+            quaternary: true,
+            circle: true,
+            renderIcon: renderIcon(DeleteIcon),
+            onClick: () => handleDeleteItem(row),
+          },
+        ),
+      ]
+    },
+  },
+]
+const { options } = storeToRefs(store)
+
+function renderIcon(icon: any) {
+  return () => h(NIcon, null, { default: () => h(icon) })
+}
+
+function handleDeleteItem(row: RowData) {
+  dialog.error({
+    title: 'Confirm',
+    content: 'Are you sure?',
+    positiveText: 'Yes, Delete',
+    negativeText: 'Cancel',
+    onPositiveClick: () => {
+      store.deleteProduct(row.id)
+      message.success('Product was deleted!')
+    },
+  })
+}
+
+function rowKey(row: RowData) {
+  return row.id
+}
+function getItems() {
+  store.getCustomers(options.value)
+}
+
+function handlePageChange(page: number) {
+  options.value.page = page
+  getItems()
+}
+
+function handleSorterChange() {
+  getItems()
+}
+
+function handleFiltersChange() {
+  getItems()
+}
+</script>
+
+<template>
+  <n-layout>
+    <n-layout-content>
+      <div class="px-3">
+        <NSpace justify="space-between" class="mb-3">
+          <n-input placeholder="Search" />
+          <NButton type="primary" @click="router.push('/Products/Create')">
+            <template #icon>
+              <NIcon>
+                <PlusIcon />
+              </NIcon>
+            </template>
+            {{ t('categories.createButton') }}
+          </NButton>
+        </NSpace>
+        <n-data-table
+          remote :columns="columns" :data="customers" :loading="isLoading" :pagination="options"
+          selectable :row-key="rowKey" @update:sorter="handleSorterChange" @update:filters="handleFiltersChange"
+          @update:page="handlePageChange"
+        />
+      </div>
+    </n-layout-content>
+  </n-layout>
+</template>
+
+<style scoped lang='scss'></style>
diff --git a/src/components/Sidebar.vue b/src/components/Sidebar.vue
index 96177d5..701f04e 100644
--- a/src/components/Sidebar.vue
+++ b/src/components/Sidebar.vue
@@ -106,22 +106,22 @@ const menuOptions: MenuOption[] = [
     ],
   },
   {
-    label: () => renderLabel(t('menu.customers'), 'customers'),
+    label: () => renderLabel(t('menu.customers'), '/customers'),
     key: 'customers',
     icon: renderIcon(CustomersIcon),
   },
   {
-    label: () => renderLabel(t('menu.announcement'), 'announcement'),
+    label: () => renderLabel(t('menu.announcement'), '/announcement'),
     key: 'notify',
     icon: renderIcon(NewsIcon),
     children: [
       {
-        label: () => renderLabel(t('menu.news'), 'news'),
+        label: () => renderLabel(t('menu.news'), '/news'),
         key: 'news',
         icon: renderIcon(NewsIcon),
       },
       {
-        label: () => renderLabel(t('menu.notifications'), 'notify'),
+        label: () => renderLabel(t('menu.notifications'), '/notify'),
         key: 'notifications',
         icon: renderIcon(NotifyIcon),
       },
@@ -138,12 +138,12 @@ const menuOptions: MenuOption[] = [
     icon: renderIcon(SettingsIcon),
     children: [
       {
-        label: () => renderLabel(t('menu.accountSettings'), 'account'),
+        label: () => renderLabel(t('menu.accountSettings'), '/account'),
         key: 'account-settings',
         icon: renderIcon(AccountSettingsIcon),
       },
       {
-        label: () => renderLabel(t('menu.websiteSettings'), 'website-settings'),
+        label: () => renderLabel(t('menu.websiteSettings'), '/website-settings'),
         key: 'website-settings',
         icon: renderIcon(WebsiteSettingsIcon),
       },
diff --git a/src/main.ts b/src/main.ts
index 5affb44..e18a585 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -9,7 +9,11 @@ import 'uno.css'
 import './styles/main.scss'
 
 const routes = setupLayouts(generatedRoutes)
-
+declare module '@vue/runtime-core' {
+  export interface ComponentCustomProperties {
+    $filters: any
+  }
+}
 async function enableMocking() {
   const isMocking = import.meta.env.VITE_API_MOCKING_ENABLED
   if (!isMocking)
@@ -28,6 +32,11 @@ app.use(router)
 Object.values(import.meta.glob<{ install: AppModule }>('./modules/*.ts', { eager: true }))
   .forEach(i => i.install?.(app, router))
 
+// register filters
+app.config.globalProperties.$filters = {}
+Object.values(import.meta.glob<any>('./common/filters/*.filter.ts', { eager: true, import: 'default' }))
+  .forEach(filters => Object.keys(filters).forEach(func => app.config.globalProperties.$filters[func] = filters[func]))
+
 router.beforeEach((to, from, next) => {
   // @ts-expect-error "Type instantiation is excessively deep and possibly infinite.ts(2589)"
   const { t } = i18n.global
diff --git a/src/mocks/handlers/account.handlers.ts b/src/mocks/handlers/account.handler.ts
similarity index 90%
rename from src/mocks/handlers/account.handlers.ts
rename to src/mocks/handlers/account.handler.ts
index 5bf166e..b5cd64c 100644
--- a/src/mocks/handlers/account.handlers.ts
+++ b/src/mocks/handlers/account.handler.ts
@@ -1,5 +1,5 @@
 import { HttpResponse, delay, http } from 'msw'
-import type { LoginResponse, LoginViewModel } from '~/models/Login'
+import type { LoginResponse, LoginViewModel } from '@/models/Login'
 
 const handlers = [
   http.post('/api/account/login', async ({ request }) => {
diff --git a/src/mocks/handlers/customer.handler.ts b/src/mocks/handlers/customer.handler.ts
new file mode 100644
index 0000000..cb30133
--- /dev/null
+++ b/src/mocks/handlers/customer.handler.ts
@@ -0,0 +1,52 @@
+import { HttpResponse, http } from 'msw'
+import _ from 'lodash'
+import { faker } from '@faker-js/faker'
+import { CreatePagedResponse } from '../handlers.utility'
+
+import type { Customer, CustomerCreateModel } from '~/models/Customer'
+
+const customers = _.times(65, createFakeCustomer)
+const handlers = [
+  http.get('/api/customer', ({ request }) => {
+    const response = CreatePagedResponse<Customer>(request, customers)
+    return HttpResponse.json(response, { status: 200 })
+  }),
+  http.post('/api/customer', async ({ request }) => {
+    const newItem = await request.json() as CustomerCreateModel
+    const customer: CustomerCreateModel = {
+      id: faker.number.int({ max: 2000 }).toString(),
+      firstName: newItem.firstName,
+      lastName: newItem.lastName,
+      address: [],
+      mobile: newItem.mobile,
+      joinDate: new Date(),
+      birthDate: newItem.birthDate,
+      email: newItem.email,
+    }
+    customers.push(customer)
+    return HttpResponse.json(customer, { status: 201 })
+  }),
+  http.delete('/api/customer/:id', ({ params }) => {
+    const { id } = params
+    const itemIndex = customers.findIndex(x => x.id === id)
+    customers.splice(itemIndex, 1)
+    return HttpResponse.json(true, { status: 200 })
+  }),
+
+]
+
+function createFakeCustomer(): Customer {
+  return {
+    id: faker.number.int().toString(),
+    firstName: faker.person.firstName(),
+    lastName: faker.person.lastName(),
+    address: [],
+    mobile: faker.phone.number(),
+    joinDate: faker.date.past(),
+    birthDate: faker.date.birthdate(),
+    email: faker.internet.email(),
+    ordersCount: faker.number.int({ max: 50 }),
+  }
+}
+
+export default handlers
diff --git a/src/models/Customer.ts b/src/models/Customer.ts
new file mode 100644
index 0000000..0da5af9
--- /dev/null
+++ b/src/models/Customer.ts
@@ -0,0 +1,19 @@
+export interface Customer {
+  id: string
+  firstName: string
+  lastName: string
+  email: string
+  mobile: string
+  address: Address[]
+  joinDate: Date
+  birthDate: Date
+  ordersCount?: number
+}
+
+export interface Address {
+
+}
+
+export interface CustomerCreateModel extends Customer {
+
+}
diff --git a/src/pages/Customers/index.vue b/src/pages/Customers/index.vue
index 5f6ea40..9599806 100644
--- a/src/pages/Customers/index.vue
+++ b/src/pages/Customers/index.vue
@@ -2,8 +2,13 @@
 
 </script>
 
+<route lang="yaml">
+meta:
+  title: Customers
+</route>
+
 <template>
-  <div />
+  <CustomerManagement />
 </template>
 
 <style scoped>
diff --git a/src/services/customer.service.ts b/src/services/customer.service.ts
new file mode 100644
index 0000000..7d50c3d
--- /dev/null
+++ b/src/services/customer.service.ts
@@ -0,0 +1,14 @@
+import { ApiService } from '~/common/api/api-service'
+import type { Customer } from '~/models/Customer'
+import type { PagedAndSortedRequest } from '~/models/PagedAndSortedRequest'
+import type { PagedListResult } from '~/models/PagedListResult'
+
+const apiService = new ApiService('customer')
+class CustomerService {
+  constructor() { }
+  async getList(options: PagedAndSortedRequest): Promise<PagedListResult<Customer>> {
+    const response = await apiService.getPagedList<Customer>('', options)
+    return response
+  }
+}
+export default new CustomerService()
diff --git a/src/store/account.store.ts b/src/store/account.store.ts
index 1171e2f..6e3de31 100644
--- a/src/store/account.store.ts
+++ b/src/store/account.store.ts
@@ -3,7 +3,7 @@ import type { Account, LoginViewModel } from '~/models/Login'
 import AccountService from '~/services/account.service'
 
 export const useAccountStore = defineStore('account', () => {
-  const user = ref<Account | null>(null)
+  const user = ref<Account | null>()
   const isLoading = ref(false)
   const loginFailed = ref(false)
 
@@ -38,13 +38,14 @@ export const useAccountStore = defineStore('account', () => {
   }
 
   return {
+    user,
     isLoading,
     loginFailed,
     login,
     logout,
     isAuthenticated,
   }
-})
+}, { persist: true })
 
 if (import.meta.hot)
   import.meta.hot.accept(acceptHMRUpdate(useAccountStore, import.meta.hot))
diff --git a/src/store/customer.store.ts b/src/store/customer.store.ts
new file mode 100644
index 0000000..b01c6f7
--- /dev/null
+++ b/src/store/customer.store.ts
@@ -0,0 +1,38 @@
+import { acceptHMRUpdate, defineStore } from 'pinia'
+import type { Customer } from '~/models/Customer'
+import type { PagedAndSortedRequest } from '~/models/PagedAndSortedRequest'
+import customerService from '~/services/customer.service'
+
+export interface CustomerState {
+
+}
+export const useCustomerStore = defineStore('Customer', () => {
+  const customers = ref<Customer[]>([])
+  const customerItem = ref<Customer>()
+  const isLoading = ref(false)
+  const isSaving = ref(false)
+  const { options } = useOptions()
+
+  async function getCustomers(options: PagedAndSortedRequest) {
+    isLoading.value = true
+    try {
+      const response = await customerService.getList(options)
+      customers.value = response.items
+      options.pageSize = Math.trunc(response.totalCount / options.itemsPerPage)
+    }
+    finally {
+      isLoading.value = false
+    }
+  }
+
+  return {
+    getCustomers,
+    customers,
+    customerItem,
+    isLoading,
+    isSaving,
+    options,
+  }
+})
+if (import.meta.hot)
+  import.meta.hot.accept(acceptHMRUpdate(useCustomerStore, import.meta.hot))