develop #3
@ -1,3 +1,95 @@
|
|||||||
{
|
{
|
||||||
"extends": ["next/core-web-vitals", "next/typescript"]
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": [
|
||||||
|
"src/**/*-vm.ts"
|
||||||
|
],
|
||||||
|
"rules": {
|
||||||
|
"react-hooks/rules-of-hooks": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"plugins": [
|
||||||
|
"prettier"
|
||||||
|
],
|
||||||
|
"settings": {
|
||||||
|
"react": {
|
||||||
|
"version": "detect"
|
||||||
|
},
|
||||||
|
"import/resolver": {
|
||||||
|
"alias": {
|
||||||
|
"map": [
|
||||||
|
[
|
||||||
|
"~",
|
||||||
|
"./src"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"extensions": [
|
||||||
|
".js",
|
||||||
|
".ts",
|
||||||
|
".tsx",
|
||||||
|
".d.ts",
|
||||||
|
".test.ts",
|
||||||
|
".json"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"no-use-before-define": "off",
|
||||||
|
"class-methods-use-this": "off",
|
||||||
|
"import/prefer-default-export": "off",
|
||||||
|
"import/no-cycle": "off",
|
||||||
|
"no-promise-executor-return": "off",
|
||||||
|
"@typescript-eslint/no-shadow": "off",
|
||||||
|
"react/require-default-props": "off",
|
||||||
|
"no-shadow": "off",
|
||||||
|
"prettier/prettier": [
|
||||||
|
"warn",
|
||||||
|
{
|
||||||
|
"printWidth": 80,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"endOfLine":"auto",
|
||||||
|
"useTabs": false,
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": false,
|
||||||
|
"quoteProps": "as-needed",
|
||||||
|
"jsxSingleQuote": false,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"arrowParens": "always"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"import/extensions": [
|
||||||
|
"error",
|
||||||
|
"ignorePackages",
|
||||||
|
{
|
||||||
|
"js": "never",
|
||||||
|
"jsx": "never",
|
||||||
|
"ts": "never",
|
||||||
|
"tsx": "never"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"react/jsx-filename-extension": [
|
||||||
|
1,
|
||||||
|
{
|
||||||
|
"extensions": [
|
||||||
|
".ts",
|
||||||
|
".tsx"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"import/no-extraneous-dependencies": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"devDependencies": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"extends": [
|
||||||
|
"airbnb",
|
||||||
|
"next/core-web-vitals",
|
||||||
|
"next/typescript",
|
||||||
|
"prettier"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
2
.gitignore
vendored
@ -9,7 +9,7 @@
|
|||||||
!.yarn/plugins
|
!.yarn/plugins
|
||||||
!.yarn/releases
|
!.yarn/releases
|
||||||
!.yarn/versions
|
!.yarn/versions
|
||||||
|
.vscode
|
||||||
# testing
|
# testing
|
||||||
/coverage
|
/coverage
|
||||||
|
|
||||||
|
70
Dockerfile
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
# syntax=docker.io/docker/dockerfile:1
|
||||||
|
|
||||||
|
FROM node:18-alpine AS base
|
||||||
|
|
||||||
|
# Install dependencies only when needed
|
||||||
|
FROM base AS deps
|
||||||
|
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
||||||
|
RUN apk add --no-cache libc6-compat
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install dependencies based on the preferred package manager
|
||||||
|
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./
|
||||||
|
RUN \
|
||||||
|
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
|
||||||
|
elif [ -f package-lock.json ]; then npm ci; \
|
||||||
|
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
|
||||||
|
else echo "Lockfile not found." && exit 1; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Rebuild the source code only when needed
|
||||||
|
FROM base AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Next.js collects completely anonymous telemetry data about general usage.
|
||||||
|
# Learn more here: https://nextjs.org/telemetry
|
||||||
|
# Uncomment the following line in case you want to disable telemetry during the build.
|
||||||
|
# ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
|
RUN \
|
||||||
|
if [ -f yarn.lock ]; then yarn run build; \
|
||||||
|
elif [ -f package-lock.json ]; then npm run build; \
|
||||||
|
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
|
||||||
|
else echo "Lockfile not found." && exit 1; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Production image, copy all the files and run next
|
||||||
|
FROM base AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
# Uncomment the following line in case you want to disable telemetry during runtime.
|
||||||
|
# ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
|
||||||
|
# Set the correct permission for prerender cache
|
||||||
|
RUN mkdir .next
|
||||||
|
RUN chown nextjs:nodejs .next
|
||||||
|
|
||||||
|
# Automatically leverage output traces to reduce image size
|
||||||
|
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
ENV PORT=3000
|
||||||
|
|
||||||
|
# server.js is created by next build from the standalone output
|
||||||
|
# https://nextjs.org/docs/pages/api-reference/next-config-js/output
|
||||||
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
CMD ["node", "server.js"]
|
20
components.json
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": true,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "tailwind.config.ts",
|
||||||
|
"css": "src/app/globals.css",
|
||||||
|
"baseColor": "zinc",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/app/components",
|
||||||
|
"utils": "@/bootstrap/helpers/lib/ui-utils",
|
||||||
|
"ui": "@/app/components",
|
||||||
|
"lib": "@/bootstrap/helpers/lib",
|
||||||
|
"hooks": "@/bootstrap/helpers/hooks"
|
||||||
|
}
|
||||||
|
}
|
27
docker-compose.yml
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres
|
||||||
|
restart: always
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
environment:
|
||||||
|
POSTGRES_PASSWORD: example
|
||||||
|
POSTGRES_USER: admin
|
||||||
|
POSTGRES_DB: nextbp
|
||||||
|
|
||||||
|
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- 3000:3000
|
||||||
|
environment:
|
||||||
|
POSTGRES_HOST: db
|
||||||
|
POSTGRES_PORT: 5432
|
||||||
|
POSTGRES_USER: admin
|
||||||
|
POSTGRES_PASS: example
|
||||||
|
POSTGRES_DB: nextbp
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
|
@ -2,6 +2,7 @@ import type { NextConfig } from "next";
|
|||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
/* config options here */
|
||||||
|
output: "standalone",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
5724
package-lock.json
generated
52
package.json
@ -5,22 +5,60 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --turbopack",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start --port 4000",
|
||||||
"lint": "next lint"
|
"lint": "next lint --fix",
|
||||||
|
"test": "vitest",
|
||||||
|
"seed": "node -r dotenv/config ./src/bootstrap/boundaries/db/seed.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@heroicons/react": "^2.1.5",
|
||||||
|
"@radix-ui/react-icons": "^1.3.1",
|
||||||
|
"@radix-ui/react-slot": "^1.1.0",
|
||||||
|
"accept-language": "^3.0.20",
|
||||||
|
"class-variance-authority": "^0.7.0",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"fp-ts": "^2.16.9",
|
||||||
|
"i18next": "^23.16.4",
|
||||||
|
"i18next-browser-languagedetector": "^8.0.0",
|
||||||
|
"i18next-resources-to-backend": "^1.2.1",
|
||||||
|
"lucide-react": "^0.454.0",
|
||||||
|
"next": "15.0.2",
|
||||||
|
"next-i18n-router": "^5.5.1",
|
||||||
|
"postgres": "^3.4.5",
|
||||||
"react": "19.0.0-rc-69d4b800-20241021",
|
"react": "19.0.0-rc-69d4b800-20241021",
|
||||||
|
"react-cookie": "^7.2.2",
|
||||||
"react-dom": "19.0.0-rc-69d4b800-20241021",
|
"react-dom": "19.0.0-rc-69d4b800-20241021",
|
||||||
"next": "15.0.1"
|
"react-i18next": "^15.1.0",
|
||||||
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"server-only": "^0.0.1",
|
||||||
|
"tailwind-merge": "^2.5.4",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"tsyringe": "^4.8.0",
|
||||||
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5",
|
"@faker-js/faker": "^9.1.0",
|
||||||
|
"@testing-library/dom": "^10.4.0",
|
||||||
|
"@testing-library/react": "^16.0.1",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^18",
|
"@types/react": "^18",
|
||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "^18",
|
||||||
"postcss": "^8",
|
"@vitejs/plugin-react": "^4.3.3",
|
||||||
"tailwindcss": "^3.4.1",
|
"bcrypt": "^5.1.1",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
"eslint": "^8",
|
"eslint": "^8",
|
||||||
"eslint-config-next": "15.0.1"
|
"eslint-config-airbnb": "^19.0.4",
|
||||||
|
"eslint-config-next": "15.0.1",
|
||||||
|
"eslint-config-prettier": "^9.1.0",
|
||||||
|
"eslint-import-resolver-alias": "^1.1.2",
|
||||||
|
"eslint-import-resolver-typescript": "^3.6.3",
|
||||||
|
"eslint-plugin-prettier": "^5.2.1",
|
||||||
|
"jsdom": "^25.0.1",
|
||||||
|
"moq.ts": "^10.0.8",
|
||||||
|
"postcss": "^8",
|
||||||
|
"prettier": "^3.3.3",
|
||||||
|
"tailwindcss": "^3.4.1",
|
||||||
|
"typescript": "^5",
|
||||||
|
"vitest": "^2.1.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
BIN
public/customers/amy-burns.png
Normal file
After Width: | Height: | Size: 7.8 KiB |
BIN
public/customers/balazs-orban.png
Normal file
After Width: | Height: | Size: 7.8 KiB |
BIN
public/customers/delba-de-oliveira.png
Normal file
After Width: | Height: | Size: 5.7 KiB |
BIN
public/customers/emil-kowalski.png
Normal file
After Width: | Height: | Size: 4.0 KiB |
BIN
public/customers/evil-rabbit.png
Normal file
After Width: | Height: | Size: 1019 B |
BIN
public/customers/guillermo-rauch.png
Normal file
After Width: | Height: | Size: 6.1 KiB |
BIN
public/customers/hector-simpson.png
Normal file
After Width: | Height: | Size: 5.5 KiB |
BIN
public/customers/jared-palmer.png
Normal file
After Width: | Height: | Size: 6.3 KiB |
BIN
public/customers/lee-robinson.png
Normal file
After Width: | Height: | Size: 5.5 KiB |
BIN
public/customers/michael-novotny.png
Normal file
After Width: | Height: | Size: 9.7 KiB |
BIN
public/customers/steph-dietz.png
Normal file
After Width: | Height: | Size: 7.0 KiB |
BIN
public/customers/steven-tey.png
Normal file
After Width: | Height: | Size: 7.2 KiB |
@ -1 +1 @@
|
|||||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clipRule="evenodd" fill="#666" fillRule="evenodd"/></svg>
|
Before Width: | Height: | Size: 391 B After Width: | Height: | Size: 389 B |
@ -1 +1 @@
|
|||||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fillRule="evenodd" clipRule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.0 KiB |
@ -1 +1 @@
|
|||||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fillRule="evenodd" clipRule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
Before Width: | Height: | Size: 385 B After Width: | Height: | Size: 383 B |
@ -0,0 +1,13 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Button from "@/app/components/button/button";
|
||||||
|
import CreateRandomInvoiceButtonVM from "@/app/[lang]/dashboard/vm/create-random-invoice-button-vm";
|
||||||
|
import { useDI } from "@/bootstrap/di/di-context";
|
||||||
|
import { useRef } from "react";
|
||||||
|
|
||||||
|
export default function CreateRandomInvoiceContainer() {
|
||||||
|
const di = useDI();
|
||||||
|
const vm = useRef(di.resolve(CreateRandomInvoiceButtonVM));
|
||||||
|
|
||||||
|
return <Button vm={vm.current} />;
|
||||||
|
}
|
@ -0,0 +1,28 @@
|
|||||||
|
import { DocumentIcon } from "@/app/components/icons/document";
|
||||||
|
import HomeIcon from "@/app/components/icons/home";
|
||||||
|
import { UserIcon } from "@/app/components/icons/user";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
|
type LinkItem = {
|
||||||
|
name: string;
|
||||||
|
href: string;
|
||||||
|
icon: (props: { className?: string }) => JSX.Element;
|
||||||
|
};
|
||||||
|
export default function navLinkPersonalVM() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
// Map of links to display in the side navigation.
|
||||||
|
// Depending on the size of the application, this would be stored in a database.
|
||||||
|
const links: LinkItem[] = [
|
||||||
|
{ name: "Home", href: "/dashboard", icon: HomeIcon },
|
||||||
|
{
|
||||||
|
name: "Invoices",
|
||||||
|
href: "/dashboard/invoices",
|
||||||
|
icon: DocumentIcon,
|
||||||
|
},
|
||||||
|
{ name: "Customers", href: "/dashboard/customers", icon: UserIcon },
|
||||||
|
];
|
||||||
|
return {
|
||||||
|
links,
|
||||||
|
isLinkActive: (link: LinkItem) => pathname === link.href,
|
||||||
|
};
|
||||||
|
}
|
@ -0,0 +1,31 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import navLinkPersonalVM from "@/app/[lang]/dashboard/components/client/nav-links/nav-link-vm";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function NavLinks() {
|
||||||
|
const { links, isLinkActive } = navLinkPersonalVM();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{links.map((link) => {
|
||||||
|
const LinkIcon = link.icon;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={link.name}
|
||||||
|
href={link.href}
|
||||||
|
className={clsx(
|
||||||
|
"flex h-[48px] grow items-center justify-center gap-2 rounded-md bg-gray-50 p-3 text-sm font-medium hover:bg-sky-100 hover:text-blue-600 md:flex-none md:justify-start md:p-2 md:px-3",
|
||||||
|
{
|
||||||
|
"bg-sky-100 text-blue-600": isLinkActive(link),
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<LinkIcon className="w-6" />
|
||||||
|
<p className="hidden md:block">{link.name}</p>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,21 @@
|
|||||||
|
import {
|
||||||
|
BanknotesIcon,
|
||||||
|
ClockIcon,
|
||||||
|
UserGroupIcon,
|
||||||
|
InboxIcon,
|
||||||
|
} from "@heroicons/react/24/outline";
|
||||||
|
|
||||||
|
export default function cardController(props: {
|
||||||
|
type: "invoices" | "customers" | "pending" | "collected";
|
||||||
|
}) {
|
||||||
|
const { type } = props;
|
||||||
|
const iconMap = {
|
||||||
|
collected: BanknotesIcon,
|
||||||
|
customers: UserGroupIcon,
|
||||||
|
pending: ClockIcon,
|
||||||
|
invoices: InboxIcon,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
Icon: iconMap[type],
|
||||||
|
};
|
||||||
|
}
|
25
src/app/[lang]/dashboard/components/server/card/card.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import cardController from "@/app/[lang]/dashboard/components/server/card/card-controller";
|
||||||
|
|
||||||
|
export function Card({
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
type,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
value: number | string;
|
||||||
|
type: "invoices" | "customers" | "pending" | "collected";
|
||||||
|
}) {
|
||||||
|
const { Icon } = cardController({ type });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl bg-gray-50 p-2 shadow-sm">
|
||||||
|
<div className="flex p-4">
|
||||||
|
{Icon ? <Icon className="h-5 w-5 text-gray-700" /> : null}
|
||||||
|
<h3 className="ml-2 text-sm font-medium">{title}</h3>
|
||||||
|
</div>
|
||||||
|
<p className="rounded-xl bg-white px-4 py-8 text-center text-2xl">
|
||||||
|
{value}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,7 @@
|
|||||||
|
import fetchSummaryInfoUsecase from "@/feature/core/summary-info/domain/usecase/fetch-summary-info-usecase";
|
||||||
|
import { connection } from "next/server";
|
||||||
|
|
||||||
|
export default function cardsController() {
|
||||||
|
connection();
|
||||||
|
return fetchSummaryInfoUsecase();
|
||||||
|
}
|
16
src/app/[lang]/dashboard/components/server/cards/cards.tsx
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { Card } from "@/app/[lang]/dashboard/components/server/card/card";
|
||||||
|
import cardsController from "@/app/[lang]/dashboard/components/server/cards/cards-controller";
|
||||||
|
|
||||||
|
export default async function CardWrapper() {
|
||||||
|
const { customersNumber, invoicesNumber, invoicesSummary } =
|
||||||
|
await cardsController();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card title="Collected" value={invoicesSummary.paid} type="collected" />
|
||||||
|
<Card title="Pending" value={invoicesSummary.pending} type="pending" />
|
||||||
|
<Card title="Total Invoices" value={invoicesNumber} type="invoices" />
|
||||||
|
<Card title="Total Customers" value={customersNumber} type="customers" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,7 @@
|
|||||||
|
import fetchCustomerInvoicesUsecase from "@/feature/core/customer-invoice/domain/usecase/fetch-customer-invoices-usecase";
|
||||||
|
import { connection } from "next/server";
|
||||||
|
|
||||||
|
export default function latestInvoicesController() {
|
||||||
|
connection();
|
||||||
|
return fetchCustomerInvoicesUsecase();
|
||||||
|
}
|
@ -0,0 +1,55 @@
|
|||||||
|
import CreateRandomInvoiceContainer from "@/app/[lang]/dashboard/components/client/create-random-invoice/create-random-invoice";
|
||||||
|
import latestInvoicesController from "@/app/[lang]/dashboard/components/server/latest-invoices/latest-invoices-controller";
|
||||||
|
import { ArrowPathIcon } from "@heroicons/react/24/outline";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { isLeft } from "fp-ts/lib/Either";
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
|
export default async function LatestInvoices() {
|
||||||
|
const latestInvoices = await latestInvoicesController();
|
||||||
|
|
||||||
|
if (isLeft(latestInvoices)) return <div>Error</div>;
|
||||||
|
|
||||||
|
const invoices = latestInvoices.right.map((invoice, i) => (
|
||||||
|
<div
|
||||||
|
key={invoice.id}
|
||||||
|
className={clsx("flex flex-row items-center justify-between py-4", {
|
||||||
|
"border-t": i !== 0,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Image
|
||||||
|
src={invoice.customerImageUrl}
|
||||||
|
alt={`${invoice.customerName}'s profile picture`}
|
||||||
|
className="mr-4 rounded-full"
|
||||||
|
width={32}
|
||||||
|
height={32}
|
||||||
|
/>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-semibold md:text-base">
|
||||||
|
{invoice.customerName}
|
||||||
|
</p>
|
||||||
|
<p className="hidden text-sm text-gray-500 sm:block">
|
||||||
|
{invoice.customerEmail}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="truncate text-sm font-medium md:text-base">
|
||||||
|
{invoice.invoicesAmount}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
return (
|
||||||
|
<div className="flex w-full flex-col md:col-span-4">
|
||||||
|
<h2 className="mb-4 text-xl md:text-2xl">Latest Invoices</h2>
|
||||||
|
<div className="flex grow flex-col max-h-[66.5vh] justify-between rounded-xl bg-gray-50 p-4">
|
||||||
|
<div className="bg-white px-6 h-full overflow-y-auto">{invoices}</div>
|
||||||
|
<div className="flex items-end mt-auto pb-2 pt-6">
|
||||||
|
<ArrowPathIcon className="h-5 w-5 text-gray-500" />
|
||||||
|
<h3 className="ml-2 text-sm text-gray-500 ">Updated just now</h3>
|
||||||
|
</div>
|
||||||
|
<CreateRandomInvoiceContainer />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,30 @@
|
|||||||
|
import Revenue from "@/feature/core/revenue/domain/entity/revenue";
|
||||||
|
import fetchRevenuesUsecase from "@/feature/core/revenue/domain/usecase/fetch-revenues-usecase";
|
||||||
|
|
||||||
|
export default async function revenueChartController() {
|
||||||
|
const revenue = await fetchRevenuesUsecase();
|
||||||
|
const chartHeight = 350;
|
||||||
|
|
||||||
|
const { yAxisLabels, topLabel } = generateYAxis(revenue);
|
||||||
|
|
||||||
|
return {
|
||||||
|
revenue,
|
||||||
|
chartHeight,
|
||||||
|
yAxisLabels,
|
||||||
|
topLabel,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateYAxis(revenue: Revenue[]) {
|
||||||
|
// Calculate what labels we need to display on the y-axis
|
||||||
|
// based on highest record and in 1000s
|
||||||
|
const yAxisLabels = [];
|
||||||
|
const highestRecord = Math.max(...revenue.map((month) => month.revenue));
|
||||||
|
const topLabel = Math.ceil(highestRecord / 1000) * 1000;
|
||||||
|
|
||||||
|
for (let i = topLabel; i >= 0; i -= 1000) {
|
||||||
|
yAxisLabels.push(`$${i / 1000}K`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { yAxisLabels, topLabel };
|
||||||
|
}
|
@ -0,0 +1,47 @@
|
|||||||
|
import revenueChartController from "@/app/[lang]/dashboard/components/server/revenue-chart/revenue-chart-controller";
|
||||||
|
import { CalendarIcon } from "@heroicons/react/24/outline";
|
||||||
|
|
||||||
|
export default async function RevenueChart() {
|
||||||
|
const { chartHeight, revenue, topLabel, yAxisLabels } =
|
||||||
|
await revenueChartController();
|
||||||
|
|
||||||
|
if (!revenue || revenue.length === 0) {
|
||||||
|
return <p className="mt-4 text-gray-400">No data available.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full md:col-span-4">
|
||||||
|
<h2 className={` mb-4 text-xl md:text-2xl`}>Recent Revenue</h2>
|
||||||
|
<div className="rounded-xl bg-gray-50 p-4">
|
||||||
|
<div className="sm:grid-cols-13 mt-0 grid grid-cols-12 items-end gap-2 rounded-md bg-white p-4 md:gap-4">
|
||||||
|
<div
|
||||||
|
className="mb-6 hidden flex-col justify-between text-sm text-gray-400 sm:flex"
|
||||||
|
style={{ height: `${chartHeight}px` }}
|
||||||
|
>
|
||||||
|
{yAxisLabels.map((label) => (
|
||||||
|
<p key={label}>{label}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{revenue.map((month) => (
|
||||||
|
<div key={month.month} className="flex flex-col items-center gap-2">
|
||||||
|
<div
|
||||||
|
className="w-full rounded-md bg-blue-300"
|
||||||
|
style={{
|
||||||
|
height: `${(chartHeight / topLabel) * month.revenue}px`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<p className="-rotate-90 text-sm text-gray-400 sm:rotate-0">
|
||||||
|
{month.month}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center pb-2 pt-6">
|
||||||
|
<CalendarIcon className="h-5 w-5 text-gray-500" />
|
||||||
|
<h3 className="ml-2 text-sm text-gray-500 ">Last 12 months</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
19
src/app/[lang]/dashboard/components/server/sidenav.tsx
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import NavLinks from "@/app/[lang]/dashboard/components/client/nav-links/nav-links";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function SideNav() {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col px-3 py-4 md:px-2">
|
||||||
|
<Link
|
||||||
|
className="mb-2 flex h-20 items-end justify-start rounded-md bg-blue-600 p-4 md:h-40"
|
||||||
|
href="/"
|
||||||
|
>
|
||||||
|
<div className="w-32 text-white md:w-40">Home</div>
|
||||||
|
</Link>
|
||||||
|
<div className="flex grow flex-row justify-between space-x-2 md:flex-col md:space-x-0 md:space-y-2">
|
||||||
|
<NavLinks />
|
||||||
|
<div className="hidden h-auto w-full grow rounded-md bg-gray-50 md:block" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,221 @@
|
|||||||
|
// Loading animation
|
||||||
|
const shimmer =
|
||||||
|
"before:absolute before:inset-0 before:-translate-x-full before:animate-[shimmer_2s_infinite] before:bg-gradient-to-r before:from-transparent before:via-white/60 before:to-transparent";
|
||||||
|
|
||||||
|
export function CardSkeleton() {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${shimmer} relative overflow-hidden rounded-xl bg-gray-100 p-2 shadow-sm`}
|
||||||
|
>
|
||||||
|
<div className="flex p-4">
|
||||||
|
<div className="h-5 w-5 rounded-md bg-gray-200" />
|
||||||
|
<div className="ml-2 h-6 w-16 rounded-md bg-gray-200 text-sm font-medium" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-center truncate rounded-xl bg-white px-4 py-8">
|
||||||
|
<div className="h-7 w-20 rounded-md bg-gray-200" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardsSkeleton() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<CardSkeleton />
|
||||||
|
<CardSkeleton />
|
||||||
|
<CardSkeleton />
|
||||||
|
<CardSkeleton />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RevenueChartSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className={`${shimmer} relative w-full overflow-hidden md:col-span-4`}>
|
||||||
|
<div className="mb-4 h-8 w-36 rounded-md bg-gray-100" />
|
||||||
|
<div className="rounded-xl bg-gray-100 p-4">
|
||||||
|
<div className="mt-0 grid h-[410px] grid-cols-12 items-end gap-2 rounded-md bg-white p-4 sm:grid-cols-13 md:gap-4" />
|
||||||
|
<div className="flex items-center pb-2 pt-6">
|
||||||
|
<div className="h-5 w-5 rounded-full bg-gray-200" />
|
||||||
|
<div className="ml-2 h-4 w-20 rounded-md bg-gray-200" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InvoiceSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-row items-center justify-between border-b border-gray-100 py-4">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="mr-2 h-8 w-8 rounded-full bg-gray-200" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="h-5 w-40 rounded-md bg-gray-200" />
|
||||||
|
<div className="mt-2 h-4 w-12 rounded-md bg-gray-200" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 h-4 w-12 rounded-md bg-gray-200" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LatestInvoicesSkeleton() {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${shimmer} relative flex w-full flex-col overflow-hidden md:col-span-4`}
|
||||||
|
>
|
||||||
|
<div className="mb-4 h-8 w-36 rounded-md bg-gray-100" />
|
||||||
|
<div className="flex grow flex-col justify-between rounded-xl bg-gray-100 p-4">
|
||||||
|
<div className="bg-white px-6">
|
||||||
|
<InvoiceSkeleton />
|
||||||
|
<InvoiceSkeleton />
|
||||||
|
<InvoiceSkeleton />
|
||||||
|
<InvoiceSkeleton />
|
||||||
|
<InvoiceSkeleton />
|
||||||
|
<div className="flex items-center pb-2 pt-6">
|
||||||
|
<div className="h-5 w-5 rounded-full bg-gray-200" />
|
||||||
|
<div className="ml-2 h-4 w-20 rounded-md bg-gray-200" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardSkeleton() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={`${shimmer} relative mb-4 h-8 w-36 overflow-hidden rounded-md bg-gray-100`}
|
||||||
|
/>
|
||||||
|
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<CardSkeleton />
|
||||||
|
<CardSkeleton />
|
||||||
|
<CardSkeleton />
|
||||||
|
<CardSkeleton />
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 grid grid-cols-1 gap-6 md:grid-cols-4 lg:grid-cols-8">
|
||||||
|
<RevenueChartSkeleton />
|
||||||
|
<LatestInvoicesSkeleton />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableRowSkeleton() {
|
||||||
|
return (
|
||||||
|
<tr className="w-full border-b border-gray-100 last-of-type:border-none [&:first-child>td:first-child]:rounded-tl-lg [&:first-child>td:last-child]:rounded-tr-lg [&:last-child>td:first-child]:rounded-bl-lg [&:last-child>td:last-child]:rounded-br-lg">
|
||||||
|
{/* Customer Name and Image */}
|
||||||
|
<td
|
||||||
|
aria-label="Customer name"
|
||||||
|
className="relative overflow-hidden whitespace-nowrap py-3 pl-6 pr-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-8 w-8 rounded-full bg-gray-100" />
|
||||||
|
<div className="h-6 w-24 rounded bg-gray-100" />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
{/* Email */}
|
||||||
|
<td aria-label="Email" className="whitespace-nowrap px-3 py-3">
|
||||||
|
<div className="h-6 w-32 rounded bg-gray-100" />
|
||||||
|
</td>
|
||||||
|
{/* Amount */}
|
||||||
|
<td aria-label="Amount" className="whitespace-nowrap px-3 py-3">
|
||||||
|
<div className="h-6 w-16 rounded bg-gray-100" />
|
||||||
|
</td>
|
||||||
|
{/* Date */}
|
||||||
|
<td aria-label="Date" className="whitespace-nowrap px-3 py-3">
|
||||||
|
<div className="h-6 w-16 rounded bg-gray-100" />
|
||||||
|
</td>
|
||||||
|
{/* Status */}
|
||||||
|
<td aria-label="Status" className="whitespace-nowrap px-3 py-3">
|
||||||
|
<div className="h-6 w-16 rounded bg-gray-100" />
|
||||||
|
</td>
|
||||||
|
{/* Actions */}
|
||||||
|
<td aria-label="Actions" className="whitespace-nowrap py-3 pl-6 pr-3">
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<div className="h-[38px] w-[38px] rounded bg-gray-100" />
|
||||||
|
<div className="h-[38px] w-[38px] rounded bg-gray-100" />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InvoicesMobileSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="mb-2 w-full rounded-md bg-white p-4">
|
||||||
|
<div className="flex items-center justify-between border-b border-gray-100 pb-8">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="mr-2 h-8 w-8 rounded-full bg-gray-100" />
|
||||||
|
<div className="h-6 w-16 rounded bg-gray-100" />
|
||||||
|
</div>
|
||||||
|
<div className="h-6 w-16 rounded bg-gray-100" />
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full items-center justify-between pt-4">
|
||||||
|
<div>
|
||||||
|
<div className="h-6 w-16 rounded bg-gray-100" />
|
||||||
|
<div className="mt-2 h-6 w-24 rounded bg-gray-100" />
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<div className="h-10 w-10 rounded bg-gray-100" />
|
||||||
|
<div className="h-10 w-10 rounded bg-gray-100" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InvoicesTableSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="mt-6 flow-root">
|
||||||
|
<div className="inline-block min-w-full align-middle">
|
||||||
|
<div className="rounded-lg bg-gray-50 p-2 md:pt-0">
|
||||||
|
<div className="md:hidden">
|
||||||
|
<InvoicesMobileSkeleton />
|
||||||
|
<InvoicesMobileSkeleton />
|
||||||
|
<InvoicesMobileSkeleton />
|
||||||
|
<InvoicesMobileSkeleton />
|
||||||
|
<InvoicesMobileSkeleton />
|
||||||
|
<InvoicesMobileSkeleton />
|
||||||
|
</div>
|
||||||
|
<table className="hidden min-w-full text-gray-900 md:table">
|
||||||
|
<thead className="rounded-lg text-left text-sm font-normal">
|
||||||
|
<tr>
|
||||||
|
<th scope="col" className="px-4 py-5 font-medium sm:pl-6">
|
||||||
|
Customer
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-3 py-5 font-medium">
|
||||||
|
Email
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-3 py-5 font-medium">
|
||||||
|
Amount
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-3 py-5 font-medium">
|
||||||
|
Date
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-3 py-5 font-medium">
|
||||||
|
Status
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
className="relative pb-4 pl-3 pr-6 pt-2 sm:pr-6"
|
||||||
|
>
|
||||||
|
<span className="sr-only">Edit</span>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white">
|
||||||
|
<TableRowSkeleton />
|
||||||
|
<TableRowSkeleton />
|
||||||
|
<TableRowSkeleton />
|
||||||
|
<TableRowSkeleton />
|
||||||
|
<TableRowSkeleton />
|
||||||
|
<TableRowSkeleton />
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
22
src/app/[lang]/dashboard/layout.tsx
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import SideNav from "@/app/[lang]/dashboard/components/server/sidenav";
|
||||||
|
import dashboardAppModule from "@/app/[lang]/dashboard/module/dashboard-app-module";
|
||||||
|
import { DiContext } from "@/bootstrap/di/di-context";
|
||||||
|
import { useRef } from "react";
|
||||||
|
|
||||||
|
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
|
const di = useRef(dashboardAppModule());
|
||||||
|
return (
|
||||||
|
<DiContext.Provider value={di.current}>
|
||||||
|
<div className="flex h-screen flex-col md:flex-row md:overflow-hidden">
|
||||||
|
<div className="w-full flex-none md:w-64">
|
||||||
|
<SideNav />
|
||||||
|
</div>
|
||||||
|
<div className="flex-grow p-6 md:overflow-y-auto md:p-12">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DiContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
5
src/app/[lang]/dashboard/loading.tsx
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import DashboardSkeleton from "@/app/[lang]/dashboard/components/server/skeletons/skeletons";
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return <DashboardSkeleton />;
|
||||||
|
}
|
16
src/app/[lang]/dashboard/module/dashboard-app-module.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import CreateRandomInvoiceButtonVM from "@/app/[lang]/dashboard/vm/create-random-invoice-button-vm";
|
||||||
|
import di from "@/bootstrap/di/init-di";
|
||||||
|
import createInvoiceUsecase from "@/feature/core/invoice/domain/usecase/create-invoice-usecase";
|
||||||
|
|
||||||
|
export default function dashboardAppModule() {
|
||||||
|
const dashboardDi = di.createChildContainer();
|
||||||
|
|
||||||
|
dashboardDi.register(createInvoiceUsecase.name, {
|
||||||
|
useValue: createInvoiceUsecase,
|
||||||
|
});
|
||||||
|
dashboardDi.register(
|
||||||
|
CreateRandomInvoiceButtonVM,
|
||||||
|
CreateRandomInvoiceButtonVM,
|
||||||
|
);
|
||||||
|
return dashboardDi;
|
||||||
|
}
|
36
src/app/[lang]/dashboard/page.tsx
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import {
|
||||||
|
LatestInvoicesSkeleton,
|
||||||
|
RevenueChartSkeleton,
|
||||||
|
} from "@/app/[lang]/dashboard/components/server/skeletons/skeletons";
|
||||||
|
import CardWrapper from "@/app/[lang]/dashboard/components/server/cards/cards";
|
||||||
|
import LatestInvoices from "@/app/[lang]/dashboard/components/server/latest-invoices/latest-invoices";
|
||||||
|
import RevenueChart from "@/app/[lang]/dashboard/components/server/revenue-chart/revenue-chart";
|
||||||
|
import { Suspense } from "react";
|
||||||
|
import { getServerTranslation } from "@/bootstrap/i18n/i18n";
|
||||||
|
import langKey from "@/bootstrap/i18n/dictionaries/lang-key";
|
||||||
|
|
||||||
|
export default async function Dashboard(props: {
|
||||||
|
params: Promise<{ lang: string }>;
|
||||||
|
}) {
|
||||||
|
const { params } = props;
|
||||||
|
const { lang } = await params;
|
||||||
|
const { t } = await getServerTranslation(lang);
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<h1 className="mb-4 text-xl md:text-2xl">
|
||||||
|
{t(langKey.global.dashboard)}
|
||||||
|
</h1>
|
||||||
|
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<CardWrapper />
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 grid grid-cols-1 gap-6 md:grid-cols-4 lg:grid-cols-8">
|
||||||
|
<Suspense fallback={<RevenueChartSkeleton />}>
|
||||||
|
<RevenueChart />
|
||||||
|
</Suspense>
|
||||||
|
<Suspense fallback={<LatestInvoicesSkeleton />}>
|
||||||
|
<LatestInvoices />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,53 @@
|
|||||||
|
import ButtonVm from "@/app/components/button/button-vm";
|
||||||
|
import { useServerAction } from "@/bootstrap/helpers/hooks/use-server-action";
|
||||||
|
import useThrottle from "@/bootstrap/helpers/hooks/use-throttle";
|
||||||
|
import BaseVM from "@/bootstrap/helpers/vm/base-vm";
|
||||||
|
import langKey from "@/bootstrap/i18n/dictionaries/lang-key";
|
||||||
|
import { InvoiceParam } from "@/feature/core/invoice/domain/param/invoice-param";
|
||||||
|
import createInvoiceUsecase from "@/feature/core/invoice/domain/usecase/create-invoice-usecase";
|
||||||
|
import { faker } from "@faker-js/faker";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export default class CreateRandomInvoiceButtonVM extends BaseVM<ButtonVm> {
|
||||||
|
private createInvoice: typeof createInvoiceUsecase;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.createInvoice = this.di.resolve(createInvoiceUsecase.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
useVM(): ButtonVm {
|
||||||
|
const router = useRouter();
|
||||||
|
const [action, isPending] = useServerAction(() =>
|
||||||
|
this.onClickHandler(router.refresh),
|
||||||
|
);
|
||||||
|
const throttledOnClick = useThrottle(action, 5000);
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
title: t(
|
||||||
|
isPending
|
||||||
|
? langKey.global.loading
|
||||||
|
: langKey.dashboard.invoice.createButton,
|
||||||
|
),
|
||||||
|
isDisable: !!isPending,
|
||||||
|
},
|
||||||
|
onClick: throttledOnClick.bind(this),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async onClickHandler(refreshPage: () => void) {
|
||||||
|
const fakedParams: InvoiceParam = {
|
||||||
|
amount: faker.number.int({
|
||||||
|
min: 1,
|
||||||
|
max: 10,
|
||||||
|
}),
|
||||||
|
status: "paid",
|
||||||
|
};
|
||||||
|
await this.createInvoice(fakedParams);
|
||||||
|
refreshPage();
|
||||||
|
}
|
||||||
|
}
|
34
src/app/[lang]/layout.tsx
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { initI18next } from "@/bootstrap/i18n/i18n";
|
||||||
|
import TranslationsProvider from "@/bootstrap/i18n/i18n-provider";
|
||||||
|
import localFont from "next/font/local";
|
||||||
|
import { PropsWithChildren } from "react";
|
||||||
|
|
||||||
|
const geistSans = localFont({
|
||||||
|
src: "./../fonts/GeistVF.woff",
|
||||||
|
variable: "--font-geist-sans",
|
||||||
|
weight: "100 900",
|
||||||
|
});
|
||||||
|
const geistMono = localFont({
|
||||||
|
src: "./../fonts/GeistMonoVF.woff",
|
||||||
|
variable: "--font-geist-mono",
|
||||||
|
weight: "100 900",
|
||||||
|
});
|
||||||
|
|
||||||
|
export default async function layout(
|
||||||
|
props: PropsWithChildren & { params: Promise<{ lang: string }> },
|
||||||
|
) {
|
||||||
|
const { params, children } = props;
|
||||||
|
const { lang } = await params;
|
||||||
|
const { resources } = await initI18next({ lng: lang });
|
||||||
|
return (
|
||||||
|
<html lang={lang}>
|
||||||
|
<body
|
||||||
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||||
|
>
|
||||||
|
<TranslationsProvider lng={lang} resources={resources}>
|
||||||
|
{children}
|
||||||
|
</TranslationsProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
15
src/app/[lang]/page.tsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<main className="flex min-h-screen flex-col p-6">
|
||||||
|
<div className="mt-4 flex grow flex-col gap-4 md:flex-row">
|
||||||
|
<div className="flex flex-col justify-center gap-6 rounded-lg bg-gray-50 px-6 py-10 md:w-2/5 md:px-20">
|
||||||
|
<div />
|
||||||
|
<p className="text-xl text-gray-800 md:text-3xl md:leading-normal">
|
||||||
|
<strong>Welcome to Acme.</strong> This is the example for the ,
|
||||||
|
brought to you by Vercel.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
7
src/app/components/button/button-vm.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
export default interface ButtonVm {
|
||||||
|
props: {
|
||||||
|
title: string;
|
||||||
|
isDisable: boolean;
|
||||||
|
};
|
||||||
|
onClick(): void;
|
||||||
|
}
|
74
src/app/components/button/button.tsx
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import BaseView, { BuildProps } from "@/bootstrap/helpers/view/base-view";
|
||||||
|
import ButtonVm from "@/app/components/button/button-vm";
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
import * as React from "react";
|
||||||
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import { cn } from "@/bootstrap/helpers/lib/ui-utils";
|
||||||
|
|
||||||
|
export default class Button extends BaseView<ButtonVm> {
|
||||||
|
protected Build(props: BuildProps<ButtonVm>): ReactNode {
|
||||||
|
const { vm } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ButtonUi disabled={vm.props.isDisable} onClick={vm.onClick}>
|
||||||
|
{vm.props.title}
|
||||||
|
</ButtonUi>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||||
|
outline:
|
||||||
|
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||||
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2",
|
||||||
|
sm: "h-8 rounded-md px-3 text-xs",
|
||||||
|
lg: "h-10 rounded-md px-8",
|
||||||
|
icon: "h-9 w-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ButtonUi = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "button";
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
ButtonUi.displayName = "Button";
|
||||||
|
|
||||||
|
export { buttonVariants };
|
20
src/app/components/icons/document.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
export function DocumentIcon(props: { className?: string }) {
|
||||||
|
const { className } = props;
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className={className}
|
||||||
|
width="15"
|
||||||
|
height="15"
|
||||||
|
viewBox="0 0 15 15"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M3 2.5C3 2.22386 3.22386 2 3.5 2H9.08579C9.21839 2 9.34557 2.05268 9.43934 2.14645L11.8536 4.56066C11.9473 4.65443 12 4.78161 12 4.91421V12.5C12 12.7761 11.7761 13 11.5 13H3.5C3.22386 13 3 12.7761 3 12.5V2.5ZM3.5 1C2.67157 1 2 1.67157 2 2.5V12.5C2 13.3284 2.67157 14 3.5 14H11.5C12.3284 14 13 13.3284 13 12.5V4.91421C13 4.51639 12.842 4.13486 12.5607 3.85355L10.1464 1.43934C9.86514 1.15804 9.48361 1 9.08579 1H3.5ZM4.5 4C4.22386 4 4 4.22386 4 4.5C4 4.77614 4.22386 5 4.5 5H7.5C7.77614 5 8 4.77614 8 4.5C8 4.22386 7.77614 4 7.5 4H4.5ZM4.5 7C4.22386 7 4 7.22386 4 7.5C4 7.77614 4.22386 8 4.5 8H10.5C10.7761 8 11 7.77614 11 7.5C11 7.22386 10.7761 7 10.5 7H4.5ZM4.5 10C4.22386 10 4 10.2239 4 10.5C4 10.7761 4.22386 11 4.5 11H10.5C10.7761 11 11 10.7761 11 10.5C11 10.2239 10.7761 10 10.5 10H4.5Z"
|
||||||
|
fill="currentColor"
|
||||||
|
fillRule="evenodd"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
20
src/app/components/icons/home.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
export default function HomeIcon(props: { className?: string }) {
|
||||||
|
const { className } = props;
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className={className}
|
||||||
|
width="15"
|
||||||
|
height="15"
|
||||||
|
viewBox="0 0 15 15"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M7.07926 0.222253C7.31275 -0.007434 7.6873 -0.007434 7.92079 0.222253L14.6708 6.86227C14.907 7.09465 14.9101 7.47453 14.6778 7.71076C14.4454 7.947 14.0655 7.95012 13.8293 7.71773L13 6.90201V12.5C13 12.7761 12.7762 13 12.5 13H2.50002C2.22388 13 2.00002 12.7761 2.00002 12.5V6.90201L1.17079 7.71773C0.934558 7.95012 0.554672 7.947 0.32229 7.71076C0.0899079 7.47453 0.0930283 7.09465 0.32926 6.86227L7.07926 0.222253ZM7.50002 1.49163L12 5.91831V12H10V8.49999C10 8.22385 9.77617 7.99999 9.50002 7.99999H6.50002C6.22388 7.99999 6.00002 8.22385 6.00002 8.49999V12H3.00002V5.91831L7.50002 1.49163ZM7.00002 12H9.00002V8.99999H7.00002V12Z"
|
||||||
|
fill="currentColor"
|
||||||
|
fillRule="evenodd"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
19
src/app/components/icons/user.tsx
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
export function UserIcon(props: { className?: string }) {
|
||||||
|
const { className } = props;
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className={className}
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
@ -2,20 +2,71 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
:root {
|
body {
|
||||||
--background: #ffffff;
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
--foreground: #171717;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
--background: #0a0a0a;
|
--background: 0 0% 100%;
|
||||||
--foreground: #ededed;
|
--foreground: 240 10% 3.9%;
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 240 10% 3.9%;
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 240 10% 3.9%;
|
||||||
|
--primary: 240 5.9% 10%;
|
||||||
|
--primary-foreground: 0 0% 98%;
|
||||||
|
--secondary: 240 4.8% 95.9%;
|
||||||
|
--secondary-foreground: 240 5.9% 10%;
|
||||||
|
--muted: 240 4.8% 95.9%;
|
||||||
|
--muted-foreground: 240 3.8% 46.1%;
|
||||||
|
--accent: 240 4.8% 95.9%;
|
||||||
|
--accent-foreground: 240 5.9% 10%;
|
||||||
|
--destructive: 0 84.2% 60.2%;
|
||||||
|
--destructive-foreground: 0 0% 98%;
|
||||||
|
--border: 240 5.9% 90%;
|
||||||
|
--input: 240 5.9% 90%;
|
||||||
|
--ring: 240 10% 3.9%;
|
||||||
|
--chart-1: 12 76% 61%;
|
||||||
|
--chart-2: 173 58% 39%;
|
||||||
|
--chart-3: 197 37% 24%;
|
||||||
|
--chart-4: 43 74% 66%;
|
||||||
|
--chart-5: 27 87% 67%;
|
||||||
|
--radius: 0.5rem;
|
||||||
|
}
|
||||||
|
.dark {
|
||||||
|
--background: 240 10% 3.9%;
|
||||||
|
--foreground: 0 0% 98%;
|
||||||
|
--card: 240 10% 3.9%;
|
||||||
|
--card-foreground: 0 0% 98%;
|
||||||
|
--popover: 240 10% 3.9%;
|
||||||
|
--popover-foreground: 0 0% 98%;
|
||||||
|
--primary: 0 0% 98%;
|
||||||
|
--primary-foreground: 240 5.9% 10%;
|
||||||
|
--secondary: 240 3.7% 15.9%;
|
||||||
|
--secondary-foreground: 0 0% 98%;
|
||||||
|
--muted: 240 3.7% 15.9%;
|
||||||
|
--muted-foreground: 240 5% 64.9%;
|
||||||
|
--accent: 240 3.7% 15.9%;
|
||||||
|
--accent-foreground: 0 0% 98%;
|
||||||
|
--destructive: 0 62.8% 30.6%;
|
||||||
|
--destructive-foreground: 0 0% 98%;
|
||||||
|
--border: 240 3.7% 15.9%;
|
||||||
|
--input: 240 3.7% 15.9%;
|
||||||
|
--ring: 240 4.9% 83.9%;
|
||||||
|
--chart-1: 220 70% 50%;
|
||||||
|
--chart-2: 160 60% 45%;
|
||||||
|
--chart-3: 30 80% 55%;
|
||||||
|
--chart-4: 280 65% 60%;
|
||||||
|
--chart-5: 340 75% 55%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
@layer base {
|
||||||
color: var(--foreground);
|
* {
|
||||||
background: var(--background);
|
@apply border-border;
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,35 +1,15 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import localFont from "next/font/local";
|
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const geistSans = localFont({
|
|
||||||
src: "./fonts/GeistVF.woff",
|
|
||||||
variable: "--font-geist-sans",
|
|
||||||
weight: "100 900",
|
|
||||||
});
|
|
||||||
const geistMono = localFont({
|
|
||||||
src: "./fonts/GeistMonoVF.woff",
|
|
||||||
variable: "--font-geist-mono",
|
|
||||||
weight: "100 900",
|
|
||||||
});
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "Create Next App",
|
||||||
description: "Generated by create next app",
|
description: "Generated by create next app",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default async function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return children;
|
||||||
<html lang="en">
|
|
||||||
<body
|
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
101
src/app/page.tsx
@ -1,101 +0,0 @@
|
|||||||
import Image from "next/image";
|
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
return (
|
|
||||||
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
|
|
||||||
<main className="flex flex-col gap-8 row-start-2 items-center sm:items-start">
|
|
||||||
<Image
|
|
||||||
className="dark:invert"
|
|
||||||
src="/next.svg"
|
|
||||||
alt="Next.js logo"
|
|
||||||
width={180}
|
|
||||||
height={38}
|
|
||||||
priority
|
|
||||||
/>
|
|
||||||
<ol className="list-inside list-decimal text-sm text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
|
|
||||||
<li className="mb-2">
|
|
||||||
Get started by editing{" "}
|
|
||||||
<code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-semibold">
|
|
||||||
src/app/page.tsx
|
|
||||||
</code>
|
|
||||||
.
|
|
||||||
</li>
|
|
||||||
<li>Save and see your changes instantly.</li>
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<div className="flex gap-4 items-center flex-col sm:flex-row">
|
|
||||||
<a
|
|
||||||
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5"
|
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
className="dark:invert"
|
|
||||||
src="/vercel.svg"
|
|
||||||
alt="Vercel logomark"
|
|
||||||
width={20}
|
|
||||||
height={20}
|
|
||||||
/>
|
|
||||||
Deploy now
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:min-w-44"
|
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
Read our docs
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
<footer className="row-start-3 flex gap-6 flex-wrap items-center justify-center">
|
|
||||||
<a
|
|
||||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
aria-hidden
|
|
||||||
src="/file.svg"
|
|
||||||
alt="File icon"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Learn
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
aria-hidden
|
|
||||||
src="/window.svg"
|
|
||||||
alt="Window icon"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Examples
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
|
||||||
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
aria-hidden
|
|
||||||
src="/globe.svg"
|
|
||||||
alt="Globe icon"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Go to nextjs.org →
|
|
||||||
</a>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
12
src/bootstrap/boundaries/db/db.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import postgres from "postgres";
|
||||||
|
|
||||||
|
const envs = process.env;
|
||||||
|
const dbConfigs = {
|
||||||
|
host: envs.POSTGRES_HOST,
|
||||||
|
port: Number(envs.POSTGRES_PORT),
|
||||||
|
username: envs.POSTGRES_USER,
|
||||||
|
password: envs.POSTGRES_PASS,
|
||||||
|
database: envs.POSTGRES_DB,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sql = postgres(dbConfigs);
|
188
src/bootstrap/boundaries/db/placeholder-data.js
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
// This file contains placeholder data that you'll be replacing with real data in the Data Fetching chapter:
|
||||||
|
// https://nextjs.org/learn/dashboard-app/fetching-data
|
||||||
|
const users = [
|
||||||
|
{
|
||||||
|
id: "410544b2-4001-4271-9855-fec4b6a6442a",
|
||||||
|
name: "User",
|
||||||
|
email: "user@nextmail.com",
|
||||||
|
password: "123456",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const customers = [
|
||||||
|
{
|
||||||
|
id: "3958dc9e-712f-4377-85e9-fec4b6a6442a",
|
||||||
|
name: "Delba de Oliveira",
|
||||||
|
email: "delba@oliveira.com",
|
||||||
|
image_url: "/customers/delba-de-oliveira.png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "3958dc9e-742f-4377-85e9-fec4b6a6442a",
|
||||||
|
name: "Lee Robinson",
|
||||||
|
email: "lee@robinson.com",
|
||||||
|
image_url: "/customers/lee-robinson.png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "3958dc9e-737f-4377-85e9-fec4b6a6442a",
|
||||||
|
name: "Hector Simpson",
|
||||||
|
email: "hector@simpson.com",
|
||||||
|
image_url: "/customers/hector-simpson.png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "50ca3e18-62cd-11ee-8c99-0242ac120002",
|
||||||
|
name: "Steven Tey",
|
||||||
|
email: "steven@tey.com",
|
||||||
|
image_url: "/customers/steven-tey.png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "3958dc9e-787f-4377-85e9-fec4b6a6442a",
|
||||||
|
name: "Steph Dietz",
|
||||||
|
email: "steph@dietz.com",
|
||||||
|
image_url: "/customers/steph-dietz.png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "76d65c26-f784-44a2-ac19-586678f7c2f2",
|
||||||
|
name: "Michael Novotny",
|
||||||
|
email: "michael@novotny.com",
|
||||||
|
image_url: "/customers/michael-novotny.png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "d6e15727-9fe1-4961-8c5b-ea44a9bd81aa",
|
||||||
|
name: "Evil Rabbit",
|
||||||
|
email: "evil@rabbit.com",
|
||||||
|
image_url: "/customers/evil-rabbit.png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "126eed9c-c90c-4ef6-a4a8-fcf7408d3c66",
|
||||||
|
name: "Emil Kowalski",
|
||||||
|
email: "emil@kowalski.com",
|
||||||
|
image_url: "/customers/emil-kowalski.png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "CC27C14A-0ACF-4F4A-A6C9-D45682C144B9",
|
||||||
|
name: "Amy Burns",
|
||||||
|
email: "amy@burns.com",
|
||||||
|
image_url: "/customers/amy-burns.png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "13D07535-C59E-4157-A011-F8D2EF4E0CBB",
|
||||||
|
name: "Balazs Orban",
|
||||||
|
email: "balazs@orban.com",
|
||||||
|
image_url: "/customers/balazs-orban.png",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const invoices = [
|
||||||
|
{
|
||||||
|
customer_id: customers[0].id,
|
||||||
|
amount: 15795,
|
||||||
|
status: "pending",
|
||||||
|
date: "2022-12-06",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
customer_id: customers[1].id,
|
||||||
|
amount: 20348,
|
||||||
|
status: "pending",
|
||||||
|
date: "2022-11-14",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
customer_id: customers[4].id,
|
||||||
|
amount: 3040,
|
||||||
|
status: "paid",
|
||||||
|
date: "2022-10-29",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
customer_id: customers[3].id,
|
||||||
|
amount: 44800,
|
||||||
|
status: "paid",
|
||||||
|
date: "2023-09-10",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
customer_id: customers[5].id,
|
||||||
|
amount: 34577,
|
||||||
|
status: "pending",
|
||||||
|
date: "2023-08-05",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
customer_id: customers[7].id,
|
||||||
|
amount: 54246,
|
||||||
|
status: "pending",
|
||||||
|
date: "2023-07-16",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
customer_id: customers[6].id,
|
||||||
|
amount: 666,
|
||||||
|
status: "pending",
|
||||||
|
date: "2023-06-27",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
customer_id: customers[3].id,
|
||||||
|
amount: 32545,
|
||||||
|
status: "paid",
|
||||||
|
date: "2023-06-09",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
customer_id: customers[4].id,
|
||||||
|
amount: 1250,
|
||||||
|
status: "paid",
|
||||||
|
date: "2023-06-17",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
customer_id: customers[5].id,
|
||||||
|
amount: 8546,
|
||||||
|
status: "paid",
|
||||||
|
date: "2023-06-07",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
customer_id: customers[1].id,
|
||||||
|
amount: 500,
|
||||||
|
status: "paid",
|
||||||
|
date: "2023-08-19",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
customer_id: customers[5].id,
|
||||||
|
amount: 8945,
|
||||||
|
status: "paid",
|
||||||
|
date: "2023-06-03",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
customer_id: customers[2].id,
|
||||||
|
amount: 8945,
|
||||||
|
status: "paid",
|
||||||
|
date: "2023-06-18",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
customer_id: customers[0].id,
|
||||||
|
amount: 8945,
|
||||||
|
status: "paid",
|
||||||
|
date: "2023-10-04",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
customer_id: customers[2].id,
|
||||||
|
amount: 1000,
|
||||||
|
status: "paid",
|
||||||
|
date: "2022-06-05",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const revenue = [
|
||||||
|
{ month: "Jan", revenue: 2000 },
|
||||||
|
{ month: "Feb", revenue: 1800 },
|
||||||
|
{ month: "Mar", revenue: 2200 },
|
||||||
|
{ month: "Apr", revenue: 2500 },
|
||||||
|
{ month: "May", revenue: 2300 },
|
||||||
|
{ month: "Jun", revenue: 3200 },
|
||||||
|
{ month: "Jul", revenue: 3500 },
|
||||||
|
{ month: "Aug", revenue: 3700 },
|
||||||
|
{ month: "Sep", revenue: 2500 },
|
||||||
|
{ month: "Oct", revenue: 2800 },
|
||||||
|
{ month: "Nov", revenue: 3000 },
|
||||||
|
{ month: "Dec", revenue: 4800 },
|
||||||
|
];
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
users,
|
||||||
|
customers,
|
||||||
|
invoices,
|
||||||
|
revenue,
|
||||||
|
};
|
189
src/bootstrap/boundaries/db/seed.js
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
/* eslint-disable no-console */
|
||||||
|
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||||
|
const bcrypt = require("bcrypt");
|
||||||
|
const postgres = require("postgres");
|
||||||
|
const {
|
||||||
|
invoices,
|
||||||
|
customers,
|
||||||
|
revenue,
|
||||||
|
users,
|
||||||
|
// eslint-disable-next-line import/extensions
|
||||||
|
} = require("./placeholder-data.js");
|
||||||
|
|
||||||
|
async function seedUsers(sql) {
|
||||||
|
try {
|
||||||
|
await sql`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`;
|
||||||
|
// Create the "users" table if it doesn't exist
|
||||||
|
const createTable = await sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
password TEXT NOT NULL
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
console.log(`Created "users" table`);
|
||||||
|
|
||||||
|
// Insert data into the "users" table
|
||||||
|
const insertedUsers = await Promise.all(
|
||||||
|
users.map(async (user) => {
|
||||||
|
const hashedPassword = await bcrypt.hash(user.password, 10);
|
||||||
|
return sql`
|
||||||
|
INSERT INTO users (id, name, email, password)
|
||||||
|
VALUES (${user.id}, ${user.name}, ${user.email}, ${hashedPassword})
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
`;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`Seeded ${insertedUsers.length} users`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
createTable,
|
||||||
|
users: insertedUsers,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error seeding users:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedInvoices(sql) {
|
||||||
|
try {
|
||||||
|
await sql`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`;
|
||||||
|
|
||||||
|
// Create the "invoices" table if it doesn't exist
|
||||||
|
const createTable = await sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS invoices (
|
||||||
|
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
|
||||||
|
customer_id UUID NOT NULL,
|
||||||
|
amount INT NOT NULL,
|
||||||
|
status VARCHAR(255) NOT NULL,
|
||||||
|
date DATE NOT NULL
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
console.log(`Created "invoices" table`);
|
||||||
|
|
||||||
|
// Insert data into the "invoices" table
|
||||||
|
const insertedInvoices = await Promise.all(
|
||||||
|
invoices.map(
|
||||||
|
(invoice) => sql`
|
||||||
|
INSERT INTO invoices (customer_id, amount, status, date)
|
||||||
|
VALUES (${invoice.customer_id}, ${invoice.amount}, ${invoice.status}, ${invoice.date})
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`Seeded ${insertedInvoices.length} invoices`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
createTable,
|
||||||
|
invoices: insertedInvoices,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error seeding invoices:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedCustomers(sql) {
|
||||||
|
try {
|
||||||
|
await sql`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`;
|
||||||
|
|
||||||
|
// Create the "customers" table if it doesn't exist
|
||||||
|
const createTable = await sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS customers (
|
||||||
|
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
email VARCHAR(255) NOT NULL,
|
||||||
|
image_url VARCHAR(255) NOT NULL
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
console.log(`Created "customers" table`);
|
||||||
|
|
||||||
|
// Insert data into the "customers" table
|
||||||
|
const insertedCustomers = await Promise.all(
|
||||||
|
customers.map(
|
||||||
|
(customer) => sql`
|
||||||
|
INSERT INTO customers (id, name, email, image_url)
|
||||||
|
VALUES (${customer.id}, ${customer.name}, ${customer.email}, ${customer.image_url})
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`Seeded ${insertedCustomers.length} customers`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
createTable,
|
||||||
|
customers: insertedCustomers,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error seeding customers:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedRevenue(sql) {
|
||||||
|
try {
|
||||||
|
// Create the "revenue" table if it doesn't exist
|
||||||
|
const createTable = await sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS revenue (
|
||||||
|
month VARCHAR(4) NOT NULL UNIQUE,
|
||||||
|
revenue INT NOT NULL
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
console.log(`Created "revenue" table`);
|
||||||
|
|
||||||
|
// Insert data into the "revenue" table
|
||||||
|
const insertedRevenue = await Promise.all(
|
||||||
|
revenue.map(
|
||||||
|
(rev) => sql`
|
||||||
|
INSERT INTO revenue (month, revenue)
|
||||||
|
VALUES (${rev.month}, ${rev.revenue})
|
||||||
|
ON CONFLICT (month) DO NOTHING;
|
||||||
|
`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`Seeded ${insertedRevenue.length} revenue`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
createTable,
|
||||||
|
revenue: insertedRevenue,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error seeding revenue:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const envs = process.env;
|
||||||
|
const dbConfigs = {
|
||||||
|
host: process.env.POSTGRES_HOST,
|
||||||
|
port: envs.POSTGRES_PORT,
|
||||||
|
username: envs.POSTGRES_USER,
|
||||||
|
password: envs.POSTGRES_PASS,
|
||||||
|
database: envs.POSTGRES_DB,
|
||||||
|
};
|
||||||
|
|
||||||
|
const sql = postgres(dbConfigs);
|
||||||
|
|
||||||
|
await seedUsers(sql);
|
||||||
|
await seedCustomers(sql);
|
||||||
|
await seedInvoices(sql);
|
||||||
|
await seedRevenue(sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(
|
||||||
|
"An error occurred while attempting to seed the database:",
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
});
|
19
src/bootstrap/di/di-context.tsx
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import di from "@/bootstrap/di/init-di";
|
||||||
|
import { createContext, use } from "react";
|
||||||
|
import { DependencyContainer } from "tsyringe";
|
||||||
|
|
||||||
|
const DiContext = createContext<null | DependencyContainer>(di);
|
||||||
|
|
||||||
|
const useDI = () => {
|
||||||
|
const di = use(DiContext);
|
||||||
|
|
||||||
|
if (!di) {
|
||||||
|
throw new Error("Di has not provided");
|
||||||
|
}
|
||||||
|
|
||||||
|
return di;
|
||||||
|
};
|
||||||
|
|
||||||
|
export { DiContext, useDI };
|
18
src/bootstrap/di/init-di.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
// "use client"
|
||||||
|
import "reflect-metadata";
|
||||||
|
import { container, DependencyContainer } from "tsyringe";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serves as a central point for initializing and configuring
|
||||||
|
* the DI container, ensuring that all necessary dependencies
|
||||||
|
* are registered and available for injection throughout the application.
|
||||||
|
*/
|
||||||
|
const InitDI = (): DependencyContainer => {
|
||||||
|
const di = container.createChildContainer();
|
||||||
|
|
||||||
|
return di;
|
||||||
|
};
|
||||||
|
|
||||||
|
const di = InitDI();
|
||||||
|
|
||||||
|
export default di;
|
1
src/bootstrap/helpers/global-helpers.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export const isServer = typeof window === "undefined";
|
40
src/bootstrap/helpers/hooks/use-server-action.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { useState, useEffect, useTransition, useRef } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param action Main server action to run
|
||||||
|
* @param onFinished Callback to run after action
|
||||||
|
* @returns transitioned action to run and is pending variable
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export const useServerAction = <P extends any[], R>(
|
||||||
|
action: (...args: P) => Promise<R>,
|
||||||
|
onFinished?: (_: R | undefined) => void,
|
||||||
|
): [(...args: P) => Promise<R | undefined>, boolean] => {
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const [result, setResult] = useState<R>();
|
||||||
|
const [finished, setFinished] = useState(false);
|
||||||
|
const resolver = useRef<(value?: R | PromiseLike<R>) => void>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!finished) return;
|
||||||
|
|
||||||
|
if (onFinished) onFinished(result);
|
||||||
|
resolver.current?.(result);
|
||||||
|
}, [result, finished, onFinished]);
|
||||||
|
|
||||||
|
const runAction = async (...args: P): Promise<R | undefined> => {
|
||||||
|
startTransition(() => {
|
||||||
|
action(...args).then((data) => {
|
||||||
|
setResult(data);
|
||||||
|
setFinished(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
resolver.current = resolve;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return [runAction, isPending];
|
||||||
|
};
|
22
src/bootstrap/helpers/hooks/use-throttle.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param callback
|
||||||
|
* @param time In miliseconds
|
||||||
|
*/
|
||||||
|
export default function useThrottle<T extends () => unknown>(
|
||||||
|
callback: T,
|
||||||
|
time: number = 2000,
|
||||||
|
) {
|
||||||
|
const lastRun = useRef(Date.now());
|
||||||
|
|
||||||
|
// eslint-disable-next-line func-names
|
||||||
|
return function () {
|
||||||
|
if (Date.now() - lastRun.current <= time) return;
|
||||||
|
lastRun.current = Date.now();
|
||||||
|
callback();
|
||||||
|
};
|
||||||
|
}
|
6
src/bootstrap/helpers/lib/ui-utils.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
8
src/bootstrap/helpers/type-helper.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
declare const _: unique symbol;
|
||||||
|
|
||||||
|
type Forbidden = { [_]: typeof _ };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* You can use this type to make your parent class method forbidden to overwrite
|
||||||
|
*/
|
||||||
|
export type NoOverride<T = void> = T & Forbidden;
|
96
src/bootstrap/helpers/view/base-view.tsx
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// import gdi from "@/bootstrap/di/init-di";
|
||||||
|
/* eslint-disable react/display-name */
|
||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
/* eslint-disable react/jsx-props-no-spreading */
|
||||||
|
import IBaseVM from "@/bootstrap/helpers/vm/i-base-vm";
|
||||||
|
import { Component, ReactNode, FC, PropsWithChildren, memo } from "react";
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* Connector Component */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
interface IVvmConnector<IVM, PROPS> extends PropsWithChildren {
|
||||||
|
View: FC<any & { vm: IVM }>;
|
||||||
|
Vm: IBaseVM<IVM>;
|
||||||
|
restProps?: PROPS;
|
||||||
|
memoizedByVM?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function is just will be used in
|
||||||
|
*/
|
||||||
|
const VvmConnector = memo(
|
||||||
|
<IVM, PROPS>(props: IVvmConnector<IVM, PROPS>) => {
|
||||||
|
const { View, Vm, restProps, children } = props;
|
||||||
|
|
||||||
|
const vm = Vm.useVM();
|
||||||
|
|
||||||
|
const allProps = {
|
||||||
|
restProps,
|
||||||
|
vm,
|
||||||
|
};
|
||||||
|
|
||||||
|
return <View {...allProps}>{children}</View>;
|
||||||
|
},
|
||||||
|
(prevProps) => {
|
||||||
|
if (prevProps.memoizedByVM) return true;
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* BaseView */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
type IVMParent = Record<string, any>;
|
||||||
|
type IPropParent = Record<string, any> | undefined;
|
||||||
|
|
||||||
|
type BaseProps<IVM extends IVMParent, PROPS extends IPropParent = undefined> = {
|
||||||
|
vm: IBaseVM<IVM>;
|
||||||
|
restProps?: PROPS;
|
||||||
|
/**
|
||||||
|
* By default it's true.
|
||||||
|
* If you pass true this view will update just by changes of vm not rest props
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
memoizedByVM?: boolean;
|
||||||
|
children?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BuildProps<
|
||||||
|
IVM extends IVMParent,
|
||||||
|
PROPS extends IPropParent = undefined,
|
||||||
|
> = {
|
||||||
|
vm: IVM;
|
||||||
|
restProps: PROPS;
|
||||||
|
children?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default abstract class BaseView<
|
||||||
|
IVM extends IVMParent,
|
||||||
|
PROPS extends IPropParent = undefined,
|
||||||
|
> extends Component<BaseProps<IVM, PROPS>> {
|
||||||
|
protected get componentName() {
|
||||||
|
return this.constructor.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected abstract Build(props: BuildProps<IVM, PROPS>): ReactNode;
|
||||||
|
|
||||||
|
render(): ReactNode {
|
||||||
|
const { vm, restProps, memoizedByVM, children, ...rest } = this.props;
|
||||||
|
|
||||||
|
VvmConnector.displayName = this.componentName;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VvmConnector
|
||||||
|
View={this.Build}
|
||||||
|
Vm={vm}
|
||||||
|
memoizedByVM={typeof memoizedByVM === "undefined" ? true : memoizedByVM}
|
||||||
|
restProps={{ ...restProps, ...rest }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</VvmConnector>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
}
|
53
src/bootstrap/helpers/vm/base-vm.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useDI } from "@/bootstrap/di/di-context";
|
||||||
|
import { NoOverride } from "@/bootstrap/helpers/type-helper";
|
||||||
|
import IBaseVM from "@/bootstrap/helpers/vm/i-base-vm";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export default abstract class BaseVM<
|
||||||
|
IVM,
|
||||||
|
DEP extends object | undefined = undefined,
|
||||||
|
> implements IBaseVM<IVM>
|
||||||
|
{
|
||||||
|
/* ------------------------------ Dependencies ------------------------------ */
|
||||||
|
protected deps!: DEP;
|
||||||
|
|
||||||
|
/* -------------------------------- Abstracts ------------------------------- */
|
||||||
|
abstract useVM(): IVM;
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
produce(dep?: DEP) {
|
||||||
|
if (dep) this.deps = dep;
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------- Getters -------------------------------- */
|
||||||
|
/**
|
||||||
|
* You can pass your rerender method after calling useRerender on your vm
|
||||||
|
* so you can access to it in any method
|
||||||
|
*/
|
||||||
|
protected rerender?: () => void;
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
protected get di() {
|
||||||
|
return useDI();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/**
|
||||||
|
* You can use this hook in your useVm method to get rerender method
|
||||||
|
* @returns Rerender Method that when ever you call it you can rerender your component
|
||||||
|
* for showing new values
|
||||||
|
*/
|
||||||
|
protected useRerender(): NoOverride<() => void> {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const [_, reState] = useState(false);
|
||||||
|
|
||||||
|
const rerender = () => reState((prev) => !prev);
|
||||||
|
return rerender as NoOverride<() => void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
}
|
3
src/bootstrap/helpers/vm/i-base-vm.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export default interface IBaseVM<VM> {
|
||||||
|
useVM(): VM;
|
||||||
|
}
|
16
src/bootstrap/i18n/dictionaries/en.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import langKey from "@/bootstrap/i18n/dictionaries/lang-key";
|
||||||
|
|
||||||
|
const en: typeof langKey = {
|
||||||
|
global: {
|
||||||
|
home: "Home",
|
||||||
|
loading: "Loading",
|
||||||
|
dashboard: "Dashboard",
|
||||||
|
},
|
||||||
|
dashboard: {
|
||||||
|
invoice: {
|
||||||
|
createButton: "Create random Invoice",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default en;
|
14
src/bootstrap/i18n/dictionaries/lang-key.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
const langKey = {
|
||||||
|
global: {
|
||||||
|
home: "global.home",
|
||||||
|
dashboard: "global.dashboard",
|
||||||
|
loading: "global.loading",
|
||||||
|
},
|
||||||
|
dashboard: {
|
||||||
|
invoice: {
|
||||||
|
createButton: "dashboard.invoice.createButton",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default langKey;
|
16
src/bootstrap/i18n/dictionaries/ru.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import langKey from "@/bootstrap/i18n/dictionaries/lang-key";
|
||||||
|
|
||||||
|
const ru: typeof langKey = {
|
||||||
|
global: {
|
||||||
|
home: "Дом",
|
||||||
|
loading: "Загрузка",
|
||||||
|
dashboard: "Панель приборов",
|
||||||
|
},
|
||||||
|
dashboard: {
|
||||||
|
invoice: {
|
||||||
|
createButton: "Создать случайный счет-фактуру",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ru;
|
18
src/bootstrap/i18n/i18n-provider.tsx
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { I18nextProvider } from "react-i18next";
|
||||||
|
import { initI18next } from "@/bootstrap/i18n/i18n";
|
||||||
|
import { createInstance, Resource } from "i18next";
|
||||||
|
import { PropsWithChildren } from "react";
|
||||||
|
|
||||||
|
export default function TranslationsProvider({
|
||||||
|
children,
|
||||||
|
lng,
|
||||||
|
resources,
|
||||||
|
}: PropsWithChildren & { lng: string; resources: Resource }) {
|
||||||
|
const i18n = createInstance();
|
||||||
|
|
||||||
|
initI18next({ lng, i18n, resources });
|
||||||
|
|
||||||
|
return <I18nextProvider i18n={i18n}>{children}</I18nextProvider>;
|
||||||
|
}
|
53
src/bootstrap/i18n/i18n.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import { getOptions, languages } from "@/bootstrap/i18n/settings";
|
||||||
|
import { createInstance, i18n, Resource } from "i18next";
|
||||||
|
import resourcesToBackend from "i18next-resources-to-backend";
|
||||||
|
import { initReactI18next } from "react-i18next/initReactI18next";
|
||||||
|
|
||||||
|
const initI18nextInstance = createInstance();
|
||||||
|
|
||||||
|
export const initI18next = async (params: {
|
||||||
|
lng: string;
|
||||||
|
i18n?: i18n;
|
||||||
|
resources?: Resource;
|
||||||
|
ns?: string;
|
||||||
|
}) => {
|
||||||
|
const { lng, i18n, ns, resources } = params;
|
||||||
|
const i18nInstance = i18n || initI18nextInstance;
|
||||||
|
await i18nInstance
|
||||||
|
.use(initReactI18next)
|
||||||
|
.use(
|
||||||
|
resourcesToBackend(
|
||||||
|
(language: string) => import(`./dictionaries/${language}.ts`),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.init({
|
||||||
|
...getOptions(lng, ns),
|
||||||
|
resources,
|
||||||
|
preload: resources ? [] : languages,
|
||||||
|
});
|
||||||
|
|
||||||
|
await i18nInstance.init();
|
||||||
|
|
||||||
|
return {
|
||||||
|
i18n: i18nInstance,
|
||||||
|
resources: i18nInstance.services.resourceStore.data,
|
||||||
|
t: i18nInstance.t,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getServerTranslation(
|
||||||
|
lng: string,
|
||||||
|
ns?: string,
|
||||||
|
options: { keyPrefix?: string } = {},
|
||||||
|
) {
|
||||||
|
const i18nextInstance = (await initI18next({ lng, ns })).i18n;
|
||||||
|
|
||||||
|
return {
|
||||||
|
t: i18nextInstance.getFixedT(
|
||||||
|
lng,
|
||||||
|
Array.isArray(ns) ? ns[0] : ns,
|
||||||
|
options?.keyPrefix,
|
||||||
|
),
|
||||||
|
i18n: i18nextInstance,
|
||||||
|
};
|
||||||
|
}
|
16
src/bootstrap/i18n/settings.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
export const fallbackLng = "en";
|
||||||
|
export const languages = [fallbackLng, "ru"];
|
||||||
|
export const defaultNS = "translation";
|
||||||
|
export const cookieName = "i18next";
|
||||||
|
|
||||||
|
export function getOptions(lng = fallbackLng, ns = defaultNS) {
|
||||||
|
return {
|
||||||
|
// debug: true,
|
||||||
|
supportedLngs: languages,
|
||||||
|
fallbackLng,
|
||||||
|
lng,
|
||||||
|
fallbackNS: defaultNS,
|
||||||
|
defaultNS,
|
||||||
|
ns,
|
||||||
|
};
|
||||||
|
}
|
11
src/feature/common/data/api-task.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { Either } from "fp-ts/lib/Either";
|
||||||
|
import { TaskEither } from "fp-ts/lib/TaskEither";
|
||||||
|
import BaseFailure from "@/feature/common/failures/base-failure";
|
||||||
|
|
||||||
|
type ApiTask<ResponseType> = TaskEither<BaseFailure<unknown>, ResponseType>;
|
||||||
|
export type ApiEither<ResponseType> = Either<
|
||||||
|
BaseFailure<unknown>,
|
||||||
|
ResponseType
|
||||||
|
>;
|
||||||
|
|
||||||
|
export default ApiTask;
|
26
src/feature/common/failures/base-failure.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { makeFailureMessage } from "@/feature/common/failures/failure-helpers";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is a class called BaseFailure that extends the Error class. It is
|
||||||
|
* used as a base class for creating custom failure classes.
|
||||||
|
*/
|
||||||
|
export default abstract class BaseFailure<META_DATA> {
|
||||||
|
/* ------------------------------- Attributes ------------------------------- */
|
||||||
|
private readonly BASE_FAILURE_MESSAGE = "failure";
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/**
|
||||||
|
* Use this message as key lang for failure messages
|
||||||
|
*/
|
||||||
|
message = this.BASE_FAILURE_MESSAGE;
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
metadata: META_DATA | undefined;
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
constructor(key: string, metadata?: META_DATA) {
|
||||||
|
this.message = makeFailureMessage(this.message, key);
|
||||||
|
this.metadata = metadata ?? undefined;
|
||||||
|
}
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
}
|
14
src/feature/common/failures/dev/arguments-failure.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import BaseDevFailure from "@/feature/common/failures/dev/base-dev-failure";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Failure for needed arguments in a method but sent wrong one
|
||||||
|
*/
|
||||||
|
export default class ArgumentsFailure<
|
||||||
|
META_DATA,
|
||||||
|
> extends BaseDevFailure<META_DATA> {
|
||||||
|
/* ------------------------------- Constructor ------------------------------ */
|
||||||
|
constructor(metadata?: META_DATA) {
|
||||||
|
super("arguments", metadata);
|
||||||
|
}
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
}
|
5
src/feature/common/failures/dev/base-dev-failure.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import BaseFailure from "@/feature/common/failures/base-failure";
|
||||||
|
|
||||||
|
export default abstract class BaseDevFailure<
|
||||||
|
META_DATA,
|
||||||
|
> extends BaseFailure<META_DATA> {}
|
12
src/feature/common/failures/dev/dependency-failure.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import BaseDevFailure from "@/feature/common/failures/dev/base-dev-failure";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is a failure of not having specific dependency
|
||||||
|
*/
|
||||||
|
export default class DependencyFailure<
|
||||||
|
META_DATA,
|
||||||
|
> extends BaseDevFailure<META_DATA> {
|
||||||
|
constructor(metadata: META_DATA) {
|
||||||
|
super("DependencyFailure", metadata);
|
||||||
|
}
|
||||||
|
}
|
99
src/feature/common/failures/failure-helpers.ts
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
import BaseFailure from "@/feature/common/failures/base-failure";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This method is supposed to save previous failure of TaskEither
|
||||||
|
* to prevent it from loosing and overriding by the new one.
|
||||||
|
*
|
||||||
|
* Usage example:
|
||||||
|
* ```ts
|
||||||
|
* tryCatch(
|
||||||
|
* async () => {
|
||||||
|
* ...
|
||||||
|
* throw ValidationFailure();
|
||||||
|
* ...
|
||||||
|
* },
|
||||||
|
* (reason) => failureOr(reason, new UserCreationFailure()),
|
||||||
|
* )
|
||||||
|
* ```
|
||||||
|
* In this example `failureOr` will return already throwed
|
||||||
|
* instance of `BaseFailure<any>` which is `ValidationFailure`.
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* @param reason is throwed object.
|
||||||
|
* Basically it can be default `Error` or instance of `BaseFailure<any>`.
|
||||||
|
* @param failure instance of `BaseFailure<any>` that will be returned
|
||||||
|
* if reason is not instance of `BaseFailure<any>`.
|
||||||
|
* @returns `BaseFailure<any>`
|
||||||
|
*/
|
||||||
|
export function failureOr(
|
||||||
|
reason: unknown,
|
||||||
|
failure: BaseFailure<any>,
|
||||||
|
): BaseFailure<any> {
|
||||||
|
if (reason instanceof BaseFailure) {
|
||||||
|
return reason;
|
||||||
|
}
|
||||||
|
return failure;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a function that maps a BaseFailure<any> instance to a new BaseFailure<any> instance of type IfType using the provided mapping function.
|
||||||
|
* @param f A function that maps an instance of IfType to a new instance of BaseFailure<any>.
|
||||||
|
* @param ctor A constructor function for IfType.
|
||||||
|
* @returns A function that maps a BaseFailure<any> instance to a new BaseFailure<any> instance of type IfType.
|
||||||
|
*/
|
||||||
|
export function mapToFailureFrom<IfType extends BaseFailure<any>>(
|
||||||
|
f: (t: IfType) => BaseFailure<any>,
|
||||||
|
ctor: new (...args: never[]) => IfType,
|
||||||
|
): (t: BaseFailure<any>) => BaseFailure<any> {
|
||||||
|
return mapIfInstance<IfType, BaseFailure<any>>(f, ctor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps an instance of a class to a response using a provided function.
|
||||||
|
*
|
||||||
|
* @template IfType - The type of the instance to map.
|
||||||
|
* @template Response - The type of the response to map to.
|
||||||
|
* @param {function} f - The function to use to map the instance to a response.
|
||||||
|
* @param {new (...args: never[]) => IfType} ctor - The constructor function of the instance to map.
|
||||||
|
* @returns {(t: IfType | Response) => IfType | Response} - A function that maps the instance to a response using the provided function.
|
||||||
|
*/
|
||||||
|
export function mapIfInstance<IfType, Response>(
|
||||||
|
f: (t: IfType) => Response,
|
||||||
|
ctor: new (...args: never[]) => IfType,
|
||||||
|
) {
|
||||||
|
return (t: IfType | Response) => {
|
||||||
|
if (t instanceof ctor) {
|
||||||
|
return f(t);
|
||||||
|
}
|
||||||
|
return t;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps a function to a value if it is not an instance of a given class.
|
||||||
|
* @template IfType The type of the value to be mapped.
|
||||||
|
* @template Response The type of the mapped value.
|
||||||
|
* @param {function} f The function to map the value with.
|
||||||
|
* @param {new (...args: never[]) => IfType} ctor The class to check the value against.
|
||||||
|
* @returns {function} A function that maps the value if it is not an instance of the given class.
|
||||||
|
*/
|
||||||
|
export function mapIfNotInstance<IfType, Response>(
|
||||||
|
f: (t: IfType) => Response,
|
||||||
|
ctor: new (...args: never[]) => IfType,
|
||||||
|
) {
|
||||||
|
return (t: IfType | Response) => {
|
||||||
|
if (t! instanceof ctor) {
|
||||||
|
return f(t);
|
||||||
|
}
|
||||||
|
return t;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets Message key and it'll add it to the failure message key hierarchy
|
||||||
|
*/
|
||||||
|
export function makeFailureMessage(message: string, key: string) {
|
||||||
|
if (!key) return message;
|
||||||
|
return `${message}.${key}`;
|
||||||
|
}
|
12
src/feature/common/failures/network-failure.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import BaseFailure from "./base-failure";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Failure for HTTP response when response dosn't have base structure
|
||||||
|
*/
|
||||||
|
export default class NetworkFailure<META_DATA> extends BaseFailure<META_DATA> {
|
||||||
|
/* ------------------------------- Constructor ------------------------------ */
|
||||||
|
constructor(metaData?: META_DATA) {
|
||||||
|
super("network", metaData);
|
||||||
|
}
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
}
|
12
src/feature/common/failures/params-failure.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import BaseFailure from "./base-failure";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Failure for params failure
|
||||||
|
*/
|
||||||
|
export default class ParamsFailure<META_DATA> extends BaseFailure<META_DATA> {
|
||||||
|
/* ------------------------------- Constructor ------------------------------ */
|
||||||
|
constructor(metadata?: META_DATA) {
|
||||||
|
super("params", metadata);
|
||||||
|
}
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
}
|
5
src/feature/common/feature-helpers.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export const formatCurrency = (amount: number) =>
|
||||||
|
(amount / 100).toLocaleString("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "USD",
|
||||||
|
});
|
30
src/feature/common/server-di.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import getCustomerInvoiceDi from "@/feature/core/customer-invoice/data/module/customer-invoice-di";
|
||||||
|
import { customerInvoiceModuleKey } from "@/feature/core/customer-invoice/invoice-module-key";
|
||||||
|
import { customerKey } from "@/feature/core/customer/customer-key";
|
||||||
|
import getCustomerDi from "@/feature/core/customer/data/module/customer-di";
|
||||||
|
import getInvoiceDi from "@/feature/core/invoice/data/module/invoice-di";
|
||||||
|
import { invoiceModuleKey } from "@/feature/core/invoice/invoice-module-key";
|
||||||
|
import { DependencyContainer } from "tsyringe";
|
||||||
|
import { summaryInfoModuleKey } from "@/feature/core/summary-info/domain/summary-info-module-key";
|
||||||
|
import getSummaryInfoDi from "@/feature/core/summary-info/data/module/summary-info-di";
|
||||||
|
import { revenueModuleKey } from "@/feature/core/revenue/domain/revenue-module-key";
|
||||||
|
import getRevenueDi from "@/feature/core/revenue/data/module/revenue-di";
|
||||||
|
|
||||||
|
const memoizedDis: Record<string, DependencyContainer> = {};
|
||||||
|
|
||||||
|
export default function serverDi(module: string): DependencyContainer {
|
||||||
|
if (memoizedDis[module]) return memoizedDis[module];
|
||||||
|
const getDi = {
|
||||||
|
[customerKey]: getCustomerDi,
|
||||||
|
[customerInvoiceModuleKey]: getCustomerInvoiceDi,
|
||||||
|
[invoiceModuleKey]: getInvoiceDi,
|
||||||
|
[summaryInfoModuleKey]: getSummaryInfoDi,
|
||||||
|
[revenueModuleKey]: getRevenueDi,
|
||||||
|
}[module];
|
||||||
|
|
||||||
|
if (!getDi) throw new Error(`Server Di didn't found for module: ${module}`);
|
||||||
|
|
||||||
|
const di = getDi();
|
||||||
|
memoizedDis[module] = di;
|
||||||
|
return di;
|
||||||
|
}
|
@ -0,0 +1,11 @@
|
|||||||
|
import di from "@/bootstrap/di/init-di";
|
||||||
|
import CustomerInvoiceDbRepo from "@/feature/core/customer-invoice/data/repo/customer-invoice-db-repo";
|
||||||
|
import { customerInvoiceRepoKey } from "@/feature/core/customer-invoice/domain/i-repo/customer-invoice-repo";
|
||||||
|
import { DependencyContainer } from "tsyringe";
|
||||||
|
|
||||||
|
export default function getCustomerInvoiceDi(): DependencyContainer {
|
||||||
|
const customerInvoiceDi = di.createChildContainer();
|
||||||
|
|
||||||
|
customerInvoiceDi.register(customerInvoiceRepoKey, CustomerInvoiceDbRepo);
|
||||||
|
return customerInvoiceDi;
|
||||||
|
}
|
@ -0,0 +1,58 @@
|
|||||||
|
import { sql } from "@/bootstrap/boundaries/db/db";
|
||||||
|
import ApiTask from "@/feature/common/data/api-task";
|
||||||
|
import { failureOr } from "@/feature/common/failures/failure-helpers";
|
||||||
|
import NetworkFailure from "@/feature/common/failures/network-failure";
|
||||||
|
import { formatCurrency } from "@/feature/common/feature-helpers";
|
||||||
|
import CustomerInvoice from "@/feature/core/customer-invoice/domain/entity/customer-invoice";
|
||||||
|
import CustomerInvoiceRepo from "@/feature/core/customer-invoice/domain/i-repo/customer-invoice-repo";
|
||||||
|
import { pipe } from "fp-ts/lib/function";
|
||||||
|
import { tryCatch } from "fp-ts/lib/TaskEither";
|
||||||
|
import postgres from "postgres";
|
||||||
|
|
||||||
|
type customerInvoiceDbResponse = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
image_url: string;
|
||||||
|
email: string;
|
||||||
|
amount: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default class CustomerInvoiceDbRepo implements CustomerInvoiceRepo {
|
||||||
|
fetchList(): ApiTask<CustomerInvoice[]> {
|
||||||
|
return pipe(
|
||||||
|
tryCatch(
|
||||||
|
async () => {
|
||||||
|
const response = (await sql`
|
||||||
|
SELECT invoices.amount, customers.name, customers.image_url, customers.email, invoices.id
|
||||||
|
FROM invoices
|
||||||
|
JOIN customers ON invoices.customer_id = customers.id
|
||||||
|
ORDER BY invoices.date DESC
|
||||||
|
LIMIT 20 `) as postgres.RowList<
|
||||||
|
customerInvoiceDbResponse[]
|
||||||
|
>;
|
||||||
|
|
||||||
|
return this.customerInvoicesDto(response);
|
||||||
|
},
|
||||||
|
(l) => failureOr(l, new NetworkFailure()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private customerInvoicesDto(
|
||||||
|
dbCustomers: customerInvoiceDbResponse[],
|
||||||
|
): CustomerInvoice[] {
|
||||||
|
return dbCustomers.map((customer) => this.customerInvoiceDto(customer));
|
||||||
|
}
|
||||||
|
|
||||||
|
private customerInvoiceDto(
|
||||||
|
dbCustomer: customerInvoiceDbResponse,
|
||||||
|
): CustomerInvoice {
|
||||||
|
return new CustomerInvoice({
|
||||||
|
id: dbCustomer.id,
|
||||||
|
customerName: dbCustomer.name,
|
||||||
|
customerEmail: dbCustomer.email,
|
||||||
|
customerImageUrl: dbCustomer.image_url,
|
||||||
|
invoicesAmount: formatCurrency(+dbCustomer.amount),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,25 @@
|
|||||||
|
export default class CustomerInvoice {
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
customerName: string;
|
||||||
|
|
||||||
|
customerImageUrl: string;
|
||||||
|
|
||||||
|
customerEmail: string;
|
||||||
|
|
||||||
|
invoicesAmount: string;
|
||||||
|
|
||||||
|
constructor({
|
||||||
|
id,
|
||||||
|
customerEmail,
|
||||||
|
customerImageUrl,
|
||||||
|
customerName,
|
||||||
|
invoicesAmount,
|
||||||
|
}: CustomerInvoice) {
|
||||||
|
this.id = id;
|
||||||
|
this.customerEmail = customerEmail;
|
||||||
|
this.customerImageUrl = customerImageUrl;
|
||||||
|
this.customerName = customerName;
|
||||||
|
this.invoicesAmount = invoicesAmount;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,8 @@
|
|||||||
|
import ApiTask from "@/feature/common/data/api-task";
|
||||||
|
import CustomerInvoice from "@/feature/core/customer-invoice/domain/entity/customer-invoice";
|
||||||
|
|
||||||
|
export default interface CustomerInvoiceRepo {
|
||||||
|
fetchList(): ApiTask<CustomerInvoice[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const customerInvoiceRepoKey = "customerInvoiceRepoKey";
|
@ -0,0 +1,17 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { ApiEither } from "@/feature/common/data/api-task";
|
||||||
|
import serverDi from "@/feature/common/server-di";
|
||||||
|
import CustomerInvoice from "@/feature/core/customer-invoice/domain/entity/customer-invoice";
|
||||||
|
import CustomerInvoiceRepo, {
|
||||||
|
customerInvoiceRepoKey,
|
||||||
|
} from "@/feature/core/customer-invoice/domain/i-repo/customer-invoice-repo";
|
||||||
|
import { customerInvoiceModuleKey } from "@/feature/core/customer-invoice/invoice-module-key";
|
||||||
|
|
||||||
|
export default function fetchCustomerInvoicesUsecase(): Promise<
|
||||||
|
ApiEither<CustomerInvoice[]>
|
||||||
|
> {
|
||||||
|
const repo = serverDi(customerInvoiceModuleKey).resolve<CustomerInvoiceRepo>(
|
||||||
|
customerInvoiceRepoKey,
|
||||||
|
);
|
||||||
|
return repo.fetchList()();
|
||||||
|
}
|
1
src/feature/core/customer-invoice/invoice-module-key.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export const customerInvoiceModuleKey = "customerInvoiceModuleKey";
|
1
src/feature/core/customer/customer-key.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export const customerKey = "customerKey";
|
11
src/feature/core/customer/data/module/customer-di.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import di from "@/bootstrap/di/init-di";
|
||||||
|
import CustomerDbRepo from "@/feature/core/customer/data/repo/customer-db-repo";
|
||||||
|
import { customerRepoKey } from "@/feature/core/customer/domain/i-repo/customer-repo";
|
||||||
|
import { DependencyContainer } from "tsyringe";
|
||||||
|
|
||||||
|
export default function getCustomerDi(): DependencyContainer {
|
||||||
|
const customerDi = di.createChildContainer();
|
||||||
|
|
||||||
|
customerDi.register(customerRepoKey, CustomerDbRepo);
|
||||||
|
return customerDi;
|
||||||
|
}
|
76
src/feature/core/customer/data/repo/customer-db-repo.ts
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import { sql } from "@/bootstrap/boundaries/db/db";
|
||||||
|
import ApiTask from "@/feature/common/data/api-task";
|
||||||
|
import { failureOr } from "@/feature/common/failures/failure-helpers";
|
||||||
|
import NetworkFailure from "@/feature/common/failures/network-failure";
|
||||||
|
import { formatCurrency } from "@/feature/common/feature-helpers";
|
||||||
|
import Customer from "@/feature/core/customer/domain/entity/customer";
|
||||||
|
import CustomerRepo from "@/feature/core/customer/domain/i-repo/customer-repo";
|
||||||
|
import { pipe } from "fp-ts/lib/function";
|
||||||
|
import { map, tryCatch } from "fp-ts/lib/TaskEither";
|
||||||
|
import postgres from "postgres";
|
||||||
|
|
||||||
|
type customerDbResponse = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
image_url: string;
|
||||||
|
total_invoices: string;
|
||||||
|
total_pending: string;
|
||||||
|
total_paid: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default class CustomerDbRepo implements CustomerRepo {
|
||||||
|
fetchList(query: string): ApiTask<Customer[]> {
|
||||||
|
return pipe(
|
||||||
|
tryCatch(
|
||||||
|
async () => {
|
||||||
|
const data = (await sql`
|
||||||
|
SELECT
|
||||||
|
customers.id,
|
||||||
|
customers.name,
|
||||||
|
customers.email,
|
||||||
|
customers.image_url,
|
||||||
|
COUNT(invoices.id) AS total_invoices,
|
||||||
|
SUM(CASE WHEN invoices.status = 'pending' THEN invoices.amount ELSE 0 END) AS total_pending,
|
||||||
|
SUM(CASE WHEN invoices.status = 'paid' THEN invoices.amount ELSE 0 END) AS total_paid
|
||||||
|
FROM customers
|
||||||
|
LEFT JOIN invoices ON customers.id = invoices.customer_id
|
||||||
|
WHERE
|
||||||
|
customers.name ILIKE ${`%${query}%`} OR
|
||||||
|
customers.email ILIKE ${`%${query}%`}
|
||||||
|
GROUP BY customers.id, customers.name, customers.email, customers.image_url
|
||||||
|
ORDER BY customers.name ASC
|
||||||
|
`) as postgres.RowList<customerDbResponse[]>;
|
||||||
|
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
(l) => failureOr(l, new NetworkFailure(l as Error)),
|
||||||
|
),
|
||||||
|
map(this.customersDto.bind(this)),
|
||||||
|
) as ApiTask<Customer[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchCustomersAmount(): Promise<number> {
|
||||||
|
const data =
|
||||||
|
(await sql`SELECT COUNT(*) FROM customers`) as postgres.RowList<
|
||||||
|
unknown[]
|
||||||
|
>;
|
||||||
|
return Number(data.count ?? "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
private customersDto(dbCustomers: customerDbResponse[]): Customer[] {
|
||||||
|
return dbCustomers.map((customer) => this.customerDto(customer));
|
||||||
|
}
|
||||||
|
|
||||||
|
private customerDto(dbCustomer: customerDbResponse): Customer {
|
||||||
|
return new Customer({
|
||||||
|
id: dbCustomer.id,
|
||||||
|
name: dbCustomer.name,
|
||||||
|
email: dbCustomer.email,
|
||||||
|
imageUrl: dbCustomer.image_url,
|
||||||
|
totalInvoices: dbCustomer.total_invoices,
|
||||||
|
totalPending: formatCurrency(Number(dbCustomer.total_pending)),
|
||||||
|
totalPaid: formatCurrency(Number(dbCustomer.total_paid)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
33
src/feature/core/customer/domain/entity/customer.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
export default class Customer {
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
imageUrl: string;
|
||||||
|
|
||||||
|
totalInvoices: string;
|
||||||
|
|
||||||
|
totalPending: string;
|
||||||
|
|
||||||
|
totalPaid: string;
|
||||||
|
|
||||||
|
constructor({
|
||||||
|
id,
|
||||||
|
email,
|
||||||
|
imageUrl,
|
||||||
|
name,
|
||||||
|
totalInvoices,
|
||||||
|
totalPaid,
|
||||||
|
totalPending,
|
||||||
|
}: Customer) {
|
||||||
|
this.id = id;
|
||||||
|
this.name = name;
|
||||||
|
this.email = email;
|
||||||
|
this.imageUrl = imageUrl;
|
||||||
|
this.totalInvoices = totalInvoices;
|
||||||
|
this.totalPaid = totalPaid;
|
||||||
|
this.totalPending = totalPending;
|
||||||
|
}
|
||||||
|
}
|
9
src/feature/core/customer/domain/i-repo/customer-repo.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import ApiTask from "@/feature/common/data/api-task";
|
||||||
|
import Customer from "@/feature/core/customer/domain/entity/customer";
|
||||||
|
|
||||||
|
export default interface CustomerRepo {
|
||||||
|
fetchList(query: string): ApiTask<Customer[]>;
|
||||||
|
fetchCustomersAmount(): Promise<number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const customerRepoKey = "customerRepoKey";
|
@ -0,0 +1,11 @@
|
|||||||
|
import "server-only";
|
||||||
|
import serverDi from "@/feature/common/server-di";
|
||||||
|
import { customerKey } from "@/feature/core/customer/customer-key";
|
||||||
|
import CustomerRepo, {
|
||||||
|
customerRepoKey,
|
||||||
|
} from "@/feature/core/customer/domain/i-repo/customer-repo";
|
||||||
|
|
||||||
|
export default async function fetchCustomersAmountUsecase(): Promise<number> {
|
||||||
|
const repo = serverDi(customerKey).resolve<CustomerRepo>(customerRepoKey);
|
||||||
|
return repo.fetchCustomersAmount();
|
||||||
|
}
|
@ -0,0 +1,16 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { ApiEither } from "@/feature/common/data/api-task";
|
||||||
|
import serverDi from "@/feature/common/server-di";
|
||||||
|
import { customerKey } from "@/feature/core/customer/customer-key";
|
||||||
|
import Customer from "@/feature/core/customer/domain/entity/customer";
|
||||||
|
import CustomerRepo, {
|
||||||
|
customerRepoKey,
|
||||||
|
} from "@/feature/core/customer/domain/i-repo/customer-repo";
|
||||||
|
|
||||||
|
export default function fetchCustomersUsecase(
|
||||||
|
query: string,
|
||||||
|
): Promise<ApiEither<Customer[]>> {
|
||||||
|
const repo = serverDi(customerKey).resolve<CustomerRepo>(customerRepoKey);
|
||||||
|
|
||||||
|
return repo.fetchList(query)();
|
||||||
|
}
|
11
src/feature/core/invoice/data/module/invoice-di.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import di from "@/bootstrap/di/init-di";
|
||||||
|
import invoiceDbRepo from "@/feature/core/invoice/data/repo/invoice-db-repo";
|
||||||
|
import { invoiceRepoKey } from "@/feature/core/invoice/domain/i-repo/invoice-repo";
|
||||||
|
import { DependencyContainer } from "tsyringe";
|
||||||
|
|
||||||
|
export default function getInvoiceDi(): DependencyContainer {
|
||||||
|
const invoiceDi = di.createChildContainer();
|
||||||
|
|
||||||
|
invoiceDi.register(invoiceRepoKey, invoiceDbRepo);
|
||||||
|
return invoiceDi;
|
||||||
|
}
|
69
src/feature/core/invoice/data/repo/invoice-db-repo.ts
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import { sql } from "@/bootstrap/boundaries/db/db";
|
||||||
|
import ApiTask from "@/feature/common/data/api-task";
|
||||||
|
import { failureOr } from "@/feature/common/failures/failure-helpers";
|
||||||
|
import NetworkFailure from "@/feature/common/failures/network-failure";
|
||||||
|
import { formatCurrency } from "@/feature/common/feature-helpers";
|
||||||
|
import InvoiceRepo from "@/feature/core/invoice/domain/i-repo/invoice-repo";
|
||||||
|
import { InvoiceParam } from "@/feature/core/invoice/domain/param/invoice-param";
|
||||||
|
import InvoiceStatusSummary from "@/feature/core/invoice/domain/value-object/invoice-status";
|
||||||
|
import { pipe } from "fp-ts/lib/function";
|
||||||
|
import { tryCatch } from "fp-ts/lib/TaskEither";
|
||||||
|
import postgres from "postgres";
|
||||||
|
|
||||||
|
type InvoiceSummaryDbResponse = { paid: string; pending: string };
|
||||||
|
export default class InvoiceDbRepo implements InvoiceRepo {
|
||||||
|
async fetchAllInvoicesAmount(): Promise<number> {
|
||||||
|
const data = (await sql`SELECT COUNT(*) FROM invoices`) as postgres.RowList<
|
||||||
|
unknown[]
|
||||||
|
>;
|
||||||
|
|
||||||
|
return data.count ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
createInvoice(params: InvoiceParam): ApiTask<string> {
|
||||||
|
return pipe(
|
||||||
|
tryCatch(
|
||||||
|
async () => {
|
||||||
|
const firstCustomerIdDb = await sql`SELECT
|
||||||
|
id FROM customers
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
const customerId = firstCustomerIdDb.at(0)?.id;
|
||||||
|
if (!customerId) throw new Error("There is no customer");
|
||||||
|
|
||||||
|
const { amount, status } = params;
|
||||||
|
const amountInCents = amount * 100;
|
||||||
|
const date = new Date().toISOString().split("T")[0];
|
||||||
|
|
||||||
|
// Insert data into the database
|
||||||
|
const result = await sql`
|
||||||
|
INSERT INTO invoices (customer_id, amount, status, date)
|
||||||
|
VALUES (${customerId}, ${amountInCents}, ${status}, ${date})
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
return result.at(0)?.id ?? "";
|
||||||
|
},
|
||||||
|
(l) => failureOr(l, new NetworkFailure(l as Error)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchInvoicesStatusSummary(): Promise<InvoiceStatusSummary> {
|
||||||
|
const invoiceStatusPromise = (await sql`SELECT
|
||||||
|
SUM(CASE WHEN status = 'paid' THEN amount ELSE 0 END) AS "paid",
|
||||||
|
SUM(CASE WHEN status = 'pending' THEN amount ELSE 0 END) AS "pending"
|
||||||
|
FROM invoices`) as postgres.RowList<InvoiceSummaryDbResponse[]>;
|
||||||
|
|
||||||
|
return this.invoiceSummaryDto(invoiceStatusPromise.at(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
private invoiceSummaryDto(
|
||||||
|
dbResponse?: InvoiceSummaryDbResponse,
|
||||||
|
): InvoiceStatusSummary {
|
||||||
|
return new InvoiceStatusSummary({
|
||||||
|
paid: formatCurrency(Number(dbResponse?.paid ?? "0")),
|
||||||
|
pending: formatCurrency(Number(dbResponse?.pending ?? "0")),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
11
src/feature/core/invoice/domain/i-repo/invoice-repo.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import ApiTask from "@/feature/common/data/api-task";
|
||||||
|
import { InvoiceParam } from "@/feature/core/invoice/domain/param/invoice-param";
|
||||||
|
import InvoiceStatusSummary from "@/feature/core/invoice/domain/value-object/invoice-status";
|
||||||
|
|
||||||
|
export default interface InvoiceRepo {
|
||||||
|
fetchAllInvoicesAmount(): Promise<number>;
|
||||||
|
fetchInvoicesStatusSummary(): Promise<InvoiceStatusSummary>;
|
||||||
|
createInvoice(params: InvoiceParam): ApiTask<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const invoiceRepoKey = "invoiceRepoKey";
|
12
src/feature/core/invoice/domain/param/invoice-param.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const invoiceSchema = z.object({
|
||||||
|
amount: z.coerce
|
||||||
|
.number()
|
||||||
|
.gt(0, { message: "Please enter an amount greater than $0." }),
|
||||||
|
status: z.enum(["pending", "paid"], {
|
||||||
|
invalid_type_error: "Please select an invoice status.",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type InvoiceParam = z.infer<typeof invoiceSchema>;
|
@ -0,0 +1,32 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { ApiEither } from "@/feature/common/data/api-task";
|
||||||
|
import ParamsFailure from "@/feature/common/failures/params-failure";
|
||||||
|
import serverDi from "@/feature/common/server-di";
|
||||||
|
import InvoiceRepo, {
|
||||||
|
invoiceRepoKey,
|
||||||
|
} from "@/feature/core/invoice/domain/i-repo/invoice-repo";
|
||||||
|
import {
|
||||||
|
InvoiceParam,
|
||||||
|
invoiceSchema,
|
||||||
|
} from "@/feature/core/invoice/domain/param/invoice-param";
|
||||||
|
import { invoiceModuleKey } from "@/feature/core/invoice/invoice-module-key";
|
||||||
|
import { pipe } from "fp-ts/lib/function";
|
||||||
|
import { chain, fromNullable, left, map, right } from "fp-ts/lib/TaskEither";
|
||||||
|
|
||||||
|
export default async function createInvoiceUsecase(
|
||||||
|
params: InvoiceParam,
|
||||||
|
): Promise<ApiEither<string>> {
|
||||||
|
const repo = serverDi(invoiceModuleKey).resolve<InvoiceRepo>(invoiceRepoKey);
|
||||||
|
|
||||||
|
return pipe(
|
||||||
|
fromNullable(new ParamsFailure())(params),
|
||||||
|
map((params) => invoiceSchema.safeParse(params)),
|
||||||
|
chain((params) => {
|
||||||
|
const isParamsValid = invoiceSchema.safeParse(params);
|
||||||
|
if (!isParamsValid.success) left(new ParamsFailure());
|
||||||
|
return right(params.data as InvoiceParam);
|
||||||
|
}),
|
||||||
|
chain((params) => repo.createInvoice(params)),
|
||||||
|
)();
|
||||||
|
}
|
@ -0,0 +1,12 @@
|
|||||||
|
import "server-only";
|
||||||
|
import serverDi from "@/feature/common/server-di";
|
||||||
|
import InvoiceRepo, {
|
||||||
|
invoiceRepoKey,
|
||||||
|
} from "@/feature/core/invoice/domain/i-repo/invoice-repo";
|
||||||
|
import { invoiceModuleKey } from "@/feature/core/invoice/invoice-module-key";
|
||||||
|
|
||||||
|
export default function fetchAllInvoicesAmountUsecase(): Promise<number> {
|
||||||
|
const repo = serverDi(invoiceModuleKey).resolve<InvoiceRepo>(invoiceRepoKey);
|
||||||
|
|
||||||
|
return repo.fetchAllInvoicesAmount();
|
||||||
|
}
|
@ -0,0 +1,12 @@
|
|||||||
|
import "server-only";
|
||||||
|
import serverDi from "@/feature/common/server-di";
|
||||||
|
import InvoiceRepo, {
|
||||||
|
invoiceRepoKey,
|
||||||
|
} from "@/feature/core/invoice/domain/i-repo/invoice-repo";
|
||||||
|
import InvoiceStatusSummary from "@/feature/core/invoice/domain/value-object/invoice-status";
|
||||||
|
import { invoiceModuleKey } from "@/feature/core/invoice/invoice-module-key";
|
||||||
|
|
||||||
|
export default function fetchInvoicesStatusSummary(): Promise<InvoiceStatusSummary> {
|
||||||
|
const repo = serverDi(invoiceModuleKey).resolve<InvoiceRepo>(invoiceRepoKey);
|
||||||
|
return repo.fetchInvoicesStatusSummary();
|
||||||
|
}
|