diff --git a/package.json b/package.json index c6283be..11601ae 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "@testing-library/react": "^11.2.6", "@testing-library/user-event": "^12.1.10", "antd": "^4.16.6", - "axios": "^0.21.1", + "axios": "^0.21.4", + "axios-mock-adapter": "^1.20.0", "classnames": "^2.3.1", "craco-antd": "^1.19.0", "i18next": "^20.3.2", diff --git a/src/api/api.ts b/src/api/api.ts index 541f15a..c268768 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -1,8 +1,9 @@ -import axios, { AxiosRequestConfig } from "axios"; +import axios, { AxiosError, AxiosRequestConfig, AxiosResponse } from "axios"; import moment from "moment"; import { ENV } from "@app/constants/env"; import { AuthEndpointsEnum, getTokens } from "@app/features/auth/auth"; +import { toCamel, toSnakeCase } from "@app/helpers/object-convert.helper"; /** * All the endpoint that do not require an access token @@ -26,6 +27,9 @@ export const getRefreshedToken = () => { * @param {AxiosRequestConfig} request */ const authInterceptor = async (request: AxiosRequestConfig) => { + request.params = toSnakeCase(request.params, true); + request.data = toSnakeCase(request.data, true); + const isAnonymous = anonymousEndpoints.some(endpoint => request.url?.startsWith(endpoint) ); @@ -46,6 +50,23 @@ const authInterceptor = async (request: AxiosRequestConfig) => { return request; }; +/** + * Axios response interceptors + * @param {AxiosResponse} response + */ +const responseInterceptor = (response: AxiosResponse) => { + response.data = toCamel(response.data); + return response; +}; + +/** + * Axios error interceptor + * @param {AxiosError} axiosError + */ +const errorInterceptor = (axiosError: AxiosError) => { + return axiosError; +}; + /** Setup an API instance */ export const api = axios.create({ baseURL: ENV.API_HOST, @@ -53,3 +74,4 @@ export const api = axios.create({ }); api.interceptors.request.use(authInterceptor); +api.interceptors.response.use(responseInterceptor, errorInterceptor); diff --git a/src/api/mock-api.ts b/src/api/mock-api.ts new file mode 100644 index 0000000..fa17859 --- /dev/null +++ b/src/api/mock-api.ts @@ -0,0 +1,7 @@ +import MockAdapter from "axios-mock-adapter"; + +import { api } from "./api"; + +export const mockApi = new MockAdapter(api, { + onNoMatch: "passthrough", +}); diff --git a/src/dummy-data/client-by-id.json b/src/dummy-data/client-by-id.json new file mode 100644 index 0000000..75cba39 --- /dev/null +++ b/src/dummy-data/client-by-id.json @@ -0,0 +1,15 @@ +{ + "data": { + "id": 1, + "name": "Tobye Dealtry", + "address": "321 Milwaukee Point", + "office_hours_from": "17:06:54", + "office_hours_to": "9:17:06", + "phone": "790-843-3927", + "web": "LTL", + "photo": "https://robohash.org/laborumsitmagni.png?size=50x50&set=set1", + "created_at": "2020-12-25T11:02:52Z", + "updated_at": "2021-03-09T00:28:41Z", + "deleted_at": "" + } +} diff --git a/src/dummy-data/clients.json b/src/dummy-data/clients.json new file mode 100644 index 0000000..d8bf4dc --- /dev/null +++ b/src/dummy-data/clients.json @@ -0,0 +1,270 @@ +{ + "data": [ + { + "id": 1, + "name": "Tobye Dealtry", + "address": "321 Milwaukee Point", + "office_hours_from": "17:06:54", + "office_hours_to": "9:17:06", + "phone": "790-843-3927", + "web": "LTL", + "photo": "https://robohash.org/laborumsitmagni.png?size=50x50&set=set1", + "created_at": "2020-12-25T11:02:52Z", + "updated_at": "2021-03-09T00:28:41Z", + "deleted_at": "" + }, + { + "id": 2, + "name": "Audy Gadeaux", + "address": "5052 Coolidge Crossing", + "office_hours_from": "15:44:18", + "office_hours_to": "4:54:38", + "phone": "480-850-7774", + "web": "QCAT", + "photo": "https://robohash.org/rerumaliquidreiciendis.png?size=50x50&set=set1", + "created_at": "2021-01-27T02:25:45Z", + "updated_at": "2020-11-17T03:52:13Z", + "deleted_at": "" + }, + { + "id": 3, + "name": "Sonny Scudder", + "address": "66 Crescent Oaks Alley", + "office_hours_from": "12:06:46", + "office_hours_to": "18:39:29", + "phone": "995-120-9052", + "web": "Corporate Events", + "photo": "https://robohash.org/quamabnobis.png?size=50x50&set=set1", + "created_at": "2021-01-18T15:58:56Z", + "updated_at": "2021-04-16T20:13:31Z", + "deleted_at": "" + }, + { + "id": 4, + "name": "Katherine Cristoferi", + "address": "263 Main Terrace", + "office_hours_from": "11:45:18", + "office_hours_to": "14:33:26", + "phone": "322-845-7746", + "web": "Business Planning", + "photo": "https://robohash.org/facererepudiandaevel.png?size=50x50&set=set1", + "created_at": "2021-06-26T14:13:36Z", + "updated_at": "2020-11-05T23:45:20Z", + "deleted_at": "" + }, + { + "id": 5, + "name": "Davida Haukey", + "address": "3 Pawling Drive", + "office_hours_from": "14:26:51", + "office_hours_to": "4:32:17", + "phone": "263-813-2382", + "web": "RHCE", + "photo": "https://robohash.org/pariaturfacilisofficiis.png?size=50x50&set=set1", + "created_at": "2021-04-29T19:58:43Z", + "updated_at": "2021-04-28T14:58:19Z", + "deleted_at": "" + }, + { + "id": 6, + "name": "Jesus Rustman", + "address": "8036 Ridgeview Terrace", + "office_hours_from": "20:48:46", + "office_hours_to": "0:51:58", + "phone": "676-826-4027", + "web": "Pediatrics", + "photo": "https://robohash.org/modivoluptasexcepturi.png?size=50x50&set=set1", + "created_at": "2021-01-16T22:18:24Z", + "updated_at": "2020-09-20T09:59:46Z", + "deleted_at": "" + }, + { + "id": 7, + "name": "Onofredo Eggleston", + "address": "3004 Huxley Center", + "office_hours_from": "9:15:45", + "office_hours_to": "10:11:46", + "phone": "916-342-2719", + "web": "SSL Certificates", + "photo": "https://robohash.org/etarchitectonecessitatibus.png?size=50x50&set=set1", + "created_at": "2020-12-04T14:36:16Z", + "updated_at": "2021-05-03T10:04:35Z", + "deleted_at": "" + }, + { + "id": 8, + "name": "Archibaldo Bunn", + "address": "21230 Anzinger Crossing", + "office_hours_from": "23:22:34", + "office_hours_to": "4:20:27", + "phone": "405-496-5793", + "web": "Hygiene", + "photo": "https://robohash.org/voluptatemestquam.png?size=50x50&set=set1", + "created_at": "2021-04-16T03:51:06Z", + "updated_at": "2020-10-07T16:16:43Z", + "deleted_at": "" + }, + { + "id": 9, + "name": "Neill Bollins", + "address": "6140 Hallows Parkway", + "office_hours_from": "0:45:33", + "office_hours_to": "23:02:20", + "phone": "477-938-5895", + "web": "Design Patterns", + "photo": "https://robohash.org/amagnamnumquam.png?size=50x50&set=set1", + "created_at": "2020-11-04T18:36:03Z", + "updated_at": "2021-03-20T04:22:45Z", + "deleted_at": "" + }, + { + "id": 10, + "name": "Kaila Lampbrecht", + "address": "5 Hermina Junction", + "office_hours_from": "6:43:30", + "office_hours_to": "11:10:46", + "phone": "480-963-2992", + "web": "VLDB", + "photo": "https://robohash.org/idinerror.png?size=50x50&set=set1", + "created_at": "2021-06-11T06:06:11Z", + "updated_at": "2021-07-11T13:22:32Z", + "deleted_at": "" + }, + { + "id": 11, + "name": "Candie Juschka", + "address": "3 Debs Terrace", + "office_hours_from": "13:07:23", + "office_hours_to": "10:41:24", + "phone": "268-920-8149", + "web": "KML", + "photo": "https://robohash.org/similiquelaboresoluta.png?size=50x50&set=set1", + "created_at": "2021-04-07T12:50:30Z", + "updated_at": "2021-06-19T07:52:13Z", + "deleted_at": "" + }, + { + "id": 12, + "name": "Shelli Sabey", + "address": "72463 Carpenter Drive", + "office_hours_from": "8:52:25", + "office_hours_to": "8:36:21", + "phone": "782-492-0204", + "web": "Program Management", + "photo": "https://robohash.org/laboriosamvoluptasvoluptate.png?size=50x50&set=set1", + "created_at": "2021-06-07T08:46:41Z", + "updated_at": "2021-03-07T11:18:21Z", + "deleted_at": "" + }, + { + "id": 13, + "name": "Stella Scaplehorn", + "address": "78 Eastlawn Street", + "office_hours_from": "3:23:08", + "office_hours_to": "7:30:12", + "phone": "815-476-0456", + "web": "MSP Practitioner", + "photo": "https://robohash.org/nametquod.png?size=50x50&set=set1", + "created_at": "2021-03-18T01:11:44Z", + "updated_at": "2021-06-29T09:06:48Z", + "deleted_at": "" + }, + { + "id": 14, + "name": "Kevan Wines", + "address": "25773 Arkansas Hill", + "office_hours_from": "10:14:52", + "office_hours_to": "23:38:08", + "phone": "304-861-1637", + "web": "Mergers & Acquisitions", + "photo": "https://robohash.org/praesentiuminciduntalias.png?size=50x50&set=set1", + "created_at": "2021-04-22T01:28:47Z", + "updated_at": "2021-08-05T14:03:35Z", + "deleted_at": "" + }, + { + "id": 15, + "name": "Rosmunda Coxall", + "address": "34 West Drive", + "office_hours_from": "20:23:20", + "office_hours_to": "1:33:49", + "phone": "685-895-9004", + "web": "LMS Test.Lab", + "photo": "https://robohash.org/delenitifacilisearum.png?size=50x50&set=set1", + "created_at": "2020-12-07T07:59:01Z", + "updated_at": "2020-10-25T17:29:02Z", + "deleted_at": "" + }, + { + "id": 16, + "name": "Vite Willock", + "address": "10 Pearson Circle", + "office_hours_from": "15:28:34", + "office_hours_to": "2:18:10", + "phone": "428-749-4461", + "web": "Workplace Safety", + "photo": "https://robohash.org/doloresrepudiandaecorrupti.png?size=50x50&set=set1", + "created_at": "2021-02-10T03:26:33Z", + "updated_at": "2020-09-27T23:48:06Z", + "deleted_at": "" + }, + { + "id": 17, + "name": "Pascale Tassell", + "address": "2 Springview Alley", + "office_hours_from": "1:03:47", + "office_hours_to": "14:55:59", + "phone": "265-973-8656", + "web": "Ultrasonic Welding", + "photo": "https://robohash.org/idiustorerum.png?size=50x50&set=set1", + "created_at": "2021-08-14T04:50:15Z", + "updated_at": "2021-01-26T11:04:34Z", + "deleted_at": "" + }, + { + "id": 18, + "name": "Niall McArd", + "address": "4 Stang Way", + "office_hours_from": "13:15:48", + "office_hours_to": "7:22:27", + "phone": "428-779-3952", + "web": "DXX", + "photo": "https://robohash.org/dictaetpossimus.png?size=50x50&set=set1", + "created_at": "2021-09-11T04:44:14Z", + "updated_at": "2021-05-07T23:00:54Z", + "deleted_at": "" + }, + { + "id": 19, + "name": "Putnam Shortland", + "address": "4 1st Park", + "office_hours_from": "8:51:06", + "office_hours_to": "21:17:37", + "phone": "966-407-9689", + "web": "EEG", + "photo": "https://robohash.org/autofficiaquia.png?size=50x50&set=set1", + "created_at": "2021-06-18T20:21:34Z", + "updated_at": "2021-05-14T06:50:54Z", + "deleted_at": "" + }, + { + "id": 20, + "name": "Myra Baudino", + "address": "09549 Katie Junction", + "office_hours_from": "6:25:27", + "office_hours_to": "2:13:31", + "phone": "115-531-6836", + "web": "Digital Signage", + "photo": "https://robohash.org/harumducimustempora.png?size=50x50&set=set1", + "created_at": "2021-01-20T20:20:16Z", + "updated_at": "2021-06-30T08:59:50Z", + "deleted_at": "" + } + ], + "pagination": { + "page": 1, + "per_page": 20, + "total": 50, + "total_pages": 3 + } +} diff --git a/src/features/auth/redux/auth.slice.ts b/src/features/auth/redux/auth.slice.ts index 8c12915..78ad414 100644 --- a/src/features/auth/redux/auth.slice.ts +++ b/src/features/auth/redux/auth.slice.ts @@ -84,7 +84,7 @@ const authSlice = createSlice({ getMe.fulfilled, (state, action: PayloadAction) => { const { data } = action.payload; - const name = `${data.first_name} ${data.last_name}`; + const name = `${data.firstName} ${data.lastName}`; const user: UserDef = { ...data, diff --git a/src/features/auth/types/auth.types.ts b/src/features/auth/types/auth.types.ts index 4278b7a..ed70e41 100644 --- a/src/features/auth/types/auth.types.ts +++ b/src/features/auth/types/auth.types.ts @@ -41,12 +41,11 @@ export interface InitialStateDef { loading: boolean; } -/* eslint-disable camelcase */ export type UserResponseDef = { data: { id: number; - first_name: string; - last_name: string; + firstName: string; + lastName: string; avatar: string; email: string; }; diff --git a/src/features/clients/api/clients.api.ts b/src/features/clients/api/clients.api.ts new file mode 100644 index 0000000..30bd2c6 --- /dev/null +++ b/src/features/clients/api/clients.api.ts @@ -0,0 +1,47 @@ +import { AxiosResponse } from "axios"; + +import { api } from "@app/api/api"; +import { mockApi } from "@app/api/mock-api"; +import mockedGetClientByIdData from "@app/dummy-data/client-by-id.json"; +import mockedGetClientsData from "@app/dummy-data/clients.json"; + +import { ClientEndpointsEnum } from "../constants/clients.endpoints"; +import { + GetClientsParam, + GetClientsResponse, + GetClientByIdResponse, +} from "../types/clients.types"; + +// TODO: Mock data +mockApi + .onGet(ClientEndpointsEnum.clients) + .reply( + () => + new Promise(resolve => { + setTimeout(() => { + resolve([200, mockedGetClientsData]); + }, 2000); + }) + ) + .onGet(new RegExp(`${ClientEndpointsEnum.clients}/*`)) + .reply( + () => + new Promise(resolve => { + setTimeout(() => { + resolve([200, mockedGetClientByIdData]); + }, 2000); + }) + ); + +// API +export const getClientsApi = ( + params?: GetClientsParam +): Promise> => { + return api.get(ClientEndpointsEnum.clients, { params }); +}; + +export const getClientByIdApi = ( + id: number +): Promise> => { + return api.get(`${ClientEndpointsEnum.clients}/${id}`); +}; diff --git a/src/features/clients/clients.ts b/src/features/clients/clients.ts new file mode 100644 index 0000000..dfd1fc5 --- /dev/null +++ b/src/features/clients/clients.ts @@ -0,0 +1,12 @@ +// ROUTES +export * from "./routes/clients.routes"; + +// REDUX +export * from "./redux/clients.slice"; + +// TYPES +export * from "./types/clients.types"; + +// CONSTANTS +export * from "./constants/clients.paths"; +export * from "./constants/clients.keys"; diff --git a/src/features/clients/constants/clients.endpoints.ts b/src/features/clients/constants/clients.endpoints.ts new file mode 100644 index 0000000..07d02f3 --- /dev/null +++ b/src/features/clients/constants/clients.endpoints.ts @@ -0,0 +1,3 @@ +export enum ClientEndpointsEnum { + clients = "/api/clients", +} diff --git a/src/features/clients/constants/clients.keys.ts b/src/features/clients/constants/clients.keys.ts new file mode 100644 index 0000000..9bf8758 --- /dev/null +++ b/src/features/clients/constants/clients.keys.ts @@ -0,0 +1 @@ +export const CLIENTS_FEATURE_KEY = "clients"; diff --git a/src/features/clients/constants/clients.paths.ts b/src/features/clients/constants/clients.paths.ts new file mode 100644 index 0000000..7153f0d --- /dev/null +++ b/src/features/clients/constants/clients.paths.ts @@ -0,0 +1,3 @@ +export enum ClientsPathsEnum { + CLIENTS = "/clients", +} diff --git a/src/features/clients/redux/clients.slice.ts b/src/features/clients/redux/clients.slice.ts new file mode 100644 index 0000000..6dee90f --- /dev/null +++ b/src/features/clients/redux/clients.slice.ts @@ -0,0 +1,98 @@ +import { createAsyncThunk, createSlice } from "@reduxjs/toolkit"; + +import { mapPagination } from "@app/helpers/table.helper"; +import { TablePaginationDef } from "@app/types/pagination.types"; + +import { getClientByIdApi, getClientsApi } from "../api/clients.api"; +import { CLIENTS_FEATURE_KEY } from "../constants/clients.keys"; +import { Client, GetClientsParam } from "../types/clients.types"; + +interface SliceState { + // Clients + clients: Client[]; + isClientsLoading: boolean; + pagination: TablePaginationDef; + + // Client + client: Client | null; + isClientLoading: boolean; +} + +const initialState: SliceState = { + // Clients + clients: [], + isClientsLoading: false, + pagination: { + current: 1, + pageSize: 20, + total: 0, + }, + + // Client + client: null, + isClientLoading: false, +}; + +export const getClients = createAsyncThunk( + `${CLIENTS_FEATURE_KEY}/getClients`, + async (params: GetClientsParam, { rejectWithValue }) => { + try { + const response = await getClientsApi(params); + return response.data; + } catch (err) { + return rejectWithValue(err.response.data); + } + } +); + +export const getClientById = createAsyncThunk( + `${CLIENTS_FEATURE_KEY}/getClientById`, + async (id: number, { rejectWithValue }) => { + try { + const response = await getClientByIdApi(id); + return response.data; + } catch (err) { + return rejectWithValue(err.response.data); + } + } +); + +const clientsSlice = createSlice({ + name: CLIENTS_FEATURE_KEY, + initialState, + reducers: { + clearClient: state => { + state.client = null; + state.isClientLoading = false; + }, + }, + extraReducers: builder => { + /** Get clients */ + builder.addCase(getClients.pending, state => { + state.isClientsLoading = true; + }); + builder.addCase(getClients.fulfilled, (state, action) => { + state.isClientsLoading = false; + state.clients = action.payload.data; + state.pagination = mapPagination(action.payload.pagination); + }); + builder.addCase(getClients.rejected, state => { + state.isClientsLoading = false; + }); + + /** Get client by id */ + builder.addCase(getClientById.pending, state => { + state.isClientLoading = true; + }); + builder.addCase(getClientById.fulfilled, (state, action) => { + state.isClientLoading = false; + state.client = action.payload.data; + }); + builder.addCase(getClientById.rejected, state => { + state.isClientLoading = false; + }); + }, +}); + +export const { clearClient } = clientsSlice.actions; +export const clientsReducer = clientsSlice.reducer; diff --git a/src/features/clients/routes/clients.routes.ts b/src/features/clients/routes/clients.routes.ts new file mode 100644 index 0000000..b8aa74f --- /dev/null +++ b/src/features/clients/routes/clients.routes.ts @@ -0,0 +1,13 @@ +import { RouteItemDef } from "@app/types/route.types"; + +import { ClientsPathsEnum } from "../constants/clients.paths"; +import ClientsScreen from "../screens/ClientsScreen/ClientsScreen"; + +const CLIENTS_SCREEN: RouteItemDef = { + id: "clients", + path: ClientsPathsEnum.CLIENTS, + navigationTitle: "clients.navigationTitle", + component: ClientsScreen, +}; + +export const CLIENTS_ROUTES = [CLIENTS_SCREEN]; diff --git a/src/features/clients/screens/ClientsScreen/ClientsScreen.tsx b/src/features/clients/screens/ClientsScreen/ClientsScreen.tsx new file mode 100644 index 0000000..4757055 --- /dev/null +++ b/src/features/clients/screens/ClientsScreen/ClientsScreen.tsx @@ -0,0 +1,62 @@ +import { useCallback, useEffect } from "react"; + +import { useTranslation } from "react-i18next"; + +import ContentLayout from "@app/components/layouts/ContentLayout/ContentLayout"; +import * as modalAction from "@app/helpers/modal.helper"; +import useSearchParams from "@app/hooks/useSearchParams"; +import { useAppDispatch } from "@app/redux/store"; + +import { getClients } from "../../redux/clients.slice"; +import { Client } from "../../types/clients.types"; +import ClientsModal from "./components/ClientsModal/ClientsModal"; +import ClientsTable from "./components/ClientsTable/ClientsTable"; + +const ClientsScreen = () => { + const { t } = useTranslation(); + const { search, updateSearchParams } = useSearchParams(); + const dispatch = useAppDispatch(); + + const fetchData = useCallback(() => { + dispatch(getClients({ page: search?.page, perPage: search?.pageSize })); + }, [dispatch, search?.page, search?.pageSize]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + const handleEdit = (item: Client) => + updateSearchParams(modalAction.edit({ id: item.id.toString() })); + + const handleAdd = () => updateSearchParams(modalAction.add()); + + const handleCloseModal = () => updateSearchParams(modalAction.close()); + + const handleSubmittedModal = () => { + fetchData(); + handleCloseModal(); + }; + + const handleDelete = (item: Client) => { + // eslint-disable-next-line no-console + console.log("Delete item", item); + }; + + return ( + + + + {/* Modal to Create / Edit Client */} + + + ); +}; + +export default ClientsScreen; diff --git a/src/features/clients/screens/ClientsScreen/components/ClientsModal/ClientsModal.tsx b/src/features/clients/screens/ClientsScreen/components/ClientsModal/ClientsModal.tsx new file mode 100644 index 0000000..cbcc099 --- /dev/null +++ b/src/features/clients/screens/ClientsScreen/components/ClientsModal/ClientsModal.tsx @@ -0,0 +1,107 @@ +import { memo, useEffect } from "react"; + +import { Col, Input } from "antd"; +import _toNumber from "lodash/toNumber"; +import { useTranslation } from "react-i18next/"; + +import { Item, useForm } from "@app/components/atoms/Form/Form"; +import FormModal from "@app/components/atoms/FormModal/FormModal"; +import { ItemModalEnum } from "@app/constants/route.constants"; +import { + Client, + clearClient, + getClientById, +} from "@app/features/clients/clients"; +import useShowModal from "@app/hooks/useShowModal"; +import { useAppDispatch, useAppSelector } from "@app/redux/store"; + +interface ClientsModalProps { + onClose: () => void; + onSubmitted: () => void; +} + +const ClientsModal = memo(({ onClose, onSubmitted }: ClientsModalProps) => { + // Hooks + const { t } = useTranslation(); + const { showModal, action, entryId } = useShowModal(); + const [form] = useForm(); + const dispatch = useAppDispatch(); + const { client, isClientLoading } = useAppSelector(state => ({ + client: state.clients.client, + isClientLoading: state.clients.isClientLoading, + })); + + // Constants + const id = _toNumber(entryId); + const editMode = action === ItemModalEnum.EDIT; + + useEffect(() => { + if (showModal) { + if (editMode && !!id) { + dispatch(getClientById(id)); + } else { + dispatch(clearClient()); + } + } + }, [showModal, editMode, id, dispatch]); + + const handleClose = () => onClose(); + + const handleFinish = (values: Partial) => { + // TODO: Create / Update client + // eslint-disable-next-line no-console + console.log(values); + onSubmitted(); + }; + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}); + +export default ClientsModal; diff --git a/src/features/clients/screens/ClientsScreen/components/ClientsTable/ClientsTable.tsx b/src/features/clients/screens/ClientsScreen/components/ClientsTable/ClientsTable.tsx new file mode 100644 index 0000000..8164d71 --- /dev/null +++ b/src/features/clients/screens/ClientsScreen/components/ClientsTable/ClientsTable.tsx @@ -0,0 +1,51 @@ +import { Table } from "antd"; +import { useTranslation } from "react-i18next"; + +import Button from "@app/components/atoms/Button/Button"; +import TableView, { + TableViewProps, +} from "@app/components/molecules/TableView/TableView"; +import { Client } from "@app/features/clients/clients"; +import { useAppSelector } from "@app/redux/store"; + +interface ClientsTableProps extends TableViewProps { + onAdd?: () => void; +} + +const ClientsTable = ({ onAdd, ...props }: ClientsTableProps) => { + const { t } = useTranslation(); + const { clients, isClientsLoading, pagination } = useAppSelector(state => ({ + clients: state.clients.clients, + isClientsLoading: state.clients.isClientsLoading, + pagination: state.clients.pagination, + })); + + return ( + ( + + )} + {...props} + > + + + 1} /> + + ); +}; + +export default ClientsTable; diff --git a/src/features/clients/types/clients.types.ts b/src/features/clients/types/clients.types.ts new file mode 100644 index 0000000..027d383 --- /dev/null +++ b/src/features/clients/types/clients.types.ts @@ -0,0 +1,26 @@ +import { ResponsePaginationDef } from "@app/types/pagination.types"; + +export type Client = { + id: number; + name: string; + address: string; + officeHoursFrom: string; + officeHoursTo: string; + phone: string; + web: string; + photo: string; +}; + +export type GetClientsParam = { + page?: ResponsePaginationDef["page"]; + perPage?: ResponsePaginationDef["perPage"]; +}; + +export type GetClientsResponse = { + data: Client[]; + pagination: ResponsePaginationDef; +}; + +export type GetClientByIdResponse = { + data: Client; +}; diff --git a/src/features/localization/config/localization.config.ts b/src/features/localization/config/localization.config.ts index 5b6650c..308df8b 100644 --- a/src/features/localization/config/localization.config.ts +++ b/src/features/localization/config/localization.config.ts @@ -99,6 +99,21 @@ i18next.use(initReactI18next).init({ inputLastNameLabel: "Last name", inputLastNamePlaceholder: "Enter last name...", }, + clients: { + navigationTitle: "Clients", + title: "Clients", + text: "Clients Content", + addClient: "Add client", + editClient: "Edit client", + name: "Name", + address: "Address", + sites: "Sites", + officeHoursFrom: "Office hours from", + officeHoursTo: "Office hours to", + phone: "Phone", + web: "Web", + photo: "Photo", + }, }, }, }, diff --git a/src/features/settings/screens/UsersScreen/UsersScreen.tsx b/src/features/settings/screens/UsersScreen/UsersScreen.tsx index 3ab213c..d763f32 100644 --- a/src/features/settings/screens/UsersScreen/UsersScreen.tsx +++ b/src/features/settings/screens/UsersScreen/UsersScreen.tsx @@ -27,7 +27,7 @@ const UsersScreen = () => { const dispatch = useAppDispatch(); const fetchData = useCallback(() => { - dispatch(getUsers({ page: search?.page, per_page: search?.pageSize })); + dispatch(getUsers({ page: search?.page, perPage: search?.pageSize })); }, [dispatch, search?.page, search?.pageSize]); useEffect(() => { diff --git a/src/features/settings/screens/UsersScreen/components/UsersFilter/UsersFilter.tsx b/src/features/settings/screens/UsersScreen/components/UsersFilter/UsersFilter.tsx index d124b36..1ef396f 100644 --- a/src/features/settings/screens/UsersScreen/components/UsersFilter/UsersFilter.tsx +++ b/src/features/settings/screens/UsersScreen/components/UsersFilter/UsersFilter.tsx @@ -49,7 +49,7 @@ const UsersFilter = () => { > {users.map(user => ( ))} diff --git a/src/features/settings/screens/UsersScreen/components/UsersModal/UsersModal.tsx b/src/features/settings/screens/UsersScreen/components/UsersModal/UsersModal.tsx index 5ea7eb3..53ca998 100644 --- a/src/features/settings/screens/UsersScreen/components/UsersModal/UsersModal.tsx +++ b/src/features/settings/screens/UsersScreen/components/UsersModal/UsersModal.tsx @@ -74,7 +74,7 @@ const UsersModal = memo(({ onClose, onSubmitted }: UsersModalProps) => { loadingContent={loading} > - + { - + { > firstName} + key="firstName" + dataIndex="firstName" + render={(firstName: UserDef["firstName"]) => firstName} sorter sortOrder={getOrderByDirection("name")} /> lastName} + key="lastName" + dataIndex="lastName" + render={(lastName: UserDef["lastName"]) => lastName} sorter - sortOrder={getOrderByDirection("last_name")} + sortOrder={getOrderByDirection("lastName")} /> ); diff --git a/src/features/settings/types/user.types.ts b/src/features/settings/types/user.types.ts index d64c1ca..fe6bde8 100644 --- a/src/features/settings/types/user.types.ts +++ b/src/features/settings/types/user.types.ts @@ -1,10 +1,9 @@ import { ResponsePaginationDef } from "@app/types/pagination.types"; -/* eslint-disable camelcase */ export type UserDef = { id: number; - first_name: string; - last_name: string; + firstName: string; + lastName: string; }; export type GetUsersResponseDef = { @@ -13,7 +12,7 @@ export type GetUsersResponseDef = { export type GetUsersParamDef = { page?: ResponsePaginationDef["page"]; - per_page?: ResponsePaginationDef["per_page"]; + perPage?: ResponsePaginationDef["perPage"]; }; export type GetUserByIdResponseDef = { diff --git a/src/helpers/object-convert.helper.ts b/src/helpers/object-convert.helper.ts new file mode 100644 index 0000000..75d003c --- /dev/null +++ b/src/helpers/object-convert.helper.ts @@ -0,0 +1,73 @@ +import { get, camelCase, snakeCase } from "lodash"; + +// check if data is array +export const isArray = (d: T): boolean => Array.isArray(d); + +// check if data is object +export const isObject = (d: T): boolean => + d === Object(d) && !isArray(d) && typeof d !== "function"; + +// convert object keys to camelCase +export function toCamel(d: T): T | Record { + if (isObject(d)) { + const o: Record = {}; + Object.keys(d as Record).forEach(k => { + o[camelCase(k)] = toCamel(get(d, k) as T); + }); + + return o; + } + if (isArray(d)) { + return (d as Array).map((o: unknown) => toCamel(o)) as T; + } + + return d; +} + +// convert object keys to snake_case +export function toSnakeCase( + d: T, + filter = false +): T | Record | undefined { + if (isObject(d)) { + const o: Record = {}; + Object.keys(d as Record).forEach(k => { + o[snakeCase(k)] = toSnakeCase(get(d, k) as T, filter); + }); + + return o; + } + if (isArray(d)) { + return (d as Array).map((o: unknown) => + toSnakeCase(o, filter) + ) as T; + } + + if (filter && d === "") { + return undefined; + } + + return d; +} + +export const flatObject = ( + value: Record, + currentKey?: unknown +): Record => { + let result: Record = {}; + + Object.keys(value).forEach(key => { + const tempKey = currentKey ? `${currentKey}.${key}` : key; + + if (typeof value[key] !== "object") { + result[tempKey] = value[key]; + } else { + result = { + ...result, + ...flatObject(value[key] as Record, tempKey), + }; + } + }); + + return result; +}; diff --git a/src/helpers/table.helper.ts b/src/helpers/table.helper.ts index e3b63be..4caf31e 100644 --- a/src/helpers/table.helper.ts +++ b/src/helpers/table.helper.ts @@ -44,7 +44,7 @@ export const mapPagination = ( ): TablePaginationDef => { return { current: pagination?.page ?? undefined, - pageSize: pagination?.per_page ?? undefined, + pageSize: pagination?.perPage ?? undefined, total: pagination?.total ?? undefined, }; }; diff --git a/src/redux/root-reducer.ts b/src/redux/root-reducer.ts index 747689e..30377c5 100644 --- a/src/redux/root-reducer.ts +++ b/src/redux/root-reducer.ts @@ -1,6 +1,10 @@ import { combineReducers } from "@reduxjs/toolkit"; import { authReducer, AUTH_FEATURE_KEY } from "@app/features/auth/auth"; +import { + clientsReducer, + CLIENTS_FEATURE_KEY, +} from "@app/features/clients/clients"; import { permissionsReducer, PERMISSIONS_FEATURE_KEY, @@ -14,6 +18,7 @@ const rootReducer = combineReducers({ [USERS_FEATURE_KEY]: usersReducer, [PERMISSIONS_FEATURE_KEY]: permissionsReducer, [AUTH_FEATURE_KEY]: authReducer, + [CLIENTS_FEATURE_KEY]: clientsReducer, }); export type RootState = ReturnType; diff --git a/src/routes/routes.config.ts b/src/routes/routes.config.ts index 6066e46..42f7671 100644 --- a/src/routes/routes.config.ts +++ b/src/routes/routes.config.ts @@ -1,8 +1,13 @@ import { AUTH_ROUTES } from "@app/features/auth/auth"; +import { CLIENTS_ROUTES } from "@app/features/clients/clients"; import { HOME_ROUTES } from "@app/features/home/home"; import { SETTINGS_ROUTES } from "@app/features/settings/settings"; export const ROOT_ROUTE = "/"; export const PUBLIC_LIST = [...AUTH_ROUTES]; -export const PRIVATE_LIST = [...HOME_ROUTES, ...SETTINGS_ROUTES]; +export const PRIVATE_LIST = [ + ...HOME_ROUTES, + ...CLIENTS_ROUTES, + ...SETTINGS_ROUTES, +]; diff --git a/src/types/pagination.types.ts b/src/types/pagination.types.ts index 3584dfa..0d2b721 100644 --- a/src/types/pagination.types.ts +++ b/src/types/pagination.types.ts @@ -1,11 +1,10 @@ -/* eslint-disable camelcase */ import { PaginationProps } from "antd"; export type ResponsePaginationDef = { page: number; - per_page: number; + perPage: number; total: number; - total_pages: number; + totalPages: number; }; export type TablePaginationDef = Pick< diff --git a/yarn.lock b/yarn.lock index 1a56b10..9bae088 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3523,6 +3523,15 @@ axe-core@^4.0.2: resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.1.1.tgz#70a7855888e287f7add66002211a423937063eaf" integrity sha512-5Kgy8Cz6LPC9DJcNb3yjAXTu3XihQgEdnIg50c//zOC/MyLP0Clg+Y8Sh9ZjjnvBrDZU4DgXS9C3T9r4/scGZQ== +axios-mock-adapter@^1.20.0: + version "1.20.0" + resolved "https://registry.yarnpkg.com/axios-mock-adapter/-/axios-mock-adapter-1.20.0.tgz#21f5b4b625306f43e8c05673616719da86e20dcb" + integrity sha512-shZRhTjLP0WWdcvHKf3rH3iW9deb3UdKbdnKUoHmmsnBhVXN3sjPJM6ZvQ2r/ywgvBVQrMnjrSyQab60G1sr2w== + dependencies: + fast-deep-equal "^3.1.3" + is-blob "^2.1.0" + is-buffer "^2.0.5" + axios@^0.19.2: version "0.19.2" resolved "https://registry.yarnpkg.com/axios/-/axios-0.19.2.tgz#3ea36c5d8818d0d5f8a8a97a6d36b86cdc00cb27" @@ -3530,12 +3539,12 @@ axios@^0.19.2: dependencies: follow-redirects "1.5.10" -axios@^0.21.1: - version "0.21.1" - resolved "https://registry.yarnpkg.com/axios/-/axios-0.21.1.tgz#22563481962f4d6bde9a76d516ef0e5d3c09b2b8" - integrity sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA== +axios@^0.21.4: + version "0.21.4" + resolved "https://registry.yarnpkg.com/axios/-/axios-0.21.4.tgz#c67b90dc0568e5c1cf2b0b858c43ba28e2eda575" + integrity sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg== dependencies: - follow-redirects "^1.10.0" + follow-redirects "^1.14.0" axobject-query@^2.2.0: version "2.2.0" @@ -6329,11 +6338,16 @@ follow-redirects@1.5.10: dependencies: debug "=3.1.0" -follow-redirects@^1.0.0, follow-redirects@^1.10.0: +follow-redirects@^1.0.0: version "1.13.1" resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.13.1.tgz#5f69b813376cee4fd0474a3aba835df04ab763b7" integrity sha512-SSG5xmZh1mkPGyKzjZP8zLjltIfpW32Y5QpdNJyjcfGxK3qo3NDDkZOZSFiGn1A6SclQxY9GzEwAHQ3dmYRWpg== +follow-redirects@^1.14.0: + version "1.14.4" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.4.tgz#838fdf48a8bbdd79e52ee51fb1c94e3ed98b9379" + integrity sha512-zwGkiSXC1MUJG/qmeIFH2HBJx9u0V46QGUe3YR1fXG8bXQxq7fLj0RjLZQ5nubr9qNJUZrH+xUcwXEoXNpfS+g== + for-in@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80" @@ -7283,11 +7297,21 @@ is-binary-path@~2.1.0: dependencies: binary-extensions "^2.0.0" +is-blob@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-blob/-/is-blob-2.1.0.tgz#e36cd82c90653f1e1b930f11baf9c64216a05385" + integrity sha512-SZ/fTft5eUhQM6oF/ZaASFDEdbFVe89Imltn9uZr03wdKMcWNVYSMjQPFtg05QuNkt5l5c135ElvXEQG0rk4tw== + is-buffer@^1.1.5: version "1.1.6" resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w== +is-buffer@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-2.0.5.tgz#ebc252e400d22ff8d77fa09888821a24a658c191" + integrity sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ== + is-callable@^1.1.4, is-callable@^1.2.2: version "1.2.2" resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.2.tgz#c7c6715cd22d4ddb48d3e19970223aceabb080d9"