├── .npmrc ├── public ├── favicon.ico ├── googlea82d7709e5ffe685.html ├── icon-192x192.png ├── icon-256x256.png ├── icon-384x384.png ├── icon-512x512.png ├── images │ ├── wallet.png │ ├── not-found.png │ ├── transaction.png │ ├── wallet-dark.jpg │ ├── finaki-graph.jpg │ ├── mobile-preview.jpg │ ├── preview-dark.jpg │ ├── preview-white.jpg │ ├── darkmode_preview.jpg │ ├── desktop-preview.jpg │ ├── lightmode_preview.jpg │ ├── login_illustration.png │ └── register_illustration.png ├── app.webmanifest └── vercel.svg ├── postcss.config.js ├── .env.example ├── .idea ├── .gitignore ├── vcs.xml ├── modules.xml ├── finaki-frontend.iml └── inspectionProfiles │ └── Project_Default.xml ├── src ├── components │ ├── Charts │ │ ├── AreaChart │ │ │ ├── constants.ts │ │ │ ├── AreaTooltip.tsx │ │ │ └── AreaChart.tsx │ │ ├── constant.ts │ │ ├── NoData.tsx │ │ ├── TooltipWrapper.tsx │ │ ├── ChartHeader.tsx │ │ ├── ChartPlaceholder.tsx │ │ ├── ChartWrapper.tsx │ │ ├── PieChart │ │ │ ├── PieTooltip.tsx │ │ │ ├── PieLabel.tsx │ │ │ └── PieChart.tsx │ │ ├── ChartContainer.tsx │ │ └── BarChart │ │ │ ├── BarChartHeader.tsx │ │ │ ├── BarTooltip.tsx │ │ │ └── BarChart.tsx │ ├── dls │ │ ├── Modal │ │ │ ├── index.ts │ │ │ ├── ModalCloseTringger.tsx │ │ │ ├── ModalTrigger.tsx │ │ │ ├── ModalOverlay.tsx │ │ │ ├── Modal.tsx │ │ │ └── ModalContent.tsx │ │ ├── TextWithIcon │ │ │ ├── Placeholder.tsx │ │ │ └── index.tsx │ │ ├── Dropdown │ │ │ ├── DropdownItem.module.scss │ │ │ ├── DropdownMenu.tsx │ │ │ ├── DropdownSubMenu.tsx │ │ │ └── DropdownItem.tsx │ │ ├── Form │ │ │ ├── FormGroup.tsx │ │ │ ├── Input.tsx │ │ │ ├── Checkbox │ │ │ │ └── Checkbox.tsx │ │ │ ├── Radio │ │ │ │ └── RadioButton.tsx │ │ │ ├── TextArea.tsx │ │ │ ├── InputWithLabel.module.scss │ │ │ ├── CurrencyInput.tsx │ │ │ └── InputWithLabel.tsx │ │ ├── Divider │ │ │ └── index.tsx │ │ ├── ActionWrapper │ │ │ └── OnHoverWrapper.tsx │ │ ├── IconWrapper │ │ │ └── index.tsx │ │ ├── Image │ │ │ └── index.tsx │ │ ├── Hamburger │ │ │ ├── Hamburger.module.scss │ │ │ └── index.tsx │ │ ├── IconButton │ │ │ └── index.tsx │ │ ├── Loading │ │ │ └── LoadingSpinner.tsx │ │ ├── Select │ │ │ └── Option.tsx │ │ ├── Heading │ │ │ └── index.tsx │ │ ├── Tooltip │ │ │ └── Tooltip.tsx │ │ └── Button │ │ │ ├── Button.tsx │ │ │ └── LoadingButton.tsx │ ├── WalletCard │ │ ├── WalletCardSkeleton.tsx │ │ ├── constants.ts │ │ ├── WalletTransaction.tsx │ │ ├── WalletCard.tsx │ │ └── WalletOption.tsx │ ├── Transactions │ │ ├── TransactionItem │ │ │ ├── index.ts │ │ │ └── Skeleton.tsx │ │ └── AllTransactions │ │ │ ├── TransactionHeader.tsx │ │ │ └── TransactionList.tsx │ ├── icons │ │ ├── CheckIcon.tsx │ │ ├── XmarkIcon.tsx │ │ ├── ChevronIcon.tsx │ │ ├── PlusIcon.tsx │ │ ├── UserIcon.tsx │ │ ├── ArrowRectangleIcon.tsx │ │ ├── GridIcon.tsx │ │ ├── ElipsisVerticalIcon.tsx │ │ ├── PhoneIcon.tsx │ │ ├── WalletIcon.tsx │ │ ├── MoonIcon.tsx │ │ ├── SunIcon.tsx │ │ ├── ArrowsIcon.tsx │ │ ├── DesktopIcon.tsx │ │ ├── PencilIcon.tsx │ │ ├── ClipboardIcon.tsx │ │ ├── ArrowIcon.tsx │ │ ├── TrashIcon.tsx │ │ ├── ArrowCircleIcon.tsx │ │ ├── GearIcon.tsx │ │ └── EyeIcon.tsx │ ├── Container │ │ ├── Container.tsx │ │ └── ContentWrapper.tsx │ ├── AuthCard │ │ ├── AuthCardContent.tsx │ │ └── AuthCard.tsx │ ├── Dashboard │ │ ├── DashboardHeader.tsx │ │ ├── WalletPercentage.tsx │ │ ├── DashboardContentWrapper.tsx │ │ ├── TransactionActivity.tsx │ │ └── RecentTrasactions.tsx │ ├── Placeholder │ │ └── index.tsx │ ├── Analytics │ │ └── ga.tsx │ ├── ThemeSelection │ │ ├── ThemeToggleIcon.tsx │ │ ├── ThemeOption.tsx │ │ └── ThemeSelection.tsx │ ├── Header │ │ ├── ProfileInfo │ │ │ ├── index.tsx │ │ │ └── Action.tsx │ │ └── Header.tsx │ ├── Navigation │ │ ├── HomeNav │ │ │ └── HomeNavLink.tsx │ │ └── AppNav │ │ │ ├── AppNavLink.tsx │ │ │ ├── AppNav.tsx │ │ │ └── DemoNav.tsx │ └── Account │ │ ├── DangerZone.tsx │ │ ├── DeviceLists │ │ ├── index.tsx │ │ └── Device.tsx │ │ └── Profile.tsx ├── app │ ├── [...not_found] │ │ └── page.tsx │ ├── app │ │ ├── [...not_found] │ │ │ └── page.tsx │ │ ├── account │ │ │ ├── page.tsx │ │ │ └── MyAccount.tsx │ │ ├── dashboard │ │ │ ├── page.tsx │ │ │ └── DashboardComponent.tsx │ │ ├── wallet │ │ │ ├── page.tsx │ │ │ ├── [id] │ │ │ │ └── page.tsx │ │ │ └── SortableItem.tsx │ │ ├── transactions │ │ │ ├── page.tsx │ │ │ └── ExportPDF.tsx │ │ ├── not-found.tsx │ │ └── layout.tsx │ ├── robots.ts │ ├── seo.ts │ ├── auth │ │ ├── login │ │ │ └── page.tsx │ │ ├── register │ │ │ └── page.tsx │ │ ├── layout.tsx │ │ └── LoginWithGoogle.tsx │ ├── sitemap.ts │ ├── provider.tsx │ ├── GetUserData.tsx │ ├── not-found.tsx │ ├── demo │ │ ├── account │ │ │ └── page.tsx │ │ ├── transactions │ │ │ └── page.tsx │ │ ├── layout.tsx │ │ ├── dashboard │ │ │ └── page.tsx │ │ └── wallet │ │ │ └── page.tsx │ ├── layout.tsx │ ├── about │ │ └── page.tsx │ └── globals.scss ├── types │ ├── LayoutSegment.ts │ ├── Theme.ts │ ├── QueryKey.ts │ ├── User.ts │ ├── Wallet.ts │ ├── IconProps.ts │ ├── Routes.ts │ └── Transaction.ts ├── data │ ├── dummy-data │ │ ├── user.json │ │ ├── wallet.json │ │ └── dashboard.json │ └── transactions_example.json ├── utils │ ├── api │ │ ├── types │ │ │ ├── Api.ts │ │ │ ├── UserAPI.ts │ │ │ ├── AuthAPI.ts │ │ │ ├── TransactionAPI.ts │ │ │ └── WalletAPI.ts │ │ ├── config.ts │ │ ├── user.ts │ │ ├── api.ts │ │ ├── authApi.ts │ │ ├── transaction.ts │ │ └── wallet.ts │ ├── timeFormat.ts │ ├── array.ts │ └── currencyFormat.ts ├── hooks │ ├── useDebounce.ts │ ├── useHydration.ts │ └── useTheme.ts └── stores │ ├── transactionStore.ts │ └── store.ts ├── .eslintrc.json ├── next.config.js ├── .gitignore ├── tsconfig.json ├── LICENSE ├── README.md └── package.json /.npmrc: -------------------------------------------------------------------------------- 1 | auto-install-peers=true -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aacmal/finaki/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/googlea82d7709e5ffe685.html: -------------------------------------------------------------------------------- 1 | google-site-verification: googlea82d7709e5ffe685.html -------------------------------------------------------------------------------- /public/icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aacmal/finaki/HEAD/public/icon-192x192.png -------------------------------------------------------------------------------- /public/icon-256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aacmal/finaki/HEAD/public/icon-256x256.png -------------------------------------------------------------------------------- /public/icon-384x384.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aacmal/finaki/HEAD/public/icon-384x384.png -------------------------------------------------------------------------------- /public/icon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aacmal/finaki/HEAD/public/icon-512x512.png -------------------------------------------------------------------------------- /public/images/wallet.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aacmal/finaki/HEAD/public/images/wallet.png -------------------------------------------------------------------------------- /public/images/not-found.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aacmal/finaki/HEAD/public/images/not-found.png -------------------------------------------------------------------------------- /public/images/transaction.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aacmal/finaki/HEAD/public/images/transaction.png -------------------------------------------------------------------------------- /public/images/wallet-dark.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aacmal/finaki/HEAD/public/images/wallet-dark.jpg -------------------------------------------------------------------------------- /public/images/finaki-graph.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aacmal/finaki/HEAD/public/images/finaki-graph.jpg -------------------------------------------------------------------------------- /public/images/mobile-preview.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aacmal/finaki/HEAD/public/images/mobile-preview.jpg -------------------------------------------------------------------------------- /public/images/preview-dark.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aacmal/finaki/HEAD/public/images/preview-dark.jpg -------------------------------------------------------------------------------- /public/images/preview-white.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aacmal/finaki/HEAD/public/images/preview-white.jpg -------------------------------------------------------------------------------- /public/images/darkmode_preview.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aacmal/finaki/HEAD/public/images/darkmode_preview.jpg -------------------------------------------------------------------------------- /public/images/desktop-preview.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aacmal/finaki/HEAD/public/images/desktop-preview.jpg -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/images/lightmode_preview.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aacmal/finaki/HEAD/public/images/lightmode_preview.jpg -------------------------------------------------------------------------------- /public/images/login_illustration.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aacmal/finaki/HEAD/public/images/login_illustration.png -------------------------------------------------------------------------------- /public/images/register_illustration.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aacmal/finaki/HEAD/public/images/register_illustration.png -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_API_PRODUCTION_URL=https://production-url/api 2 | NEXT_PUBLIC_API_DEVELOPMENT_URL=http://localhost:3001/api 3 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Editor-based HTTP Client requests 5 | /httpRequests/ 6 | -------------------------------------------------------------------------------- /src/components/Charts/AreaChart/constants.ts: -------------------------------------------------------------------------------- 1 | export const stopColor = { 2 | default: "#3b82f6", 3 | transparent: "#ffffff7f", 4 | }; 5 | -------------------------------------------------------------------------------- /src/app/[...not_found]/page.tsx: -------------------------------------------------------------------------------- 1 | import { notFound } from "next/navigation"; 2 | 3 | export default function NotFoundCatchAll() { 4 | notFound(); 5 | return null; 6 | } 7 | -------------------------------------------------------------------------------- /src/app/app/[...not_found]/page.tsx: -------------------------------------------------------------------------------- 1 | import { notFound } from "next/navigation"; 2 | 3 | export default function NotFoundCatchAll() { 4 | notFound(); 5 | return null; 6 | } 7 | -------------------------------------------------------------------------------- /src/types/LayoutSegment.ts: -------------------------------------------------------------------------------- 1 | export enum LayoutSegment { 2 | DASHBOARD = "dashboard", 3 | WALLET = "wallet", 4 | TRANSACTIONS = "transactions", 5 | ACCOUNT = "account", 6 | } 7 | -------------------------------------------------------------------------------- /src/types/Theme.ts: -------------------------------------------------------------------------------- 1 | export enum Theme { 2 | Light = 'light', 3 | Dark = 'dark', 4 | } 5 | 6 | 7 | // set theme same as user preference use undefined 8 | export type ThemeState = Theme; -------------------------------------------------------------------------------- /src/data/dummy-data/user.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "6451ae1759bfa320f0728f7e", 3 | "name": "Johns", 4 | "email": "john@mail.com", 5 | "token": "e4b29048d1da6a1e8cf2", 6 | "telegramAccount": {} 7 | } -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/utils/api/types/Api.ts: -------------------------------------------------------------------------------- 1 | export interface GenericResponse { 2 | message: string; 3 | data: object | null; 4 | } 5 | 6 | export interface GenericRequest { 7 | param: object; 8 | query?: object; 9 | } 10 | -------------------------------------------------------------------------------- /src/components/dls/Modal/index.ts: -------------------------------------------------------------------------------- 1 | import ModalCloseTringger from "./ModalCloseTringger"; 2 | import ModalContent from "./ModalContent"; 3 | import Modal from "./Modal"; 4 | 5 | export { Modal, ModalContent, ModalCloseTringger }; -------------------------------------------------------------------------------- /src/types/QueryKey.ts: -------------------------------------------------------------------------------- 1 | export enum QueryKey { 2 | RECENT_TRANSACTIONS = "recent-transactions", 3 | TRANSACTIONS = "transaction", 4 | TOTAL_TRANSACTIONS = "total-transactions", 5 | WALLETS = "wallets", 6 | USER = "user", 7 | } 8 | -------------------------------------------------------------------------------- /src/types/User.ts: -------------------------------------------------------------------------------- 1 | export interface User { 2 | id: string; 3 | name: string; 4 | email: string; 5 | token?: string | null; 6 | telegramAccount?: { 7 | username: string; 8 | firstName: string; 9 | } | null; 10 | } 11 | -------------------------------------------------------------------------------- /src/components/WalletCard/WalletCardSkeleton.tsx: -------------------------------------------------------------------------------- 1 | import Placeholder from "../Placeholder"; 2 | 3 | const WalletCardSkeleton = () => { 4 | return ; 5 | }; 6 | 7 | export default WalletCardSkeleton; 8 | -------------------------------------------------------------------------------- /src/components/Transactions/TransactionItem/index.ts: -------------------------------------------------------------------------------- 1 | import SimpleTransactionItem from "./Simple"; 2 | import FullTransactionItem from "./Full"; 3 | import SimpleTSkeleton from "./Skeleton"; 4 | 5 | export { SimpleTransactionItem, FullTransactionItem, SimpleTSkeleton }; -------------------------------------------------------------------------------- /src/app/app/account/page.tsx: -------------------------------------------------------------------------------- 1 | import MyAccount from "./MyAccount"; 2 | 3 | export const metadata = { 4 | title: "Akun Saya", 5 | description: "Akun Saya", 6 | }; 7 | 8 | const Page = () => { 9 | return ; 10 | }; 11 | 12 | export default Page; 13 | -------------------------------------------------------------------------------- /src/app/app/dashboard/page.tsx: -------------------------------------------------------------------------------- 1 | import DashboardComponent from "./DashboardComponent"; 2 | 3 | export const metadata = { 4 | title: "Dashboard", 5 | description: "Dashboard", 6 | }; 7 | 8 | const Page = () => { 9 | return ; 10 | }; 11 | 12 | export default Page; 13 | -------------------------------------------------------------------------------- /src/app/app/wallet/page.tsx: -------------------------------------------------------------------------------- 1 | import MyWallets from "./MyWallets"; 2 | 3 | export const metadata = { 4 | title: "Semua Dompet", 5 | description: "Semua dompet saya", 6 | }; 7 | 8 | const AllWalletsPage = () => { 9 | return ; 10 | }; 11 | 12 | export default AllWalletsPage; 13 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/app/robots.ts: -------------------------------------------------------------------------------- 1 | import { MetadataRoute } from "next"; 2 | 3 | export default function robots(): MetadataRoute.Robots { 4 | return { 5 | rules: { 6 | userAgent: "*", 7 | allow: "/", 8 | disallow: "/app/", 9 | }, 10 | sitemap: "https://finaki.acml.me/sitemap.xml", 11 | }; 12 | } 13 | -------------------------------------------------------------------------------- /src/types/Wallet.ts: -------------------------------------------------------------------------------- 1 | export interface WalletData { 2 | _id: string; 3 | name: string; 4 | balance: number; 5 | color: string; 6 | isCredit: boolean; 7 | creadtedAt?: string; 8 | updatedAt?: Date; 9 | } 10 | 11 | export interface BalanceHistory { 12 | timestamp: string; 13 | value: number; 14 | } 15 | -------------------------------------------------------------------------------- /src/app/app/transactions/page.tsx: -------------------------------------------------------------------------------- 1 | import AllTransactions from "./AllTransactions"; 2 | 3 | export const metadata = { 4 | title: "Semua Transaksi", 5 | description: "Semua transaksi", 6 | }; 7 | 8 | const TransactionsPage = () => { 9 | return ; 10 | }; 11 | 12 | export default TransactionsPage; 13 | -------------------------------------------------------------------------------- /src/app/app/wallet/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | import WalletById from "./WalletById"; 2 | 3 | export const metadata = { 4 | title: "Detail Dompet", 5 | description: "Detail dompet", 6 | robots: "noindex, nofollow", 7 | }; 8 | 9 | const WalletDetailPage = () => { 10 | return ; 11 | }; 12 | 13 | export default WalletDetailPage; 14 | -------------------------------------------------------------------------------- /src/components/Charts/constant.ts: -------------------------------------------------------------------------------- 1 | export const COLOR = { 2 | INCOME: "#3b82f6", 3 | OUTCOME: "#f97316", 4 | }; 5 | 6 | export const PIE_CHART = { 7 | red: "#ef4444", 8 | cyan: "#06b6d4", 9 | yellow: "#eab308", 10 | blue: "#3b82f6", 11 | green: "#22c55e", 12 | orange: "#f97316", 13 | purple: "#a855f7", 14 | gray: "#6b7280", 15 | }; 16 | -------------------------------------------------------------------------------- /src/data/dummy-data/wallet.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": [ 3 | { 4 | "_id": "64545d70a1a609cfd0c254b6", 5 | "name": "Gopay", 6 | "balance": 35834, 7 | "color": "cyan" 8 | }, 9 | { 10 | "_id": "64545d1ccc0796bc8e2656ed", 11 | "name": "OVO", 12 | "balance": 9701, 13 | "color": "purple" 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /src/components/Transactions/TransactionItem/Skeleton.tsx: -------------------------------------------------------------------------------- 1 | import Placeholder from "../../Placeholder"; 2 | import React from "react"; 3 | 4 | type Props = { 5 | delay?: number; 6 | }; 7 | 8 | const SimpleTSkeleton = (props: Props) => { 9 | return ; 10 | }; 11 | 12 | export default SimpleTSkeleton; 13 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "extends": [ 4 | "eslint:recommended", 5 | "next/core-web-vitals", 6 | "plugin:@typescript-eslint/recommended" 7 | ], 8 | "rules": { 9 | "no-unused-vars": "off", 10 | "@typescript-eslint/no-unused-vars": ["error"], 11 | "@typescript-eslint/no-explicit-any": "off" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/components/dls/TextWithIcon/Placeholder.tsx: -------------------------------------------------------------------------------- 1 | import Placeholder from "../../Placeholder"; 2 | import React from "react"; 3 | 4 | type Props = { 5 | delay?: number; 6 | }; 7 | 8 | const TextWithIconPlaceholder = (props: Props) => { 9 | return ; 10 | }; 11 | 12 | export default TextWithIconPlaceholder; 13 | -------------------------------------------------------------------------------- /src/components/dls/Dropdown/DropdownItem.module.scss: -------------------------------------------------------------------------------- 1 | .dropdownItem { 2 | 3 | .itemIndicator{ 4 | width: 25px; 5 | display: inline-flex; 6 | justify-content: center; 7 | align-items: center; 8 | position: absolute; 9 | left: 0; 10 | bottom: 0; 11 | top: 0; 12 | 13 | svg{ 14 | width: 20px; 15 | height: 20px; 16 | } 17 | } 18 | } -------------------------------------------------------------------------------- /src/utils/api/types/UserAPI.ts: -------------------------------------------------------------------------------- 1 | import { User } from "@/types/User"; 2 | import { GenericResponse } from "./Api"; 3 | 4 | export interface UserResponse extends GenericResponse { 5 | data: User; 6 | } 7 | 8 | export interface UserDevicesResponse extends GenericResponse { 9 | data: { 10 | userAgent: string; 11 | createdAt: string; 12 | isCurrent: boolean; 13 | _id: string; 14 | }[]; 15 | } -------------------------------------------------------------------------------- /src/components/dls/Modal/ModalCloseTringger.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from "react"; 2 | import { ModalContext } from "./Modal"; 3 | 4 | type Props = { 5 | children: React.ReactNode; 6 | }; 7 | 8 | const ModalCloseTringger = (props: Props) => { 9 | const { close } = useContext(ModalContext); 10 | 11 | return
{props.children}
; 12 | }; 13 | 14 | export default ModalCloseTringger; 15 | -------------------------------------------------------------------------------- /src/app/seo.ts: -------------------------------------------------------------------------------- 1 | export const desciprtion = 2 | "Finaki adalah aplikasi manajemen keuangan all-in-one, yang memungkinkan Anda untuk mengatur pengeluaran, pendapatan, dan tabungan dengan mudah dan efisien."; 3 | export const keywords = 4 | "aplikasi manajemen keuangan, finaki, sistem informasi, aplikasi keuangan gratis, aplikasi pencatatan pengeluaran, aplikasi pelacakan pemasukan, apliksi keuangan online"; 5 | 6 | export const title = "Finaki - Aplikasi Manajemen Keuangan"; 7 | -------------------------------------------------------------------------------- /src/components/Charts/NoData.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | type Props = { 4 | className?: string; 5 | message?: string; 6 | }; 7 | 8 | const NoData = (props: Props) => { 9 | return ( 10 |
11 | 12 | {props.message ?? "Belum ada data yang ditampilkan."} 13 | 14 |
15 | ); 16 | }; 17 | 18 | export default NoData; 19 | -------------------------------------------------------------------------------- /src/components/dls/Modal/ModalTrigger.tsx: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | import { ModalContext } from "./Modal"; 3 | 4 | type Props = { 5 | children: React.ReactNode; 6 | className?: string; 7 | }; 8 | 9 | const ModalTrigger = ({ children, className }: Props) => { 10 | const { open } = useContext(ModalContext); 11 | 12 | return ( 13 |
14 | {children} 15 |
16 | ); 17 | }; 18 | 19 | export default ModalTrigger; 20 | -------------------------------------------------------------------------------- /src/utils/timeFormat.ts: -------------------------------------------------------------------------------- 1 | export const dateFormat = (timestamp: string) => 2 | new Date(timestamp).toLocaleDateString("id-ID", { 3 | weekday: "long", 4 | day: "numeric", 5 | month: "long", 6 | year: "numeric", 7 | }); 8 | 9 | export const timeFormat = (timestamp: string) => 10 | new Date(timestamp).toLocaleTimeString( 11 | Intl.DateTimeFormat().resolvedOptions().locale, 12 | { 13 | hour: "numeric", 14 | minute: "numeric", 15 | hour12: false, 16 | } 17 | ); 18 | -------------------------------------------------------------------------------- /src/hooks/useDebounce.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from "react"; 2 | 3 | export const useDebounce = (value: T, delay = 500) => { 4 | const [debouncedValue, setDebouncedValue] = useState(); 5 | const timerRef = useRef(); 6 | 7 | useEffect(() => { 8 | timerRef.current = setTimeout(() => setDebouncedValue(value), delay); 9 | return () => { 10 | clearTimeout(timerRef.current); 11 | }; 12 | }, [value, delay]); 13 | 14 | return debouncedValue; 15 | }; 16 | -------------------------------------------------------------------------------- /.idea/finaki-frontend.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/components/Charts/TooltipWrapper.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | 3 | type Props = { 4 | children: React.ReactNode; 5 | className?: string; 6 | }; 7 | 8 | const TooltipWrapper = ({ children, className }: Props) => { 9 | return ( 10 |
11 | {children} 12 |
13 | ); 14 | }; 15 | 16 | export default TooltipWrapper; 17 | -------------------------------------------------------------------------------- /src/components/dls/Modal/ModalOverlay.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | 3 | type Props = { 4 | onClick?: () => void; 5 | className?: string; 6 | }; 7 | 8 | const ModalOverlay = ({ onClick, className }: Props) => { 9 | return ( 10 |
17 | ); 18 | }; 19 | 20 | export default ModalOverlay; 21 | -------------------------------------------------------------------------------- /src/components/icons/CheckIcon.tsx: -------------------------------------------------------------------------------- 1 | import { defaultOutlineIconProps, IconProps } from "@/types/IconProps"; 2 | 3 | const CheckIcon = (props: IconProps) => { 4 | return ( 5 | 11 | 16 | 17 | ); 18 | }; 19 | 20 | export default CheckIcon; 21 | -------------------------------------------------------------------------------- /src/components/icons/XmarkIcon.tsx: -------------------------------------------------------------------------------- 1 | import { defaultOutlineIconProps, IconProps } from "@/types/IconProps"; 2 | 3 | const XmarkIcon = (props: IconProps) => { 4 | return ( 5 | 11 | 16 | 17 | ); 18 | }; 19 | 20 | export default XmarkIcon; 21 | -------------------------------------------------------------------------------- /src/components/Container/Container.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | import React from "react"; 3 | 4 | type ContainerProps = { 5 | children: React.ReactNode; 6 | className?: string; 7 | }; 8 | 9 | const Container = ({ children, className }: ContainerProps) => { 10 | return ( 11 |
17 | {children} 18 |
19 | ); 20 | }; 21 | 22 | export default Container; 23 | -------------------------------------------------------------------------------- /src/components/dls/Form/FormGroup.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | type Props = { 4 | children: React.ReactNode; 5 | // eslint-disable-next-line no-unused-vars 6 | onSubmit: (e: React.FormEvent) => void; 7 | className?: string; 8 | }; 9 | 10 | const FormGroup = ({ children, onSubmit, className }: Props) => { 11 | return ( 12 |
13 |
{children}
14 |
15 | ); 16 | }; 17 | 18 | export default FormGroup; 19 | -------------------------------------------------------------------------------- /src/components/icons/ChevronIcon.tsx: -------------------------------------------------------------------------------- 1 | import { defaultOutlineIconProps, IconProps } from "@/types/IconProps"; 2 | 3 | const ChevronIcon = (props: IconProps) => { 4 | return ( 5 | 11 | 16 | 17 | ); 18 | }; 19 | 20 | export default ChevronIcon; 21 | -------------------------------------------------------------------------------- /src/components/Charts/ChartHeader.tsx: -------------------------------------------------------------------------------- 1 | import Heading from "../dls/Heading"; 2 | 3 | type ChartHeaderProps = { 4 | title: string; 5 | children?: React.ReactNode; 6 | }; 7 | 8 | const ChartHeader = ({ title, children }: ChartHeaderProps) => ( 9 |
10 | 11 | {title} 12 | 13 |
14 | {children} 15 |
16 |
17 | ); 18 | 19 | export default ChartHeader; 20 | -------------------------------------------------------------------------------- /src/types/IconProps.ts: -------------------------------------------------------------------------------- 1 | export interface IconProps { 2 | className?: string | undefined; 3 | fill?: string; 4 | stroke?: string; 5 | strokeWidth?: number; 6 | width?: number; 7 | height?: number; 8 | } 9 | 10 | export interface ArrowIconProps extends IconProps { 11 | direction: 'up' | 'down' | 'left' | 'right'; 12 | } 13 | 14 | export const defaultIconProps: IconProps = { 15 | fill: 'currentColor', 16 | } 17 | 18 | export const defaultOutlineIconProps: IconProps = { 19 | fill: 'none', 20 | stroke: 'currentColor', 21 | strokeWidth: 1.5, 22 | } -------------------------------------------------------------------------------- /src/components/icons/PlusIcon.tsx: -------------------------------------------------------------------------------- 1 | import { defaultOutlineIconProps, IconProps } from "@/types/IconProps"; 2 | import React from "react"; 3 | 4 | const PlusIcon = (props: IconProps) => { 5 | return ( 6 | 12 | 17 | 18 | ); 19 | }; 20 | 21 | export default PlusIcon; 22 | -------------------------------------------------------------------------------- /src/types/Routes.ts: -------------------------------------------------------------------------------- 1 | export enum Routes { 2 | App = "/app", 3 | Dashboard = "/app/dashboard", 4 | Wallet = "/app/wallet", 5 | Transactions = "/app/transactions", 6 | Account = "/app/account", 7 | Auth = "/auth", 8 | Login = "/auth/login", 9 | Register = "/auth/register", 10 | Home = "/", 11 | Demo = "/demo", 12 | DashboardDemo = "/demo/dashboard", 13 | WalletDemo = "/demo/wallet", 14 | TransactionsDemo = "/demo/transactions", 15 | AccountDemo = "/demo/account", 16 | ForgotPassword = "/auth/forgot-password", 17 | About = "/about", 18 | } 19 | -------------------------------------------------------------------------------- /src/types/Transaction.ts: -------------------------------------------------------------------------------- 1 | export interface Transaction { 2 | _id: string; 3 | walletId?: string; 4 | amount: number; 5 | description: string; 6 | note: string; 7 | type: string; 8 | createdAt: string; 9 | updatedAt: string; 10 | // category?: string; 11 | } 12 | 13 | export interface TransactionByDate { 14 | date: string; 15 | transactions: Transaction[]; 16 | } 17 | 18 | export interface TotalTransactionByDay { 19 | _id: { 20 | day: number; 21 | }; 22 | timestamp: string; 23 | in: number; 24 | out: number; 25 | totalAmount: number; 26 | } 27 | -------------------------------------------------------------------------------- /src/components/AuthCard/AuthCardContent.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | import React from "react"; 3 | 4 | type Props = { 5 | children: React.ReactNode; 6 | className?: string; 7 | }; 8 | 9 | const AuthCardContent = ({ children, className }: Props) => { 10 | return ( 11 |
17 | {children} 18 |
19 | ); 20 | }; 21 | 22 | export default AuthCardContent; 23 | -------------------------------------------------------------------------------- /src/components/icons/UserIcon.tsx: -------------------------------------------------------------------------------- 1 | import { defaultOutlineIconProps, IconProps } from '@/types/IconProps' 2 | import React from 'react' 3 | 4 | 5 | const UserIcon = (props: IconProps) => { 6 | return ( 7 | 8 | 9 | 10 | 11 | ) 12 | } 13 | 14 | export default UserIcon -------------------------------------------------------------------------------- /src/components/Charts/ChartPlaceholder.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | type Props = { 4 | message: string; 5 | }; 6 | 7 | const ChartPlaceholder = ({ message }: Props) => { 8 | return ( 9 |
10 | {message} 11 |
12 | ); 13 | }; 14 | 15 | export const ChartLoading = () => ( 16 | 17 | ); 18 | 19 | export const ChartError = () => ; 20 | 21 | export default ChartPlaceholder; 22 | -------------------------------------------------------------------------------- /src/components/Dashboard/DashboardHeader.tsx: -------------------------------------------------------------------------------- 1 | import Heading from "../dls/Heading"; 2 | 3 | type Props = { 4 | title: string; 5 | children?: React.ReactNode; 6 | }; 7 | 8 | const DashboardHeader = ({ title, children }: Props) => ( 9 |
10 | 11 | {title} 12 | 13 | {children && ( 14 |
15 | {children} 16 |
17 | )} 18 |
19 | ); 20 | 21 | export default DashboardHeader; 22 | -------------------------------------------------------------------------------- /src/components/Placeholder/index.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | import React from "react"; 3 | 4 | type Props = { 5 | className: string; 6 | animationDelay?: number; 7 | }; 8 | 9 | const Placeholder = ({ className, animationDelay = 0 }: Props) => { 10 | return ( 11 |
20 | ); 21 | }; 22 | 23 | export default Placeholder; 24 | -------------------------------------------------------------------------------- /src/app/app/account/MyAccount.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import DangerZone from "../../../components/Account/DangerZone"; 4 | import DevicesLists from "../../../components/Account/DeviceLists"; 5 | import Profile from "../../../components/Account/Profile"; 6 | import ThemeSelection from "../../../components/ThemeSelection/ThemeSelection"; 7 | 8 | const MyAccount = () => { 9 | return ( 10 |
11 | 12 | 13 | 14 | 15 |
16 | ); 17 | }; 18 | 19 | export default MyAccount; 20 | -------------------------------------------------------------------------------- /src/components/Container/ContentWrapper.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | import React from "react"; 3 | 4 | type Props = { 5 | className?: string; 6 | children: React.ReactNode; 7 | }; 8 | 9 | const ContentWrapper = ({ className, children }: Props) => { 10 | return ( 11 |
17 | {children} 18 |
19 | ); 20 | }; 21 | 22 | export default ContentWrapper; 23 | -------------------------------------------------------------------------------- /src/components/icons/ArrowRectangleIcon.tsx: -------------------------------------------------------------------------------- 1 | import { defaultOutlineIconProps, IconProps } from '@/types/IconProps' 2 | import React from 'react' 3 | 4 | 5 | const ArrowRectangleIcon = (props: IconProps) => { 6 | return ( 7 | 8 | 9 | 10 | 11 | ) 12 | } 13 | 14 | export default ArrowRectangleIcon -------------------------------------------------------------------------------- /src/components/dls/Divider/index.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | import React from "react"; 3 | 4 | type Props = { 5 | vertical?: boolean; 6 | horizontal?: boolean; 7 | className?: string; 8 | }; 9 | 10 | const Divider = ({ vertical, horizontal, className }: Props) => { 11 | return ( 12 |
24 | ); 25 | }; 26 | 27 | export default Divider; 28 | -------------------------------------------------------------------------------- /src/components/dls/ActionWrapper/OnHoverWrapper.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames' 2 | import React from 'react' 3 | 4 | type OnHoverWrapper = { 5 | children: React.ReactNode 6 | className?: string 7 | } 8 | 9 | const OnHoverWrapper = ({ 10 | children, 11 | className 12 | }: OnHoverWrapper) => { 13 | return ( 14 |
15 | {children} 16 |
17 | ) 18 | } 19 | 20 | export default OnHoverWrapper -------------------------------------------------------------------------------- /src/app/auth/login/page.tsx: -------------------------------------------------------------------------------- 1 | import LoginComponent from "./LoginComponent"; 2 | 3 | export const metadata = { 4 | title: "Login", 5 | description: "Login to your Finaki account", 6 | }; 7 | 8 | const jsonLd = { 9 | "@context": "https://schema.org", 10 | "@type": "WebPage", 11 | name: "Login | Finaki", 12 | description: "Login to your Finaki account", 13 | }; 14 | 15 | const LoginPage = () => { 16 | return ( 17 | <> 18 | 21 | 22 | ); 23 | }; 24 | 25 | export default GoogleAnalytics; 26 | -------------------------------------------------------------------------------- /src/components/dls/Image/index.tsx: -------------------------------------------------------------------------------- 1 | import { default as Img } from "next/image"; 2 | 3 | type Props = { 4 | src: string; 5 | alt: string; 6 | className: string; 7 | priority?: boolean; 8 | sizes?: string; 9 | }; 10 | 11 | const Image = ({ src, alt, className, priority, sizes }: Props) => { 12 | return ( 13 |
14 |
15 | {alt} 24 |
25 |
26 | ); 27 | }; 28 | 29 | export default Image; 30 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 12 | -------------------------------------------------------------------------------- /src/components/Charts/AreaChart/AreaTooltip.tsx: -------------------------------------------------------------------------------- 1 | import { currencyFormat } from "@/utils/currencyFormat"; 2 | import TooltipWrapper from "../TooltipWrapper"; 3 | 4 | const renderAreaTooltip = ({ active, payload }: any) => { 5 | if (active) { 6 | const date = new Date(payload[0]?.payload.timestamp).toLocaleDateString( 7 | "id-ID", 8 | { 9 | day: "numeric", 10 | month: "short", 11 | } 12 | ); 13 | return ( 14 | 15 |

{date}

16 |

17 | {currencyFormat(payload[0].value)} 18 |

19 |
20 | ); 21 | } 22 | }; 23 | 24 | export default renderAreaTooltip; 25 | -------------------------------------------------------------------------------- /src/components/Charts/ChartWrapper.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from "react"; 2 | import { ResponsiveContainer } from "recharts"; 3 | 4 | type Props = { 5 | children: ReactElement; 6 | className?: string; 7 | }; 8 | 9 | /** 10 | * 11 | * @param children use this to set type of chart 12 | * @param className use this to set the width and height of the chart with tailwind class 13 | * @returns 14 | * 15 | */ 16 | 17 | const ChartWrapper = ({ children, className }: Props) => { 18 | return ( 19 |
20 | 21 | {children} 22 | 23 |
24 | ); 25 | }; 26 | 27 | export default ChartWrapper; 28 | -------------------------------------------------------------------------------- /src/components/icons/SunIcon.tsx: -------------------------------------------------------------------------------- 1 | import { defaultOutlineIconProps, IconProps } from "@/types/IconProps"; 2 | import React from "react"; 3 | 4 | const SunIcon = (props: IconProps) => { 5 | return ( 6 | 12 | 17 | 18 | ); 19 | }; 20 | 21 | export default SunIcon; 22 | -------------------------------------------------------------------------------- /src/utils/api/types/AuthAPI.ts: -------------------------------------------------------------------------------- 1 | import { GenericResponse } from "./Api"; 2 | 3 | export interface RegisterInput { 4 | name: string; 5 | email: string; 6 | password: string; 7 | } 8 | 9 | export interface LoginInput { 10 | email: string; 11 | password: string; 12 | } 13 | 14 | export interface RefreshTokenResponse extends GenericResponse { 15 | data: { 16 | accessToken: string; 17 | }; 18 | } 19 | 20 | export interface LoginResponse extends GenericResponse { 21 | data: { 22 | accessToken: string; 23 | }; 24 | } 25 | 26 | export interface LogoutResponse extends GenericResponse { 27 | data: null; 28 | } 29 | 30 | export interface ResetPasswordInput { 31 | token: string; 32 | password: string; 33 | } 34 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | 3 | const withPWA = require("@ducanh2912/next-pwa").default({ 4 | dest: "public", 5 | }); 6 | const nextConfig = { 7 | async redirects() { 8 | return [ 9 | { 10 | source: '/app', 11 | destination: '/app/dashboard', 12 | permanent: true, 13 | }, 14 | { 15 | source: '/auth', 16 | destination: '/auth/login', 17 | permanent: true, 18 | }, 19 | { 20 | source: '/demo', 21 | destination: '/demo/dashboard', 22 | permanent: true, 23 | } 24 | ] 25 | }, 26 | experimental: { 27 | appDir: true, 28 | }, 29 | } 30 | 31 | module.exports = withPWA({ 32 | ...nextConfig 33 | }) 34 | -------------------------------------------------------------------------------- /src/app/app/transactions/ExportPDF.tsx: -------------------------------------------------------------------------------- 1 | import ExportTransactionModal from "../../../components/Transactions/ExportTransactionModal"; 2 | import Button from "../../../components/dls/Button/Button"; 3 | import { useState } from "react"; 4 | 5 | const ExportPDF = () => { 6 | const [isOpen, setIsOpen] = useState(false); 7 | return ( 8 | <> 9 | 17 | 18 | 19 | ); 20 | }; 21 | 22 | export default ExportPDF; 23 | -------------------------------------------------------------------------------- /src/app/sitemap.ts: -------------------------------------------------------------------------------- 1 | import { MetadataRoute } from "next"; 2 | 3 | const domain = "https://finaki.acml.me"; 4 | export default function sitemap(): MetadataRoute.Sitemap { 5 | return [ 6 | { 7 | url: domain, 8 | lastModified: new Date(), 9 | }, 10 | { 11 | url: `${domain}/auth/login`, 12 | lastModified: new Date(), 13 | }, 14 | { 15 | url: `${domain}/auth/register`, 16 | lastModified: new Date(), 17 | }, 18 | { 19 | url: `${domain}/demo/transactions`, 20 | lastModified: new Date(), 21 | }, 22 | { 23 | url: `${domain}/about`, 24 | lastModified: new Date(), 25 | }, 26 | { 27 | url: `${domain}/telegram-integration`, 28 | lastModified: new Date(), 29 | }, 30 | ]; 31 | } 32 | -------------------------------------------------------------------------------- /src/components/Transactions/AllTransactions/TransactionHeader.tsx: -------------------------------------------------------------------------------- 1 | const TransactionHeader = () => { 2 | return ( 3 | 4 | 5 | 6 | Waktu 7 | 8 | 9 | Deskripsi 10 | 11 | Dompet 12 | 13 | Jumlah (Rp.) 14 | 15 | 16 | 17 | 18 | ); 19 | }; 20 | 21 | export default TransactionHeader; 22 | -------------------------------------------------------------------------------- /src/app/provider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 4 | import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; 5 | import React from "react"; 6 | import useTheme from "../hooks/useTheme"; 7 | 8 | const queryClient = new QueryClient({ 9 | defaultOptions: { 10 | queries: { 11 | retry: false, 12 | refetchOnWindowFocus: false, 13 | staleTime: 1000 * 60 * 5, // 5 minutes 14 | }, 15 | }, 16 | }); 17 | 18 | const RQProvider = ({ children }: { children: React.ReactNode }) => { 19 | useTheme(); 20 | return ( 21 | 22 | {children} 23 | 24 | 25 | ); 26 | }; 27 | 28 | export default RQProvider; 29 | -------------------------------------------------------------------------------- /src/components/icons/ArrowsIcon.tsx: -------------------------------------------------------------------------------- 1 | import { IconProps } from '@/types/IconProps' 2 | import React from 'react' 3 | 4 | const ArrowsIcon = ({ 5 | strokeWidth = 1.5, 6 | stroke = 'currentColor', 7 | ...props 8 | }: IconProps) => { 9 | return ( 10 | 11 | 12 | 13 | 14 | 15 | ) 16 | } 17 | 18 | export default ArrowsIcon -------------------------------------------------------------------------------- /src/components/Charts/PieChart/PieTooltip.tsx: -------------------------------------------------------------------------------- 1 | import { PIE_CHART } from "../constant"; 2 | import { currencyFormat } from "@/utils/currencyFormat"; 3 | import TooltipWrapper from "../TooltipWrapper"; 4 | 5 | const renderPieTooltip = ({ active, payload }: any) => { 6 | if (active) { 7 | return ( 8 | 9 |

17 | {payload[0].name} 18 |

19 |

{currencyFormat(payload[0].value)}

20 |
21 | ); 22 | } 23 | }; 24 | export default renderPieTooltip; 25 | -------------------------------------------------------------------------------- /src/components/icons/DesktopIcon.tsx: -------------------------------------------------------------------------------- 1 | import { defaultIconProps, IconProps } from "@/types/IconProps"; 2 | import React from "react"; 3 | 4 | const DesktopIcon = (props: IconProps) => { 5 | return ( 6 | 13 | 18 | 19 | ); 20 | }; 21 | 22 | export default DesktopIcon; 23 | -------------------------------------------------------------------------------- /src/app/GetUserData.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { instance } from "@/api/api"; 4 | import { getUserData } from "@/api/user"; 5 | import { QueryKey } from "@/types/QueryKey"; 6 | import { useQuery } from "@tanstack/react-query"; 7 | import { useEffect } from "react"; 8 | import useStore from "../stores/store"; 9 | 10 | const GetUserData = () => { 11 | const setUser = useStore((state) => state.setUser); 12 | useQuery({ 13 | queryKey: [QueryKey.USER], 14 | queryFn: getUserData, 15 | onSuccess: (data) => { 16 | setUser(data); 17 | }, 18 | staleTime: 0, 19 | }); 20 | 21 | useEffect(() => { 22 | instance.defaults.headers["Authorization"] = `Bearer ${localStorage.getItem( 23 | "access-token" 24 | )}`; 25 | }, []); 26 | 27 | return <>; 28 | }; 29 | 30 | export default GetUserData; 31 | -------------------------------------------------------------------------------- /src/components/icons/PencilIcon.tsx: -------------------------------------------------------------------------------- 1 | import { defaultIconProps, IconProps } from '@/types/IconProps' 2 | import React from 'react' 3 | 4 | 5 | const PencilIcon = ({ 6 | ...props 7 | }: IconProps) => { 8 | return ( 9 | 10 | 11 | 12 | 13 | 14 | ) 15 | } 16 | 17 | export default PencilIcon -------------------------------------------------------------------------------- /src/utils/api/config.ts: -------------------------------------------------------------------------------- 1 | // const BASE_URL = "https://finaki-backend-git-test-axcamz.vercel.app/api"; // test server 2 | export const BASE_URL = 3 | process.env.NODE_ENV === "production" 4 | ? process.env.NEXT_PUBLIC_API_PRODUCTION_URL 5 | : process.env.NEXT_PUBLIC_API_DEVELOPMENT_URL; 6 | 7 | /** 8 | * 9 | * @param path url path 10 | * @param parameters url parameters 11 | * @returns url with parameters 12 | * @example makeUrl("/transactions", { limit: 10 }) => "/transactions?limit=10" 13 | */ 14 | export const makeUrl = ( 15 | path: string, 16 | parameters?: Record 17 | ): string => { 18 | if (!parameters) return path; 19 | 20 | const params = Object.keys(parameters) 21 | .map((key) => { 22 | return `${key}=${parameters[key]}`; 23 | }) 24 | .join("&"); 25 | 26 | return `${path}?${params}`; 27 | }; 28 | -------------------------------------------------------------------------------- /src/hooks/useHydration.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import useStore from "../stores/store"; 3 | 4 | const useHydration = (): boolean => { 5 | const [hydrated, setHydrated] = useState(false); 6 | 7 | useEffect(() => { 8 | // Note: This is just in case you want to take into account manual rehydration. 9 | // You can remove the following line if you don't need it. 10 | const unsubHydrate = useStore.persist.onHydrate(() => setHydrated(false)); 11 | 12 | const unsubFinishHydration = useStore.persist.onFinishHydration(() => 13 | setHydrated(true) 14 | ); 15 | 16 | setHydrated(useStore.persist.hasHydrated()); 17 | 18 | return () => { 19 | unsubHydrate(); 20 | unsubFinishHydration(); 21 | }; 22 | }, []); 23 | 24 | return hydrated; 25 | }; 26 | 27 | export default useHydration; 28 | -------------------------------------------------------------------------------- /src/app/app/not-found.tsx: -------------------------------------------------------------------------------- 1 | import Heading from "../../components/dls/Heading"; 2 | import { Routes } from "@/types/Routes"; 3 | import Image from "next/image"; 4 | import Link from "next/link"; 5 | 6 | const NotFound = () => { 7 | return ( 8 |
9 | Not Found Image 15 | 16 | Ups! Ada yang salah 17 | 18 | 22 | Kembali ke Dashboard 23 | 24 |
25 | ); 26 | }; 27 | 28 | export default NotFound; 29 | -------------------------------------------------------------------------------- /src/components/dls/Hamburger/Hamburger.module.scss: -------------------------------------------------------------------------------- 1 | label.hamburgerMenu { 2 | display: flex; 3 | flex-direction: column; 4 | width: 30px; 5 | cursor: pointer; 6 | } 7 | label.hamburgerMenu span { 8 | border-radius: 10px; 9 | height: 3px; 10 | margin: 3px 0; 11 | transition: 0.4s cubic-bezier(0.68, -0.6, 0.32, 1.6); 12 | } 13 | 14 | .hamburgerMenu span:nth-child(1) { 15 | width: 50%; 16 | } 17 | 18 | .hamburgerMenu span:nth-child(2) { 19 | width: 100%; 20 | } 21 | 22 | .hamburgerMenu span:nth-child(3) { 23 | width: 75%; 24 | } 25 | 26 | span.st { 27 | transform-origin: bottom; 28 | transform: rotatez(45deg) translate(3px, 0.2px); 29 | } 30 | 31 | span.nd { 32 | transform-origin: top; 33 | transform: rotatez(-45deg); 34 | } 35 | 36 | span.rd { 37 | transform-origin: bottom; 38 | width: 50%; 39 | transform: translate(7px, -7px) rotatez(45deg); 40 | } -------------------------------------------------------------------------------- /src/utils/api/user.ts: -------------------------------------------------------------------------------- 1 | import { instance } from "./api"; 2 | import { UserDevicesResponse, UserResponse } from "./types/UserAPI"; 3 | 4 | export const getUserData = async () => { 5 | const response = await instance.get("/user"); 6 | return response.data.data; 7 | }; 8 | 9 | export const getUserDevices = async () => { 10 | const response = await instance.get("/user/devices", { 11 | withCredentials: true, 12 | }); 13 | return response.data.data; 14 | } 15 | 16 | export const logoutDevices = async (deviceIds: string[]) => { 17 | const response = await instance.post("/user/devices", { deviceIds }, { 18 | withCredentials: true, 19 | }); 20 | return response.data.data; 21 | } 22 | 23 | export const detachTelegramAccount = async () => { 24 | const response = await instance.delete("/user/telegram"); 25 | return response.data.data; 26 | } -------------------------------------------------------------------------------- /src/components/icons/ClipboardIcon.tsx: -------------------------------------------------------------------------------- 1 | import { defaultOutlineIconProps, IconProps } from "@/types/IconProps"; 2 | import React from "react"; 3 | 4 | 5 | const ClipboardIcon = (props: IconProps) => { 6 | return ( 7 | 13 | 18 | 19 | ); 20 | }; 21 | 22 | export default ClipboardIcon; 23 | -------------------------------------------------------------------------------- /src/hooks/useTheme.ts: -------------------------------------------------------------------------------- 1 | import { Theme } from "@/types/Theme"; 2 | import { useEffect } from "react"; 3 | import useStore from "../stores/store"; 4 | 5 | export default function useTheme() { 6 | const { colorTheme, setColorTheme } = useStore((state) => ({ 7 | colorTheme: state.colorTheme, 8 | setColorTheme: state.setColorTheme, 9 | })); 10 | 11 | const setMetaThemeColor = (color: string) => { 12 | document 13 | .querySelector('meta[name="theme-color"]') 14 | ?.setAttribute("content", color); 15 | }; 16 | 17 | useEffect(() => { 18 | if (colorTheme === Theme.Dark) { 19 | document.documentElement.classList.add(Theme.Dark); 20 | setMetaThemeColor("#0f172a"); 21 | } else { 22 | document.documentElement.classList.remove(Theme.Dark); 23 | setMetaThemeColor("#e7e5e4"); 24 | } 25 | }, [colorTheme]); 26 | 27 | return { colorTheme, setColorTheme }; 28 | } 29 | -------------------------------------------------------------------------------- /src/utils/array.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | // group by day using timestamp 3 | 4 | import { Transaction } from "@/types/Transaction"; 5 | 6 | export const groupByDay = (array: any[]): any[] => { 7 | const result = array.reduce((acc, item) => { 8 | const date = new Date(item.createdAt).toLocaleDateString( 9 | Intl.DateTimeFormat().resolvedOptions().locale, 10 | { 11 | year: "numeric", 12 | month: "long", 13 | day: "numeric", 14 | } 15 | ); 16 | if (!acc[date]) { 17 | acc[date] = []; 18 | } 19 | acc[date].push(item); 20 | return acc; 21 | }, {}); 22 | return Object.keys(result).map((date) => ({ date, data: result[date] })); 23 | }; 24 | 25 | export const flatInfiniteTransaction = (array: any[]): Transaction[] => { 26 | // is already flat 27 | return array.flatMap((item) => item.transactions); 28 | }; 29 | -------------------------------------------------------------------------------- /src/components/Dashboard/WalletPercentage.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import DashboardContentWrapper from "./DashboardContentWrapper"; 3 | import DashboardHeader from "./DashboardHeader"; 4 | import PieChart from "../Charts/PieChart/PieChart"; 5 | import { WalletData } from "@/types/Wallet"; 6 | 7 | type Props = { 8 | data: WalletData[] | undefined; 9 | loading?: boolean; 10 | }; 11 | 12 | const WalletPercentage = ({ data, loading }: Props) => { 13 | const pieChartData = data?.map((wallets: WalletData) => ({ 14 | name: wallets.name, 15 | value: wallets.balance, 16 | color: wallets.color, 17 | })); 18 | 19 | return ( 20 | 21 | 22 | 23 | 24 | ); 25 | }; 26 | 27 | export default WalletPercentage; 28 | -------------------------------------------------------------------------------- /src/components/dls/Form/Input.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | import React, { forwardRef, Ref } from "react"; 3 | 4 | interface Props extends React.InputHTMLAttributes { 5 | transparent?: boolean; 6 | className?: string; 7 | error?: any; 8 | } 9 | 10 | // eslint-disable-next-line react/display-name 11 | const Input = forwardRef( 12 | ({ transparent, className, ...props }: Props, ref: Ref) => { 13 | return ( 14 | 26 | ); 27 | } 28 | ); 29 | 30 | export default Input; 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | 38 | # vscode 39 | .vscode 40 | 41 | # env 42 | .env.local 43 | .env 44 | 45 | # PWA 46 | **/public/precache.*.*.js 47 | **/public/sw.js 48 | **/public/workbox-*.js 49 | **/public/worker-*.js 50 | **/public/fallback-*.js 51 | **/public/precache.*.*.js.map 52 | **/public/sw.js.map 53 | **/public/workbox-*.js.map 54 | **/public/worker-*.js.map 55 | **/public/fallback-*.js 56 | -------------------------------------------------------------------------------- /src/components/dls/Hamburger/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import style from "./Hamburger.module.scss"; 3 | import classNames from "classnames"; 4 | 5 | type Props = { 6 | className?: string; 7 | isOpen: boolean; 8 | }; 9 | 10 | const Hamburger = ({ className, isOpen }: Props) => { 11 | return ( 12 | 29 | ); 30 | }; 31 | 32 | export default Hamburger; 33 | -------------------------------------------------------------------------------- /src/components/Charts/ChartContainer.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | 3 | type Props = { 4 | children: React.ReactNode; 5 | width?: string; 6 | height?: number; 7 | className?: string; 8 | border?: boolean; 9 | theme?: "default" | "transparent"; 10 | }; 11 | 12 | const ChartContainer = ({ 13 | border = false, 14 | className, 15 | children, 16 | theme = "default", 17 | }: Props) => { 18 | return ( 19 |
31 | {children} 32 |
33 | ); 34 | }; 35 | 36 | export default ChartContainer; 37 | -------------------------------------------------------------------------------- /src/components/dls/Form/Checkbox/Checkbox.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | import React, { forwardRef } from "react"; 3 | 4 | interface Props extends React.HTMLAttributes { 5 | className?: string; 6 | label?: string; 7 | } 8 | 9 | const Checkbox = forwardRef(function Checkbox( 10 | { className, label, id, ...props }: Props, 11 | ref: React.Ref 12 | ) { 13 | return ( 14 |
20 | 27 | 30 |
31 | ); 32 | }); 33 | 34 | export default Checkbox; 35 | -------------------------------------------------------------------------------- /src/components/icons/ArrowIcon.tsx: -------------------------------------------------------------------------------- 1 | import { ArrowIconProps, defaultOutlineIconProps } from "@/types/IconProps"; 2 | import classNames from "classnames"; 3 | 4 | const ArrowIcon = ({ direction, className, ...props }: ArrowIconProps) => { 5 | return ( 6 | 21 | 26 | 27 | ); 28 | }; 29 | 30 | export default ArrowIcon; 31 | -------------------------------------------------------------------------------- /src/components/ThemeSelection/ThemeToggleIcon.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import IconButton from "../dls/IconButton"; 4 | import MoonIcon from "../icons/MoonIcon"; 5 | import SunIcon from "../icons/SunIcon"; 6 | import { Theme } from "@/types/Theme"; 7 | import useHydration from "../../hooks/useHydration"; 8 | import useTheme from "../../hooks/useTheme"; 9 | 10 | const ThemeToggleIcon = () => { 11 | const { colorTheme, setColorTheme } = useTheme(); 12 | const hydrated = useHydration(); 13 | 14 | function toggleTheme() { 15 | if (colorTheme === Theme.Light) { 16 | setColorTheme(Theme.Dark); 17 | } else { 18 | setColorTheme(Theme.Light); 19 | } 20 | } 21 | 22 | if (!hydrated) return <>; 23 | return ( 24 | 25 | {colorTheme === Theme.Light ? : } 26 | 27 | ); 28 | }; 29 | 30 | export default ThemeToggleIcon; 31 | -------------------------------------------------------------------------------- /src/components/Charts/BarChart/BarChartHeader.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ChartHeader from "../ChartHeader"; 3 | 4 | type BarChartHeaderProps = { 5 | COLOR: any; 6 | }; 7 | 8 | const BarChartHeader = ({ COLOR }: BarChartHeaderProps) => ( 9 | 10 |
11 |
12 |
16 | Masuk 17 |
18 |
19 |
23 | Keluar 24 |
25 |
26 |
27 | ); 28 | export default BarChartHeader; 29 | -------------------------------------------------------------------------------- /src/app/not-found.tsx: -------------------------------------------------------------------------------- 1 | import Heading from "../components/dls/Heading"; 2 | import { Metadata } from "next"; 3 | import Image from "next/image"; 4 | import Link from "next/link"; 5 | 6 | export const metadata: Metadata = { 7 | title: "Not Found", 8 | description: "Halaman tidak ditemukan", 9 | }; 10 | 11 | const NotFound = () => { 12 | return ( 13 |
14 | Not Found Image 20 | 21 | Ups! Ada yang salah 22 | 23 | 27 | Kembali ke Home 28 | 29 |
30 | ); 31 | }; 32 | 33 | export default NotFound; 34 | -------------------------------------------------------------------------------- /src/components/dls/TextWithIcon/index.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | import React from "react"; 3 | import Heading from "../Heading"; 4 | 5 | type TextWithIconProps = { 6 | iconPosition?: "left" | "right"; 7 | icon: React.ReactNode; 8 | children: React.ReactNode; 9 | className?: string; 10 | }; 11 | 12 | const TextWithIcon = ({ 13 | iconPosition = "left", 14 | icon, 15 | className, 16 | children, 17 | }: TextWithIconProps) => { 18 | return ( 19 |
25 | {iconPosition === "left" &&
{icon}
} 26 | 27 | {children} 28 | 29 | {iconPosition === "right" &&
{icon}
} 30 |
31 | ); 32 | }; 33 | 34 | export default TextWithIcon; 35 | -------------------------------------------------------------------------------- /src/components/icons/TrashIcon.tsx: -------------------------------------------------------------------------------- 1 | import { defaultIconProps, IconProps } from '@/types/IconProps' 2 | import React from 'react' 3 | 4 | 5 | const TrashIcon = ({ ...props }: IconProps) => { 6 | return ( 7 | 8 | 9 | 10 | 11 | ) 12 | } 13 | 14 | export default TrashIcon -------------------------------------------------------------------------------- /src/components/dls/IconButton/index.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | import React from "react"; 3 | import IconWrapper from "../IconWrapper"; 4 | 5 | interface Props extends React.ButtonHTMLAttributes { 6 | children: React.ReactNode; 7 | className?: string; 8 | shape?: "circle" | "square"; 9 | onClick?: () => void; 10 | onMouseEnter?: () => void; 11 | onMouseLeave?: () => void; 12 | } 13 | 14 | const IconButton = ({ 15 | children, 16 | className, 17 | shape = "square", 18 | onClick, 19 | ...props 20 | }: Props) => { 21 | return ( 22 | 36 | ); 37 | }; 38 | 39 | export default IconButton; 40 | -------------------------------------------------------------------------------- /src/components/Dashboard/DashboardContentWrapper.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | 3 | type Props = { 4 | children: React.ReactNode; 5 | width?: string; 6 | height?: number; 7 | className?: string; 8 | border?: boolean; 9 | theme?: "default" | "transparent"; 10 | }; 11 | 12 | const DashboardContentWrapper = ({ 13 | border = false, 14 | className, 15 | children, 16 | theme = "default", 17 | }: Props) => { 18 | return ( 19 |
32 | {children} 33 |
34 | ); 35 | }; 36 | 37 | export default DashboardContentWrapper; 38 | -------------------------------------------------------------------------------- /src/components/ThemeSelection/ThemeOption.tsx: -------------------------------------------------------------------------------- 1 | import Image from "../dls/Image"; 2 | import classNames from "classnames"; 3 | import React from "react"; 4 | 5 | type ThemeOptionProps = { 6 | active: boolean; 7 | src: string; 8 | alt: string; 9 | className?: string; 10 | onClick: () => void; 11 | }; 12 | 13 | const ThemeOption = ({ active, src, alt, onClick }: ThemeOptionProps) => { 14 | return ( 15 |
23 | {alt} 30 |
31 | ); 32 | }; 33 | 34 | export default ThemeOption; 35 | -------------------------------------------------------------------------------- /public/app.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "theme_color": "#e7e5e4", 3 | "background_color": "#FFF", 4 | "display": "standalone", 5 | "scope": "/", 6 | "start_url": "/app", 7 | "name": "Finaki", 8 | "description": "Finaki adalah aplikasi manajemen keuangan all-in-one, yang memungkinkan Anda untuk mengatur pengeluaran, pendapatan, dan tabungan dengan mudah dan efisien.", 9 | "short_name": "Finaki", 10 | "icons": [ 11 | { 12 | "src": "/icon-192x192.png", 13 | "sizes": "192x192", 14 | "type": "image/png" 15 | }, 16 | { 17 | "src": "/icon-256x256.png", 18 | "sizes": "256x256", 19 | "type": "image/png" 20 | }, 21 | { 22 | "src": "/icon-384x384.png", 23 | "sizes": "384x384", 24 | "type": "image/png" 25 | }, 26 | { 27 | "src": "/icon-512x512.png", 28 | "sizes": "512x512", 29 | "type": "image/png" 30 | } 31 | ] 32 | } -------------------------------------------------------------------------------- /src/components/AuthCard/AuthCard.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | import React from "react"; 3 | import Image from "../dls/Image"; 4 | 5 | type Props = { 6 | children: React.ReactNode; 7 | imageUrl?: string; 8 | imageAlt?: string; 9 | className?: string; 10 | }; 11 | 12 | const AuthCard = ({ children, className, imageUrl, imageAlt }: Props) => { 13 | return ( 14 |
20 | {imageUrl && ( 21 | {imageAlt 27 | )} 28 | 29 | {children} 30 |
31 | ); 32 | }; 33 | 34 | export default AuthCard; 35 | -------------------------------------------------------------------------------- /src/components/Header/ProfileInfo/index.tsx: -------------------------------------------------------------------------------- 1 | import IconWrapper from "../../dls/IconWrapper"; 2 | import LoadingSpinner from "../../dls/Loading/LoadingSpinner"; 3 | import ChevronIcon from "../../icons/ChevronIcon"; 4 | import useStore from "../../../stores/store"; 5 | import Action from "./Action"; 6 | 7 | const ProfileInfo = () => { 8 | const { user } = useStore((state) => ({ user: state.user })); 9 | 10 | if (!user) return ; 11 | return ( 12 |
13 | 14 | Hi, {user?.name.split(" ")[0]} 15 | 16 | 17 | 23 | 24 | 25 |
26 | ); 27 | }; 28 | 29 | export default ProfileInfo; 30 | -------------------------------------------------------------------------------- /src/components/dls/Loading/LoadingSpinner.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | interface Props { 4 | className?: string; 5 | } 6 | 7 | const LoadingSpinner = ({ className }: Props) => { 8 | return ( 9 | 20 | 28 | 36 | 37 | 38 | ); 39 | }; 40 | 41 | export default LoadingSpinner; 42 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "paths": { 18 | "@/icons/*": ["src/components/icons/*"], 19 | "@/types/*": ["src/types/*"], 20 | "@/api/*": ["src/utils/api/*"], 21 | "@/dls/*": ["src/components/dls/*"], 22 | "@/components/*": ["src/components/*"], 23 | "@/utils/*": ["src/utils/*"], 24 | "@/data/*": ["src/data/*"], 25 | }, 26 | "plugins": [ 27 | { 28 | "name": "next" 29 | } 30 | ], 31 | "baseUrl": ".", 32 | }, 33 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 34 | "exclude": ["node_modules"] 35 | } 36 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /src/utils/api/types/TransactionAPI.ts: -------------------------------------------------------------------------------- 1 | import { TotalTransactionByDay, Transaction } from "@/types/Transaction"; 2 | import { GenericResponse } from "./Api"; 3 | 4 | export enum Interval { 5 | // Daily = "daily", 6 | Weekly = "weekly", 7 | Monthly = "monthly" 8 | } 9 | 10 | export interface TransactionResponse extends GenericResponse { 11 | data: Transaction; 12 | } 13 | 14 | export interface TransactionsResponse extends GenericResponse { 15 | data: Transaction[]; 16 | } 17 | 18 | export interface InfiniteTransactionResponse extends GenericResponse { 19 | data: { 20 | transactions: Transaction[]; 21 | totalPages: number; 22 | currentPage: number; 23 | }; 24 | } 25 | 26 | export interface TotalTransactionByDayResponse extends GenericResponse { 27 | data: TotalTransactionByDay[]; 28 | } 29 | 30 | export interface TransactionInput { 31 | walletId?: string; 32 | description: string; 33 | amount: number; 34 | type: string; 35 | note: string; 36 | } 37 | 38 | export interface EditTransactionInput { 39 | id: string; 40 | transactionInput: TransactionInput; 41 | } 42 | -------------------------------------------------------------------------------- /src/components/icons/ArrowCircleIcon.tsx: -------------------------------------------------------------------------------- 1 | import { ArrowIconProps, defaultIconProps } from '@/types/IconProps' 2 | import classNames from 'classnames' 3 | import React from 'react' 4 | 5 | const ArrowCircleIcon = ({ 6 | fill = 'currentColor', 7 | direction, 8 | className, 9 | }: ArrowIconProps) => { 10 | return ( 11 | 25 | 26 | 27 | 28 | ) 29 | } 30 | 31 | export default ArrowCircleIcon -------------------------------------------------------------------------------- /src/components/dls/Modal/Modal.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useState } from "react"; 2 | 3 | type ModalProps = { 4 | children: React.ReactNode; 5 | defaultOpen?: boolean; 6 | stateOpen?: boolean; 7 | onClose?: () => void; 8 | }; 9 | 10 | export const ModalContext = createContext(false); 11 | // create provider 12 | export const Modal = ({ 13 | children, 14 | defaultOpen, 15 | stateOpen, 16 | ...props 17 | }: ModalProps) => { 18 | const [isOpen, setIsOpen] = useState(defaultOpen ?? false); 19 | 20 | function open() { 21 | setIsOpen(true); 22 | } 23 | 24 | function close() { 25 | setIsOpen(false); 26 | props.onClose && props.onClose(); 27 | } 28 | 29 | if (typeof window !== "undefined") { 30 | if (isOpen === true) { 31 | document.documentElement.style.overflow = "hidden"; 32 | } else { 33 | document.documentElement.style.overflow = ""; 34 | } 35 | } 36 | 37 | return ( 38 | 39 | {children} 40 | 41 | ); 42 | }; 43 | 44 | export default Modal; 45 | -------------------------------------------------------------------------------- /src/utils/currencyFormat.ts: -------------------------------------------------------------------------------- 1 | type CurrencyFormatOptions = { 2 | style?: "currency"; 3 | currency?: string; 4 | minimumFractionDigits?: number; 5 | currencyDisplay?: "symbol" | "code" | "name"; 6 | } 7 | 8 | const initalOptions: CurrencyFormatOptions = { 9 | style: "currency", 10 | currency: "IDR", 11 | minimumFractionDigits: 0, 12 | }; 13 | 14 | 15 | /** 16 | * Format number to currency format (1000 -> Rp 1.000) 17 | * @param value 18 | * @param options 19 | * @returns 20 | * @example 21 | * currencyFormat(1000) // Rp 1.000 22 | * currencyFormat(1000, { currency: "USD" }) // $1,000.00 23 | */ 24 | export const currencyFormat = (value: number, options?: CurrencyFormatOptions) => { 25 | const number = new Intl.NumberFormat("id-ID", options || initalOptions); 26 | 27 | return number.format(value); 28 | }; 29 | 30 | /** 31 | * Remove currency from string value ("Rp 1.000" -> 1000) 32 | * 33 | * @param value 34 | * @returns 35 | * @example 36 | * removeCurrencyFormat("Rp 1.000") // 1000 37 | */ 38 | export const removeCurrencyFormat = (value: string) => { 39 | return Number(value.replace(/[^0-9]/g, "")); 40 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Aca M 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/app/demo/account/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import ContentWrapper from "../../../components/Container/ContentWrapper"; 4 | import ThemeSelection from "../../../components/ThemeSelection/ThemeSelection"; 5 | import Heading from "../../../components/dls/Heading"; 6 | import { Routes } from "@/types/Routes"; 7 | import Link from "next/link"; 8 | 9 | const Page = () => { 10 | return ( 11 |
12 |
13 | Login untuk melihat profile anda 14 | 15 | 19 | Daftar 20 | 21 | 25 | Masuk 26 | 27 | 28 |
29 | 30 |
31 | ); 32 | }; 33 | 34 | export default Page; 35 | -------------------------------------------------------------------------------- /src/components/Navigation/HomeNav/HomeNavLink.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | import Link from "next/link"; 3 | import React from "react"; 4 | 5 | type Props = { 6 | href: string; 7 | children: React.ReactNode; 8 | isActive: boolean; 9 | type?: "primary" | "secondary"; 10 | className?: string; 11 | }; 12 | 13 | const HomeNavLink = ({ 14 | children, 15 | isActive, 16 | href, 17 | type = "primary", 18 | className, 19 | }: Props) => { 20 | return ( 21 | 37 | {children} 38 | 39 | ); 40 | }; 41 | 42 | export default HomeNavLink; 43 | -------------------------------------------------------------------------------- /src/components/Navigation/AppNav/AppNavLink.tsx: -------------------------------------------------------------------------------- 1 | import IconWrapper from "../../dls/IconWrapper"; 2 | import classNames from "classnames"; 3 | import Link from "next/link"; 4 | import React from "react"; 5 | 6 | type NavLinkProps = { 7 | href: string; 8 | children: React.ReactNode; 9 | icon: React.ReactNode; 10 | active?: boolean; 11 | }; 12 | 13 | const AppNavLink = ({ href, children, icon, active = false }: NavLinkProps) => { 14 | return ( 15 | 16 |
29 | {icon} 30 | {children} 31 |
32 | 33 | ); 34 | }; 35 | 36 | export default AppNavLink; 37 | -------------------------------------------------------------------------------- /src/components/Charts/PieChart/PieLabel.tsx: -------------------------------------------------------------------------------- 1 | const renderPieLabel = (props: any) => { 2 | const { payload } = props; 3 | let orderedPayload = payload.sort( 4 | (a: any, b: any) => b.payload.percent - a.payload.percent 5 | ); 6 | 7 | if (orderedPayload.length > 6) { 8 | orderedPayload = orderedPayload.slice(0, 5); 9 | const other = orderedPayload.reduce((acc: any, curr: any) => { 10 | return acc + curr.payload.percent; 11 | }, 0); 12 | 13 | orderedPayload.push({ 14 | value: "Lainnya", 15 | color: "#57C5B6", 16 | payload: { 17 | percent: 1 - other, 18 | }, 19 | }); 20 | } 21 | 22 | return ( 23 |
    24 | {orderedPayload.map((entry: any, index: number) => { 25 | const percent = ((entry.payload.percent || 0) * 100).toFixed(1); 26 | return ( 27 |
  • 28 | 29 | {percent}% 30 | 31 | {entry.value} 32 |
  • 33 | ); 34 | })} 35 |
36 | ); 37 | }; 38 | 39 | export default renderPieLabel; 40 | -------------------------------------------------------------------------------- /src/components/dls/Select/Option.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | import React, { useContext, useEffect } from "react"; 3 | import { SelectContext, SelectContextType } from "./Select"; 4 | 5 | type Props = { 6 | children: React.ReactNode; 7 | value: string | null; 8 | selected?: boolean; 9 | } & React.HTMLAttributes; 10 | 11 | const Option = ({ children, value, selected, ...props }: Props) => { 12 | const { setSelected } = useContext(SelectContext) as SelectContextType; 13 | 14 | useEffect(() => { 15 | if (selected) { 16 | setTimeout(() => { 17 | setSelected({ 18 | value, 19 | label: children as string, 20 | }); 21 | }, 0); 22 | } 23 | // eslint-disable-next-line react-hooks/exhaustive-deps 24 | }, []); 25 | 26 | return ( 27 |
32 | setSelected({ 33 | value, 34 | label: children as string, 35 | }) 36 | } 37 | role="listitem" 38 | {...props} 39 | > 40 | {children} 41 |
42 | ); 43 | }; 44 | 45 | export default Option; 46 | -------------------------------------------------------------------------------- /src/app/auth/layout.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Routes } from "@/types/Routes"; 4 | import { usePathname, useRouter } from "next/navigation"; 5 | import React, { useEffect } from "react"; 6 | import {GoogleOAuthProvider} from "@react-oauth/google"; 7 | 8 | type Props = { 9 | children: React.ReactNode; 10 | }; 11 | 12 | const AuthLayout = ({ children }: Props) => { 13 | const router = useRouter(); 14 | const pathname = usePathname(); 15 | 16 | const registerPage = pathname.includes(Routes.Register); 17 | const loginPage = pathname.includes(Routes.Login); 18 | 19 | // redirect to app if already logged in or has access token 20 | useEffect(() => { 21 | const accessToken = window.localStorage.getItem("access-token"); 22 | 23 | // redirect if on register or login page, otherwise do nothing 24 | if (accessToken && (registerPage || loginPage)) { 25 | router.push(Routes.App); 26 | } 27 | // eslint-disable-next-line react-hooks/exhaustive-deps 28 | }, []); 29 | 30 | return ( 31 | 32 |
{children}
33 |
34 | ); 35 | }; 36 | 37 | export default AuthLayout; 38 | -------------------------------------------------------------------------------- /src/components/Account/DangerZone.tsx: -------------------------------------------------------------------------------- 1 | import { detachTelegramAccount } from "@/api/user"; 2 | import LoadingButton from "../dls/Button/LoadingButton"; 3 | import { useMutation } from "@tanstack/react-query"; 4 | import { toast } from "react-hot-toast"; 5 | import useStore from "../../stores/store"; 6 | 7 | const DangerZone = () => { 8 | const { user, setUser } = useStore((state) => ({ 9 | user: state.user, 10 | setUser: state.setUser, 11 | })); 12 | 13 | const { isLoading, mutate } = useMutation({ 14 | mutationFn: detachTelegramAccount, 15 | onSuccess: () => { 16 | setUser({ 17 | ...user!, 18 | telegramAccount: null, 19 | }); 20 | toast.success("Akun Telegram Berhasil diputuskan"); 21 | }, 22 | onError: () => { 23 | toast.error("Gagal memutuskan akun Telegram"); 24 | }, 25 | }); 26 | 27 | return ( 28 |
29 | {user?.telegramAccount?.username && ( 30 | mutate()} 36 | className="font-bold" 37 | /> 38 | )} 39 |
40 | ); 41 | }; 42 | 43 | export default DangerZone; 44 | -------------------------------------------------------------------------------- /src/components/dls/Dropdown/DropdownMenu.tsx: -------------------------------------------------------------------------------- 1 | import * as RadixDropdown from "@radix-ui/react-dropdown-menu"; 2 | 3 | interface Props { 4 | trigger: React.ReactNode; 5 | children: React.ReactNode; 6 | align?: "start" | "center" | "end"; 7 | // eslint-disable-next-line no-unused-vars 8 | onOpenChange?: (open: boolean) => void; 9 | sideOffset?: number; 10 | alignOffset?: number; 11 | } 12 | 13 | const DropdownMenu = ({ 14 | trigger, 15 | children, 16 | align = "end", 17 | sideOffset = -5, 18 | alignOffset = -15, 19 | onOpenChange, 20 | }: Props) => { 21 | return ( 22 | 23 | 24 |
{trigger}
25 |
26 | 27 | 33 | {children} 34 | 35 | 36 |
37 | ); 38 | }; 39 | 40 | export default DropdownMenu; 41 | -------------------------------------------------------------------------------- /src/components/dls/Dropdown/DropdownSubMenu.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Portal, 3 | Sub, 4 | SubContent, 5 | SubTrigger, 6 | } from "@radix-ui/react-dropdown-menu"; 7 | 8 | import styles from "./DropdownItem.module.scss"; 9 | import classNames from "classnames"; 10 | 11 | interface Props extends React.ComponentProps { 12 | trigger: React.ReactNode; 13 | iconTrigger?: React.ReactNode; 14 | className?: string 15 | } 16 | 17 | const DropdownSubMenu = ({ children, trigger, iconTrigger, className }: Props) => { 18 | return ( 19 | 20 | 21 |
22 | {iconTrigger && ( 23 |
{iconTrigger}
24 | )} 25 | {trigger} 26 |
27 |
28 | 29 | 30 | {children} 31 | 32 | 33 |
34 | ); 35 | }; 36 | 37 | export default DropdownSubMenu; 38 | -------------------------------------------------------------------------------- /src/utils/api/api.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import jwtDecode, { JwtPayload } from "jwt-decode"; 3 | import { refreshAccessToken } from "./authApi"; 4 | import { BASE_URL } from "./config"; 5 | 6 | export const instance = axios.create({ 7 | baseURL: BASE_URL, 8 | headers: { 9 | "Content-Type": "application/json", 10 | }, 11 | }); 12 | 13 | instance.interceptors.request.use( 14 | async (config) => { 15 | let currentToken = window?.localStorage.getItem("access-token"); 16 | 17 | if (currentToken === null || currentToken === undefined) { 18 | const refreshToken = await refreshAccessToken(); 19 | window?.localStorage.setItem("access-token", refreshToken.accessToken); 20 | currentToken = refreshToken.accessToken; 21 | } else { 22 | const decodedToken = jwtDecode(currentToken) as JwtPayload; 23 | const currentTime = new Date().getTime(); 24 | if (!!decodedToken.exp && decodedToken.exp * 1000 < currentTime) { 25 | const refreshToken = await refreshAccessToken(); 26 | window?.localStorage.setItem("access-token", refreshToken.accessToken); 27 | currentToken = refreshToken.accessToken; 28 | } 29 | } 30 | 31 | config.headers["Authorization"] = `Bearer ${currentToken}`; 32 | 33 | return config; 34 | }, 35 | (error) => { 36 | return Promise.reject(error); 37 | } 38 | ); 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Welcome to Finaki repository 👋

2 | 3 | ![finaki preview](https://github.com/acmaul/finaki/assets/61030878/2dc529c6-5ce3-4366-83af-b8d293fc1b5d) 4 | 5 |
Financial record website created with NextJS. 6 |
7 | 8 |
9 | Try it now! 10 |
11 | 12 | ## 💵 About 13 | A web-based application created to solve financial recording solutions, Finaki was created so that users can record finances anywhere and anytime. 14 | 15 | ## ✨ Features 16 | - 💳 Authentication 17 | - Login 18 | - Register 19 | - Forgot Password 20 | - Logout 21 | - 📝 CRUD 22 | - Transaction 23 | - Wallet 24 | - 😎 Dark Mode 25 | - 👮 Security 26 | - JWT 27 | - Password Hashing 28 | - 🤙 Responsive UI 29 | - 📱 PWA 30 | - 🏫 Export Transaction to PDF 31 | 32 | 33 | ## 🚀 Tech Stack 34 | - Front end 35 | - [NextJS](https://nextjs.org/) 36 | - [ReactJS](https://reactjs.org/) 37 | - [TypeScript](https://www.typescriptlang.org/) 38 | - [React Query](https://react-query.tanstack.com/) 39 | - [Tailwind CSS](https://tailwindcss.com/) 40 | - Back end 41 | - [ExpressJS](https://expressjs.com/) 42 | - [MongoDB](https://www.mongodb.com/) 43 | 44 | 45 | ## ⚖️ License 46 | Distributed under the MIT License. See `LICENSE` for more information. 47 | -------------------------------------------------------------------------------- /src/components/icons/GearIcon.tsx: -------------------------------------------------------------------------------- 1 | import {IconProps} from '@/types/IconProps' 2 | import React from 'react' 3 | 4 | const GearIcon = ({ 5 | ...props 6 | }: IconProps) => { 7 | return ( 8 | 9 | 10 | 11 | ) 12 | 13 | } 14 | 15 | export default GearIcon -------------------------------------------------------------------------------- /src/components/dls/Form/Radio/RadioButton.tsx: -------------------------------------------------------------------------------- 1 | import IconWrapper from "../../IconWrapper"; 2 | import classNames from "classnames"; 3 | import { forwardRef, InputHTMLAttributes, Ref } from "react"; 4 | 5 | interface Props extends InputHTMLAttributes { 6 | id: string; 7 | name: string; 8 | label: string; 9 | className?: string; 10 | icon?: React.ReactNode; 11 | checked?: boolean; 12 | } 13 | 14 | // eslint-disable-next-line react/display-name 15 | const RadioButton = forwardRef( 16 | ( 17 | { id, label, className, icon, ...props }: Props, 18 | ref: Ref 19 | ) => { 20 | return ( 21 |
  • 22 | 29 | 36 |
  • 37 | ); 38 | } 39 | ); 40 | 41 | export default RadioButton; 42 | -------------------------------------------------------------------------------- /src/components/ThemeSelection/ThemeSelection.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Heading from "../dls/Heading"; 4 | import { Theme } from "@/types/Theme"; 5 | import useHydration from "../../hooks/useHydration"; 6 | import useTheme from "../../hooks/useTheme"; 7 | import ContentWrapper from "../Container/ContentWrapper"; 8 | import ThemeOption from "./ThemeOption"; 9 | 10 | const ThemeSelection = () => { 11 | const { colorTheme, setColorTheme } = useTheme(); 12 | const hydrated = useHydration(); 13 | 14 | const themeList = [ 15 | { 16 | src: "/images/lightmode_preview.jpg", 17 | alt: "lightmode preview", 18 | }, 19 | { 20 | src: "/images/darkmode_preview.jpg", 21 | alt: "darkmode preview", 22 | }, 23 | ]; 24 | 25 | if (!hydrated) return <>; 26 | return ( 27 |
    28 | 29 | Tema 30 | 31 | 32 | setColorTheme(Theme.Light)} 35 | active={colorTheme === Theme.Light} 36 | /> 37 | setColorTheme(Theme.Dark)} 40 | active={colorTheme === Theme.Dark} 41 | /> 42 | 43 |
    44 | ); 45 | }; 46 | 47 | export default ThemeSelection; 48 | -------------------------------------------------------------------------------- /src/components/WalletCard/constants.ts: -------------------------------------------------------------------------------- 1 | export type WalletColor = 2 | | "blue" 3 | | "orange" 4 | | "green" 5 | | "red" 6 | | "purple" 7 | | "yellow" 8 | | "gray" 9 | | "cyan"; 10 | 11 | export const walletColors = { 12 | blue: "from-blue-400 to-blue-600", 13 | orange: "from-orange-400 to-orange-600", 14 | green: "from-green-400 to-green-600", 15 | red: "from-red-400 to-red-600", 16 | purple: "from-purple-400 to-purple-600", 17 | yellow: "from-yellow-400 to-yellow-600", 18 | gray: "from-gray-400 to-gray-600", 19 | cyan: "from-cyan-400 to-cyan-600", 20 | }; 21 | 22 | export const indicatorColor = { 23 | blue: "#3b82f6", 24 | orange: "#f97316", 25 | green: "#22c55e", 26 | red: "#ef4444", 27 | purple: "#a855f7", 28 | yellow: "#eab308", 29 | gray: "#6b7280", 30 | cyan: "#06b6d4", 31 | }; 32 | 33 | export const hashCodeColor = { 34 | blue: "#3b82f6", 35 | orange: "#f97316", 36 | green: "#22c55e", 37 | red: "#ef4444", 38 | purple: "#a855f7", 39 | yellow: "#eab308", 40 | gray: "#6b7280", 41 | cyan: "#06b6d4", 42 | }; 43 | 44 | export const walletLabelColor = { 45 | blue: "bg-blue-200 text-blue-700", 46 | orange: "bg-orange-200 text-orange-700", 47 | green: "bg-green-200 text-green-700", 48 | red: "bg-red-200 text-red-700", 49 | purple: "bg-purple-200 text-purple-700", 50 | yellow: "bg-yellow-200 text-yellow-700", 51 | gray: "bg-gray-200 text-gray-700", 52 | cyan: "bg-cyan-200 text-cyan-700", 53 | }; 54 | -------------------------------------------------------------------------------- /src/utils/api/types/WalletAPI.ts: -------------------------------------------------------------------------------- 1 | import { BalanceHistory, WalletData } from "@/types/Wallet"; 2 | import { GenericRequest, GenericResponse } from "./Api"; 3 | 4 | export interface WalletInput { 5 | name: string; 6 | balance?: number; 7 | color: string; 8 | isCredit: boolean; 9 | } 10 | 11 | export interface CreatedWalletResponse extends GenericResponse { 12 | data: WalletData; 13 | } 14 | 15 | export interface UpdateWalletRequest { 16 | id: string; 17 | data: WalletInput; 18 | } 19 | 20 | export interface UpdatedWalletResponse extends GenericResponse { 21 | data: WalletData; 22 | } 23 | 24 | export interface AllWalletResponse { 25 | data: WalletData[]; 26 | } 27 | 28 | export interface WalletResponse extends GenericResponse { 29 | data: WalletData; 30 | } 31 | 32 | export interface UpdatedWalletColorResponse extends GenericResponse { 33 | data: { 34 | _id: string; 35 | color: string; 36 | }; 37 | } 38 | 39 | export interface DeleteWalletRequest extends GenericRequest { 40 | param: { 41 | id: string; 42 | }; 43 | query: { 44 | deleteTransactions: boolean; 45 | }; 46 | } 47 | 48 | export interface WalletBalanceActivityResponse extends GenericResponse { 49 | data: BalanceHistory[]; 50 | } 51 | 52 | export interface WalletDetailsResponse extends GenericResponse { 53 | data: WalletData; 54 | } 55 | 56 | export interface TransferBalance { 57 | sourceWallet: string; 58 | destinationWallet: string; 59 | amount: number; 60 | } 61 | -------------------------------------------------------------------------------- /src/app/demo/transactions/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import TransactionHeader from "../../../components/Transactions/AllTransactions/TransactionHeader"; 4 | import TransactionList from "../../../components/Transactions/AllTransactions/TransactionList"; 5 | import Heading from "../../../components/dls/Heading"; 6 | import { QueryKey } from "@/types/QueryKey"; 7 | import { Transaction } from "@/types/Transaction"; 8 | import { groupByDay } from "@/utils/array"; 9 | import { useQuery } from "@tanstack/react-query"; 10 | import Head from "next/head"; 11 | 12 | const TransactionsPage = () => { 13 | const { data, isLoading } = useQuery({ 14 | queryKey: [QueryKey.TRANSACTIONS], 15 | queryFn: (): Promise => 16 | new Promise((resolve) => { 17 | import("@/data/dummy-data/transaction.json").then((data) => { 18 | resolve(data.data as Transaction[]); 19 | }); 20 | }), 21 | }); 22 | 23 | if (isLoading || !data) { 24 | return ( 25 | 26 | Memuat... 27 | 28 | ); 29 | } 30 | 31 | const transaction = groupByDay(data); 32 | 33 | return ( 34 | <> 35 | 36 | Transaksi 37 | 38 | 39 | 40 | 41 |
    42 | 43 | ); 44 | }; 45 | 46 | export default TransactionsPage; 47 | -------------------------------------------------------------------------------- /src/components/Transactions/AllTransactions/TransactionList.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Transaction } from "@/types/Transaction"; 4 | import classNames from "classnames"; 5 | import { Fragment } from "react"; 6 | import { FullTransactionItem } from "../TransactionItem"; 7 | 8 | type Props = { 9 | data: { 10 | date: string; 11 | data: Transaction[]; 12 | }[]; 13 | }; 14 | 15 | const TransactionList = ({ data }: Props) => { 16 | return ( 17 | 18 | {data.map((transactionData, index) => { 19 | return ( 20 | 21 | 22 | 23 |

    24 | {transactionData.date} 25 |

    26 |
    0 } 31 | )} 32 | >
    33 | 34 | 35 | {transactionData.data?.map((transaction: Transaction) => ( 36 | 40 | ))} 41 |
    42 | ); 43 | })} 44 | 45 | ); 46 | }; 47 | 48 | export default TransactionList; 49 | -------------------------------------------------------------------------------- /src/data/dummy-data/dashboard.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": [ 3 | { 4 | "_id": { 5 | "day": 29 6 | }, 7 | "timestamp": "2023-04-29T01:42:05.768Z", 8 | "in": 53255, 9 | "out": 53259, 10 | "totalAmount": 106514 11 | }, 12 | { 13 | "_id": { 14 | "day": 30 15 | }, 16 | "timestamp": "2023-04-30T01:42:05.768Z", 17 | "in": 26004, 18 | "out": 45502, 19 | "totalAmount": 71506 20 | }, 21 | { 22 | "_id": { 23 | "day": 1 24 | }, 25 | "timestamp": "2023-05-01T01:42:05.768Z", 26 | "in": 34147, 27 | "out": 23933, 28 | "totalAmount": 58080 29 | }, 30 | { 31 | "_id": { 32 | "day": 2 33 | }, 34 | "timestamp": "2023-05-02T01:42:05.768Z", 35 | "in": 42517, 36 | "out": 23709, 37 | "totalAmount": 66226 38 | }, 39 | { 40 | "_id": { 41 | "day": 3 42 | }, 43 | "timestamp": "2023-05-03T01:42:05.768Z", 44 | "in": 31752, 45 | "out": 17149, 46 | "totalAmount": 48901 47 | }, 48 | { 49 | "_id": { 50 | "day": 4 51 | }, 52 | "timestamp": "2023-05-04T01:42:05.768Z", 53 | "in": 22893, 54 | "out": 13800, 55 | "totalAmount": 36693 56 | }, 57 | { 58 | "_id": { 59 | "day": 5 60 | }, 61 | "timestamp": "2023-05-05T01:42:05.768Z", 62 | "in": 31101, 63 | "out": 18782, 64 | "totalAmount": 49883 65 | } 66 | ] 67 | } 68 | -------------------------------------------------------------------------------- /src/app/app/wallet/SortableItem.tsx: -------------------------------------------------------------------------------- 1 | import WalletCard from "../../../components/WalletCard/WalletCard"; 2 | import { useSortable } from "@dnd-kit/sortable"; 3 | import { CSS } from "@dnd-kit/utilities"; 4 | import classNames from "classnames"; 5 | import React, { ComponentProps } from "react"; 6 | import { RxDragHandleDots2 } from "react-icons/rx"; 7 | 8 | type Props = ComponentProps; 9 | 10 | export const SortableWalletCard = ({ ...props }: Props) => { 11 | const { 12 | attributes, 13 | listeners, 14 | isDragging, 15 | setNodeRef, 16 | transform, 17 | transition, 18 | } = useSortable({ id: props.id }); 19 | 20 | const style: React.CSSProperties = { 21 | transform: CSS.Transform.toString(transform), 22 | transition, 23 | }; 24 | 25 | return ( 26 |
    33 | 40 | 48 |
    49 | ); 50 | }; 51 | -------------------------------------------------------------------------------- /src/components/Charts/BarChart/BarTooltip.tsx: -------------------------------------------------------------------------------- 1 | import { currencyFormat } from "@/utils/currencyFormat"; 2 | import { COLOR } from "../constant"; 3 | import TooltipWrapper from "../TooltipWrapper"; 4 | 5 | const renderBarTooltip = ({ active, payload }: any) => { 6 | if (active) { 7 | const date = new Date(payload[0]?.payload.timestamp).toLocaleDateString( 8 | "id-ID", 9 | { 10 | day: "numeric", 11 | month: "short", 12 | } 13 | ); 14 | return ( 15 | 16 |

    {date}

    17 |
    18 |
    22 | 23 | {currencyFormat(payload[0].value)} 24 | 25 |
    26 |
    27 |
    31 | 32 | {currencyFormat(payload[1].value)} 33 | 34 |
    35 | {/*

    {date}

    36 |

    {`Rp. ${payload[0].value}`}

    */} 37 | 38 | ); 39 | } 40 | }; 41 | 42 | export default renderBarTooltip; 43 | -------------------------------------------------------------------------------- /src/components/dls/Form/TextArea.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | import React, { forwardRef, LegacyRef } from "react"; 3 | 4 | interface Props extends React.TextareaHTMLAttributes { 5 | transparent?: boolean; 6 | className?: string; 7 | id: string; 8 | label?: string; 9 | placeholder?: string; 10 | padding?: string; 11 | error?: { 12 | message: string; 13 | }; 14 | } 15 | 16 | // eslint-disable-next-line react/display-name 17 | const TextArea = forwardRef( 18 | ( 19 | { 20 | className, 21 | id, 22 | error, 23 | label, 24 | placeholder, 25 | padding = "px-4 py-4", 26 | ...props 27 | }: Props, 28 | ref: LegacyRef 29 | ) => { 30 | return ( 31 |
    37 | 45 | 46 | {error && ( 47 | 51 | {error.message} 52 | 53 | )} 54 |
    55 | ); 56 | } 57 | ); 58 | 59 | export default TextArea; 60 | -------------------------------------------------------------------------------- /src/components/dls/Modal/ModalContent.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | import React, { useContext } from "react"; 3 | import { ModalContext } from "./Modal"; 4 | import ModalOverlay from "./ModalOverlay"; 5 | 6 | type Props = { 7 | children: React.ReactNode; 8 | closeOnOverlayClick?: boolean; 9 | className?: string; 10 | onClickOverlay?: () => void; 11 | }; 12 | 13 | const ModalContent = ({ 14 | children, 15 | closeOnOverlayClick, 16 | className, 17 | onClickOverlay, 18 | }: Props) => { 19 | const { isOpen, close } = useContext(ModalContext); 20 | return ( 21 |
    28 | { 31 | if (closeOnOverlayClick) { 32 | close(); 33 | } 34 | onClickOverlay && onClickOverlay(); 35 | }} 36 | /> 37 |
    45 | {isOpen && children} 46 |
    47 |
    48 | ); 49 | }; 50 | 51 | export default ModalContent; 52 | -------------------------------------------------------------------------------- /src/components/dls/Heading/index.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | 3 | type HeadingTextProps = { 4 | children: React.ReactNode; 5 | className?: string; 6 | fontWeight?: "normal" | "medium" | "bold" | "extrabold"; 7 | isItalic?: boolean; 8 | isUnderline?: boolean; 9 | defaultColor?: "bright" | "dark"; 10 | level: 1 | 2 | 3 | 4 | 5 | 6; 11 | gradient?: boolean 12 | }; 13 | 14 | const Heading = ({ 15 | children, 16 | className, 17 | defaultColor = "dark", 18 | level, 19 | fontWeight = "bold", 20 | isItalic = false, 21 | isUnderline = false, 22 | gradient = false 23 | }: HeadingTextProps) => { 24 | return ( 25 |
    49 | {children} 50 |
    51 | ); 52 | }; 53 | 54 | export default Heading; 55 | -------------------------------------------------------------------------------- /src/components/dls/Tooltip/Tooltip.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as TooltipPrimitive from "@radix-ui/react-tooltip"; 4 | import classNames from "classnames"; 5 | import * as React from "react"; 6 | 7 | const TooltipTrigger = TooltipPrimitive.Trigger; 8 | 9 | const TooltipContent = React.forwardRef< 10 | React.ElementRef, 11 | React.ComponentPropsWithoutRef 12 | >(({ className, sideOffset = 4, ...props }, ref) => ( 13 | 22 | )); 23 | TooltipContent.displayName = TooltipPrimitive.Content.displayName; 24 | 25 | const Tooltip = ({ 26 | children, 27 | content, 28 | }: { 29 | children: React.ReactNode; 30 | content: string; 31 | }) => { 32 | return ( 33 | 34 | 35 | {children} 36 | 37 |

    {content}

    38 |
    39 |
    40 |
    41 | ); 42 | }; 43 | 44 | export default Tooltip; 45 | -------------------------------------------------------------------------------- /src/components/icons/EyeIcon.tsx: -------------------------------------------------------------------------------- 1 | import { defaultOutlineIconProps, IconProps } from "@/types/IconProps"; 2 | import React from "react"; 3 | 4 | type EyeIconProps = IconProps & { 5 | isVisible?: boolean; 6 | }; 7 | 8 | const EyeIcon = ({ isVisible = false, ...props }: EyeIconProps) => { 9 | return ( 10 | 16 | {!isVisible ? ( 17 | // Visible icon 18 | <> 19 | 24 | 29 | 30 | ) : ( 31 | // Hide icon 32 | <> 33 | 38 | 39 | )} 40 | 41 | ); 42 | }; 43 | 44 | export default EyeIcon; 45 | -------------------------------------------------------------------------------- /src/components/Header/ProfileInfo/Action.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import OnHoverWrapper from "../../dls/ActionWrapper/OnHoverWrapper"; 4 | import IconWrapper from "../../dls/IconWrapper"; 5 | import ArrowRectangleIcon from "../../icons/ArrowRectangleIcon"; 6 | import { Routes } from "@/types/Routes"; 7 | import { logoutUser } from "@/utils/api/authApi"; 8 | import { useQueryClient } from "@tanstack/react-query"; 9 | import { useRouter } from "next/navigation"; 10 | import { toast } from "react-hot-toast"; 11 | import useStore from "../../../stores/store"; 12 | 13 | const Action = () => { 14 | const router = useRouter(); 15 | const queryClient = useQueryClient(); 16 | const setUser = useStore((state) => state.setUser); 17 | 18 | const handleLogout = () => { 19 | logoutUser() 20 | .then(() => { 21 | toast.success("Logout berhasil"); 22 | }) 23 | .catch(() => { 24 | toast.error("Ada kesalahan saat logout"); 25 | }) 26 | .finally(() => { 27 | queryClient.invalidateQueries(); 28 | queryClient.removeQueries(); 29 | router.push(Routes.Home); 30 | setUser(null); 31 | localStorage.removeItem("access-token"); 32 | }); 33 | }; 34 | 35 | return ( 36 | 37 | 42 | 43 | 44 | 45 | Log out 46 | 47 | 48 | ); 49 | }; 50 | 51 | export default Action; 52 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Inter } from "@next/font/google"; 2 | import classNames from "classnames"; 3 | import "./globals.scss"; 4 | import HomeNav from "../components/Navigation/HomeNav/HomeNav"; 5 | import { Toaster } from "react-hot-toast"; 6 | import GoogleAnalytics from "../components/Analytics/ga"; 7 | import RQProvider from "./provider"; 8 | import * as Seo from "./seo"; 9 | 10 | // font set up 11 | const font = Inter({ 12 | subsets: ["latin"], 13 | }); 14 | 15 | export const metadata = { 16 | metadataBase: new URL("https://finaki.acml.me/"), 17 | title: "Finkai", 18 | keywords: Seo.keywords, 19 | description: Seo.desciprtion, 20 | manifest: "/app.webmanifest", 21 | openGraph: { 22 | title: Seo.title, 23 | description: Seo.desciprtion, 24 | }, 25 | twitter: { 26 | title: Seo.title, 27 | description: Seo.desciprtion, 28 | }, 29 | }; 30 | 31 | export default function RootLayout({ 32 | children, 33 | }: { 34 | children: React.ReactNode; 35 | }) { 36 | return ( 37 | 38 | {/* 39 | will contain the components returned by the nearest parent 40 | head.tsx. Find out more at https://beta.nextjs.org/docs/api-reference/file-conventions/head 41 | */} 42 | 43 | {/* theme color */} 44 | 45 | 46 | 49 | 50 | 51 | 52 |
    {children}
    53 |
    54 | 55 | 56 | 57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /src/app/about/page.tsx: -------------------------------------------------------------------------------- 1 | import Heading from "../../components/dls/Heading"; 2 | 3 | export const metadata = { 4 | title: "About", 5 | description: "About Finaki project", 6 | }; 7 | const AboutPage = () => { 8 | return ( 9 |
    10 |
    11 | 12 | About this project 13 | 14 |

    15 | Finaki is a web-based application for your financial management, you 16 | can record incoming and outgoing transactions. Equipped with graphs to 17 | make it easier for you to analyze your finances. 18 |

    19 |
    20 | 21 | - Background 22 | 23 |

    24 | This application was created to assist me in recording incoming or 25 | outgoing finances so that they are more precisely controlled, and 26 | can be analyzed. 27 |

    28 |
    29 |
    30 | 31 | - Technology 32 | 33 |

    34 | This application is made with Typescript on the frontend and 35 | backend. The frontend is built with NextJS and TailwindCSS, and the 36 | backed is built with ExpressJS. For security this application is 37 | equipped with JWT (JSON Web Token). 38 |

    39 |
    40 |
    41 |
    42 | ); 43 | }; 44 | 45 | export default AboutPage; 46 | -------------------------------------------------------------------------------- /src/components/Navigation/AppNav/AppNav.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { usePathname } from "next/navigation"; 4 | 5 | import ArrowsIcon from "../../icons/ArrowsIcon"; 6 | import GridIcon from "../../icons/GridIcon"; 7 | import UserIcon from "../../icons/UserIcon"; 8 | import WalletIcon from "../../icons/WalletIcon"; 9 | import { defaultIconProps } from "@/types/IconProps"; 10 | import { Routes } from "@/types/Routes"; 11 | import AppNavLink from "./AppNavLink"; 12 | 13 | const AppNav = () => { 14 | const pathname = usePathname(); 15 | 16 | return ( 17 | 47 | ); 48 | }; 49 | 50 | export default AppNav; 51 | -------------------------------------------------------------------------------- /src/components/Navigation/AppNav/DemoNav.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { usePathname } from "next/navigation"; 4 | 5 | import ArrowsIcon from "../../icons/ArrowsIcon"; 6 | import GridIcon from "../../icons/GridIcon"; 7 | import UserIcon from "../../icons/UserIcon"; 8 | import WalletIcon from "../../icons/WalletIcon"; 9 | import { defaultIconProps } from "@/types/IconProps"; 10 | import { Routes } from "@/types/Routes"; 11 | import AppNavLink from "./AppNavLink"; 12 | 13 | const DemoNav = () => { 14 | const pathname = usePathname(); 15 | 16 | return ( 17 | 47 | ); 48 | }; 49 | 50 | export default DemoNav; 51 | -------------------------------------------------------------------------------- /src/components/dls/Form/InputWithLabel.module.scss: -------------------------------------------------------------------------------- 1 | // .inputWithLabelWrapper{ 2 | // Disable this line because the module.scss file is not working with the darkmode and change the color in the global.scss file 3 | 4 | 5 | // position: relative; 6 | 7 | // label { 8 | // @apply text-gray-500; 9 | // position: absolute; 10 | // left: 30px; 11 | // top: 50%; 12 | // transform: translateY(-50%); 13 | // padding: 0 5px; 14 | // transition: all 0.2s ease-in-out; 15 | // } 16 | 17 | // input{ 18 | // @apply bg-gray-100; 19 | // transition: all 0.2s ease-in-out; 20 | // &:focus + label { 21 | // @apply bg-white dark:bg-slate-900; 22 | // top: 0 !important; 23 | // left: 20px; 24 | // } 25 | 26 | // &:not(:placeholder-shown) + label { 27 | // @apply bg-white; 28 | // top: 0 !important; 29 | // left: 20px; 30 | 31 | // } 32 | 33 | // &::placeholder { 34 | // color: transparent; 35 | // } 36 | 37 | // &:focus::placeholder { 38 | // @apply text-gray-400; 39 | // transition-delay: 100ms; 40 | // } 41 | 42 | // &:not(:placeholder-shown){ 43 | // @apply bg-white; 44 | // @apply ring-1 ring-gray-300 invalid:ring-pink-400; 45 | // } 46 | 47 | // &:not(:placeholder-shown) ~ .toggleHide { 48 | // display: block; 49 | // } 50 | 51 | // &:focus{ 52 | // @apply bg-white ring-2 ring-blue-400; 53 | // } 54 | 55 | // &:focus:not(:placeholder-shown){ 56 | // @apply ring-2 ring-blue-400 ; 57 | // } 58 | // } 59 | 60 | // .toggleHide{ 61 | // @apply text-gray-500; 62 | // display: none; 63 | // position: absolute; 64 | // right: 20px; 65 | // top: 50%; 66 | // transform: translateY(-50%); 67 | // cursor: pointer; 68 | // } 69 | // } -------------------------------------------------------------------------------- /src/components/dls/Form/CurrencyInput.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-vars */ 2 | import { currencyFormat } from "@/utils/currencyFormat"; 3 | import { forwardRef, useState } from "react"; 4 | import InputWithLabel from "./InputWithLabel"; 5 | 6 | type Props = { 7 | placeholder: string; 8 | label?: string; 9 | id: string; 10 | prefixSymbol?: string; 11 | error?: any; 12 | className?: string; 13 | required?: boolean; 14 | minLength?: number; 15 | defaultValue?: string; 16 | onChange?: (e: React.ChangeEvent) => void; 17 | onBlur?: (e: React.FocusEvent) => void; 18 | value?: string; 19 | onReset?: () => void; 20 | min?: number; 21 | inputStyle?: string; 22 | }; 23 | 24 | const CurrencyInput = forwardRef(function CurrencyInput( 25 | { prefixSymbol = "Rp", className, inputStyle, ...props }: Props, 26 | ref: React.Ref 27 | ) { 28 | const [value, setValue] = useState( 29 | props.value ? parseInt(props.value) : 0 30 | ); 31 | 32 | return ( 33 | { 43 | setValue(() => { 44 | const newValue = parseInt(e.target.value.replace(/[^0-9]/g, "")); 45 | if (isNaN(newValue)) return 0; 46 | return newValue; 47 | }); 48 | props.onChange && props.onChange(e); 49 | }} 50 | value={ 51 | props.value 52 | ? value 53 | ? `${prefixSymbol} ${currencyFormat(value, {})}` 54 | : "" 55 | : "" 56 | } 57 | id={props.id} 58 | /> 59 | ); 60 | }); 61 | 62 | export default CurrencyInput; 63 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "finaki-frontend", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@dnd-kit/core": "^6.0.8", 13 | "@dnd-kit/sortable": "^7.0.2", 14 | "@dnd-kit/utilities": "^3.2.1", 15 | "@ducanh2912/next-pwa": "^9.5.0", 16 | "@next/font": "^13.0.7", 17 | "@radix-ui/react-dropdown-menu": "^2.0.2", 18 | "@radix-ui/react-tooltip": "^1.0.7", 19 | "@react-oauth/google": "^0.11.1", 20 | "@tanstack/react-query": "^4.22.0", 21 | "@tanstack/react-query-devtools": "^4.22.0", 22 | "@types/node": "18.11.13", 23 | "@types/react": "18.0.26", 24 | "@types/react-dom": "18.0.9", 25 | "axios": "^1.2.2", 26 | "classnames": "^2.3.2", 27 | "eslint-config-next": "^13.2.0", 28 | "jspdf": "^2.5.1", 29 | "jspdf-autotable": "^3.5.31", 30 | "jwt-decode": "^3.1.2", 31 | "next": "^13.4.9", 32 | "react": "^18.2.0", 33 | "react-dom": "^18.2.0", 34 | "react-hook-form": "^7.42.1", 35 | "react-hot-toast": "^2.4.0", 36 | "react-icons": "^4.8.0", 37 | "react-intersection-observer": "^9.4.3", 38 | "react-select": "^5.7.0", 39 | "recharts": "^2.7.2", 40 | "sass": "^1.56.2", 41 | "ua-parser-js": "^1.0.33", 42 | "zustand": "^4.3.9" 43 | }, 44 | "devDependencies": { 45 | "@types/react-datepicker": "^4.15.0", 46 | "@types/ua-parser-js": "^0.7.36", 47 | "@typescript-eslint/eslint-plugin": "^6.7.0", 48 | "@typescript-eslint/parser": "^6.7.0", 49 | "autoprefixer": "^10.4.13", 50 | "eslint": "8.29.0", 51 | "postcss": "^8.4.19", 52 | "tailwindcss": "^3.2.4", 53 | "tailwindcss-animate": "^1.0.7", 54 | "typescript": "4.9.4", 55 | "webpack": "^5.88.2" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/components/dls/Button/Button.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | import React from "react"; 3 | 4 | interface Props extends React.ButtonHTMLAttributes { 5 | children: React.ReactNode; 6 | type: "submit" | "button"; 7 | width: "full" | "auto" | "fit"; 8 | className?: string; 9 | isLoading?: boolean; 10 | buttonStyle?: "primary" | "secondary" | "danger"; 11 | background?: boolean; 12 | } 13 | 14 | const Button = ({ 15 | children, 16 | type, 17 | width, 18 | className, 19 | buttonStyle = "primary", 20 | background = true, 21 | ...props 22 | }: Props) => { 23 | return ( 24 | 53 | ); 54 | }; 55 | 56 | export default Button; 57 | -------------------------------------------------------------------------------- /src/components/dls/Button/LoadingButton.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | import LoadingSpinner from "../Loading/LoadingSpinner"; 3 | import Button from "./Button"; 4 | 5 | type Props = { 6 | isLoading: boolean; 7 | isSuccess?: boolean; 8 | onSuccessText?: string; 9 | isError?: boolean; 10 | onErrorText?: string; 11 | onLoadingText: string; 12 | width?: "full" | "auto" | "fit"; 13 | title: string; 14 | loadingOnSuccess?: boolean; 15 | styleButton?: "primary" | "secondary" | "danger"; 16 | background?: boolean; 17 | stroke?: string; 18 | onClick?: () => void; 19 | className?: string; 20 | disabled?: boolean; 21 | }; 22 | 23 | const LoadingButton = ({ 24 | isLoading, 25 | isSuccess, 26 | width = "full", 27 | onSuccessText, 28 | onLoadingText, 29 | isError, 30 | onErrorText, 31 | title, 32 | loadingOnSuccess, 33 | styleButton, 34 | className, 35 | background, 36 | disabled, 37 | onClick, 38 | }: Props) => { 39 | return ( 40 | 69 | ); 70 | }; 71 | 72 | export default LoadingButton; 73 | -------------------------------------------------------------------------------- /src/components/Header/Header.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Divider from "../dls/Divider"; 4 | import Heading from "../dls/Heading"; 5 | import PlusIcon from "../icons/PlusIcon"; 6 | import { LayoutSegment } from "@/types/LayoutSegment"; 7 | import { useSelectedLayoutSegment } from "next/navigation"; 8 | import useStore from "../../stores/store"; 9 | import ProfileInfo from "./ProfileInfo"; 10 | 11 | const Header = () => { 12 | const selectedLayout = useSelectedLayoutSegment(); 13 | const { setOpen } = useStore((state) => state.addTransactionState); 14 | 15 | const getHeaderTitle = (selectedLayout: string | null) => { 16 | switch (selectedLayout) { 17 | case LayoutSegment.DASHBOARD: 18 | return "Dashboard"; 19 | case LayoutSegment.WALLET: 20 | return "Dompet"; 21 | case LayoutSegment.TRANSACTIONS: 22 | return "Transaksi"; 23 | case LayoutSegment.ACCOUNT: 24 | return "Akun saya"; 25 | default: 26 | return ""; 27 | } 28 | }; 29 | return ( 30 |
    31 | {getHeaderTitle(selectedLayout)} 32 |
    33 |
    34 |
    setOpen(true)}> 35 | Tambah Transaksi 36 |
    37 | 38 |
    39 |
    40 |
    41 | 42 | 43 |
    44 |
    45 | ); 46 | }; 47 | 48 | export default Header; 49 | -------------------------------------------------------------------------------- /src/components/WalletCard/WalletTransaction.tsx: -------------------------------------------------------------------------------- 1 | import Heading from "../dls/Heading"; 2 | import { Transaction } from "@/types/Transaction"; 3 | import { SimpleTransactionItem } from "../Transactions/TransactionItem"; 4 | 5 | type Props = { 6 | transactions: Transaction[] | undefined; 7 | }; 8 | 9 | const WalletTransaction = (props: Props) => { 10 | if (!props.transactions) 11 | return ( 12 |
    13 | 14 | Riwayat Transaksi 15 | 16 |
    17 | {Array(3) 18 | .fill("") 19 | .map((_, index) => ( 20 |
    27 | ))} 28 |
    29 |
    30 | ); 31 | 32 | return ( 33 |
    34 | 35 | Riwayat Transaksi 36 | 37 |
    38 | {props.transactions.length > 0 ? ( 39 | props.transactions?.map((transaction, index) => { 40 | return ( 41 | 47 | ); 48 | }) 49 | ) : ( 50 |
    51 | Tidak ada transaksi 52 |
    53 | )} 54 |
    55 |
    56 | ); 57 | }; 58 | 59 | export default WalletTransaction; 60 | -------------------------------------------------------------------------------- /src/components/Charts/PieChart/PieChart.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Cell, Legend, Pie, PieChart as PiChart, Tooltip } from "recharts"; 4 | import { ChartLoading } from "../ChartPlaceholder"; 5 | import ChartWrapper from "../ChartWrapper"; 6 | import { PIE_CHART } from "../constant"; 7 | import renderPieLabel from "./PieLabel"; 8 | import renderPieTooltip from "./PieTooltip"; 9 | 10 | export type PieChartData = { 11 | name: string; 12 | value: number; 13 | color: string; 14 | }; 15 | 16 | type Props = { 17 | data: PieChartData[]; 18 | legend?: boolean; 19 | loading?: boolean; 20 | }; 21 | 22 | const PieChart = ({ data, legend = true, loading }: Props) => { 23 | return ( 24 | 25 | {loading ? ( 26 | 27 | ) : data.length > 0 ? ( 28 | 29 | 39 | {data?.map((entry, index) => ( 40 | 48 | ))} 49 | 50 | 57 | 58 | 59 | ) : ( 60 |
    61 | Belum ada Dompet yang ditambahkan 62 |
    63 | )} 64 |
    65 | ); 66 | }; 67 | 68 | export default PieChart; 69 | -------------------------------------------------------------------------------- /src/app/auth/LoginWithGoogle.tsx: -------------------------------------------------------------------------------- 1 | import Button from "../../components/dls/Button/Button"; 2 | import { FcGoogle } from "react-icons/fc"; 3 | import { useGoogleLogin } from "@react-oauth/google"; 4 | import { loginWithGoogleCode } from "@/api/authApi"; 5 | import { Routes } from "@/types/Routes"; 6 | import { useRouter } from "next/navigation"; 7 | import LoadingSpinner from "../../components/dls/Loading/LoadingSpinner"; 8 | import classNames from "classnames"; 9 | import { useState } from "react"; 10 | 11 | const LoginWithGoogle = ({ 12 | onError, 13 | }: { 14 | onError: (message: string) => void; 15 | }) => { 16 | const router = useRouter(); 17 | const [loading, setLoading] = useState(false); 18 | 19 | const login = useGoogleLogin({ 20 | onError: (err) => { 21 | onError(err.error_description!); 22 | }, 23 | onSuccess: ({ code }) => { 24 | loginWithGoogleCode(code) 25 | .then((res) => { 26 | router.replace(Routes.App); 27 | localStorage.setItem("access-token", res.data.accessToken); 28 | }) 29 | .catch((err) => { 30 | onError(err.response.data.message); 31 | }) 32 | .finally(() => { 33 | setLoading(false); 34 | }); 35 | }, 36 | flow: "auth-code", 37 | }); 38 | return ( 39 | 59 | ); 60 | }; 61 | 62 | export default LoginWithGoogle; 63 | -------------------------------------------------------------------------------- /src/utils/api/authApi.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { BASE_URL } from "./config"; 3 | import { GenericResponse } from "./types/Api"; 4 | import { 5 | LoginInput, 6 | LoginResponse, 7 | LogoutResponse, 8 | RefreshTokenResponse, 9 | RegisterInput, 10 | ResetPasswordInput, 11 | } from "./types/AuthAPI"; 12 | 13 | export const authApi = axios.create({ 14 | baseURL: `${BASE_URL}/auth`, 15 | withCredentials: true, 16 | }); 17 | 18 | export const refreshAccessToken = async () => { 19 | const response = await authApi.get("/refresh-token"); 20 | return response.data.data; 21 | }; 22 | 23 | export const loginUser = async (data: LoginInput) => { 24 | const response = await authApi.post("/sign", data); 25 | return response.data.data; 26 | }; 27 | 28 | export const registerUser = async (data: RegisterInput) => { 29 | const response = await authApi.post("/register", data); 30 | return response.data.data; 31 | }; 32 | 33 | export const logoutUser = async () => { 34 | const response = await authApi.delete("/logout"); 35 | return response.data.message; 36 | }; 37 | 38 | export const getUser = async () => { 39 | const response = await authApi.get("/user"); 40 | return response.data.data; 41 | }; 42 | 43 | export const forgotPassword = async (email: string) => { 44 | const response = await authApi.post("/forgot-password", { 45 | email, 46 | }); 47 | return response.data; 48 | }; 49 | 50 | export const verifyResetPasswordToken = async (token: string) => { 51 | const response = await authApi.get( 52 | `/reset-password?token=${token}` 53 | ); 54 | return response.data.data; 55 | }; 56 | 57 | export const resetPassword = async (data: ResetPasswordInput) => { 58 | const response = await authApi.post("/reset-password", data); 59 | return response.data.message; 60 | }; 61 | 62 | export const loginWithGoogleCode = async (code: string)=> { 63 | const response = await authApi.post("/login-with-google", { 64 | code 65 | }) 66 | return response.data 67 | } -------------------------------------------------------------------------------- /src/app/app/layout.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { getUserData } from "@/api/user"; 4 | import Container from "../../components/Container/Container"; 5 | import Header from "../../components/Header/Header"; 6 | import AppNav from "../../components/Navigation/AppNav/AppNav"; 7 | import AddTransaction from "../../components/Transactions/AddTransaction"; 8 | import TransactionDetail from "../../components/Transactions/TransactionDetail"; 9 | import DeleteWalletDialog from "../../components/WalletCard/DeleteWalletDialog"; 10 | import TransferBalanceDialog from "../../components/WalletCard/TransferBalanceDialog"; 11 | import { QueryKey } from "@/types/QueryKey"; 12 | import { Routes } from "@/types/Routes"; 13 | import { useQuery } from "@tanstack/react-query"; 14 | import { useRouter } from "next/navigation"; 15 | import { useEffect } from "react"; 16 | import useStore from "../../stores/store"; 17 | 18 | type Props = { 19 | children: React.ReactNode; 20 | }; 21 | 22 | const AppLayout = ({ children }: Props) => { 23 | const setUser = useStore((state) => state.setUser); 24 | const router = useRouter(); 25 | 26 | const { isLoading, data, isError } = useQuery({ 27 | queryKey: [QueryKey.USER], 28 | queryFn: getUserData, 29 | onSuccess: (data) => { 30 | setUser(data); 31 | }, 32 | staleTime: 0, 33 | }); 34 | 35 | useEffect(() => { 36 | if (isError) { 37 | router.push(Routes.Login); 38 | 39 | // remove access token if error 40 | window.localStorage.removeItem("access-token"); 41 | } 42 | // eslint-disable-next-line react-hooks/exhaustive-deps 43 | }, [isError]); 44 | 45 | if (isLoading || !data) { 46 | return ( 47 |
    48 | Loading... 49 |
    50 | ); 51 | } 52 | 53 | return ( 54 | <> 55 | 56 | 57 |
    58 |
    59 |
    {children}
    60 |
    61 |
    62 | 63 | 64 | 65 | 66 | 67 | ); 68 | }; 69 | 70 | export default AppLayout; 71 | -------------------------------------------------------------------------------- /src/components/Dashboard/TransactionActivity.tsx: -------------------------------------------------------------------------------- 1 | import { TotalTransactionByDay } from "@/types/Transaction"; 2 | import AreaChart from "../Charts/AreaChart/AreaChart"; 3 | import DashboardContentWrapper from "./DashboardContentWrapper"; 4 | import DashboardHeader from "./DashboardHeader"; 5 | import DropdownMenu from "../dls/Dropdown/DropdownMenu"; 6 | import { DropdownItem } from "../dls/Dropdown/DropdownItem"; 7 | import { BsClockHistory } from "react-icons/bs"; 8 | import { Interval } from "@/api/types/TransactionAPI"; 9 | import useTransaction from "../../stores/transactionStore"; 10 | import { shallow } from "zustand/shallow"; 11 | 12 | type Props = { 13 | data: TotalTransactionByDay[] | undefined; 14 | loading?: boolean; 15 | }; 16 | 17 | const TransactionActivity = ({ data, loading }: Props) => { 18 | const areaChartData = data?.map((item) => ({ 19 | day: item._id.day as unknown as string, 20 | timestamp: item.timestamp, 21 | value: item.totalAmount, 22 | })); 23 | 24 | const { interval, setInterval } = useTransaction( 25 | (state) => ({ 26 | interval: state.interval, 27 | setInterval: state.setInterval, 28 | }), 29 | shallow 30 | ); 31 | 32 | const ButtonTrigger = () => ( 33 | 40 | ); 41 | 42 | return ( 43 | 44 | 45 | }> 46 | {Object.values(Interval).map((val) => ( 47 | setInterval(val as Interval)} 50 | className="px-3 font-medium capitalize" 51 | key={val} 52 | > 53 | {val} 54 | 55 | ))} 56 | 57 | 58 | 59 | 60 | ); 61 | }; 62 | 63 | export default TransactionActivity; 64 | -------------------------------------------------------------------------------- /src/utils/api/transaction.ts: -------------------------------------------------------------------------------- 1 | import { Transaction } from "@/types/Transaction"; 2 | import { instance } from "./api"; 3 | import { makeUrl } from "./config"; 4 | import { 5 | EditTransactionInput, 6 | InfiniteTransactionResponse, Interval, 7 | TotalTransactionByDayResponse, 8 | TransactionInput, 9 | TransactionResponse, 10 | TransactionsResponse, 11 | } from "./types/TransactionAPI"; 12 | 13 | export const insertNewTransaction = async (data: TransactionInput) => { 14 | const response = await instance.post( 15 | "/transactions", 16 | data 17 | ); 18 | return response.data.data; 19 | }; 20 | 21 | export const getTransactionsByDate = async () => { 22 | const response = await instance.get("/transactions/by-date"); 23 | return response.data; 24 | }; 25 | 26 | export const editTransaction = async ({ 27 | id, 28 | transactionInput, 29 | }: EditTransactionInput): Promise => { 30 | const response = await instance.put(`/transactions/${id}`, transactionInput); 31 | return response.data.data; 32 | }; 33 | 34 | export const deleteTransaction = async (id: string) => { 35 | const response = await instance.delete( 36 | `/transactions/${id}` 37 | ); 38 | return response.data.data; 39 | }; 40 | 41 | export const getTotalTransactionByPeriod = async ( 42 | interval: Interval 43 | ) => { 44 | const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; 45 | const response = await instance.get( 46 | makeUrl("/transactions/total", { interval, timezone }) 47 | ); 48 | 49 | return response.data.data; 50 | }; 51 | 52 | export const getAllTransactions = async (query: { 53 | limit: number; 54 | page?: number; 55 | search?: string; 56 | }) => { 57 | const response = await instance.get( 58 | makeUrl("/transactions", query) 59 | ); 60 | 61 | return response.data.data; 62 | }; 63 | 64 | export const getAllTransactionByMonth = async (date: Date) => { 65 | const month = date.getMonth() + 1; 66 | const year = date.getFullYear(); 67 | const response = await instance.get( 68 | makeUrl(`/transactions/by-month/${month}/${year}`) 69 | ); 70 | 71 | return response.data.data; 72 | }; 73 | -------------------------------------------------------------------------------- /src/data/transactions_example.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": [ 3 | { 4 | "date": "2018-01-01", 5 | "transactions": [ 6 | { 7 | "id": 1, 8 | "amount": 100, 9 | "type": "out", 10 | "category": "food", 11 | "description": "McDonalds", 12 | "time": "18:30" 13 | }, 14 | { 15 | "id": 2, 16 | "amount": 200, 17 | "type": "out", 18 | "category": "food", 19 | "description": "KFC", 20 | "time": "19:30" 21 | }, 22 | { 23 | "id": 3, 24 | "amount": 300, 25 | "type": "in", 26 | "category": "food", 27 | "description": "Burger King", 28 | "time": "20:30" 29 | } 30 | ] 31 | }, 32 | { 33 | "date": "2018-01-02", 34 | "transactions": [ 35 | { 36 | "id": 4, 37 | "amount": 100, 38 | "type": "out", 39 | "category": "food", 40 | "description": "McDonalds", 41 | "time": "18:30" 42 | }, 43 | { 44 | "id": 5, 45 | "amount": 200, 46 | "type": "out", 47 | "category": "food", 48 | "description": "KFC", 49 | "time": "19:30" 50 | }, 51 | { 52 | "id": 6, 53 | "amount": 300, 54 | "type": "in", 55 | "category": "food", 56 | "description": "Burger King", 57 | "time": "20:30" 58 | } 59 | ] 60 | }, 61 | { 62 | "date": "2018-01-03", 63 | "transactions": [ 64 | { 65 | "id": 7, 66 | "amount": 100, 67 | "type": "out", 68 | "category": "food", 69 | "description": "McDonalds", 70 | "time": "18:30" 71 | }, 72 | { 73 | "id": 8, 74 | "amount": 200, 75 | "type": "in", 76 | "category": "food", 77 | "description": "KFC", 78 | "time": "19:30" 79 | }, 80 | { 81 | "id": 9, 82 | "amount": 300, 83 | "type": "out", 84 | "category": "food", 85 | "description": "Burger King", 86 | "time": "20:30" 87 | } 88 | ] 89 | } 90 | ] 91 | } -------------------------------------------------------------------------------- /src/app/app/dashboard/DashboardComponent.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | getAllTransactions, 5 | getTotalTransactionByPeriod, 6 | } from "@/api/transaction"; 7 | import { getAllWallets } from "@/api/wallet"; 8 | import Ratio from "../../../components/Dashboard/Ratio"; 9 | import RecentTransactions from "../../../components/Dashboard/RecentTrasactions"; 10 | import TransactionActivity from "../../../components/Dashboard/TransactionActivity"; 11 | import WalletPercentage from "../../../components/Dashboard/WalletPercentage"; 12 | import { QueryKey } from "@/types/QueryKey"; 13 | import { useQuery } from "@tanstack/react-query"; 14 | import useTransaction from "../../../stores/transactionStore"; 15 | import { shallow } from "zustand/shallow"; 16 | 17 | const DashboardComponent = () => { 18 | const { interval } = useTransaction( 19 | (state) => ({ 20 | interval: state.interval, 21 | }), 22 | shallow 23 | ); 24 | 25 | const totalTransactionQuery = useQuery({ 26 | queryKey: [QueryKey.TOTAL_TRANSACTIONS, interval], 27 | queryFn: () => getTotalTransactionByPeriod(interval), 28 | }); 29 | 30 | const recentTransactionsQuery = useQuery({ 31 | queryKey: [QueryKey.RECENT_TRANSACTIONS], 32 | queryFn: () => 33 | getAllTransactions({ 34 | limit: 4, 35 | }), 36 | }); 37 | 38 | const walletQuery = useQuery({ 39 | queryKey: [QueryKey.WALLETS], 40 | queryFn: getAllWallets, 41 | }); 42 | 43 | return ( 44 |
    45 | 49 | 53 |
    54 | 58 | 63 |
    64 |
    65 | ); 66 | }; 67 | 68 | export default DashboardComponent; 69 | -------------------------------------------------------------------------------- /src/components/Charts/BarChart/BarChart.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import {TotalTransactionByDay} from "@/types/Transaction"; 4 | import classNames from "classnames"; 5 | import {Bar, BarChart as BaChart, CartesianGrid, Tooltip, XAxis,} from "recharts"; 6 | import {ChartError, ChartLoading} from "../ChartPlaceholder"; 7 | import ChartWrapper from "../ChartWrapper"; 8 | 9 | import renderBarTooltip from "./BarTooltip"; 10 | import useTransaction from "../../../stores/transactionStore"; 11 | import {shallow} from "zustand/shallow"; 12 | import {Interval} from "@/api/types/TransactionAPI"; 13 | 14 | type Props = { 15 | data: TotalTransactionByDay[]; 16 | className?: string; 17 | color: any; 18 | size?: "medium" | "large"; 19 | loading?: boolean; 20 | }; 21 | 22 | const BarChart = ({ data, color, loading, className }: Props) => { 23 | const interval = useTransaction(state => state.interval, shallow) 24 | const barSize = interval === Interval.Weekly ? 8 :4 25 | 26 | return ( 27 | 28 | {loading ? ( 29 | 30 | ) : data ? ( 31 | 32 | 37 | 41 | 48 | 55 | 63 | 64 | ) : ( 65 | 66 | )} 67 | 68 | ); 69 | }; 70 | 71 | export default BarChart; 72 | -------------------------------------------------------------------------------- /src/components/dls/Form/InputWithLabel.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import EyeIcon from "../../icons/EyeIcon"; 4 | import classNames from "classnames"; 5 | import { forwardRef, useState } from "react"; 6 | import IconWrapper from "../IconWrapper"; 7 | 8 | interface Props extends React.InputHTMLAttributes { 9 | type: "text" | "number" | "password" | "email"; 10 | placeholder: string; 11 | value?: string | number; 12 | label?: string; 13 | id: string; 14 | defaultValue?: string; 15 | required?: boolean; 16 | minLength?: number; 17 | className?: string; 18 | inputStyle?: string; 19 | error?: { 20 | message: string; 21 | }; 22 | } 23 | 24 | // eslint-disable-next-line react/display-name 25 | const InputWithLabel = forwardRef( 26 | ( 27 | { id, type, label, className, inputStyle, error, ...props }: Props, 28 | ref: React.Ref 29 | ) => { 30 | const [isPasswordVisible, setIsPasswordVisible] = useState(false); 31 | 32 | return ( 33 |
    39 | 54 | 55 | {error && ( 56 | 57 | {error.message} 58 | 59 | )} 60 | {type === "password" && ( 61 | 63 | setIsPasswordVisible((currentState) => !currentState) 64 | } 65 | className={"toggleHide"} 66 | > 67 | 68 | 69 | )} 70 |
    71 | ); 72 | } 73 | ); 74 | 75 | export default InputWithLabel; 76 | -------------------------------------------------------------------------------- /src/stores/transactionStore.ts: -------------------------------------------------------------------------------- 1 | import {Transaction} from "@/types/Transaction"; 2 | import {create} from "zustand"; 3 | import {Interval} from "@/api/types/TransactionAPI"; 4 | 5 | interface TransactionStore { 6 | transactionDetailState: { 7 | isOpen: boolean; 8 | transaction?: Transaction; 9 | }; 10 | setTransactionDetailState: ({ 11 | isOpen, 12 | transaction, 13 | }: { 14 | isOpen: boolean; 15 | transaction?: Transaction; 16 | }) => void; 17 | interval: Interval; 18 | setInterval: (val: Interval) => void; 19 | transactions: Transaction[]; 20 | setTransactions: (transactions: Transaction[]) => void; 21 | dispatchUpdateTransaction: (id: string, newTransaction: Transaction) => void; 22 | dispatchDeleteTransaction: (id: string) => void; 23 | dispatchAddTransaction: (transaction: Transaction) => void; 24 | pushTransactions: (transactions: Transaction[]) => void; 25 | } 26 | 27 | const useTransaction = create((set) => ({ 28 | transactionDetailState: { 29 | isOpen: false, 30 | }, 31 | transactions: [], 32 | interval: Interval.Weekly, 33 | setInterval: (val) => { 34 | set({ interval: val }) 35 | }, 36 | setTransactions: (transactions: Transaction[]) => { 37 | set({ transactions }); 38 | }, 39 | pushTransactions: (transactions: Transaction[]) => { 40 | set((state) => ({ 41 | transactions: [...state.transactions, ...transactions], 42 | })); 43 | }, 44 | dispatchUpdateTransaction: (id, newTransaction) => 45 | set((state) => ({ 46 | transactions: state.transactions.map((transaction) => 47 | transaction._id === id ? newTransaction : transaction 48 | ), 49 | })), 50 | dispatchDeleteTransaction: (id) => 51 | set((state) => ({ 52 | transactions: state.transactions.filter( 53 | (transaction) => transaction._id !== id 54 | ), 55 | })), 56 | dispatchAddTransaction: (transaction) => 57 | set((state) => ({ 58 | transactions: [transaction, ...state.transactions], 59 | })), 60 | 61 | setTransactionDetailState: ({ 62 | isOpen, 63 | transaction: transactionDetailState, 64 | }) => 65 | set({ 66 | transactionDetailState: { 67 | isOpen: isOpen, 68 | transaction: transactionDetailState, 69 | }, 70 | }), 71 | })); 72 | 73 | export default useTransaction; 74 | -------------------------------------------------------------------------------- /src/app/demo/layout.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { getUserData } from "@/api/user"; 4 | import Container from "../../components/Container/Container"; 5 | import Header from "../../components/Header/Header"; 6 | import DemoNav from "../../components/Navigation/AppNav/DemoNav"; 7 | import AddTransaction from "../../components/Transactions/AddTransaction"; 8 | import TransactionDetail from "../../components/Transactions/TransactionDetail"; 9 | import DeleteWalletDialog from "../../components/WalletCard/DeleteWalletDialog"; 10 | import TransferBalanceDialog from "../../components/WalletCard/TransferBalanceDialog"; 11 | import { QueryKey } from "@/types/QueryKey"; 12 | import { Routes } from "@/types/Routes"; 13 | import { useQuery, useQueryClient } from "@tanstack/react-query"; 14 | import Link from "next/link"; 15 | import { useRouter } from "next/navigation"; 16 | import { useEffect } from "react"; 17 | 18 | type Props = { 19 | children: React.ReactNode; 20 | }; 21 | 22 | const AppLayout = ({ children }: Props) => { 23 | const router = useRouter(); 24 | 25 | const queryClient = useQueryClient(); 26 | useQuery({ 27 | queryKey: [QueryKey.USER], 28 | queryFn: getUserData, 29 | onSuccess: () => { 30 | router.push(Routes.App); 31 | }, 32 | staleTime: 0, 33 | }); 34 | 35 | useEffect(() => { 36 | document.title = "Demo"; 37 | return () => { 38 | queryClient.removeQueries(); 39 | document.title = "Finaki"; 40 | }; 41 | // eslint-disable-next-line react-hooks/exhaustive-deps 42 | }, []); 43 | 44 | return ( 45 | <> 46 |
    47 | Sekarang ini anda sedang dalam akun Demo 48 | 52 | Daftar Sekarang 53 | 54 | Untuk menggunakan seluruh fitur 55 |
    56 | 57 | 58 |
    59 |
    60 |
    {children}
    61 |
    62 |
    63 | 64 | 65 | 66 | 67 | 68 | ); 69 | }; 70 | 71 | export default AppLayout; 72 | -------------------------------------------------------------------------------- /src/app/demo/dashboard/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Ratio from "../../../components/Dashboard/Ratio"; 4 | import RecentTransactions from "../../../components/Dashboard/RecentTrasactions"; 5 | import TransactionActivity from "../../../components/Dashboard/TransactionActivity"; 6 | import WalletPercentage from "../../../components/Dashboard/WalletPercentage"; 7 | import { QueryKey } from "@/types/QueryKey"; 8 | import { TotalTransactionByDay, Transaction } from "@/types/Transaction"; 9 | import { WalletData } from "@/types/Wallet"; 10 | import { useQuery } from "@tanstack/react-query"; 11 | 12 | const Page = () => { 13 | const totalTransactionQuery = useQuery({ 14 | queryKey: [QueryKey.TOTAL_TRANSACTIONS], 15 | queryFn: (): Promise => 16 | new Promise((resolve) => { 17 | import("@/data/dummy-data/dashboard.json").then((data) => { 18 | resolve(data.data); 19 | }); 20 | }), 21 | }); 22 | 23 | const recentTransactionsQuery = useQuery({ 24 | queryKey: [QueryKey.RECENT_TRANSACTIONS], 25 | queryFn: (): Promise => 26 | new Promise((resolve) => { 27 | import("@/data/dummy-data/transaction.json").then((data) => { 28 | resolve(data.data as unknown as Transaction[]); 29 | }); 30 | }), 31 | }); 32 | 33 | const walletQuery = useQuery({ 34 | queryKey: [QueryKey.WALLETS], 35 | queryFn: (): Promise => 36 | new Promise((resolve) => { 37 | import("@/data/dummy-data/wallet.json").then((data) => { 38 | resolve(data.data as WalletData[]); 39 | }); 40 | }), 41 | }); 42 | 43 | return ( 44 |
    45 | 49 | 53 |
    54 | 58 | 63 |
    64 |
    65 | ); 66 | }; 67 | 68 | export default Page; 69 | -------------------------------------------------------------------------------- /src/components/Dashboard/RecentTrasactions.tsx: -------------------------------------------------------------------------------- 1 | import { Routes } from "@/types/Routes"; 2 | import { Transaction } from "@/types/Transaction"; 3 | import Link from "next/link"; 4 | import { ChartError } from "../Charts/ChartPlaceholder"; 5 | import { 6 | SimpleTransactionItem, 7 | SimpleTSkeleton, 8 | } from "../Transactions/TransactionItem"; 9 | import DashboardContentWrapper from "./DashboardContentWrapper"; 10 | import DashboardHeader from "./DashboardHeader"; 11 | 12 | type Props = { 13 | data: Transaction[] | undefined; 14 | isLoading: boolean; 15 | isError: boolean; 16 | }; 17 | 18 | const RecentTransactions = ({ data, isLoading, isError }: Props) => { 19 | if (isLoading || isError) { 20 | return ( 21 | 22 | 23 |
    24 | {isLoading && 25 | Array(4) 26 | .fill("") 27 | .map((_, index) => ( 28 | 29 | ))} 30 | 31 | {isError && } 32 |
    33 |
    34 | ); 35 | } 36 | 37 | const slicedData = data ? data.slice(0, 4) : []; 38 | const lengthData = slicedData.length; 39 | 40 | return ( 41 | 42 | 43 | {slicedData?.length > 0 && ( 44 | 48 | Lihat semua 49 | 50 | )} 51 | 52 | {slicedData?.length > 0 ? ( 53 |
      54 | {slicedData.map((transaction, index) => { 55 | return ( 56 | 61 | ); 62 | })} 63 |
    64 | ) : ( 65 |
    66 | Belum ada transaksi 67 |
    68 | )} 69 |
    70 | ); 71 | }; 72 | 73 | export default RecentTransactions; 74 | -------------------------------------------------------------------------------- /src/utils/api/wallet.ts: -------------------------------------------------------------------------------- 1 | import { instance } from "./api"; 2 | import { makeUrl } from "./config"; 3 | import { 4 | AllWalletResponse, 5 | CreatedWalletResponse, 6 | DeleteWalletRequest, 7 | TransferBalance, 8 | UpdatedWalletColorResponse, 9 | UpdatedWalletResponse, 10 | UpdateWalletRequest, 11 | WalletDetailsResponse, 12 | WalletInput, 13 | } from "./types/WalletAPI"; 14 | 15 | import { Transaction } from "@/types/Transaction"; 16 | import { TransactionsResponse } from "./types/TransactionAPI"; 17 | 18 | export const createNewWallet = async (data: WalletInput) => { 19 | const response = await instance.post("/wallets", data); 20 | return response.data.data; 21 | }; 22 | 23 | export const getAllWallets = async () => { 24 | const response = await instance.get("/wallets"); 25 | 26 | return response.data.data; 27 | }; 28 | 29 | export const updateWallet = async ({ id, data }: UpdateWalletRequest) => { 30 | const response = await instance.put( 31 | `/wallets/${id}`, 32 | data 33 | ); 34 | return response.data.data; 35 | }; 36 | 37 | export const deleteWallet = async (data: DeleteWalletRequest) => { 38 | const response = await instance.delete( 39 | makeUrl(`/wallets/${data.param.id}`, data?.query) 40 | ); 41 | return response.data.data; 42 | }; 43 | 44 | export const getOneWallet = async (id: string) => { 45 | const response = await instance.get(`/wallets/${id}`); 46 | return response.data.data; 47 | }; 48 | 49 | export const updateWalletColor = async ({ 50 | id, 51 | color, 52 | }: Record) => { 53 | const response = await instance.patch( 54 | `/wallets/${id}/color`, 55 | { color } 56 | ); 57 | return response.data.data; 58 | }; 59 | 60 | export const transferBalance = async (data: TransferBalance) => { 61 | const response = await instance.post(`/wallets/transfer-balance`, data); 62 | return response.data.data; 63 | }; 64 | 65 | export const getWalletTransactions = async ( 66 | id: string 67 | ): Promise => { 68 | const response = await instance.get( 69 | `/wallets/${id}/transactions` 70 | ); 71 | return response.data.data; 72 | }; 73 | 74 | export const reoderWallets = async (params: { walletIds: string[] }) => { 75 | const data = { 76 | walletIds: JSON.stringify(params.walletIds), 77 | }; 78 | const response = await instance.post(`/wallets/reorder`, data); 79 | return response.data; 80 | }; 81 | 82 | // export const getWalletBalanceActivity = async (walletId: string) => { 83 | // const response = await instance.get( 84 | // `/wallets/${walletId}/balance-activity` 85 | // ); 86 | // return response.data.data; 87 | // }; 88 | -------------------------------------------------------------------------------- /src/components/Account/DeviceLists/index.tsx: -------------------------------------------------------------------------------- 1 | import { getUserDevices, logoutDevices } from "@/api/user"; 2 | import ContentWrapper from "../../Container/ContentWrapper"; 3 | import LoadingButton from "../../dls/Button/LoadingButton"; 4 | import Heading from "../../dls/Heading"; 5 | import { QueryKey } from "@/types/QueryKey"; 6 | import { Routes } from "@/types/Routes"; 7 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 8 | import { useRouter } from "next/navigation"; 9 | import { toast } from "react-hot-toast"; 10 | import useStore from "../../../stores/store"; 11 | import Device from "./Device"; 12 | 13 | const DevicesLists = () => { 14 | const setUser = useStore((state) => state.setUser); 15 | const router = useRouter(); 16 | const queryClient = useQueryClient(); 17 | 18 | const { data, isLoading } = useQuery({ 19 | queryKey: ["devices"], 20 | queryFn: getUserDevices, 21 | }); 22 | 23 | const deleteDeviceMutation = useMutation({ 24 | mutationFn: logoutDevices, 25 | onSuccess: () => { 26 | setUser(null); 27 | router.push(Routes.Home); 28 | localStorage.removeItem("access-token"); 29 | queryClient.invalidateQueries(); 30 | queryClient.removeQueries([QueryKey.USER]); 31 | toast.success("Berhasil keluar dari semua perangkat"); 32 | }, 33 | }); 34 | 35 | const handleDeleteAllDevices = () => { 36 | const ids = data?.map((device) => device._id); 37 | if (!ids || ids!.length === 0) return; 38 | deleteDeviceMutation.mutate(ids); 39 | }; 40 | 41 | if (isLoading) return <>; 42 | if (!data) return <>; 43 | const sortedData = data?.sort((a, b) => { 44 | if (a.isCurrent) return -1; 45 | if (b.isCurrent) return 1; 46 | return 0; 47 | }); 48 | return ( 49 |
    50 |
    51 | 52 | Daftar Perangkat 53 | 54 | 63 |
    64 | 65 | {sortedData.map((device, index) => ( 66 | 74 | ))} 75 | 76 |
    77 | ); 78 | }; 79 | 80 | export default DevicesLists; 81 | -------------------------------------------------------------------------------- /src/app/demo/wallet/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Placeholder from "../../../components/Placeholder"; 4 | import AddNewWallet from "../../../components/WalletCard/AddNewWallet"; 5 | import WalletCard from "../../../components/WalletCard/WalletCard"; 6 | import WalletCardSkeleton from "../../../components/WalletCard/WalletCardSkeleton"; 7 | import { QueryKey } from "@/types/QueryKey"; 8 | import { WalletData } from "@/types/Wallet"; 9 | import { currencyFormat } from "@/utils/currencyFormat"; 10 | import { useQuery } from "@tanstack/react-query"; 11 | 12 | const AllWalletsPage = () => { 13 | const { data, isLoading } = useQuery({ 14 | queryKey: [QueryKey.WALLETS], 15 | queryFn: (): Promise => 16 | new Promise((resolve) => { 17 | import("@/data/dummy-data/wallet.json").then((data) => { 18 | resolve(data.data as WalletData[]); 19 | }); 20 | }), 21 | }); 22 | 23 | if (isLoading || !data) { 24 | return ( 25 |
    26 |
    27 |
    28 |
    29 | 30 | Total saldo 31 | 32 | 33 |
    34 |
    35 |
    36 | 37 | 38 | 39 |
    40 |
    41 |
    42 | ); 43 | } 44 | 45 | const totalBalance = data.reduce( 46 | (acc: number, curr: any) => acc + curr.balance, 47 | 0 48 | ); 49 | 50 | return ( 51 |
    52 |
    53 |
    54 | 55 | Total saldo 56 | 57 | {currencyFormat(totalBalance)} 58 |
    59 | 60 |
    61 |
    62 | {data.map((wallet: any) => ( 63 | 71 | ))} 72 |
    73 |
    74 | ); 75 | }; 76 | 77 | export default AllWalletsPage; 78 | -------------------------------------------------------------------------------- /src/components/Charts/AreaChart/AreaChart.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import classNames from "classnames"; 4 | import { 5 | AreaChart as ArChart, 6 | Area, 7 | CartesianGrid, 8 | Tooltip, 9 | XAxis, 10 | } from "recharts"; 11 | import { ChartError, ChartLoading } from "../ChartPlaceholder"; 12 | import ChartWrapper from "../ChartWrapper"; 13 | import renderAreaTooltip from "./AreaTooltip"; 14 | import { stopColor } from "./constants"; 15 | 16 | export type AreaChartData = { 17 | day?: string; 18 | timestamp: string; 19 | value: number; 20 | }; 21 | 22 | type Props = { 23 | chartName?: string; 24 | data: AreaChartData[]; 25 | size?: "medium" | "large"; 26 | theme?: "default" | "transparent"; 27 | xAxis?: boolean; 28 | horizonalLines?: boolean; 29 | loading?: boolean; 30 | }; 31 | 32 | const AreaChart = ({ 33 | data, 34 | size = "large", 35 | theme = "default", 36 | xAxis = true, 37 | horizonalLines = true, 38 | loading, 39 | }: Props) => { 40 | return ( 41 | 47 | {loading ? ( 48 | 49 | ) : data ? ( 50 | 51 | 52 | 53 | 58 | 63 | 64 | 65 | 74 | 81 | 82 | 91 | 92 | ) : ( 93 | 94 | )} 95 | 96 | ); 97 | }; 98 | 99 | export default AreaChart; 100 | -------------------------------------------------------------------------------- /src/components/Account/Profile.tsx: -------------------------------------------------------------------------------- 1 | import Heading from "../dls/Heading"; 2 | import IconWrapper from "../dls/IconWrapper"; 3 | import ClipboardIcon from "../icons/ClipboardIcon"; 4 | import { toast } from "react-hot-toast"; 5 | import useStore from "../../stores/store"; 6 | import ContentWrapper from "../Container/ContentWrapper"; 7 | import Link from "next/link"; 8 | 9 | type Props = { 10 | className?: string; 11 | }; 12 | 13 | const Profile = (props: Props) => { 14 | const { user } = useStore((state) => ({ user: state.user })); 15 | 16 | const copyTokenToClipboard = () => { 17 | navigator.clipboard.writeText(user?.token || ""); 18 | toast.success("Token berhasil disalin"); 19 | }; 20 | 21 | return ( 22 |
    23 | 24 | Informasi tentang akun 25 | 26 | 27 |
    28 | Nama 29 | {user?.name} 30 |
    31 |
    32 | Email 33 | {user?.email} 34 |
    35 | 36 | 37 | Token 38 | 39 |
    40 |
    41 | {user?.token} 42 | 46 | 47 | 48 |
    49 |

    50 | 54 | Hubungkan dengan Telegram 55 | 56 |

    57 |
    58 |
    59 | {user?.telegramAccount?.username && ( 60 | <> 61 |
    62 | 63 | Akun Telegram yang terhubung 64 | 65 |
    66 | Username 67 | 68 | @{user?.telegramAccount?.username} 69 | 70 |
    71 |
    72 | Nama 73 | 74 | {user?.telegramAccount?.firstName} 75 | 76 |
    77 | 78 | )} 79 |
    80 |
    81 | ); 82 | }; 83 | 84 | export default Profile; 85 | -------------------------------------------------------------------------------- /src/components/dls/Dropdown/DropdownItem.tsx: -------------------------------------------------------------------------------- 1 | import CheckIcon from "../../icons/CheckIcon"; 2 | import { 3 | CheckboxItem, 4 | Item, 5 | ItemIndicator, 6 | SubTrigger, 7 | } from "@radix-ui/react-dropdown-menu"; 8 | import classNames from "classnames"; 9 | 10 | import styles from "./DropdownItem.module.scss"; 11 | import React from "react"; 12 | 13 | interface ItemProps extends React.ComponentProps { 14 | indicator?: React.ReactNode; 15 | icon?: React.ReactNode; 16 | shouldCloseAfterClick?: boolean; 17 | } 18 | 19 | interface CheckboxItemProps extends React.ComponentProps { 20 | children: React.ReactNode; 21 | className?: string; 22 | shouldCloseAfterClick?: boolean; 23 | } 24 | 25 | interface SubTriggerProps extends React.ComponentProps { 26 | indicator?: React.ReactNode; 27 | } 28 | 29 | const DropdownItem = ({ 30 | children, 31 | className, 32 | icon, 33 | shouldCloseAfterClick = false, 34 | onClick, 35 | ...props 36 | }: ItemProps) => { 37 | return ( 38 | { 40 | // by default Dropdown menu will close after clicking on an item 41 | // but if we want to keep it open, we can pass shouldCloseAfterClick = false 42 | if (!shouldCloseAfterClick) { 43 | e.preventDefault(); 44 | } 45 | if (onClick) { 46 | onClick(e); 47 | } 48 | }} 49 | className={classNames( 50 | styles.dropdownItem, 51 | "py-1 px-2 rounded hover:bg-blue-100 dark:hover:bg-blue-500/50 relative cursor-default", 52 | { "pl-7": !!icon }, 53 | className 54 | )} 55 | {...props} 56 | > 57 | {icon &&
    {icon}
    } 58 | {children} 59 |
    60 | ); 61 | }; 62 | 63 | const DropdownCheckboxItem = ({ 64 | children, 65 | className, 66 | shouldCloseAfterClick = false, 67 | onClick, 68 | ...props 69 | }: CheckboxItemProps) => { 70 | return ( 71 | { 73 | // by default Dropdown menu will close after clicking on an item 74 | // but if we want to keep it open, we can pass shouldCloseAfterClick = false 75 | if (!shouldCloseAfterClick) { 76 | e.preventDefault(); 77 | } 78 | if (onClick) { 79 | onClick(e); 80 | } 81 | }} 82 | className={classNames(styles.dropdownItem, className)} 83 | {...props} 84 | > 85 | 86 | 87 | 88 | {children} 89 | 90 | ); 91 | }; 92 | 93 | const DropdownSubTrigger = ({ 94 | children, 95 | className, 96 | indicator, 97 | ...props 98 | }: SubTriggerProps) => { 99 | return ( 100 | 104 | {indicator && ( 105 | 106 | {indicator} 107 | 108 | )} 109 | {children} 110 | 111 | ); 112 | }; 113 | 114 | export { DropdownItem, DropdownSubTrigger, DropdownCheckboxItem }; 115 | -------------------------------------------------------------------------------- /src/components/Account/DeviceLists/Device.tsx: -------------------------------------------------------------------------------- 1 | import { logoutDevices } from "@/api/user"; 2 | import LoadingButton from "../../dls/Button/LoadingButton"; 3 | import DesktopIcon from "../../icons/DesktopIcon"; 4 | import PhoneIcon from "../../icons/PhoneIcon"; 5 | import { useMutation, useQueryClient } from "@tanstack/react-query"; 6 | import classNames from "classnames"; 7 | import { toast } from "react-hot-toast"; 8 | import parser from "ua-parser-js"; 9 | 10 | type Props = { 11 | _id: string; 12 | userAgent: string; 13 | createdAt: string; 14 | isCurrent: boolean; 15 | isLastItem: boolean; 16 | }; 17 | 18 | const Device = ({ 19 | _id, 20 | userAgent, 21 | createdAt, 22 | isCurrent, 23 | isLastItem, 24 | }: Props) => { 25 | const queryClient = useQueryClient(); 26 | 27 | const ua = parser(userAgent); 28 | const date = new Date(createdAt).toLocaleDateString("id-ID", { 29 | day: "numeric", 30 | month: "short", 31 | year: "numeric", 32 | }); 33 | 34 | const { isLoading, mutate } = useMutation({ 35 | mutationFn: logoutDevices, 36 | onSuccess: () => { 37 | queryClient.setQueryData(["devices"], (oldData: any) => { 38 | return oldData.filter((device: any) => device._id !== _id); 39 | }); 40 | toast.success( 41 | `Perangkat ${ua.device.model} berhasil dihapus, proses ini setidaknya membutuhkan waktu 15 menit` 42 | ); 43 | }, 44 | }); 45 | 46 | const handleDeleteDevice = (id: string) => { 47 | mutate([id]); 48 | }; 49 | 50 | return ( 51 |
    59 |
    60 |
    61 | {ua.device.type === "mobile" ? ( 62 | 63 | ) : ( 64 | 65 | )} 66 |
    67 |
    68 | 69 | {ua.device.model || ua.os.name} 70 | {isCurrent && ( 71 | 72 | Sekarang 73 | 74 | )} 75 | 76 | {ua.browser.name} 77 | Login pada : {date} 78 |
    79 |
    80 | {!isCurrent && ( 81 | handleDeleteDevice(_id)} 83 | styleButton="danger" 84 | className={classNames( 85 | "!font-bold !px-3 !py-2 lg:invisible lg:group-hover:visible", 86 | { "lg:!visible": isLoading } 87 | )} 88 | width="fit" 89 | title="Hapus" 90 | isLoading={isLoading} 91 | onLoadingText="Menghapus" 92 | background={false} 93 | /> 94 | )} 95 |
    96 | ); 97 | }; 98 | 99 | export default Device; 100 | -------------------------------------------------------------------------------- /src/app/globals.scss: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | 6 | /* create custom style for Tooltop recharts component */ 7 | // .recharts-tooltip-wrapper{ 8 | // outline: none; 9 | // padding: 2px; 10 | // background-color: #fff; 11 | // border-radius: 10px; 12 | // .recharts-default-tooltip{ 13 | // border-radius: 10px !important; 14 | // border: 1px solid blue !important; 15 | // } 16 | 17 | // } 18 | 19 | *{ 20 | outline: none; 21 | -webkit-tap-highlight-color: rgba(0,0,0,0); 22 | -webkit-tap-highlight-color: transparent; 23 | } 24 | 25 | // Remove the spin button from number input 26 | /* Chrome, Safari, Edge, Opera */ 27 | input::-webkit-outer-spin-button, 28 | input::-webkit-inner-spin-button { 29 | -webkit-appearance: none; 30 | margin: 0; 31 | } 32 | /* Firefox */ 33 | input[type=number] { 34 | -moz-appearance: textfield; 35 | } 36 | 37 | .recharts-cartesian-axis-tick text tspan { 38 | @apply dark:text-slate-400; 39 | } 40 | 41 | // this line bellow is for the InputWithLabel component, because the module.scss file is not working with the darkmode 42 | 43 | .input-with-label-wrapper{ 44 | position: relative; 45 | 46 | label { 47 | @apply text-slate-500 dark:text-slate-300; 48 | position: absolute; 49 | left: 10px; 50 | top: 27px; 51 | transform: translateY(-50%); 52 | padding: 0 5px; 53 | transition: all 0.2s ease-in-out; 54 | } 55 | 56 | 57 | input, textarea{ 58 | @apply bg-gray-100 text-slate-800 dark:text-slate-100 dark:bg-slate-500 ; 59 | transition: all 0.2s ease-in-out; 60 | &:focus + label { 61 | @apply bg-white dark:bg-slate-600; 62 | top: 0 !important; 63 | left: 20px; 64 | } 65 | 66 | &:not(:placeholder-shown) + label { 67 | @apply bg-white dark:bg-slate-600; 68 | top: 0 !important; 69 | left: 20px; 70 | 71 | } 72 | 73 | &::placeholder { 74 | color: transparent; 75 | } 76 | 77 | &:focus::placeholder { 78 | @apply text-gray-400; 79 | transition-delay: 100ms; 80 | } 81 | 82 | &:not(:placeholder-shown){ 83 | @apply bg-transparent; 84 | @apply ring-1 ring-gray-300 invalid:ring-pink-400; 85 | } 86 | 87 | 88 | &:focus{ 89 | @apply bg-transparent ring-2 ring-blue-400; 90 | } 91 | 92 | &:focus:not(:placeholder-shown){ 93 | @apply ring-2 ring-blue-400 ; 94 | } 95 | 96 | &:not(:placeholder-shown) ~ .toggleHide { 97 | display: block; 98 | } 99 | } 100 | 101 | .toggleHide{ 102 | @apply text-gray-500; 103 | display: none; 104 | position: absolute; 105 | right: 20px; 106 | top: 50%; 107 | transform: translateY(-50%); 108 | cursor: pointer; 109 | } 110 | 111 | } 112 | 113 | .hero-image{ 114 | transform: scaleX(1) scaleY(1) scaleZ(1) rotateX(-15deg) rotateY(32deg) rotateZ(1deg) translateX(0px) translateY(0px) translateZ(0px) skewX(0deg) skewY(0deg); 115 | border-radius: 10px; 116 | overflow: hidden; 117 | } 118 | 119 | .hero-image:nth-child(2){ 120 | transform: scaleX(1) scaleY(1) scaleZ(1) rotateX(-15deg) rotateY(32deg) rotateZ(1deg) translateX(0px) translateY(0px) translateZ(0px) skewX(0deg) skewY(0deg); 121 | border-radius: 10px; 122 | overflow: hidden; 123 | position: absolute; 124 | top: 100px; 125 | left: 80px; 126 | 127 | @media screen and (max-width: 768px) { 128 | top: 50px; 129 | left: 20px; 130 | } 131 | } 132 | 133 | -------------------------------------------------------------------------------- /src/components/WalletCard/WalletCard.tsx: -------------------------------------------------------------------------------- 1 | import { QueryKey } from "@/types/QueryKey"; 2 | import { WalletData } from "@/types/Wallet"; 3 | import { updateWalletColor } from "@/utils/api/wallet"; 4 | import { currencyFormat } from "@/utils/currencyFormat"; 5 | import { useMutation, useQueryClient } from "@tanstack/react-query"; 6 | import classNames from "classnames"; 7 | import Link from "next/link"; 8 | import { useState } from "react"; 9 | import { WalletColor, walletColors } from "./constants"; 10 | import WalletCardDropdown from "./WalletCardDropdown"; 11 | 12 | type Props = { 13 | id: string; 14 | isDefault?: boolean; 15 | initColorKey: WalletColor; 16 | name: string; 17 | balance: number; 18 | link?: string; 19 | isCredit: boolean; 20 | demoMode?: boolean; 21 | }; 22 | 23 | const WalletCard = ({ 24 | id, 25 | isDefault = true, 26 | initColorKey, 27 | name, 28 | balance, 29 | isCredit, 30 | demoMode = false, 31 | }: Props) => { 32 | const [colorKey, setColorKey] = useState(initColorKey); 33 | 34 | const queryClient = useQueryClient(); 35 | const colorMutation = useMutation({ 36 | mutationFn: updateWalletColor, 37 | onSuccess: (data) => { 38 | // update colorKey in cache 39 | queryClient.setQueryData([QueryKey.WALLETS], (oldData: any) => { 40 | return oldData.map((wallet: WalletData) => { 41 | if (wallet._id === id) { 42 | return { ...wallet, color: data.color }; 43 | } 44 | return wallet; 45 | }); 46 | }); 47 | 48 | // update single wallet color in cache 49 | if (queryClient.getQueryData([QueryKey.WALLETS, id])) { 50 | queryClient.setQueryData([QueryKey.WALLETS, id], (oldData: any) => { 51 | return { ...oldData, color: data.color }; 52 | }); 53 | } 54 | }, 55 | onError: () => { 56 | // reset colorKey to initColorKey 57 | setColorKey(initColorKey); 58 | }, 59 | }); 60 | 61 | const handleUpdateColor = () => { 62 | // if colorKey is not changed, do nothing 63 | if (colorKey === initColorKey) return; 64 | colorMutation.mutate({ id, color: colorKey }); 65 | }; 66 | 67 | return ( 68 |
    77 |
    78 | 85 | {name} 86 | 87 | 93 |
    94 |
    95 |
    96 |
    Saldo {isCredit && "minus"}
    97 |
    98 | {isCredit && "-"} 99 | {currencyFormat(balance)} 100 |
    101 |
    102 |
    103 |
    104 | ); 105 | }; 106 | 107 | export default WalletCard; 108 | -------------------------------------------------------------------------------- /src/stores/store.ts: -------------------------------------------------------------------------------- 1 | import { Theme, ThemeState } from "@/types/Theme"; 2 | import { User } from "@/types/User"; 3 | import { create } from "zustand"; 4 | import { persist } from "zustand/middleware"; 5 | 6 | interface Store { 7 | user: User | null; 8 | colorTheme: ThemeState; 9 | accessToken: string | null; 10 | deleteWalletId: string | null; 11 | transactionDetailState: { 12 | transactionId: string | null; 13 | setTransactionId: (id: string | null) => void; 14 | }; 15 | addTransactionState: { 16 | walletId: string | null; 17 | setWalletId: (id: string | null) => void; 18 | isOpen: boolean; 19 | setOpen: (isOpen: boolean) => void; 20 | }; 21 | transferBalanceState: { 22 | sourceWalletId: string | null; 23 | setSourceWalletId: (id: string | null) => void; 24 | destinationWalletId: string | null; 25 | setDestinationWalletId: (id: string | null) => void; 26 | isOpen: boolean; 27 | setOpen: (isOpen: boolean) => void; 28 | }; 29 | setDeleteWalletId: (id: string | null) => void; 30 | setToken: (token: string) => void; 31 | setUser: (user: User | null) => void; 32 | setColorTheme: (theme: ThemeState) => void; 33 | } 34 | 35 | const useStore = create()( 36 | persist( 37 | (set, get) => ({ 38 | user: null, 39 | colorTheme: Theme.Light, 40 | accessToken: null, 41 | deleteWalletId: null, 42 | transactionDetailState: { 43 | transactionId: null, 44 | setTransactionId: (id: string | null) => 45 | set({ 46 | transactionDetailState: { 47 | ...get().transactionDetailState, 48 | transactionId: id, 49 | }, 50 | }), 51 | }, 52 | addTransactionState: { 53 | walletId: null, 54 | setWalletId: (id: string | null) => 55 | set({ 56 | addTransactionState: { ...get().addTransactionState, walletId: id }, 57 | }), 58 | isOpen: false, 59 | setOpen: (isOpen: boolean) => 60 | set({ 61 | addTransactionState: { 62 | ...get().addTransactionState, 63 | isOpen: isOpen, 64 | walletId: isOpen ? get().addTransactionState.walletId : null, 65 | }, 66 | }), 67 | }, 68 | transferBalanceState: { 69 | sourceWalletId: null, 70 | setSourceWalletId: (id: string | null) => 71 | set({ 72 | transferBalanceState: { 73 | ...get().transferBalanceState, 74 | sourceWalletId: id, 75 | }, 76 | }), 77 | destinationWalletId: null, 78 | setDestinationWalletId: (id: string | null) => 79 | set({ 80 | transferBalanceState: { 81 | ...get().transferBalanceState, 82 | destinationWalletId: id, 83 | }, 84 | }), 85 | isOpen: false, 86 | setOpen: (isOpen: boolean) => 87 | set({ 88 | transferBalanceState: { ...get().transferBalanceState, isOpen }, 89 | }), 90 | }, 91 | setDeleteWalletId: (id: string | null) => set({ deleteWalletId: id }), 92 | setToken: (token: string) => set({ accessToken: token }), 93 | setUser: (user) => set({ user }), 94 | setColorTheme: (theme: ThemeState) => set({ colorTheme: theme }), 95 | }), 96 | { 97 | name: "user-data", 98 | partialize(state) { 99 | return { 100 | colorTheme: state.colorTheme, 101 | }; 102 | }, 103 | } 104 | ) 105 | ); 106 | 107 | export default useStore; 108 | -------------------------------------------------------------------------------- /src/components/WalletCard/WalletOption.tsx: -------------------------------------------------------------------------------- 1 | import IconButton from "../dls/IconButton"; 2 | import ArrowsIcon from "../icons/ArrowsIcon"; 3 | import ElipsisVerticalIcon from "../icons/ElipsisVerticalIcon"; 4 | import PlusIcon from "../icons/PlusIcon"; 5 | import { useCallback } from "react"; 6 | import useStore from "../../stores/store"; 7 | import { WalletData } from "@/types/Wallet"; 8 | import DropdownMenu from "../dls/Dropdown/DropdownMenu"; 9 | import { DropdownItem } from "../dls/Dropdown/DropdownItem"; 10 | import PencilIcon from "../icons/PencilIcon"; 11 | import TrashIcon from "../icons/TrashIcon"; 12 | import XmarkIcon from "../icons/XmarkIcon"; 13 | import CheckIcon from "../icons/CheckIcon"; 14 | 15 | type Props = { 16 | walletData: WalletData; 17 | state: any; 18 | }; 19 | 20 | const WalletOption = ({ walletData, state }: Props) => { 21 | const { setSourceWalletId, setOpen, setDeleteWalletId, setWalletId } = 22 | useStore((state) => ({ 23 | setSourceWalletId: state.transferBalanceState.setSourceWalletId, 24 | setOpen: state.transferBalanceState.setOpen, 25 | setDeleteWalletId: state.setDeleteWalletId, 26 | setWalletId: state.addTransactionState.setWalletId, 27 | })); 28 | 29 | const handleTransferBalance = useCallback(() => { 30 | setOpen(true); 31 | setSourceWalletId(walletData._id); 32 | // eslint-disable-next-line react-hooks/exhaustive-deps 33 | }, []); 34 | 35 | if (state.edit) { 36 | return ( 37 |
    38 | state.setEdit(false)} 40 | shape="circle" 41 | className="bg-white/40 text-slate-50 !p-2" 42 | > 43 | 44 | 45 | 51 | 52 | 53 |
    54 | ); 55 | } 56 | 57 | return ( 58 |
    59 | setWalletId(walletData._id)} 61 | shape="circle" 62 | className="bg-white/40 text-slate-50 !p-2" 63 | > 64 | 65 | 66 | 71 | 72 | 73 | 78 | 82 | 83 | } 84 | > 85 | state.setEdit(true)} 88 | icon={} 89 | > 90 | Edit Dompet 91 | 92 | } 96 | onClick={() => setDeleteWalletId(walletData._id)} 97 | > 98 | Hapus dompet 99 | 100 | 101 |
    102 | ); 103 | }; 104 | 105 | export default WalletOption; 106 | --------------------------------------------------------------------------------