diff --git a/example.env b/example.env index f4a2319..d560057 100644 --- a/example.env +++ b/example.env @@ -1 +1 @@ -VITE_BACKEND_URL=https://backend.url \ No newline at end of file +VITE_BACKEND_URL=https://api.fanesp.online \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index a5ede1a..89a20b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@emotion/react": "^11.11.4", "@emotion/styled": "^11.11.5", "@fontsource/roboto": "^5.0.13", + "@hookform/resolvers": "^3.9.0", "@mui/icons-material": "^5.15.18", "@mui/material": "^5.15.18", "@mui/x-charts": "^7.5.0", @@ -22,9 +23,13 @@ "notistack": "^3.0.1", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-dropzone": "^14.3.5", + "react-hook-form": "^7.52.2", "react-loading-skeleton": "^3.4.0", "react-router-dom": "^6.23.1", - "tailwind-merge": "^2.3.0" + "react-simple-image-viewer": "^1.2.2", + "tailwind-merge": "^2.3.0", + "yup": "^1.4.0" }, "devDependencies": { "@types/lodash": "^4.17.4", @@ -718,6 +723,14 @@ "resolved": "https://registry.npmjs.org/@fontsource/roboto/-/roboto-5.0.13.tgz", "integrity": "sha512-j61DHjsdUCKMXSdNLTOxcG701FWnF0jcqNNQi2iPCDxU8seN/sMxeh62dC++UiagCWq9ghTypX+Pcy7kX+QOeQ==" }, + "node_modules/@hookform/resolvers": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.9.0.tgz", + "integrity": "sha512-bU0Gr4EepJ/EQsH/IwEzYLsT/PEj5C0ynLQ4m+GSHS+xKH4TfSelhluTgOaoc4kA5s7eCsQbM4wvZLzELmWzUg==", + "peerDependencies": { + "react-hook-form": "^7.0.0" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.14", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", @@ -1689,6 +1702,15 @@ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, + "node_modules/attr-accept": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz", + "integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/autoprefixer": { "version": "10.4.19", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.19.tgz", @@ -2633,6 +2655,18 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/file-selector": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz", + "integrity": "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==", + "license": "MIT", + "dependencies": { + "tslib": "^2.7.0" + }, + "engines": { + "node": ">= 12" + } + }, "node_modules/fill-range": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", @@ -3865,6 +3899,11 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, + "node_modules/property-expr": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz", + "integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==" + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -3922,6 +3961,38 @@ "react": "^18.3.1" } }, + "node_modules/react-dropzone": { + "version": "14.3.5", + "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.3.5.tgz", + "integrity": "sha512-9nDUaEEpqZLOz5v5SUcFA0CjM4vq8YbqO0WRls+EYT7+DvxUdzDPKNCPLqGfj3YL9MsniCLCD4RFA6M95V6KMQ==", + "license": "MIT", + "dependencies": { + "attr-accept": "^2.2.4", + "file-selector": "^2.1.0", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">= 10.13" + }, + "peerDependencies": { + "react": ">= 16.8 || 18.0.0" + } + }, + "node_modules/react-hook-form": { + "version": "7.52.2", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.52.2.tgz", + "integrity": "sha512-pqfPEbERnxxiNMPd0bzmt1tuaPcVccywFDpyk2uV5xCIBphHV5T8SVnX9/o3kplPE1zzKt77+YIoq+EMwJp56A==", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", @@ -3974,6 +4045,16 @@ "react-dom": ">=16.8" } }, + "node_modules/react-simple-image-viewer": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/react-simple-image-viewer/-/react-simple-image-viewer-1.2.2.tgz", + "integrity": "sha512-Vk9p6Glm7uE4cSEBGkqZPGC3qoZcAwd48nq5/JN13NKd9rUrUIWZWFEmRzO+FVwl6c0UdjSDkthGoaoiYeWVjg==", + "license": "MIT", + "peerDependencies": { + "react": "^18.1.0", + "react-dom": "^18.1.0" + } + }, "node_modules/react-transition-group": { "version": "4.4.5", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", @@ -4455,6 +4536,11 @@ "node": ">=0.8" } }, + "node_modules/tiny-case": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz", + "integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==" + }, "node_modules/to-fast-properties": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", @@ -4475,6 +4561,11 @@ "node": ">=8.0" } }, + "node_modules/toposort": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz", + "integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==" + }, "node_modules/ts-api-utils": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", @@ -4494,9 +4585,10 @@ "dev": true }, "node_modules/tslib": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", - "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" }, "node_modules/type-check": { "version": "0.4.0", @@ -4821,6 +4913,28 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/yup": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/yup/-/yup-1.4.0.tgz", + "integrity": "sha512-wPbgkJRCqIf+OHyiTBQoJiP5PFuAXaWiJK6AmYkzQAh5/c2K9hzSApBZG5wV9KoKSePF7sAxmNSvh/13YHkFDg==", + "dependencies": { + "property-expr": "^2.0.5", + "tiny-case": "^1.0.3", + "toposort": "^2.0.2", + "type-fest": "^2.19.0" + } + }, + "node_modules/yup/node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/package.json b/package.json index 74e6ed8..ab2ae84 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "@emotion/react": "^11.11.4", "@emotion/styled": "^11.11.5", "@fontsource/roboto": "^5.0.13", + "@hookform/resolvers": "^3.9.0", "@mui/icons-material": "^5.15.18", "@mui/material": "^5.15.18", "@mui/x-charts": "^7.5.0", @@ -25,9 +26,13 @@ "notistack": "^3.0.1", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-dropzone": "^14.3.5", + "react-hook-form": "^7.52.2", "react-loading-skeleton": "^3.4.0", "react-router-dom": "^6.23.1", - "tailwind-merge": "^2.3.0" + "react-simple-image-viewer": "^1.2.2", + "tailwind-merge": "^2.3.0", + "yup": "^1.4.0" }, "devDependencies": { "@types/lodash": "^4.17.4", diff --git a/src/api/services/balance.ts b/src/api/balance/balance.ts similarity index 63% rename from src/api/services/balance.ts rename to src/api/balance/balance.ts index 9e2b93d..b7fd058 100644 --- a/src/api/services/balance.ts +++ b/src/api/balance/balance.ts @@ -1,18 +1,14 @@ import { APIResponse } from "@types"; +import { BalanceModel } from "./model"; import API from ".."; -type GetResponse = { - balance: number; - updated_at: string; -}; - export default class BalanceServices { basePath: string = "/balance"; private api: API = new API(); async get() { const targetPath = this.basePath; - const res: APIResponse = await this.api.GET(targetPath); + const res: APIResponse = await this.api.GET(targetPath); return res; } } diff --git a/src/api/balance/model.ts b/src/api/balance/model.ts new file mode 100644 index 0000000..49db66a --- /dev/null +++ b/src/api/balance/model.ts @@ -0,0 +1,4 @@ +export type BalanceModel = { + balance: number; + updated_at: string; +}; diff --git a/src/api/balanceHistory/balanceHistory.ts b/src/api/balanceHistory/balanceHistory.ts new file mode 100644 index 0000000..4b62771 --- /dev/null +++ b/src/api/balanceHistory/balanceHistory.ts @@ -0,0 +1,15 @@ +import { APIResponse } from "@types"; +import API from ".."; +import { BalanceHistoryModel } from "./model"; + +export default class BalanceHistoryServices { + basePath: string = "/balance/history"; + private api: API = new API(); + + async get(queryParams?: string) { + const targetPath = `${this.basePath}?${queryParams}`; + const res: APIResponse = + await this.api.GET(targetPath); + return res; + } +} diff --git a/src/api/balanceHistory/model.ts b/src/api/balanceHistory/model.ts new file mode 100644 index 0000000..0bd590a --- /dev/null +++ b/src/api/balanceHistory/model.ts @@ -0,0 +1,18 @@ +import { PaginationType } from "@types"; + +export type BalanceHistoryModel = { + amount: number; + prev_balance: number; + activity: string; + note: string; + user: { + npm: string; + name: string; + }; + created_at: string; +}; + +export type BalanceHistoryResponseModel = { + data: BalanceHistoryModel[]; + pagination?: PaginationType; +}; diff --git a/src/api/file/file.ts b/src/api/file/file.ts new file mode 100644 index 0000000..5e85c31 --- /dev/null +++ b/src/api/file/file.ts @@ -0,0 +1,35 @@ +import API from ".."; +import { UploadModel } from "./model"; +import { FetchCallback } from "@types"; + +export default class FileServices { + basePath: string = "/file/images"; + private api: API = new API(); + + async post(submission: FormData, callback: FetchCallback) { + const targetPath = `${this.basePath}`; + const res = await this.api.POSTFORM(targetPath, submission); + + if (!res?.status) { + callback.onError(res?.message || "unknown error"); + } else { + if (res.data) callback.onSuccess(res.data); + } + + callback.onFullfilled && callback.onFullfilled(); + } + + async delete(urlId: string, callback: FetchCallback) { + const targetPath = `${this.basePath}/${urlId}`; + + const res = await this.api.DELETE(targetPath); + + if (!res?.status) { + callback.onError(res?.message || "unknown error"); + } else { + if (res.data) callback.onSuccess(res?.data); + } + + callback.onFullfilled && callback.onFullfilled(); + } +} diff --git a/src/api/file/model.ts b/src/api/file/model.ts new file mode 100644 index 0000000..31a53cd --- /dev/null +++ b/src/api/file/model.ts @@ -0,0 +1,3 @@ +export type UploadModel = { + url_id: string; +}; diff --git a/src/api/index.ts b/src/api/index.ts index ad5b856..0d40980 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -3,80 +3,128 @@ import { APIResponse } from "@types"; import axios, { AxiosError, - AxiosHeaders, AxiosInstance, AxiosRequestConfig, isAxiosError, } from "axios"; type Headers = { - Accept: string; "Content-type": string; }; export default class API { - headers: Headers = { - Accept: "application/json", - "Content-type": "application/json", - }; api: AxiosInstance; + private headers: Headers; constructor() { + this.headers = { + "Content-type": "application/json", + }; this.api = axios.create({ baseURL: `${import.meta.env.VITE_BACKEND_URL}/v1`, - headers: this.headers as unknown as AxiosHeaders, httpsAgent: false, } as AxiosRequestConfig); } async GET(path: string): Promise> { try { - const res = await this.api.get(path); + const res = await this.api.get(path, { headers: this.headers }); return res.data; } catch (err: AxiosError | any) { if (isAxiosError(err)) { - return err?.response?.data; + return { + status: false, + message: err?.response?.data?.message || err?.response?.data, + data: null, + } as unknown as APIResponse; } else { - return err; + return { + status: false, + message: "Internal Server Error", + } as APIResponse; } } } - async POST(path: string, data: any): Promise> { + async POST(path: string, data: any): Promise> { try { - const res = await this.api.post(path, data); + const res = await this.api.post(path, data, { headers: this.headers }); return res.data; } catch (err: AxiosError | any) { if (isAxiosError(err)) { - return err?.response?.data; + return { + status: false, + message: err?.response?.data?.message || err?.response?.data, + data: null, + } as unknown as APIResponse; } else { - return err; + return { + status: false, + message: "Internal Server Error", + } as APIResponse; } } } - async PUT(path: string, data: any): Promise> { + async POSTFORM(path: string, data: any): Promise> { try { - const res = await this.api.put(path, data); + const headers: Headers = { + "Content-type": "multipart/form-data", + }; + const res = await this.api.post(path, data, { headers }); return res.data; } catch (err: AxiosError | any) { if (isAxiosError(err)) { - return err?.response?.data; + return { + status: false, + message: err?.response?.data?.message || err?.response?.data, + data: null, + } as unknown as APIResponse; } else { - return err; + return { + status: false, + message: "Internal Server Error", + } as APIResponse; } } } - async DELETE(path: string): Promise> { + async PUT(path: string, data: any): Promise> { + try { + const res = await this.api.put(path, data, { headers: this.headers }); + return res.data; + } catch (err: AxiosError | any) { + if (isAxiosError(err)) { + return { + status: false, + message: err?.response?.data?.message || err?.response?.data, + data: null, + } as unknown as APIResponse; + } else { + return { + status: false, + message: "Internal Server Error", + } as APIResponse; + } + } + } + + async DELETE(path: string): Promise> { try { const res = await this.api.delete(path); return res.data; } catch (err: AxiosError | any) { if (isAxiosError(err)) { - return err?.response?.data; + return { + status: false, + message: err?.response?.data?.message || err?.response?.data, + data: null, + } as unknown as APIResponse; } else { - return err; + return { + status: false, + message: "Internal Server Error", + } as APIResponse; } } } diff --git a/src/api/kasSubmission/kasSubmission.ts b/src/api/kasSubmission/kasSubmission.ts new file mode 100644 index 0000000..7aa7fa3 --- /dev/null +++ b/src/api/kasSubmission/kasSubmission.ts @@ -0,0 +1,45 @@ +import API from ".."; +import { FetchCallback } from "@types"; +import { + KasSubmissionModel, + UserModel, + KasSubmissionCreateModel, +} from "./model"; + +export default class KasSubmissionService { + kasPath: string = "/kas-submissions"; + userPath: string = "/users"; + private api: API = new API(); + private apiForm: API = new API(); + + async post( + submission: KasSubmissionCreateModel | string, + callback: FetchCallback, + ) { + const targetPath = `${this.kasPath}`; + + const res = await this.apiForm.POSTFORM( + targetPath, + submission, + ); + + if (!res?.status) { + callback.onError(res?.message || "unknown error"); + } else { + if (res.data) callback.onSuccess(res.data); + } + + callback.onFullfilled && callback.onFullfilled(); + } + + async get(params: string, callback: FetchCallback) { + const targetPath = `${this.userPath}?${params}`; + + const res = await this.api.GET(targetPath); + if (!res?.status) { + callback.onError(res?.message || "unknown error"); + } else { + callback.onSuccess(res?.data); + } + } +} diff --git a/src/api/kasSubmission/model.ts b/src/api/kasSubmission/model.ts new file mode 100644 index 0000000..2725ce1 --- /dev/null +++ b/src/api/kasSubmission/model.ts @@ -0,0 +1,27 @@ +export type KasSubmissionModel = { + submission_id: string; + user: UserModel; + payed_amount: number; + status: { + ID: number; + Name: string; + }; + note: string; + evidence: string; + submitted_at: string; + updated_at: string; +}; + +export type UserModel = { + npm: string; + name: string; +}; + +export type KasSubmissionCreateModel = { + user: { + npm: string; + }; + payed_amount: number; + note: string; + evidence: string | File[]; +}; diff --git a/src/api/payedKas/model.ts b/src/api/payedKas/model.ts new file mode 100644 index 0000000..5b10339 --- /dev/null +++ b/src/api/payedKas/model.ts @@ -0,0 +1,18 @@ +export type PayedKasModel = { + submission_id: string; + user: { + npm: string; + name: string; + email: string; + kas_payed: number; + month_start_pay: { + id: number; + }; + }; + payed_amount: number; + status: string; + note: string; + evidence: string; + submitted_at: string; + updated_at: string; +}; diff --git a/src/api/payedKas/payedKas.ts b/src/api/payedKas/payedKas.ts new file mode 100644 index 0000000..d6e5645 --- /dev/null +++ b/src/api/payedKas/payedKas.ts @@ -0,0 +1,14 @@ +import { APIResponse } from "@types"; +import { PayedKasModel } from "./model"; +import API from ".."; + +export default class PayedKasService { + basePath: string = "/kas"; + private api: API = new API(); + + async get() { + const targetPath = this.basePath + "?sort=created_at&order_by=desc"; + const res: APIResponse = await this.api.GET(targetPath); + return res; + } +} diff --git a/src/api/services/balanceHistory.ts b/src/api/services/balanceHistory.ts deleted file mode 100644 index d3e72c7..0000000 --- a/src/api/services/balanceHistory.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { APIResponse, PaginationType } from "@types"; -import API from ".."; - -export type BalanceHistoryType = { - amount: number; - prev_balance: number; - activity: string; - note: string; - user: { - npm: string; - name: string; - }; - created_at: string; -}; - -export type GetResponse = { - data: BalanceHistoryType[]; - pagination?: PaginationType; -}; - -export default class BalanceHistoryServices { - basePath: string = "/balance/history"; - private api: API = new API(); - - async get(queryParams?: string) { - const targetPath = `${this.basePath}?${queryParams}`; - const res: APIResponse = - await this.api.GET(targetPath); - return res; - } -} diff --git a/src/api/services/payedKas.ts b/src/api/services/payedKas.ts deleted file mode 100644 index 8ec43c4..0000000 --- a/src/api/services/payedKas.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { APIResponse } from "@types"; -import API from ".."; - -export type GetResponse = { - submission_id: string; - user: { - npm: string; - name: string; - email: string; - kas_payed: number; - month_start_pay: { - id: number; - }; - }; - payed_amount: number; - status: string; - note: string; - evidence: string; - submitted_at: string; - updated_at: string; -}; - -export default class PayedKasService { - basePath: string = "/kas"; - private api: API = new API(); - - async get() { - const targetPath = this.basePath + "?sort=created_at&order_by=desc"; - const res: APIResponse = await this.api.GET(targetPath); - return res; - } -} diff --git a/src/common/components/Button/ActionButton/index.tsx b/src/common/components/Button/ActionButton/index.tsx new file mode 100644 index 0000000..ad0239f --- /dev/null +++ b/src/common/components/Button/ActionButton/index.tsx @@ -0,0 +1,52 @@ +import { Button, CircularProgress } from "@mui/material"; + +interface Props { + onClick?: () => void; + disabledLoading?: boolean; + submitLoading?: boolean; + icon?: React.ReactNode; + label: string; + variant?: "contained" | "outlined"; + size?: "small" | "medium" | "large"; + color?: "primary" | "inherit" | "secondary"; + className?: string; + autoFocus?: boolean; +} +function ActionButton(props: Props): JSX.Element { + const { + onClick, + disabledLoading, + submitLoading, + icon, + label, + variant = "contained", + size = "small", + color = "inherit", + className, + autoFocus + } = props; + + return ( + + ); +} + +export default ActionButton; diff --git a/src/common/components/Button/DownloadButton/index.tsx b/src/common/components/Button/DownloadButton/index.tsx new file mode 100644 index 0000000..567e013 --- /dev/null +++ b/src/common/components/Button/DownloadButton/index.tsx @@ -0,0 +1,45 @@ +import { Check, Download } from "@mui/icons-material"; +import { CircularProgress } from "@mui/material"; +import { downloadImage } from "@utils/imageDownloader"; +import { useState } from "react"; + +interface Props { + className?: string; + url: string; +} +function DownloadButton(props: Props): JSX.Element { + const { className, url } = props; + const [downloadState, setDownloadState] = useState("idle"); + + const handleDownload = async () => { + if (downloadState !== "idle") return; + + setDownloadState("loading"); + await downloadImage(url); + + setTimeout(() => { + setDownloadState("idle"); + }, 2000); + }; + + return ( + + ); +} + +type DownloadState = "idle" | "loading"; + +export default DownloadButton; diff --git a/src/common/components/Button/index.ts b/src/common/components/Button/index.ts new file mode 100644 index 0000000..111c452 --- /dev/null +++ b/src/common/components/Button/index.ts @@ -0,0 +1,2 @@ +export { default as ActionButton } from "./ActionButton"; +export { default as DownloadButton } from "./DownloadButton"; diff --git a/src/common/components/Dialog/BaseDialog/index.tsx b/src/common/components/Dialog/BaseDialog/index.tsx new file mode 100644 index 0000000..560cc3d --- /dev/null +++ b/src/common/components/Dialog/BaseDialog/index.tsx @@ -0,0 +1,49 @@ +import { Dialog, DialogTitle } from "@mui/material"; + +interface Props { + open: boolean; + onClose: () => void; + title?: string; + message?: string; + children: React.ReactNode; + width?: number; +} +function BaseDialog(props: Props): JSX.Element { + const { open, onClose, title, message, children, width = 360 } = props; + + return ( + + {title && ( + + {title} + {message && ( +
{message}
+ )} +
+ )} + {children} +
+ ); +} + +export default BaseDialog; diff --git a/src/common/components/Dialog/ConfirmDeleteDialog/index.tsx b/src/common/components/Dialog/ConfirmDeleteDialog/index.tsx new file mode 100644 index 0000000..b022225 --- /dev/null +++ b/src/common/components/Dialog/ConfirmDeleteDialog/index.tsx @@ -0,0 +1,62 @@ +import { + Dialog, + DialogActions, + DialogContent, + Icon, + Typography +} from "@mui/material"; +import { DeleteForever } from "@mui/icons-material"; +import ActionButton from "../../Button/ActionButton"; + +interface Props { + className?: string; + open: boolean; + onClose: () => void; + onClickDelete: () => void; +} +function ConfirmDeleteDialog(props: Props): JSX.Element { + return ( + + + + + + + Are you sure deleting this? + + + + + + + + ); +} + +export default ConfirmDeleteDialog; diff --git a/src/common/components/Dialog/DialogContent/index.tsx b/src/common/components/Dialog/DialogContent/index.tsx new file mode 100644 index 0000000..61d00e6 --- /dev/null +++ b/src/common/components/Dialog/DialogContent/index.tsx @@ -0,0 +1,22 @@ +import { DialogContent as MUIDialogContent } from "@mui/material"; +import clsx from "clsx"; + +interface Props { + children: React.ReactNode; + className?: string; +} +function DialogContent(props: Props): JSX.Element { + const { children, className } = props; + + return ( + +
{children}
+
+ ); +} + +export default DialogContent; diff --git a/src/common/components/Dialog/DialogFooter/index.tsx b/src/common/components/Dialog/DialogFooter/index.tsx new file mode 100644 index 0000000..baafe9f --- /dev/null +++ b/src/common/components/Dialog/DialogFooter/index.tsx @@ -0,0 +1,35 @@ +import { ActionButton } from "@components/Button"; +import { DialogActions } from "@mui/material"; +import clsx from "clsx"; + +interface Props { + children?: React.ReactNode; + className?: string; + onCancel?: () => void; + cancelLabel?: string; +} +function DialogFooter(props: Props): JSX.Element { + const { children, className, onCancel, cancelLabel = "Cancel" } = props; + + return ( + +
+ {onCancel ? ( + + ) : null} + {children} +
+
+ ); +} + +export default DialogFooter; diff --git a/src/common/components/Dialog/LoadingDialog/index.tsx b/src/common/components/Dialog/LoadingDialog/index.tsx new file mode 100644 index 0000000..9ef966c --- /dev/null +++ b/src/common/components/Dialog/LoadingDialog/index.tsx @@ -0,0 +1,30 @@ +import { CircularProgress, Dialog } from "@mui/material"; + +interface Props { + open: boolean; + onClose: () => void; +} +function LoadingDialog(props: Props): JSX.Element { + const { open, onClose } = props; + return ( + + + + ); +} + +export default LoadingDialog; diff --git a/src/common/components/Dialog/index.ts b/src/common/components/Dialog/index.ts new file mode 100644 index 0000000..41ae457 --- /dev/null +++ b/src/common/components/Dialog/index.ts @@ -0,0 +1,5 @@ +export { default as ConfirmDeleteDialog } from "./ConfirmDeleteDialog"; +export { default as BaseDialog } from "./BaseDialog"; +export { default as DialogContent } from "./DialogContent"; +export { default as DialogFooter } from "./DialogFooter"; +export { default as LoadingDialog } from "./LoadingDialog"; diff --git a/src/common/components/Dropzone/index.tsx b/src/common/components/Dropzone/index.tsx new file mode 100644 index 0000000..8737126 --- /dev/null +++ b/src/common/components/Dropzone/index.tsx @@ -0,0 +1,79 @@ +import ImagePreview from "@components/ImagePreview"; +import { Upload } from "@mui/icons-material"; +import clsx from "clsx"; +import { useCallback, useState } from "react"; +import { useDropzone } from "react-dropzone"; + +interface Props { + acceptTypeFile?: string[]; + error?: boolean; + helperText?: string; + onDropFile(files: File[]): void; +} + +const Dropzone = (props: Props) => { + const { error, helperText, acceptTypeFile, onDropFile } = props; + const [imgUrl, setImgUrl] = useState(null); + const acceptFile = acceptTypeFile?.reduce( + (acc, type) => { + acc[`image/${type}`] = []; + return acc; + }, + {} as Record, + ); + const onDrop = useCallback(async (acceptedFiles: File[]) => { + setImgUrl(URL.createObjectURL(acceptedFiles[0])); + onDropFile(acceptedFiles); + // setImgUrl(""); + }, []); + + const { getRootProps, getInputProps, isDragActive, open } = useDropzone({ + onDrop, + noClick: true, + accept: acceptTypeFile?.reduce( + (acc, type) => { + acc[`image/${type}`] = []; + return acc; + }, + {} as Record, + ), + }); + + return ( +
+
+ +
+ + + Upload transfered evidence + +
+ {imgUrl && } +
+ + {helperText && {helperText}} + + {isDragActive && ( +
+ + Drop anywhere to upload... + +
+ )} +
+ ); +}; + +export default Dropzone; diff --git a/src/common/components/ImagePreview/index.tsx b/src/common/components/ImagePreview/index.tsx new file mode 100644 index 0000000..c204157 --- /dev/null +++ b/src/common/components/ImagePreview/index.tsx @@ -0,0 +1,45 @@ +import { Box } from "@mui/material"; +import { useState } from "react"; +import ImageViewer from "react-simple-image-viewer"; + +interface Props { + imageUrl: string; + isLocal: boolean; +} +const ImagePreview = (props: Props) => { + const { imageUrl, isLocal } = props; + const [isViewerOpen, setIsViewerOpen] = useState(false); + + const baseImageURL = import.meta.env.VITE_BACKEND_URL + "/v1/file/images/"; + + return ( + <> + setIsViewerOpen(true)} + alt={imageUrl} + src={isLocal ? imageUrl : baseImageURL + imageUrl} + /> + {isViewerOpen && ( + setIsViewerOpen(false)} + backgroundStyle={{ + backgroundColor: "rgba(100, 100, 100, 0.1)", + backdropFilter: "blur(1px)", + }} + /> + )} + + ); +}; + +export default ImagePreview; diff --git a/src/common/components/Input/SearchBar/index.tsx b/src/common/components/Input/SearchBar/index.tsx new file mode 100644 index 0000000..99b924b --- /dev/null +++ b/src/common/components/Input/SearchBar/index.tsx @@ -0,0 +1,49 @@ +import useDebouncer from "@hooks/useDebouncer"; +import { TextField } from "@mui/material"; +import { useEffect, useRef, useState } from "react"; + +interface Props { + className?: string; + label?: string; + placeholder?: string; + onChange: (value: string) => void; +} +const SearchBar = (props: Props) => { + const { className, label, placeholder, onChange } = props; + + const isFirstRender = useRef(true); + const [tempValue, setTempValue] = useState(""); + const debouncedValue = useDebouncer(tempValue, 1000); + + useEffect(() => { + if (isFirstRender.current) { + isFirstRender.current = false; + return; + } + onChange(debouncedValue); + }, [debouncedValue]); + + return ( + setTempValue(e.target.value)} + label={label} + placeholder={placeholder} + sx={{ + "& .MuiInputBase-input": { + height: "1.2rem", + }, + "& .MuiOutlinedInput-root": { + "& input": { + color: "white", + padding: "0.5rem 0.6rem", + fontSize: "0.75rem", + }, + }, + }} + /> + ); +}; + +export default SearchBar; diff --git a/src/common/components/Input/index.ts b/src/common/components/Input/index.ts new file mode 100644 index 0000000..a8a6510 --- /dev/null +++ b/src/common/components/Input/index.ts @@ -0,0 +1 @@ +export { default as SearchBar } from "./SearchBar"; diff --git a/src/common/hooks/useDebounceValue.ts b/src/common/hooks/useDebouncer.tsx similarity index 50% rename from src/common/hooks/useDebounceValue.ts rename to src/common/hooks/useDebouncer.tsx index 09892a1..8ef83d2 100644 --- a/src/common/hooks/useDebounceValue.ts +++ b/src/common/hooks/useDebouncer.tsx @@ -1,12 +1,13 @@ -import { useState, useEffect } from "react"; +import { useEffect, useState } from "react"; -function useDebounceValue(value: string, delay: number): string { - const [debouncedValue, setDebouncedValue] = useState(value); +function useDebouncer(value: string, delay: number): string { + const [debouncedValue, setDebouncedValue] = useState(value); useEffect(() => { const handler = setTimeout(() => { setDebouncedValue(value); }, delay); + return () => { clearTimeout(handler); }; @@ -15,4 +16,4 @@ function useDebounceValue(value: string, delay: number): string { return debouncedValue; } -export default useDebounceValue; +export default useDebouncer; diff --git a/src/common/hooks/useDialog.tsx b/src/common/hooks/useDialog.tsx new file mode 100644 index 0000000..ec48496 --- /dev/null +++ b/src/common/hooks/useDialog.tsx @@ -0,0 +1,19 @@ +import { useState } from "react"; + +interface HookReturn { + isOpen: boolean; + onOpen: () => void; + onClose: () => void; +} +const useDialog = (): HookReturn => { + const [isOpen, setOpen] = useState(false); + + return { + isOpen, + onOpen: () => setOpen(true), + onClose: () => setOpen(false) + }; +}; + +export default useDialog; +export type { HookReturn as UseDialogReturn }; diff --git a/src/common/hooks/useUploadFile.tsx b/src/common/hooks/useUploadFile.tsx new file mode 100644 index 0000000..94551d3 --- /dev/null +++ b/src/common/hooks/useUploadFile.tsx @@ -0,0 +1,29 @@ +import FileServices from "@api/file/file"; +import { snackbar } from "@utils/snackbar"; +import { useFormContext } from "react-hook-form"; + +const useUploadFile = () => { + const fileService = new FileServices(); + const { trigger, setValue, getValues } = useFormContext(); + + const handleUploadImage = async (file: File[]) => { + const formData = new FormData(); + formData.append("file", file[0]); + + await fileService.post(formData, { + onSuccess: (data) => { + setValue("evidence", data.url_id); + trigger("evidence"); + console.log(data.url_id); + }, + onError: (errMessage) => { + snackbar.error(errMessage); + }, + }); + console.log(getValues("evidence")); + }; + return { + handleUploadImage, + }; +}; +export default useUploadFile; diff --git a/src/common/types/index.ts b/src/common/types/index.ts index 030bb21..d030a42 100644 --- a/src/common/types/index.ts +++ b/src/common/types/index.ts @@ -1,3 +1,9 @@ +export type FilterParams = { + params: { + [key: string]: string | number; + }; +}; + export type APIResponse = { status: boolean; status_code: number; @@ -7,9 +13,21 @@ export type APIResponse = { pagination?: PaginationType; } | null; +export type CommonOptions = { + value: number | string; + label: string; +}; + export type FilterType = { - order_by?: "desc" | "asc"; - sort?: string; + key: string; + label: string; + options: CommonOptions[]; +}; + +export type FetchCallback = { + onSuccess: (data: T) => void; + onError: (errMessage: string) => void; + onFullfilled?: () => void; }; export type PaginationType = { @@ -24,7 +42,12 @@ export type APIFieldError = { message: string; }; -export type AppType = "home" | "payed kas" | "balance" | "balance history"; +export type AppType = + | "home" + | "payed kas" + | "balance" + | "balance history" + | "kas submission"; export type AppList = { displayName: string; diff --git a/src/common/utils/consts.ts b/src/common/utils/consts.ts index e871d98..34f193f 100644 --- a/src/common/utils/consts.ts +++ b/src/common/utils/consts.ts @@ -3,4 +3,5 @@ export const appDisplayName = { balance: "Balance", "balance history": "Balance History", home: "Home", + "kas submission": "Kas Submission", }; diff --git a/src/common/utils/glassmorphism.ts b/src/common/utils/glassmorphism.ts index 11989db..9b876f0 100644 --- a/src/common/utils/glassmorphism.ts +++ b/src/common/utils/glassmorphism.ts @@ -8,6 +8,7 @@ const glassmorphism = ({ container, border, hover }: GlassmorphismProps) => { if (container) { classes += "bg-fuchsia-300/5 "; } + if (hover) { classes += "hover:bg-violet-300/10 transition-all duration-200 "; } diff --git a/src/common/utils/imageDownloader.ts b/src/common/utils/imageDownloader.ts new file mode 100644 index 0000000..ae3ea3b --- /dev/null +++ b/src/common/utils/imageDownloader.ts @@ -0,0 +1,19 @@ +export const getImageFilename = (url: string): string => { + const urlParts = url.split("-"); + if (urlParts.length === 1) return urlParts[0]; + urlParts.shift(); + return urlParts.join("-"); +}; + +export const downloadImage = async (url: string): Promise => { + const baseImageURL = import.meta.env.VITE_BACKEND_URL + "/v1/file/images/"; + + const response = await fetch(baseImageURL + url); + const blob = await response.blob(); + const link = document.createElement("a"); + link.href = URL.createObjectURL(blob); + link.download = getImageFilename(url); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); +}; diff --git a/src/common/utils/localStorage.ts b/src/common/utils/localStorage.ts new file mode 100644 index 0000000..59e3088 --- /dev/null +++ b/src/common/utils/localStorage.ts @@ -0,0 +1,25 @@ +export const useLocalStorage = () => { + const setItem = (key: string, value: unknown) => { + try { + localStorage.setItem(key, JSON.stringify(value)); + } catch (err: unknown) { + console.log(err); + } + }; + const getItem = (key: string) => { + try { + const item = localStorage.getItem(key); + return item ? JSON.parse(item) : undefined; + } catch (err: unknown) { + console.log(err); + } + }; + const removeItem = (key: string) => { + try { + localStorage.removeItem(key); + } catch (err: unknown) { + console.log(err); + } + }; + return { setItem, getItem, removeItem }; +}; diff --git a/src/pages/Balance/context/index.tsx b/src/pages/Balance/context/index.tsx index eb2aed3..abe8f3d 100644 --- a/src/pages/Balance/context/index.tsx +++ b/src/pages/Balance/context/index.tsx @@ -1,10 +1,10 @@ -import { BalanceHistoryType } from "@services/balanceHistory"; +import { BalanceHistoryModel } from "@api/balanceHistory/model"; import { createContext, useContext, useState } from "react"; type BalanceStateType = { balance: number; balanceLoading: boolean; - balanceHistory: BalanceHistoryType[]; + balanceHistory: BalanceHistoryModel[]; balanceHistoryLoading: boolean; totalBalanceHistory: number; }; diff --git a/src/pages/Balance/hooks/useBalance.ts b/src/pages/Balance/hooks/useBalance.ts index 8b070ab..8e3d5e6 100644 --- a/src/pages/Balance/hooks/useBalance.ts +++ b/src/pages/Balance/hooks/useBalance.ts @@ -1,4 +1,4 @@ -import BalanceServices from "@services/balance"; +import BalanceServices from "@api/balance/balance"; import { useBalanceContext } from "../context"; import { errMessage, snackbar } from "@utils/snackbar"; diff --git a/src/pages/Balance/hooks/useBalanceHistory.ts b/src/pages/Balance/hooks/useBalanceHistory.ts index 85ded79..b465934 100644 --- a/src/pages/Balance/hooks/useBalanceHistory.ts +++ b/src/pages/Balance/hooks/useBalanceHistory.ts @@ -1,4 +1,4 @@ -import BalanceHistoryServices from "@services/balanceHistory"; +import BalanceHistoryServices from "@api/balanceHistory/balanceHistory"; import { useBalanceContext } from "../context"; import { balanceHistoryFormatter } from "@utils/formatter"; import { errMessage, snackbar } from "@utils/snackbar"; diff --git a/src/pages/BalanceHistory/context/index.tsx b/src/pages/BalanceHistory/context/index.tsx index 86513ff..3bfb207 100644 --- a/src/pages/BalanceHistory/context/index.tsx +++ b/src/pages/BalanceHistory/context/index.tsx @@ -1,11 +1,11 @@ -import { BalanceHistoryType } from "@services/balanceHistory"; +import { BalanceHistoryModel } from "@api/balanceHistory/model"; import { FilterType, PaginationType } from "@types"; import { createContext, useContext, useState } from "react"; type BalanceHistoryStateType = { balance: number; balanceLoading: boolean; - balanceHistory: BalanceHistoryType[]; + balanceHistory: BalanceHistoryModel[]; balanceHistoryLoading: boolean; mode: "list" | "table" | "chart"; filter?: FilterType; diff --git a/src/pages/BalanceHistory/hooks/useBalance.ts b/src/pages/BalanceHistory/hooks/useBalance.ts index 50e8902..ab2d8ca 100644 --- a/src/pages/BalanceHistory/hooks/useBalance.ts +++ b/src/pages/BalanceHistory/hooks/useBalance.ts @@ -1,4 +1,4 @@ -import BalanceServices from "@services/balance"; +import BalanceServices from "@api/balance/balance"; import { useBalanceHistoryContext } from "../context"; import { snackbar, errMessage } from "@utils/snackbar"; diff --git a/src/pages/BalanceHistory/hooks/useBalanceHistory.ts b/src/pages/BalanceHistory/hooks/useBalanceHistory.ts index 5dd8099..929cb37 100644 --- a/src/pages/BalanceHistory/hooks/useBalanceHistory.ts +++ b/src/pages/BalanceHistory/hooks/useBalanceHistory.ts @@ -1,4 +1,4 @@ -import BalanceHistoryServices from "@services/balanceHistory"; +import BalanceHistoryServices from "@api/balanceHistory/balanceHistory"; import { useBalanceHistoryContext } from "../context"; import { FilterType, PaginationType } from "@types"; import { balanceHistoryFormatter } from "@utils/formatter"; diff --git a/src/pages/BalanceHistory/hooks/useGroupedChartData.ts b/src/pages/BalanceHistory/hooks/useGroupedChartData.ts index 2470004..e6ff181 100644 --- a/src/pages/BalanceHistory/hooks/useGroupedChartData.ts +++ b/src/pages/BalanceHistory/hooks/useGroupedChartData.ts @@ -1,4 +1,4 @@ -import { BalanceHistoryType } from "@services/balanceHistory"; +import { BalanceHistoryModel } from "@api/balanceHistory/model"; import { useState, useEffect } from "react"; type GroupedChartDataType = { @@ -6,7 +6,7 @@ type GroupedChartDataType = { total_amount: number; }; -function useGroupedChartData(balanceHistory: BalanceHistoryType[]) { +function useGroupedChartData(balanceHistory: BalanceHistoryModel[]) { const [groupedChartData, setGroupedChartData] = useState< GroupedChartDataType[] >([]); diff --git a/src/pages/BalanceHistory/partials/BalanceHistoryCard.tsx b/src/pages/BalanceHistory/partials/BalanceHistoryCard.tsx index 505e756..8f08692 100644 --- a/src/pages/BalanceHistory/partials/BalanceHistoryCard.tsx +++ b/src/pages/BalanceHistory/partials/BalanceHistoryCard.tsx @@ -1,10 +1,10 @@ -import { BalanceHistoryType } from "@services/balanceHistory"; +import { BalanceHistoryModel } from "@api/balanceHistory/model"; import { cn } from "@utils/index"; import { amountFormatter } from "@utils/formatter"; import glassmorphism from "@utils/glassmorphism"; interface Props { - history: BalanceHistoryType; + history: BalanceHistoryModel; } const BalanceHistoryCard = (props: Props) => { const { history } = props; diff --git a/src/pages/CreateKas/Create/hooks/useCreateKasSubmission.ts b/src/pages/CreateKas/Create/hooks/useCreateKasSubmission.ts new file mode 100644 index 0000000..9116da7 --- /dev/null +++ b/src/pages/CreateKas/Create/hooks/useCreateKasSubmission.ts @@ -0,0 +1,67 @@ +import KasSubmissionService from "@api/kasSubmission/kasSubmission"; +import { KasSubmissionCreateModel } from "@api/kasSubmission/model"; +import { snackbar } from "@utils/snackbar"; +import { useFormContext } from "react-hook-form"; +import { useCreateKasContext } from "@pages/CreateKas/context"; +import { Redirect } from "../utils/redirect"; +import useUploadFile from "@hooks/useUploadFile"; + +const useCreateKasSubmission = () => { + const kasService = new KasSubmissionService(); + const { getValues, trigger, handleSubmit } = useFormContext(); + const { setState } = useCreateKasContext(); + const { handleRedirect } = Redirect(); + const { handleUploadImage } = useUploadFile(); + + const handleKasSubmission = async () => { + return handleSubmit(async (values) => { + const submissionData: KasSubmissionCreateModel = { + user: { npm: values.user.npm }, + payed_amount: values.payed_amount, + note: values.note, + evidence: values.evidence, + }; + console.log(values.evidence + "-1"); + kasService.post(JSON.stringify(submissionData), { + onSuccess: (data) => { + snackbar.success("Successfully, Wait for Admin Validation"); + console.log(data); + handleRedirect(); + }, + onError: (errMessage) => { + snackbar.error(errMessage); + }, + }); + })(); + }; + + const handleSubmitForm = async () => { + const fileImage = getValues("evidence"); + trigger(); + console.log("fileImage", fileImage); + const isValid = await trigger(); + if (!isValid || !fileImage) { + return; + } + + setState((prevState) => ({ + ...prevState, + createKasLoading: true, + })); + + console.log(fileImage + "2"); + await handleUploadImage(fileImage); + await handleSubmit(handleKasSubmission)(); + + setState((prevState) => ({ + ...prevState, + createKasLoading: false, + })); + }; + + return { + handleSubmitForm, + }; +}; + +export default useCreateKasSubmission; diff --git a/src/pages/CreateKas/Create/hooks/useCreateKasSubmissionForm.ts b/src/pages/CreateKas/Create/hooks/useCreateKasSubmissionForm.ts new file mode 100644 index 0000000..1cb2ce7 --- /dev/null +++ b/src/pages/CreateKas/Create/hooks/useCreateKasSubmissionForm.ts @@ -0,0 +1,28 @@ +import { useCreateKasContext } from "../../context"; +import { Resolver, useForm, UseFormReturn } from "react-hook-form"; +import { KasSubmissionCreateModel } from "@api/kasSubmission/model"; +import { + kassubmissionreqDefaultValues, + kassubmissionValidations, + kassubmissionDetailsFormatter, +} from "../utils/form"; + +interface HookReturn { + kassubmissionreqForm: UseFormReturn; +} + +const useCreateKasSubmissionForm = (): HookReturn => { + const { state } = useCreateKasContext(); + + const kassubmissionreqForm = useForm({ + defaultValues: kassubmissionreqDefaultValues, + values: kassubmissionDetailsFormatter(state.kassubmissionreqDetails), + resolver: kassubmissionValidations as Resolver, + }); + + return { + kassubmissionreqForm, + }; +}; + +export default useCreateKasSubmissionForm; diff --git a/src/pages/CreateKas/Create/utils/form.ts b/src/pages/CreateKas/Create/utils/form.ts new file mode 100644 index 0000000..dfb1dda --- /dev/null +++ b/src/pages/CreateKas/Create/utils/form.ts @@ -0,0 +1,54 @@ +import * as yup from "yup"; +import { yupResolver } from "@hookform/resolvers/yup"; +import { KasSubmissionCreateModel } from "@api/kasSubmission/model"; + +export const kassubmissionreqDefaultValues: KasSubmissionCreateModel = { + user: { + npm: "", + }, + payed_amount: 0, + note: "", + evidence: "", +}; + +export const kassubmissionValidations = yupResolver( + yup.object().shape({ + user: yup.object().shape({ + npm: yup + .string() + .typeError("User is required") + .max(10, "User is required") + .required("User is required"), + }), + payed_amount: yup + .number() + .typeError("Payed Amount is required") + .moreThan(0, "Payed Amount must be greater than 0") + .required("Payed Amount is Required"), + note: yup + .string() + .typeError("Payed Amount is required") + .required("Note is Required"), + evidence: yup + .mixed() + .test("is-required", "Required", (value) => { + if (!value) return false; + if (Array.isArray(value)) return value.length > 0; + if (typeof value === "string") return value.trim() !== ""; + return false; + }), + }), +); + +export const kassubmissionDetailsFormatter = ( + data: KasSubmissionCreateModel, +): KasSubmissionCreateModel => { + return { + user: { + npm: data?.user?.npm, + }, + payed_amount: data?.payed_amount, + note: data?.note, + evidence: data?.evidence, + }; +}; diff --git a/src/pages/CreateKas/Create/utils/redirect.ts b/src/pages/CreateKas/Create/utils/redirect.ts new file mode 100644 index 0000000..4089251 --- /dev/null +++ b/src/pages/CreateKas/Create/utils/redirect.ts @@ -0,0 +1,19 @@ +import { useHomepageContext } from "@pages/Homepage/context"; + +interface HookReturn { + handleRedirect: () => void; +} + +export const Redirect = ():HookReturn => { + const { setState } = useHomepageContext(); + + + const handleRedirect = () => { + setState((prevState) => ({ + ...prevState, + app: "balance", + })); + }; + + return {handleRedirect}; +}; diff --git a/src/pages/CreateKas/List/hooks/useUserFilter.ts b/src/pages/CreateKas/List/hooks/useUserFilter.ts new file mode 100644 index 0000000..4cb3b6a --- /dev/null +++ b/src/pages/CreateKas/List/hooks/useUserFilter.ts @@ -0,0 +1,50 @@ +import { useCreateKasContext } from "@pages/CreateKas/context"; +import useGetUser from "@pages/CreateKas/hooks/useUser"; +import KasSubmissionService from "@api/kasSubmission/kasSubmission"; +import { snackbar } from "@utils/snackbar"; +import { userFilter } from "../utils/userFilter"; + +interface HookReturn { + handleChangeSearch: (value: string) => void; +} + +const useUserFilter = (): HookReturn => { + const { setState } = useCreateKasContext(); + const { fetchUsers } = useGetUser(); + const kasService = new KasSubmissionService(); + + const handleChangeSearch = (value: string) => { + if (value === "" || value === null) { + fetchUsers(); + return; + } + + setState((prevState) => ({ + ...prevState, + userLoading: true, + })); + + kasService.get(value, { + onSuccess: (data) => { + setState((prevState) => ({ + ...prevState, + user: value || value !== "" ? userFilter(data, value) : data, + userLoading: false, + })); + }, + onError: (errMessage) => { + snackbar.error(errMessage); + setState((prevState) => ({ + ...prevState, + userLoading: false, + })); + }, + }); + }; + + return { + handleChangeSearch, + }; +}; + +export default useUserFilter; diff --git a/src/pages/CreateKas/List/utils/filterMapper.ts b/src/pages/CreateKas/List/utils/filterMapper.ts new file mode 100644 index 0000000..899b110 --- /dev/null +++ b/src/pages/CreateKas/List/utils/filterMapper.ts @@ -0,0 +1,11 @@ +import { StateType } from "@pages/CreateKas/context"; +import { FilterParams } from "@types"; + +export const filterMapper = (filters: StateType["filters"]): FilterParams => { + return { + params: { + name: filters.name, + npm: filters.npm, + }, + }; +}; diff --git a/src/pages/CreateKas/List/utils/userFilter.ts b/src/pages/CreateKas/List/utils/userFilter.ts new file mode 100644 index 0000000..7fc840d --- /dev/null +++ b/src/pages/CreateKas/List/utils/userFilter.ts @@ -0,0 +1,9 @@ +import { UserModel } from "@api/kasSubmission/model"; + +export const userFilter = (users: UserModel[], searchTerm: string) => { + return users.filter( + (user) => + user.npm.toLowerCase().includes(searchTerm.toLowerCase()) || + user.name.toLowerCase().includes(searchTerm.toLowerCase()), + ); +}; diff --git a/src/pages/CreateKas/context/index.tsx b/src/pages/CreateKas/context/index.tsx new file mode 100644 index 0000000..8b8450b --- /dev/null +++ b/src/pages/CreateKas/context/index.tsx @@ -0,0 +1,76 @@ +import { KasSubmissionCreateModel, UserModel } from "@api/kasSubmission/model"; +import { createContext, useContext, useState } from "react"; +import useDialog, { UseDialogReturn } from "@hooks/useDialog"; + +type StateType = { + user: UserModel[]; + userLoading: boolean; + createKasLoading: boolean; + uploadFileLoading: boolean; + kassubmissionreqDetails: KasSubmissionCreateModel; + listNoteOptions: string[]; + + filters: { + npm: string; + name: string; + }; +}; + +export const initialState: StateType = { + user: [], + userLoading: false, + kassubmissionreqDetails: {} as KasSubmissionCreateModel, + createKasLoading: false, + uploadFileLoading: false, + listNoteOptions: [ + "Payment via BCA", + "Payment via Mandiri", + "Payment via Gopay", + "Payment via cash", + "Payment via OVO", + "Payment via DANA", + ], + + filters: { + npm: "", + name: "", + }, +}; + +type ContextType = { + state: StateType; + setState: React.Dispatch>; + dialog: { + userreqDetails: UseDialogReturn; + }; +}; + +const CreateKasContext = createContext(null); + +const useCreateKasContext = (): ContextType => { + const context = useContext(CreateKasContext); + if (!context) { + throw new Error( + "useCreateKasContext must be used within a CreateKasProvider", + ); + } + return context; +}; + +const CreateKasProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const [state, setState] = useState(initialState); + const dialog = { + userreqDetails: useDialog(), + }; + + return ( + + {children} + + ); +}; + +export { CreateKasProvider, useCreateKasContext }; +export type { StateType }; diff --git a/src/pages/CreateKas/hooks/useUser.ts b/src/pages/CreateKas/hooks/useUser.ts new file mode 100644 index 0000000..deb9c23 --- /dev/null +++ b/src/pages/CreateKas/hooks/useUser.ts @@ -0,0 +1,57 @@ +import KasSubmissionService from "@api/kasSubmission/kasSubmission"; +import { useCreateKasContext } from "../context"; +import { snackbar } from "@utils/snackbar"; +import { FilterParams } from "@types"; +import { filterMapper } from "../List/utils/filterMapper"; +import { useFormContext } from "react-hook-form"; + +interface HookReturn { + fetchUsers: (filterParams?: FilterParams) => void; + handleActiveUser: () => { npm: string; name: string } | null; +} + +const useUser = (): HookReturn => { + const { state, setState } = useCreateKasContext(); + const kasService = new KasSubmissionService(); + const { getValues } = useFormContext(); + + const handleActiveUser = () => { + const activeUser = state.user.find( + (user) => user.npm === getValues("user.npm"), + ); + return activeUser ? { npm: activeUser.npm, name: activeUser.name } : null; + }; + + const fetchUsers = ( + filterParams: FilterParams = filterMapper(Object.assign(state.filters)), + ) => { + setState((prevState) => ({ + ...prevState, + userLoading: true, + })); + + kasService.get(JSON.stringify(filterParams), { + onSuccess: (data) => { + setState((prevState) => ({ + ...prevState, + userLoading: false, + user: data, + })); + }, + onError: (error: unknown) => { + snackbar.error(JSON.stringify(error)); + setState((prevState) => ({ + ...prevState, + userLoading: false, + })); + }, + }); + }; + + return { + handleActiveUser, + fetchUsers, + }; +}; + +export default useUser; diff --git a/src/pages/CreateKas/index.tsx b/src/pages/CreateKas/index.tsx new file mode 100644 index 0000000..b96687c --- /dev/null +++ b/src/pages/CreateKas/index.tsx @@ -0,0 +1,12 @@ +import { CreateKasProvider } from "@pages/CreateKas/context"; +import CreateKasLayout from "./layout"; + +const index = () => { + return ( + + + + ); +}; + +export default index; diff --git a/src/pages/CreateKas/layout.tsx b/src/pages/CreateKas/layout.tsx new file mode 100644 index 0000000..4c1c174 --- /dev/null +++ b/src/pages/CreateKas/layout.tsx @@ -0,0 +1,14 @@ +import useCreateKasSubmissionForm from "./Create/hooks/useCreateKasSubmissionForm"; +import KasBody from "./partials/KasBody"; +import { FormProvider } from "react-hook-form"; + +const CreateKasLayout = () => { + const { kassubmissionreqForm } = useCreateKasSubmissionForm(); + return ( + + + + ); +}; + +export default CreateKasLayout; diff --git a/src/pages/CreateKas/partials/AmountPaid.tsx b/src/pages/CreateKas/partials/AmountPaid.tsx new file mode 100644 index 0000000..92a410e --- /dev/null +++ b/src/pages/CreateKas/partials/AmountPaid.tsx @@ -0,0 +1,82 @@ +import React, { useState } from "react"; +import { cn } from "@utils/index"; +import glassmorphism from "@utils/glassmorphism"; +import { Controller, useFormContext } from "react-hook-form"; + +const AmountPaid: React.FC = () => { + const [monthCount, setMonthCount] = useState(0); + const { control, setValue, trigger } = useFormContext(); + + const handleMonthCount = (count: number) => { + const newCount = Math.max(0, count); + setMonthCount(newCount); + const newPayedAmount = 10000 * newCount; + + setValue("payed_amount", newPayedAmount); + trigger("payed_amount"); + }; + + const handlePayedAmountChange = (value: number) => { + setValue("payed_amount", value); + trigger("payed_amount"); + }; + + return ( +
+ +
+ + {monthCount} Month + + ( +
+
+ Rp. + { + const value = parseInt(e.target.value) || 0; + field.onChange(e); + handlePayedAmountChange(value); + }} + className={`outline outline-offset-1 outline-none w-sm w-20 border-bottom rounded-lg text-md bg-transparent ${glassmorphism({ hover: true })}`} + /> + , - +
+
+ {fieldState.error?.message} +
+
+ )} + /> +
+
+ ); +}; + +export default AmountPaid; diff --git a/src/pages/CreateKas/partials/DialogUsers.tsx b/src/pages/CreateKas/partials/DialogUsers.tsx new file mode 100644 index 0000000..10e0c65 --- /dev/null +++ b/src/pages/CreateKas/partials/DialogUsers.tsx @@ -0,0 +1,84 @@ +import React, { useEffect } from "react"; +import { BaseDialog, DialogContent } from "@components/Dialog"; +import { useFormContext } from "react-hook-form"; +import { SearchBar } from "@components/Input"; +import { LoadingDialog } from "@components/Dialog"; +import useGetUser from "../hooks/useUser"; +import glassmorphism from "@utils/glassmorphism"; +import { useLocalStorage } from "@utils/localStorage"; +import { UserModel } from "@api/kasSubmission/model"; +import useUserFilter from "../List/hooks/useUserFilter"; +import { useCreateKasContext } from "../context"; + +interface Props { + isOpen: boolean; + onClose: () => void; +} + +const DialogUsers: React.FC = ({ isOpen, onClose }) => { + const { handleChangeSearch } = useUserFilter(); + const { setValue, getValues } = useFormContext(); + const { setItem } = useLocalStorage(); + const { state } = useCreateKasContext(); + const { userLoading, user } = state; + const { fetchUsers } = useGetUser(); + + useEffect(() => { + fetchUsers(); + }, []); + + const handleUserSelect = (selectedUser: UserModel) => { + const selectedNpm = selectedUser.npm; + setValue("user.npm", selectedNpm); + setItem("user.npm", selectedNpm); + onClose(); + }; + + return ( + + +
+ +
+ + {userLoading && } +
+ {user?.map((item, index) => ( +
handleUserSelect(item)} + className={`cursor-pointer p-2 my-2 rounded-lg ${glassmorphism({ + hover: true, + })} ${item.npm === getValues("user.npm") && glassmorphism({ container: true })}`} + > + {item.npm} - {item.name} +
+ ))} + + {user.length == 0 && ( +
NPM not found
+ )} +
+
+
+ ); +}; + +export default DialogUsers; diff --git a/src/pages/CreateKas/partials/KasBody.tsx b/src/pages/CreateKas/partials/KasBody.tsx new file mode 100644 index 0000000..149351c --- /dev/null +++ b/src/pages/CreateKas/partials/KasBody.tsx @@ -0,0 +1,36 @@ +import React from "react"; +import NoteKas from "./NoteKas"; +import glassmorphism from "@utils/glassmorphism"; +import UploadImage from "./UploadImage"; +import useCreateKasSubmission from "../Create/hooks/useCreateKasSubmission"; +import { ActionButton } from "@components/Button"; +import { useCreateKasContext } from "../context"; +import SearchUserButton from "./SearchUserButton"; +import AmountPaid from "./AmountPaid"; + +const KasBody: React.FC = () => { + const { state } = useCreateKasContext(); + const { handleSubmitForm } = useCreateKasSubmission(); + + return ( +
+ + + + + +
+ ); +}; + +export default KasBody; diff --git a/src/pages/CreateKas/partials/NoteKas.tsx b/src/pages/CreateKas/partials/NoteKas.tsx new file mode 100644 index 0000000..5e5b306 --- /dev/null +++ b/src/pages/CreateKas/partials/NoteKas.tsx @@ -0,0 +1,54 @@ +import React, { useEffect } from "react"; +import glassmorphism from "@utils/glassmorphism"; +import { Controller, useFormContext } from "react-hook-form"; +import { useLocalStorage } from "@utils/localStorage"; +import QuickNoteOptions from "./QuickNoteOptions"; + +const NoteKas: React.FC = () => { + const { control, setValue } = useFormContext(); + const { setItem, getItem } = useLocalStorage(); + + useEffect(() => { + setValue("note", getItem("note")); + }, [setValue, getItem]); + + return ( + <> +
+ + ( + <> +