├── .github ├── actions │ └── app-setup │ │ └── action.yml ├── dependabot.yml └── workflows │ ├── app-preview.yml │ ├── app-release.yml │ ├── app-shared.yml │ └── health.yml ├── .gitignore ├── LICENSE ├── README.md └── app ├── .env.sample ├── .env.tests ├── .eslintrc.js ├── .nvmrc ├── .prettierignore ├── .prettierrc ├── .storybook ├── main.js ├── preview-head.html ├── preview.js └── public │ └── mockServiceWorker.js ├── docker-compose.common.yml ├── docker-compose.yml ├── jest.config.js ├── jest.integration.config.js ├── jest.setup.tsx ├── jest.unit.config.js ├── next-env.d.ts ├── next.config.js ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── prisma ├── migrations │ ├── 20220712141833_next_auth │ │ └── migration.sql │ ├── 20220817063758_create_company_product_customer_invoice │ │ └── migration.sql │ ├── 20220817144122_remove_product_defaults │ │ └── migration.sql │ ├── 20220818062848_reuse_product_customer_company_tables │ │ └── migration.sql │ ├── 20220818065314_correct_invoice_uniqueness │ │ └── migration.sql │ ├── 20220825054009_add_invoice_product_date │ │ └── migration.sql │ ├── 20220826084222_make_invoice_product_unique_in_invoiceproduct │ │ └── migration.sql │ ├── 20221005144142_make_some_fields_optional │ │ └── migration.sql │ ├── 20221025193634_customer_to_client │ │ └── migration.sql │ ├── 20221025204805_better_data_model │ │ └── migration.sql │ ├── 20221026121251_fix_invoice_relationships_with_company_and_client │ │ └── migration.sql │ ├── 20221030085537_remove_invoice_id_from_company │ │ └── migration.sql │ ├── 20221030090837_make_invoice_number_optional │ │ └── migration.sql │ ├── 20221118111735_client_names_into_contact_name │ │ └── migration.sql │ ├── 20221118113050_add_company_contact_name │ │ └── migration.sql │ ├── 20221119133130_add_message_to_invoice │ │ └── migration.sql │ ├── 20221120151731_add_order_to_line_item │ │ └── migration.sql │ ├── 20221205155229_add_default_message_to_company_info │ │ └── migration.sql │ ├── 20221213152207_make_client_number_optional │ │ └── migration.sql │ └── migration_lock.toml └── schema.prisma ├── public ├── beet-bill-logo-email.png ├── beetbill_cookie_policy.pdf ├── beetbill_privacy_policy.pdf ├── beetbill_terms_and_conditions.pdf ├── favicon.ico ├── fonts │ ├── Inter-Black.ttf │ ├── Inter-Bold.ttf │ ├── Inter-ExtraBold.ttf │ ├── Inter-ExtraLight.ttf │ ├── Inter-Light.ttf │ ├── Inter-Medium.ttf │ ├── Inter-Regular.ttf │ ├── Inter-SemiBold.ttf │ └── Inter-Thin.ttf ├── icons │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── icon-128x128.png │ ├── icon-144x144.png │ ├── icon-152x152.png │ ├── icon-192x192.png │ ├── icon-384x384.png │ ├── icon-512x512.png │ ├── icon-72x72.png │ ├── icon-96x96.png │ └── icon.svg └── manifest.json ├── scripts ├── app-build └── merge-coverage-report ├── src ├── components │ ├── Avatar.stories.tsx │ ├── Avatar.tsx │ ├── Button.stories.tsx │ ├── Button.tsx │ ├── Card.stories.tsx │ ├── Card.tsx │ ├── Chip.stories.tsx │ ├── Chip.tsx │ ├── ClientsTable.stories.tsx │ ├── ClientsTable.tsx │ ├── ConfirmationDialog.stories.tsx │ ├── ConfirmationDialog.tsx │ ├── CreateEditClientForm.tsx │ ├── CreateEditInvoiceForm │ │ ├── CreateEditInvoiceForm.tsx │ │ ├── InvoiceFormValues.ts │ │ ├── LineItemOverlay.tsx │ │ ├── LineItemsTable.tsx │ │ ├── LinteItem.tsx │ │ ├── SortableLineItem.tsx │ │ ├── Table.tsx │ │ └── index.ts │ ├── CreateEditProductForm.tsx │ ├── EditCompanyForm.tsx │ ├── EmptyContent.stories.tsx │ ├── EmptyContent.tsx │ ├── Fields │ │ ├── AutocompleteField.stories.tsx │ │ ├── AutocompleteField.tsx │ │ ├── Error.tsx │ │ ├── Label.tsx │ │ ├── SelectField.stories.tsx │ │ ├── SelectField.tsx │ │ ├── TextAreaField.stories.tsx │ │ ├── TextAreaField.tsx │ │ ├── TextField.stories.tsx │ │ ├── TextField.tsx │ │ ├── Tip.tsx │ │ ├── Toggle.stories.tsx │ │ └── Toggle.tsx │ ├── FormCard.tsx │ ├── FullscreenCard.tsx │ ├── IconButton.stories.tsx │ ├── IconButton.tsx │ ├── Icons │ │ ├── GoogleIcon.tsx │ │ ├── Icon.tsx │ │ ├── Icons.stories.tsx │ │ ├── UserPlaceholderIcon.tsx │ │ └── types.ts │ ├── InvoicePDF │ │ ├── InvoicePDF.tsx │ │ ├── Table.tsx │ │ ├── index.ts │ │ └── styles.ts │ ├── InvoicePreview.tsx │ ├── InvoiceStatusMenu.tsx │ ├── InvoiceTotalSection.tsx │ ├── InvoicesTable.tsx │ ├── Layout │ │ ├── BlankLayout.tsx │ │ ├── FullScreenSpinner.stories.tsx │ │ ├── FullScreenSpinner.tsx │ │ ├── Layout.tsx │ │ ├── SidebarLayout.stories.tsx │ │ ├── SidebarLayout.test.tsx │ │ ├── SidebarLayout.tsx │ │ └── index.ts │ ├── LinkButton.stories.tsx │ ├── LinkButton.tsx │ ├── LinkIconButton.stories.tsx │ ├── LinkIconButton.tsx │ ├── Logo.tsx │ ├── Message.stories.tsx │ ├── Message.tsx │ ├── ProductsTable.stories.tsx │ ├── ProductsTable.tsx │ ├── Providers.tsx │ ├── Sidebar │ │ ├── MobileSidebarOpenControls.stories.tsx │ │ ├── MobileSidebarOpenControls.tsx │ │ ├── NavLink.stories.tsx │ │ ├── NavLink.tsx │ │ ├── NavLinks.stories.tsx │ │ ├── NavLinks.tsx │ │ ├── Sidebar.stories.tsx │ │ ├── Sidebar.tsx │ │ ├── SidebarButton.stories.tsx │ │ ├── SidebarButton.tsx │ │ ├── UserMenu.tsx │ │ └── index.ts │ ├── SignInForm.stories.tsx │ ├── SignInForm.tsx │ ├── Spinner.stories.tsx │ ├── Spinner.tsx │ ├── Table.tsx │ ├── Toast.stories.tsx │ ├── Toast.tsx │ ├── WithAuthentication.test.tsx │ ├── WithAuthentication.tsx │ ├── WithNoAuthentication.test.tsx │ └── WithNoAuthentication.tsx ├── lib │ ├── api.ts │ ├── appName.ts │ ├── capitalizeFirstLetter.ts │ ├── clients │ │ ├── queryKeys.ts │ │ ├── useClient.ts │ │ ├── useClients.ts │ │ ├── useCreateClient.ts │ │ ├── useDeleteClient.ts │ │ └── useUpdateClient.ts │ ├── companies │ │ ├── queryKeys.ts │ │ ├── useCompany.ts │ │ └── useUpdateCompany.ts │ ├── emailRegexp.ts │ ├── format.ts │ ├── invoices │ │ ├── calculateTotal.ts │ │ ├── downloadInvoice.tsx │ │ ├── getTitle.ts │ │ ├── queryKeys.ts │ │ ├── useCreateInvoice.ts │ │ ├── useDeleteInvoice.ts │ │ ├── useInvoice.ts │ │ ├── useInvoices.ts │ │ ├── useLatestNumberByPrefix.ts │ │ └── useUpdateInvoice.ts │ ├── products │ │ ├── queryKeys.ts │ │ ├── useCreateProduct.ts │ │ ├── useDeleteProduct.ts │ │ ├── useProduct.ts │ │ ├── useProducts.ts │ │ └── useUpdateProduct.ts │ ├── routes.ts │ ├── testing.tsx │ ├── useDisclosure.tsx │ ├── useDisclosureForId.ts │ ├── useDropdownAnchor.ts │ ├── useFilterFromUrl.ts │ ├── useMutation.ts │ ├── useSortFromUrl.ts │ ├── useSticky.ts │ ├── userName.ts │ └── utilityTypes.ts ├── pages │ ├── 404.page.test.tsx │ ├── 404.page.tsx │ ├── _app.next.tsx │ ├── _document.next.tsx │ ├── api │ │ ├── auth │ │ │ └── [...nextauth].route.ts │ │ ├── health.route.ts │ │ └── trpc │ │ │ └── [trpc].route.ts │ ├── auth │ │ ├── error.page.test.tsx │ │ ├── error.page.tsx │ │ ├── signin.page.test.tsx │ │ ├── signin.page.tsx │ │ ├── verify.page.test.tsx │ │ └── verify.page.tsx │ ├── clients.page.test.tsx │ ├── clients.page.tsx │ ├── clients │ │ ├── [clientId].page.test.tsx │ │ ├── [clientId].page.tsx │ │ ├── new.page.test.tsx │ │ └── new.page.tsx │ ├── company.page.test.tsx │ ├── company.page.tsx │ ├── index.page.tsx │ ├── index.test.tsx │ ├── invoices.page.test.tsx │ ├── invoices.page.tsx │ ├── invoices │ │ ├── [invoiceId].page.test.tsx │ │ ├── [invoiceId].page.tsx │ │ ├── [invoiceId] │ │ │ ├── preview.page.test.tsx │ │ │ └── preview.page.tsx │ │ ├── new.page.test.tsx │ │ └── new.page.tsx │ ├── products.page.test.tsx │ ├── products.page.tsx │ └── products │ │ ├── [productId].page.test.tsx │ │ ├── [productId].page.tsx │ │ ├── new.page.test.tsx │ │ └── new.page.tsx ├── server │ ├── auth │ │ ├── authOptions.ts │ │ ├── callbacks.ts │ │ ├── email.test.tsx │ │ ├── email.tsx │ │ └── events.ts │ ├── clients │ │ ├── createClient.ts │ │ ├── deleteClient.ts │ │ ├── getClient.ts │ │ ├── getClients.ts │ │ ├── mapClientEntity.ts │ │ ├── types.ts │ │ ├── updateClient.ts │ │ └── utils.ts │ ├── company │ │ ├── getCompany.ts │ │ ├── mapCompanyEntity.ts │ │ ├── types.ts │ │ └── updateCompany.ts │ ├── createContext.ts │ ├── invoices │ │ ├── createInvoice.ts │ │ ├── deleteInvoice.ts │ │ ├── getInvoice.ts │ │ ├── getInvoices.ts │ │ ├── mapInvoiceEntity.ts │ │ ├── types.ts │ │ ├── updateInvoice.ts │ │ └── utils.ts │ ├── prisma.ts │ ├── products │ │ ├── createProduct.ts │ │ ├── deleteProduct.ts │ │ ├── getProduct.ts │ │ ├── getProducts.ts │ │ ├── mapProductEntity.ts │ │ ├── types.ts │ │ └── updateProduct.ts │ ├── router.ts │ └── trpc.ts ├── styles │ └── globals.css └── tests-integration │ ├── clients │ ├── createClient.test.ts │ ├── deleteClient.test.ts │ ├── getClient.test.ts │ ├── getClients.test.ts │ └── updateClient.test.ts │ ├── company │ ├── getCompany.test.ts │ └── updateCompany.test.ts │ ├── invoices │ ├── createInvoice.test.ts │ ├── deleteInvoice.test.ts │ ├── getInvoice.test.ts │ ├── getInvoices.test.ts │ └── updateInvoice.test.ts │ ├── products │ ├── createProduct.test.ts │ ├── deleteProduct.test.ts │ ├── getProduct.test.ts │ ├── getProducts.test.ts │ └── updateProduct.test.ts │ ├── setup.ts │ └── testData.ts ├── tailwind.config.js ├── tsconfig.json ├── types.d.ts └── vercel.json /.github/actions/app-setup/action.yml: -------------------------------------------------------------------------------- 1 | name: Setup app 2 | runs: 3 | using: composite 4 | steps: 5 | - uses: actions/setup-node@v4 6 | with: 7 | node-version-file: 'app/.nvmrc' 8 | 9 | - uses: pnpm/action-setup@v4 10 | name: Install pnpm 11 | id: pnpm-install 12 | with: 13 | version: 9 14 | run_install: false 15 | 16 | - name: Get pnpm store directory 17 | id: pnpm-cache 18 | shell: bash 19 | run: | 20 | echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT 21 | 22 | - uses: actions/cache@v3 23 | name: Setup pnpm cache 24 | with: 25 | path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} 26 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 27 | restore-keys: | 28 | ${{ runner.os }}-pnpm-store- 29 | 30 | - name: Install dependencies 31 | run: cd app && pnpm install 32 | shell: bash 33 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" # See documentation for possible values 9 | directory: "/app" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.github/workflows/app-preview.yml: -------------------------------------------------------------------------------- 1 | name: App test and preview deployment 2 | 3 | on: 4 | pull_request 5 | 6 | jobs: 7 | # validate: 8 | # uses: ./.github/workflows/app-shared.yml 9 | 10 | deployAppVercel: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | - uses: amondnet/vercel-action@v25 15 | with: 16 | vercel-token: ${{ secrets.VERCEL_TOKEN }} 17 | vercel-org-id: ${{ secrets.VERCEL_ORG_ID}} 18 | vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID}} 19 | github-comment: "true" 20 | github-token: ${{ secrets.GITHUB_TOKEN }} 21 | scope: ${{ secrets.VERCEL_ORG_ID}} 22 | 23 | deployStorybookVercel: 24 | runs-on: ubuntu-latest 25 | steps: 26 | - uses: actions/checkout@v3 27 | - uses: amondnet/vercel-action@v25 28 | with: 29 | vercel-token: ${{ secrets.VERCEL_TOKEN }} 30 | vercel-org-id: ${{ secrets.VERCEL_ORG_ID}} 31 | vercel-project-id: ${{ secrets.VERCEL_STORYBOOK_PROJECT_ID}} 32 | github-comment: "true" 33 | github-token: ${{ secrets.GITHUB_TOKEN }} 34 | scope: ${{ secrets.VERCEL_ORG_ID}} 35 | 36 | -------------------------------------------------------------------------------- /.github/workflows/app-release.yml: -------------------------------------------------------------------------------- 1 | name: App test and production deployment 2 | 3 | on: 4 | push: 5 | branches: 6 | - "main" 7 | 8 | jobs: 9 | # validate: 10 | # uses: ./.github/workflows/app-shared.yml 11 | 12 | deployAppVercel: 13 | runs-on: ubuntu-latest 14 | # needs: [validate] 15 | steps: 16 | - uses: actions/checkout@v3 17 | - uses: amondnet/vercel-action@v25 18 | with: 19 | vercel-args: '--prod' 20 | vercel-token: ${{ secrets.VERCEL_TOKEN }} 21 | vercel-org-id: ${{ secrets.VERCEL_ORG_ID}} 22 | vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID}} 23 | scope: ${{ secrets.VERCEL_ORG_ID}} 24 | 25 | deployStorybookVercel: 26 | runs-on: ubuntu-latest 27 | # needs: [validate] 28 | steps: 29 | - uses: actions/checkout@v3 30 | - uses: amondnet/vercel-action@v25 31 | with: 32 | vercel-args: '--prod' 33 | vercel-token: ${{ secrets.VERCEL_TOKEN }} 34 | vercel-org-id: ${{ secrets.VERCEL_ORG_ID}} 35 | vercel-project-id: ${{ secrets.VERCEL_STORYBOOK_PROJECT_ID}} 36 | scope: ${{ secrets.VERCEL_ORG_ID}} 37 | -------------------------------------------------------------------------------- /.github/workflows/app-shared.yml: -------------------------------------------------------------------------------- 1 | name: Shared app workflow 2 | on: 3 | workflow_call: 4 | 5 | jobs: 6 | lint: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v3 10 | - uses: ./.github/actions/app-setup 11 | - name: Lint 12 | run: cd app && pnpm lint 13 | 14 | unitTest: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v3 18 | - uses: ./.github/actions/app-setup 19 | - name: Unit Test 20 | run: cd app && pnpm run test:unit 21 | 22 | # integrationTest: 23 | # runs-on: ubuntu-latest 24 | # env: 25 | # DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/tests" 26 | # POSTGRES_INTEGRATIONS_PORT: 5432 27 | 28 | # services: 29 | # postgres: 30 | # image: postgres 31 | # env: 32 | # POSTGRES_PASSWORD: postgres 33 | # ports: 34 | # - 5432:5432 35 | 36 | # steps: 37 | # - uses: actions/checkout@v3 38 | # - uses: ./.github/actions/app-setup 39 | # - name: Integration Test 40 | # run: cd app && pnpm run test:integration 41 | 42 | typeCheck: 43 | runs-on: ubuntu-latest 44 | steps: 45 | - uses: actions/checkout@v3 46 | - uses: ./.github/actions/app-setup 47 | - name: Typecheck 48 | run: cd app && pnpm run typecheck 49 | -------------------------------------------------------------------------------- /.github/workflows/health.yml: -------------------------------------------------------------------------------- 1 | name: Health check 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: "0 5 * * *" # At 5:00 AM every day 7 | 8 | jobs: 9 | healthCheck: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Check the deployed service URL 13 | uses: jtalk/url-health-check-action@v4 14 | with: 15 | url: https://app.beetbill.com/api/health?apiKey=${{ secrets.API_KEY }} 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 David Saltares 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # invoicing 2 | Invoicing app built using Next.js 3 | 4 | ## Requirements 5 | 6 | - Node 20, fnm recommended 7 | - pnpm 8 | - Docker 9 | 10 | ## Run locally 11 | 12 | ```bash 13 | cd app 14 | mkdir database 15 | pnpm install 16 | pnpm docker:up 17 | pnpm dev 18 | ``` 19 | -------------------------------------------------------------------------------- /app/.env.sample: -------------------------------------------------------------------------------- 1 | # database 2 | DATABASE_URL=postgresql://prisma:prisma@localhost:5432/app 3 | DIRECT_DATABASE_URL=postgresql://prisma:prisma@localhost:5432/app 4 | 5 | # next-auth 6 | NEXT_AUTH_SECRET=next-auth-secret 7 | EMAIL_SERVER_USER=email-user 8 | EMAIL_SERVER_PASSWORD=email-pass 9 | EMAIL_SERVER_HOST=localhost 10 | EMAIL_SERVER_PORT=1025 11 | EMAIL_FROM=no-reply@invoicing.saltares.com 12 | NEXTAUTH_URL=http://localhost:3000 13 | GOOGLE_CLIENT_ID=google-client-id 14 | GOOGLE_CLIENT_SECRET=google-client-secret 15 | 16 | # API key for keep alive endpoint 17 | API_KEY=api-key 18 | -------------------------------------------------------------------------------- /app/.env.tests: -------------------------------------------------------------------------------- 1 | # database 2 | DATABASE_URL=postgresql://prisma:prisma@localhost:5432/app 3 | DIRECT_DATABASE_URL=postgresql://prisma:prisma@localhost:5432/app 4 | 5 | # next-auth 6 | NEXT_AUTH_SECRET=next-auth-secret 7 | EMAIL_SERVER_USER=email-user 8 | EMAIL_SERVER_PASSWORD=email-pass 9 | EMAIL_SERVER_HOST=localhost 10 | EMAIL_SERVER_PORT=1025 11 | EMAIL_FROM=no-reply@invoicing.saltares.com 12 | NEXTAUTH_URL=http://localhost:3000 13 | 14 | # API key for keep alive endpoint 15 | API_KEY=api-key 16 | -------------------------------------------------------------------------------- /app/.nvmrc: -------------------------------------------------------------------------------- 1 | v20 2 | -------------------------------------------------------------------------------- /app/.prettierignore: -------------------------------------------------------------------------------- 1 | .next/ 2 | coverage/ 3 | -------------------------------------------------------------------------------- /app/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true 3 | } 4 | -------------------------------------------------------------------------------- /app/.storybook/main.js: -------------------------------------------------------------------------------- 1 | const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin'); 2 | 3 | module.exports = { 4 | stories: ['../src/**/*.stories.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'], 5 | addons: [ 6 | '@storybook/addon-links', 7 | '@storybook/addon-essentials', 8 | '@storybook/addon-interactions', 9 | 'storybook-addon-next-router', 10 | { 11 | name: '@storybook/addon-postcss', 12 | options: { 13 | postcssLoaderOptions: { 14 | implementation: require('postcss'), 15 | }, 16 | }, 17 | }, 18 | ], 19 | framework: '@storybook/react', 20 | core: { 21 | builder: '@storybook/builder-webpack5', 22 | }, 23 | webpackFinal: async (config, { configType }) => { 24 | config.resolve.plugins = [new TsconfigPathsPlugin()]; 25 | return config; 26 | }, 27 | }; 28 | -------------------------------------------------------------------------------- /app/.storybook/preview-head.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | -------------------------------------------------------------------------------- /app/.storybook/preview.js: -------------------------------------------------------------------------------- 1 | import '../src/styles/globals.css'; 2 | import 'tailwindcss/tailwind.css'; 3 | import '@fortawesome/fontawesome-svg-core/styles.css'; 4 | import { config } from '@fortawesome/fontawesome-svg-core'; 5 | import { RouterContext } from 'next/dist/shared/lib/router-context'; 6 | import { initialize, mswDecorator } from 'msw-storybook-addon'; 7 | 8 | initialize(); 9 | 10 | config.autoAddCss = false; 11 | 12 | export const decorators = [mswDecorator]; 13 | 14 | export const parameters = { 15 | layout: 'fullscreen', 16 | actions: { argTypesRegex: '^on[A-Z].*' }, 17 | controls: { 18 | matchers: { 19 | date: /Date$/, 20 | }, 21 | }, 22 | nextRouter: { 23 | Provider: RouterContext.Provider, 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /app/docker-compose.common.yml: -------------------------------------------------------------------------------- 1 | # Set the version of docker compose to use 2 | version: '3.9' 3 | 4 | # The containers that compose the project 5 | services: 6 | db: 7 | image: postgres:14 8 | restart: always 9 | environment: 10 | POSTGRES_USER: prisma 11 | POSTGRES_PASSWORD: prisma 12 | POSTGRES_DB: app 13 | -------------------------------------------------------------------------------- /app/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.9' 2 | 3 | services: 4 | db-local: 5 | extends: 6 | file: docker-compose.common.yml 7 | service: db 8 | container_name: dev-postgres 9 | volumes: 10 | - ./database:/var/lib/postgresql/data 11 | ports: 12 | - 5432:5432 13 | 14 | db-tests: 15 | extends: 16 | file: docker-compose.common.yml 17 | service: db 18 | container_name: tests-postgres 19 | ports: 20 | - 5433:5432 21 | 22 | mailhog: 23 | image: mailhog/mailhog 24 | logging: 25 | driver: 'none' # disable saving logs 26 | ports: 27 | - 1025:1025 # smtp server 28 | - 8025:8025 # web ui 29 | -------------------------------------------------------------------------------- /app/jest.config.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const { parse: parseJsonWithComments } = require('comment-json'); 3 | 4 | require('dotenv').config({ 5 | path: './.env.tests', 6 | }); 7 | 8 | const getTsConfigPathAliases = () => { 9 | const tsConfig = parseJsonWithComments( 10 | fs.readFileSync('./tsconfig.json', 'utf-8') 11 | ); 12 | 13 | return Object.entries(tsConfig.compilerOptions.paths).reduce( 14 | (aliases, [alias, aliasPaths]) => { 15 | const newAlias = `^${alias.replace('/*', '')}(.*)$`; 16 | const newAliasPath = `/${aliasPaths[0].replace('/*', '')}$1`; 17 | 18 | return { 19 | ...aliases, 20 | [newAlias]: newAliasPath, 21 | }; 22 | }, 23 | {} 24 | ); 25 | }; 26 | 27 | module.exports = { 28 | collectCoverage: true, 29 | coverageDirectory: 'coverage/unit', 30 | collectCoverageFrom: [ 31 | './src/**/*.{js,jsx,ts,tsx}', 32 | '!./src/**/*.stories.{js,jsx,ts,tsx}', 33 | '!./src/tests-integration/**', 34 | '!**/*.d.ts', 35 | ], 36 | moduleNameMapper: { 37 | ...getTsConfigPathAliases(), 38 | }, 39 | moduleDirectories: ['node_modules', '/'], 40 | testPathIgnorePatterns: ['tests-integration'], 41 | testEnvironment: 'jsdom', 42 | setupFilesAfterEnv: ['/jest.setup.tsx'], 43 | }; 44 | -------------------------------------------------------------------------------- /app/jest.integration.config.js: -------------------------------------------------------------------------------- 1 | const baseConfig = require('./jest.config'); 2 | const nextJest = require('next/jest'); 3 | 4 | module.exports = nextJest()({ 5 | ...baseConfig, 6 | testRegex: 'tests-integration\\/.*\\.test\\.tsx?$', 7 | testPathIgnorePatterns: ['/node_modules/', '/.next/'], 8 | coverageDirectory: 'coverage/integration', 9 | globalSetup: '/src/tests-integration/setup.ts', 10 | testEnvironment: 'node', 11 | }); 12 | -------------------------------------------------------------------------------- /app/jest.unit.config.js: -------------------------------------------------------------------------------- 1 | const baseConfig = require('./jest.config'); 2 | const nextJest = require('next/jest'); 3 | 4 | module.exports = nextJest()({ 5 | ...baseConfig, 6 | coverageDirectory: 'coverage/unit', 7 | testPathIgnorePatterns: ['tests-integration'], 8 | }); 9 | -------------------------------------------------------------------------------- /app/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /app/next.config.js: -------------------------------------------------------------------------------- 1 | const withBundleAnalyzer = require('@next/bundle-analyzer')({ 2 | enabled: process.env.ANALYZE === 'true', 3 | }); 4 | const withPWA = require('next-pwa')({ 5 | dest: 'public', 6 | }); 7 | 8 | /** @type {import('next').NextConfig} */ 9 | const nextConfig = { 10 | reactStrictMode: true, 11 | swcMinify: true, 12 | pageExtensions: ['page.ts', 'page.tsx', 'next.tsx', 'route.ts'], 13 | eslint: { 14 | dirs: ['src'], 15 | }, 16 | }; 17 | 18 | module.exports = withBundleAnalyzer(withPWA(nextConfig)); 19 | -------------------------------------------------------------------------------- /app/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /app/prisma/migrations/20220712141833_next_auth/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "Account" ( 3 | "id" TEXT NOT NULL, 4 | "userId" TEXT NOT NULL, 5 | "type" TEXT NOT NULL, 6 | "provider" TEXT NOT NULL, 7 | "providerAccountId" TEXT NOT NULL, 8 | "refresh_token" TEXT, 9 | "access_token" TEXT, 10 | "expires_at" INTEGER, 11 | "token_type" TEXT, 12 | "scope" TEXT, 13 | "id_token" TEXT, 14 | "session_state" TEXT, 15 | 16 | CONSTRAINT "Account_pkey" PRIMARY KEY ("id") 17 | ); 18 | 19 | -- CreateTable 20 | CREATE TABLE "Session" ( 21 | "id" TEXT NOT NULL, 22 | "sessionToken" TEXT NOT NULL, 23 | "userId" TEXT NOT NULL, 24 | "expires" TIMESTAMP(3) NOT NULL, 25 | 26 | CONSTRAINT "Session_pkey" PRIMARY KEY ("id") 27 | ); 28 | 29 | -- CreateTable 30 | CREATE TABLE "User" ( 31 | "id" TEXT NOT NULL, 32 | "name" TEXT, 33 | "email" TEXT, 34 | "emailVerified" TIMESTAMP(3), 35 | "image" TEXT, 36 | 37 | CONSTRAINT "User_pkey" PRIMARY KEY ("id") 38 | ); 39 | 40 | -- CreateTable 41 | CREATE TABLE "VerificationToken" ( 42 | "identifier" TEXT NOT NULL, 43 | "token" TEXT NOT NULL, 44 | "expires" TIMESTAMP(3) NOT NULL 45 | ); 46 | 47 | -- CreateIndex 48 | CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId"); 49 | 50 | -- CreateIndex 51 | CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken"); 52 | 53 | -- CreateIndex 54 | CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); 55 | 56 | -- CreateIndex 57 | CREATE UNIQUE INDEX "VerificationToken_token_key" ON "VerificationToken"("token"); 58 | 59 | -- CreateIndex 60 | CREATE UNIQUE INDEX "VerificationToken_identifier_token_key" ON "VerificationToken"("identifier", "token"); 61 | 62 | -- AddForeignKey 63 | ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; 64 | 65 | -- AddForeignKey 66 | ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; 67 | -------------------------------------------------------------------------------- /app/prisma/migrations/20220817144122_remove_product_defaults/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "InvoiceProduct" ALTER COLUMN "includesVat" DROP DEFAULT, 3 | ALTER COLUMN "price" DROP DEFAULT, 4 | ALTER COLUMN "currency" DROP DEFAULT, 5 | ALTER COLUMN "vat" DROP DEFAULT, 6 | ALTER COLUMN "unit" DROP DEFAULT; 7 | 8 | -- AlterTable 9 | ALTER TABLE "Product" ALTER COLUMN "includesVat" DROP DEFAULT, 10 | ALTER COLUMN "price" DROP DEFAULT, 11 | ALTER COLUMN "currency" DROP DEFAULT, 12 | ALTER COLUMN "vat" DROP DEFAULT, 13 | ALTER COLUMN "unit" DROP DEFAULT; 14 | -------------------------------------------------------------------------------- /app/prisma/migrations/20220818065314_correct_invoice_uniqueness/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - A unique constraint covering the columns `[prefix,number,companyId]` on the table `Invoice` will be added. If there are existing duplicate values, this will fail. 5 | 6 | */ 7 | -- CreateIndex 8 | CREATE UNIQUE INDEX "Invoice_prefix_number_companyId_key" ON "Invoice"("prefix", "number", "companyId"); 9 | -------------------------------------------------------------------------------- /app/prisma/migrations/20220825054009_add_invoice_product_date/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - Added the required column `date` to the `InvoiceProduct` table without a default value. This is not possible if the table is not empty. 5 | 6 | */ 7 | -- AlterTable 8 | ALTER TABLE "InvoiceProduct" ADD COLUMN "date" TIMESTAMP(3) NOT NULL; 9 | -------------------------------------------------------------------------------- /app/prisma/migrations/20220826084222_make_invoice_product_unique_in_invoiceproduct/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - A unique constraint covering the columns `[invoiceId,productId]` on the table `InvoiceProduct` will be added. If there are existing duplicate values, this will fail. 5 | 6 | */ 7 | -- CreateIndex 8 | CREATE UNIQUE INDEX "InvoiceProduct_invoiceId_productId_key" ON "InvoiceProduct"("invoiceId", "productId"); 9 | -------------------------------------------------------------------------------- /app/prisma/migrations/20221005144142_make_some_fields_optional/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Company" ALTER COLUMN "vatNumber" DROP NOT NULL, 3 | ALTER COLUMN "email" DROP NOT NULL, 4 | ALTER COLUMN "website" DROP NOT NULL, 5 | ALTER COLUMN "country" DROP NOT NULL, 6 | ALTER COLUMN "address" DROP NOT NULL, 7 | ALTER COLUMN "postCode" DROP NOT NULL, 8 | ALTER COLUMN "city" DROP NOT NULL, 9 | ALTER COLUMN "iban" DROP NOT NULL; 10 | 11 | -- AlterTable 12 | ALTER TABLE "Customer" ALTER COLUMN "vatNumber" DROP NOT NULL, 13 | ALTER COLUMN "firstName" DROP NOT NULL, 14 | ALTER COLUMN "lastName" DROP NOT NULL, 15 | ALTER COLUMN "email" DROP NOT NULL, 16 | ALTER COLUMN "country" DROP NOT NULL, 17 | ALTER COLUMN "address" DROP NOT NULL, 18 | ALTER COLUMN "postCode" DROP NOT NULL, 19 | ALTER COLUMN "city" DROP NOT NULL, 20 | ALTER COLUMN "paymentTerms" SET DEFAULT 7; 21 | 22 | -- AlterTable 23 | ALTER TABLE "Invoice" ALTER COLUMN "date" SET DEFAULT CURRENT_TIMESTAMP; 24 | 25 | -- AlterTable 26 | ALTER TABLE "InvoiceProduct" ALTER COLUMN "quantity" SET DEFAULT 1, 27 | ALTER COLUMN "date" SET DEFAULT CURRENT_TIMESTAMP; 28 | 29 | -- AlterTable 30 | ALTER TABLE "Product" ALTER COLUMN "includesVat" SET DEFAULT false, 31 | ALTER COLUMN "price" SET DEFAULT 0, 32 | ALTER COLUMN "currency" SET DEFAULT 'EUR', 33 | ALTER COLUMN "vat" SET DEFAULT 0, 34 | ALTER COLUMN "unit" SET DEFAULT 'h'; 35 | -------------------------------------------------------------------------------- /app/prisma/migrations/20221026121251_fix_invoice_relationships_with_company_and_client/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the column `invoiceId` on the `Client` table. All the data in the column will be lost. 5 | 6 | */ 7 | -- DropForeignKey 8 | ALTER TABLE "Invoice" DROP CONSTRAINT "Invoice_clientStateId_fkey"; 9 | 10 | -- DropForeignKey 11 | ALTER TABLE "Invoice" DROP CONSTRAINT "Invoice_companyStateId_fkey"; 12 | 13 | -- DropIndex 14 | DROP INDEX "Invoice_clientStateId_key"; 15 | 16 | -- DropIndex 17 | DROP INDEX "Invoice_companyStateId_key"; 18 | 19 | -- AlterTable 20 | ALTER TABLE "Client" DROP COLUMN "invoiceId"; 21 | 22 | -- AddForeignKey 23 | ALTER TABLE "Invoice" ADD CONSTRAINT "Invoice_companyStateId_fkey" FOREIGN KEY ("companyStateId") REFERENCES "CompanyState"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 24 | 25 | -- AddForeignKey 26 | ALTER TABLE "Invoice" ADD CONSTRAINT "Invoice_clientStateId_fkey" FOREIGN KEY ("clientStateId") REFERENCES "ClientState"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 27 | -------------------------------------------------------------------------------- /app/prisma/migrations/20221030085537_remove_invoice_id_from_company/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the column `invoiceId` on the `Company` table. All the data in the column will be lost. 5 | 6 | */ 7 | -- AlterTable 8 | ALTER TABLE "Company" DROP COLUMN "invoiceId"; 9 | -------------------------------------------------------------------------------- /app/prisma/migrations/20221030090837_make_invoice_number_optional/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Invoice" ALTER COLUMN "number" DROP NOT NULL; 3 | -------------------------------------------------------------------------------- /app/prisma/migrations/20221118111735_client_names_into_contact_name/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the column `firstName` on the `ClientState` table. All the data in the column will be lost. 5 | - You are about to drop the column `lastName` on the `ClientState` table. All the data in the column will be lost. 6 | 7 | */ 8 | -- AlterTable 9 | ALTER TABLE "ClientState" DROP COLUMN "firstName", 10 | DROP COLUMN "lastName", 11 | ADD COLUMN "contactName" TEXT; 12 | -------------------------------------------------------------------------------- /app/prisma/migrations/20221118113050_add_company_contact_name/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "CompanyState" ADD COLUMN "contactName" TEXT; 3 | -------------------------------------------------------------------------------- /app/prisma/migrations/20221119133130_add_message_to_invoice/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Invoice" ADD COLUMN "message" TEXT NOT NULL DEFAULT ''; 3 | -------------------------------------------------------------------------------- /app/prisma/migrations/20221120151731_add_order_to_line_item/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "LineItem" ADD COLUMN "order" INTEGER NOT NULL DEFAULT 0; 3 | -------------------------------------------------------------------------------- /app/prisma/migrations/20221205155229_add_default_message_to_company_info/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "CompanyState" ADD COLUMN "message" TEXT; 3 | -------------------------------------------------------------------------------- /app/prisma/migrations/20221213152207_make_client_number_optional/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "ClientState" ALTER COLUMN "number" DROP NOT NULL; 3 | -------------------------------------------------------------------------------- /app/prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /app/public/beet-bill-logo-email.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dsaltares/beetbill/77d67f6f98f8cd18c13c9feed8a62e76814fec30/app/public/beet-bill-logo-email.png -------------------------------------------------------------------------------- /app/public/beetbill_cookie_policy.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dsaltares/beetbill/77d67f6f98f8cd18c13c9feed8a62e76814fec30/app/public/beetbill_cookie_policy.pdf -------------------------------------------------------------------------------- /app/public/beetbill_privacy_policy.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dsaltares/beetbill/77d67f6f98f8cd18c13c9feed8a62e76814fec30/app/public/beetbill_privacy_policy.pdf -------------------------------------------------------------------------------- /app/public/beetbill_terms_and_conditions.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dsaltares/beetbill/77d67f6f98f8cd18c13c9feed8a62e76814fec30/app/public/beetbill_terms_and_conditions.pdf -------------------------------------------------------------------------------- /app/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dsaltares/beetbill/77d67f6f98f8cd18c13c9feed8a62e76814fec30/app/public/favicon.ico -------------------------------------------------------------------------------- /app/public/fonts/Inter-Black.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dsaltares/beetbill/77d67f6f98f8cd18c13c9feed8a62e76814fec30/app/public/fonts/Inter-Black.ttf -------------------------------------------------------------------------------- /app/public/fonts/Inter-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dsaltares/beetbill/77d67f6f98f8cd18c13c9feed8a62e76814fec30/app/public/fonts/Inter-Bold.ttf -------------------------------------------------------------------------------- /app/public/fonts/Inter-ExtraBold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dsaltares/beetbill/77d67f6f98f8cd18c13c9feed8a62e76814fec30/app/public/fonts/Inter-ExtraBold.ttf -------------------------------------------------------------------------------- /app/public/fonts/Inter-ExtraLight.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dsaltares/beetbill/77d67f6f98f8cd18c13c9feed8a62e76814fec30/app/public/fonts/Inter-ExtraLight.ttf -------------------------------------------------------------------------------- /app/public/fonts/Inter-Light.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dsaltares/beetbill/77d67f6f98f8cd18c13c9feed8a62e76814fec30/app/public/fonts/Inter-Light.ttf -------------------------------------------------------------------------------- /app/public/fonts/Inter-Medium.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dsaltares/beetbill/77d67f6f98f8cd18c13c9feed8a62e76814fec30/app/public/fonts/Inter-Medium.ttf -------------------------------------------------------------------------------- /app/public/fonts/Inter-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dsaltares/beetbill/77d67f6f98f8cd18c13c9feed8a62e76814fec30/app/public/fonts/Inter-Regular.ttf -------------------------------------------------------------------------------- /app/public/fonts/Inter-SemiBold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dsaltares/beetbill/77d67f6f98f8cd18c13c9feed8a62e76814fec30/app/public/fonts/Inter-SemiBold.ttf -------------------------------------------------------------------------------- /app/public/fonts/Inter-Thin.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dsaltares/beetbill/77d67f6f98f8cd18c13c9feed8a62e76814fec30/app/public/fonts/Inter-Thin.ttf -------------------------------------------------------------------------------- /app/public/icons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dsaltares/beetbill/77d67f6f98f8cd18c13c9feed8a62e76814fec30/app/public/icons/favicon-16x16.png -------------------------------------------------------------------------------- /app/public/icons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dsaltares/beetbill/77d67f6f98f8cd18c13c9feed8a62e76814fec30/app/public/icons/favicon-32x32.png -------------------------------------------------------------------------------- /app/public/icons/icon-128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dsaltares/beetbill/77d67f6f98f8cd18c13c9feed8a62e76814fec30/app/public/icons/icon-128x128.png -------------------------------------------------------------------------------- /app/public/icons/icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dsaltares/beetbill/77d67f6f98f8cd18c13c9feed8a62e76814fec30/app/public/icons/icon-144x144.png -------------------------------------------------------------------------------- /app/public/icons/icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dsaltares/beetbill/77d67f6f98f8cd18c13c9feed8a62e76814fec30/app/public/icons/icon-152x152.png -------------------------------------------------------------------------------- /app/public/icons/icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dsaltares/beetbill/77d67f6f98f8cd18c13c9feed8a62e76814fec30/app/public/icons/icon-192x192.png -------------------------------------------------------------------------------- /app/public/icons/icon-384x384.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dsaltares/beetbill/77d67f6f98f8cd18c13c9feed8a62e76814fec30/app/public/icons/icon-384x384.png -------------------------------------------------------------------------------- /app/public/icons/icon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dsaltares/beetbill/77d67f6f98f8cd18c13c9feed8a62e76814fec30/app/public/icons/icon-512x512.png -------------------------------------------------------------------------------- /app/public/icons/icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dsaltares/beetbill/77d67f6f98f8cd18c13c9feed8a62e76814fec30/app/public/icons/icon-72x72.png -------------------------------------------------------------------------------- /app/public/icons/icon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dsaltares/beetbill/77d67f6f98f8cd18c13c9feed8a62e76814fec30/app/public/icons/icon-96x96.png -------------------------------------------------------------------------------- /app/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Beet Bill", 3 | "theme_color": "#6B26D9", 4 | "background_color": "#F7F5FF", 5 | "display": "fullscreen", 6 | "scope": "/", 7 | "start_url": "/", 8 | "icons": [ 9 | { 10 | "src": "icons/icon-72x72.png", 11 | "sizes": "72x72", 12 | "type": "image/png" 13 | }, 14 | { 15 | "src": "icons/icon-96x96.png", 16 | "sizes": "96x96", 17 | "type": "image/png" 18 | }, 19 | { 20 | "src": "icons/icon-128x128.png", 21 | "sizes": "128x128", 22 | "type": "image/png" 23 | }, 24 | { 25 | "src": "icons/icon-144x144.png", 26 | "sizes": "144x144", 27 | "type": "image/png" 28 | }, 29 | { 30 | "src": "icons/icon-152x152.png", 31 | "sizes": "152x152", 32 | "type": "image/png" 33 | }, 34 | { 35 | "src": "icons/icon-192x192.png", 36 | "sizes": "192x192", 37 | "type": "image/png" 38 | }, 39 | { 40 | "src": "icons/icon-384x384.png", 41 | "sizes": "384x384", 42 | "type": "image/png" 43 | }, 44 | { 45 | "src": "icons/icon-512x512.png", 46 | "sizes": "512x512", 47 | "type": "image/png" 48 | } 49 | ], 50 | "splash_pages": null 51 | } 52 | -------------------------------------------------------------------------------- /app/scripts/app-build: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Builds the NextJS application using `next build`, with additional setup 4 | # steps for Prisma. 5 | # 6 | # If running under the production environment on Vercel, this script also 7 | # applies any pending database migrations using Prisma. 8 | 9 | set -e 10 | 11 | prisma generate 12 | 13 | if [ "$VERCEL_ENV" = "production" ]; then 14 | echo "Running $VERCEL_ENV migrations for prisma" 15 | prisma migrate deploy 16 | fi 17 | 18 | exec next build 19 | 20 | -------------------------------------------------------------------------------- /app/scripts/merge-coverage-report: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | COVERAGE_DIR="./coverage" 6 | 7 | if [ ! -d "$COVERAGE_DIR" ]; then 8 | echo 'Missing coverage directory -- you need to have ran tests locally at least once before' 9 | exit 1 10 | fi 11 | 12 | # Copy report files from integration and unit tests into a shared reports directory 13 | if [ -d "$COVERAGE_DIR/summary" ]; then 14 | rm -rf "$COVERAGE_DIR/summary" 15 | fi 16 | 17 | mkdir "$COVERAGE_DIR/summary" 18 | 19 | cp "$COVERAGE_DIR/unit/coverage-final.json" "$COVERAGE_DIR/summary/unit-report.json" 20 | cp "$COVERAGE_DIR/integration/coverage-final.json" "$COVERAGE_DIR/summary/integration-report.json" 21 | 22 | nyc merge "$COVERAGE_DIR/summary" "$COVERAGE_DIR/summary/merged-report.json" 23 | nyc report --reporter html -t coverage/summary --report-dir coverage/summary 24 | 25 | # Unless told otherwise, try to open the coverage report: 26 | if [ -z $MERGE_COVERAGE_SKIP_OPEN ]; then 27 | open $COVERAGE_DIR/summary/index.html 28 | fi 29 | -------------------------------------------------------------------------------- /app/src/components/Avatar.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { ComponentStory, ComponentMeta } from '@storybook/react'; 3 | import Avatar from './Avatar'; 4 | 5 | export default { 6 | title: 'Avatar', 7 | component: Avatar, 8 | parameters: { 9 | layout: 'centered', 10 | }, 11 | } as ComponentMeta; 12 | 13 | const Template: ComponentStory = (args) => ; 14 | 15 | export const WithImage = Template.bind({}); 16 | export const WithInitials = Template.bind({}); 17 | export const WithInitialFromEmail = Template.bind({}); 18 | export const WithPlaceholder = Template.bind({}); 19 | 20 | WithImage.args = { 21 | user: { 22 | image: 'https://avatars.githubusercontent.com/u/570314?v=4', 23 | }, 24 | }; 25 | WithInitials.args = { 26 | user: { 27 | name: 'Ada Lovelace', 28 | }, 29 | }; 30 | WithInitialFromEmail.args = { 31 | user: { 32 | email: 'ada.lovelace@company.com', 33 | }, 34 | }; 35 | WithPlaceholder.args = { 36 | user: {}, 37 | }; 38 | -------------------------------------------------------------------------------- /app/src/components/Avatar.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @next/next/no-img-element */ 2 | import type { Session } from 'next-auth'; 3 | import { getInitials } from '@lib/userName'; 4 | import UserPlaceholderIcon from './Icons/UserPlaceholderIcon'; 5 | 6 | type AvatarProps = { 7 | user: NonNullable; 8 | }; 9 | 10 | const Avatar = ({ user }: AvatarProps) => { 11 | if (user.image) { 12 | return ( 13 | Avatar 20 | ); 21 | } 22 | 23 | const initials = getInitials(user); 24 | return initials ? ( 25 |
26 | {initials} 27 |
28 | ) : ( 29 |
30 | 31 |
32 | ); 33 | }; 34 | 35 | export default Avatar; 36 | -------------------------------------------------------------------------------- /app/src/components/Button.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { ComponentStory, ComponentMeta } from '@storybook/react'; 3 | import { 4 | faBoxOpen, 5 | faBriefcase, 6 | faFileInvoice, 7 | faUsers, 8 | } from '@fortawesome/free-solid-svg-icons'; 9 | import Button from './Button'; 10 | import GoogleIcon from './Icons/GoogleIcon'; 11 | 12 | const Icons = { 13 | none: undefined, 14 | faBriefcase, 15 | faUsers, 16 | faBoxOpen, 17 | faFileInvoice, 18 | GoogleIcon, 19 | }; 20 | const IconInputType = { 21 | options: Object.keys(Icons), 22 | mapping: Icons, 23 | control: { type: 'select' }, 24 | }; 25 | 26 | export default { 27 | title: 'Button', 28 | component: Button, 29 | argTypes: { 30 | disabled: { control: 'boolean' }, 31 | onClick: { action: 'clicked' }, 32 | startIcon: IconInputType, 33 | endIcon: IconInputType, 34 | color: { 35 | control: { type: 'radio' }, 36 | options: ['primary', 'secondary', 'tertiary', 'danger'], 37 | }, 38 | }, 39 | parameters: { 40 | layout: 'centered', 41 | }, 42 | } as ComponentMeta; 43 | 44 | const Template: ComponentStory = (args) => 24 | 25 | 26 | ); 27 | }; 28 | 29 | export const Default = Template.bind({}); 30 | Default.args = { 31 | title: 'Are you sure you want to delete the client?', 32 | description: 33 | "This action can not be undone. You will have to add the client's details again if you want to collaborate with them in the future", 34 | confirm: { 35 | label: 'Delete', 36 | icon: faTrash, 37 | }, 38 | cancel: { 39 | label: 'Cancel', 40 | }, 41 | }; 42 | -------------------------------------------------------------------------------- /app/src/components/CreateEditInvoiceForm/InvoiceFormValues.ts: -------------------------------------------------------------------------------- 1 | import type { Invoice } from '@server/invoices/types'; 2 | import type { Product } from '@server/products/types'; 3 | 4 | export type LineItemFormValue = { 5 | product: Product; 6 | date: string; 7 | quantity: string; 8 | }; 9 | 10 | export type InvoiceFormValues = { 11 | status: Invoice['status']; 12 | prefix: Invoice['prefix']; 13 | date: string; 14 | message: string; 15 | client: Invoice['client']; 16 | items: LineItemFormValue[]; 17 | }; 18 | -------------------------------------------------------------------------------- /app/src/components/CreateEditInvoiceForm/LineItemOverlay.tsx: -------------------------------------------------------------------------------- 1 | import LineItem, { type LineItemProps } from './LinteItem'; 2 | 3 | const LineItemOverlay = (props: LineItemProps) => ( 4 | 5 | 6 | 7 | 16 | 17 | 18 | 19 | 20 |
8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
21 | ); 22 | 23 | export default LineItemOverlay; 24 | -------------------------------------------------------------------------------- /app/src/components/CreateEditInvoiceForm/SortableLineItem.tsx: -------------------------------------------------------------------------------- 1 | import { useSortable } from '@dnd-kit/sortable'; 2 | import { CSS } from '@dnd-kit/utilities'; 3 | import LineItem, { type LineItemProps } from './LinteItem'; 4 | 5 | const SortableLineItem = ({ item, disabled, ...props }: LineItemProps) => { 6 | const { 7 | attributes, 8 | listeners, 9 | setNodeRef, 10 | transform, 11 | transition, 12 | isDragging, 13 | } = useSortable({ id: item.id }); 14 | 15 | const style = { 16 | transform: CSS.Transform.toString(transform), 17 | transition, 18 | }; 19 | 20 | return ( 21 | 30 | ); 31 | }; 32 | 33 | export default SortableLineItem; 34 | -------------------------------------------------------------------------------- /app/src/components/CreateEditInvoiceForm/Table.tsx: -------------------------------------------------------------------------------- 1 | import type { PropsWithChildren } from 'react'; 2 | 3 | const Table = ({ children }: PropsWithChildren) => ( 4 |
5 | 6 | {children} 7 |
8 |
9 | ); 10 | 11 | export const Head = ({ children }: PropsWithChildren) => ( 12 | {children} 13 | ); 14 | 15 | export const Body = ({ children }: PropsWithChildren) => ( 16 | {children} 17 | ); 18 | 19 | export const HeaderCell = ({ children }: PropsWithChildren) => ( 20 | 24 | {children} 25 | 26 | ); 27 | 28 | export const BodyCell = ({ children }: PropsWithChildren) => ( 29 | {children} 30 | ); 31 | 32 | export default Table; 33 | -------------------------------------------------------------------------------- /app/src/components/CreateEditInvoiceForm/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './CreateEditInvoiceForm'; 2 | -------------------------------------------------------------------------------- /app/src/components/EmptyContent.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { ComponentStory, ComponentMeta } from '@storybook/react'; 3 | import EmptyContent from './EmptyContent'; 4 | 5 | export default { 6 | title: 'EmptyContent', 7 | component: EmptyContent, 8 | argTypes: { 9 | onClick: { action: 'clicked' }, 10 | }, 11 | parameters: { 12 | layout: 'centered', 13 | }, 14 | } as ComponentMeta; 15 | 16 | const Template: ComponentStory = (args) => ( 17 | 18 | ); 19 | 20 | export const Default = Template.bind({}); 21 | Default.args = { 22 | message: "You don't have any invoices yet", 23 | createLabel: 'Add invoices', 24 | createHref: '/', 25 | }; 26 | -------------------------------------------------------------------------------- /app/src/components/EmptyContent.tsx: -------------------------------------------------------------------------------- 1 | import { faArrowRight } from '@fortawesome/free-solid-svg-icons'; 2 | import LinkButton from './LinkButton'; 3 | 4 | type EmptyContentProps = { 5 | message: string; 6 | createLabel: string; 7 | createHref: string; 8 | }; 9 | 10 | const EmptyContent = ({ 11 | message, 12 | createLabel, 13 | createHref, 14 | }: EmptyContentProps) => ( 15 |
16 |

{message}

17 | 18 | {createLabel} 19 | 20 |
21 | ); 22 | 23 | export default EmptyContent; 24 | -------------------------------------------------------------------------------- /app/src/components/Fields/AutocompleteField.stories.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import type { ComponentStory, ComponentMeta } from '@storybook/react'; 3 | import AutocompleteField from './AutocompleteField'; 4 | 5 | export default { 6 | title: 'AutocompleteField', 7 | component: AutocompleteField, 8 | argTypes: { 9 | disabled: { control: 'boolean' }, 10 | value: { control: { disable: true } }, 11 | options: { control: { disable: true } }, 12 | id: { control: { disable: true } }, 13 | }, 14 | parameters: { 15 | layout: 'centered', 16 | }, 17 | } as ComponentMeta; 18 | 19 | const Template: ComponentStory = (args) => { 20 | const [value, setValue] = useState(undefined); 21 | return ( 22 |
23 | option} 30 | optionToKey={(option: string) => option} 31 | /> 32 |
33 | ); 34 | }; 35 | 36 | export const Default = Template.bind({}); 37 | Default.args = { 38 | placeholder: 'Superhero', 39 | disabled: false, 40 | error: '', 41 | tip: 'This is a tip', 42 | label: 'Options', 43 | }; 44 | -------------------------------------------------------------------------------- /app/src/components/Fields/Error.tsx: -------------------------------------------------------------------------------- 1 | import type { PropsWithChildren } from 'react'; 2 | 3 | const Error = ({ children }: PropsWithChildren) => ( 4 | 5 | {children} 6 | 7 | ); 8 | 9 | export default Error; 10 | -------------------------------------------------------------------------------- /app/src/components/Fields/Label.tsx: -------------------------------------------------------------------------------- 1 | import type { PropsWithChildren } from 'react'; 2 | import cn from 'classnames'; 3 | 4 | type LabelProps = PropsWithChildren<{ 5 | htmlFor?: string; 6 | error?: boolean; 7 | disabled?: boolean; 8 | required?: boolean; 9 | }>; 10 | 11 | const Label = ({ 12 | htmlFor, 13 | error, 14 | disabled, 15 | required, 16 | children, 17 | }: LabelProps) => ( 18 | 29 | ); 30 | 31 | export default Label; 32 | -------------------------------------------------------------------------------- /app/src/components/Fields/SelectField.stories.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import type { ComponentStory, ComponentMeta } from '@storybook/react'; 3 | import SelectField from './SelectField'; 4 | 5 | export default { 6 | title: 'SelectField', 7 | component: SelectField, 8 | argTypes: { 9 | disabled: { control: 'boolean' }, 10 | value: { control: { disable: true } }, 11 | options: { control: { disable: true } }, 12 | id: { control: { disable: true } }, 13 | }, 14 | parameters: { 15 | layout: 'centered', 16 | }, 17 | } as ComponentMeta; 18 | 19 | const Template: ComponentStory = (args) => { 20 | const [value, setValue] = useState(undefined); 21 | return ( 22 |
23 | option} 30 | optionToKey={(option: string) => option} 31 | /> 32 |
33 | ); 34 | }; 35 | 36 | export const Default = Template.bind({}); 37 | Default.args = { 38 | placeholder: 'Select an option', 39 | disabled: false, 40 | error: '', 41 | tip: 'This is a tip', 42 | label: 'Options', 43 | }; 44 | -------------------------------------------------------------------------------- /app/src/components/Fields/TextAreaField.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { ComponentStory, ComponentMeta } from '@storybook/react'; 3 | import TextAreaField from './TextAreaField'; 4 | 5 | export default { 6 | title: 'TextAreaField', 7 | component: TextAreaField, 8 | argTypes: { 9 | disabled: { control: 'boolean' }, 10 | onFocus: { action: 'focus' }, 11 | onBlur: { action: 'onBlur' }, 12 | endAdornment: { control: 'text' }, 13 | startAdornment: { control: 'text' }, 14 | }, 15 | parameters: { 16 | layout: 'centered', 17 | }, 18 | } as ComponentMeta; 19 | 20 | const Template: ComponentStory = (args) => ( 21 |
22 | 23 |
24 | ); 25 | 26 | export const Default = Template.bind({}); 27 | Default.args = { 28 | placeholder: 'Company number', 29 | disabled: false, 30 | error: '', 31 | tip: 'This is a tip', 32 | label: 'Company ID', 33 | rows: 3, 34 | }; 35 | -------------------------------------------------------------------------------- /app/src/components/Fields/TextField.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { ComponentStory, ComponentMeta } from '@storybook/react'; 3 | import TextField from './TextField'; 4 | 5 | export default { 6 | title: 'TextField', 7 | component: TextField, 8 | argTypes: { 9 | disabled: { control: 'boolean' }, 10 | onFocus: { action: 'focus' }, 11 | onBlur: { action: 'onBlur' }, 12 | endAdornment: { control: 'text' }, 13 | startAdornment: { control: 'text' }, 14 | }, 15 | parameters: { 16 | layout: 'centered', 17 | }, 18 | } as ComponentMeta; 19 | 20 | const Template: ComponentStory = (args) => ( 21 |
22 | 23 |
24 | ); 25 | 26 | export const Default = Template.bind({}); 27 | Default.args = { 28 | placeholder: 'Company number', 29 | disabled: false, 30 | error: '', 31 | tip: 'This is a tip', 32 | label: 'Company ID', 33 | startAdornment: undefined, 34 | endAdornment: '%', 35 | }; 36 | -------------------------------------------------------------------------------- /app/src/components/Fields/Tip.tsx: -------------------------------------------------------------------------------- 1 | import type { PropsWithChildren } from 'react'; 2 | import cn from 'classnames'; 3 | 4 | type TipProps = PropsWithChildren<{ 5 | disabled?: boolean; 6 | }>; 7 | 8 | const Tip = ({ disabled, children }: TipProps) => ( 9 | 15 | {children} 16 | 17 | ); 18 | 19 | export default Tip; 20 | -------------------------------------------------------------------------------- /app/src/components/Fields/Toggle.stories.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import type { ComponentStory, ComponentMeta } from '@storybook/react'; 3 | import Toggle from './Toggle'; 4 | 5 | export default { 6 | title: 'Toggle', 7 | component: Toggle, 8 | argTypes: { 9 | disabled: { control: 'boolean' }, 10 | checked: { control: { disable: true } }, 11 | id: { control: { disable: true } }, 12 | name: { control: { disable: true } }, 13 | onChange: { control: { disable: true } }, 14 | }, 15 | parameters: { 16 | layout: 'centered', 17 | }, 18 | } as ComponentMeta; 19 | 20 | const Template: ComponentStory = (args) => { 21 | const [checked, setChecked] = useState(false); 22 | return ( 23 | { 27 | setChecked(e.target.checked); 28 | }} 29 | /> 30 | ); 31 | }; 32 | 33 | export const Default = Template.bind({}); 34 | Default.args = { 35 | label: 'Toggle me', 36 | disabled: false, 37 | }; 38 | -------------------------------------------------------------------------------- /app/src/components/Fields/Toggle.tsx: -------------------------------------------------------------------------------- 1 | import cn from 'classnames'; 2 | import type { InputHTMLAttributes } from 'react'; 3 | 4 | type ToggleProps = { 5 | id?: InputHTMLAttributes['id']; 6 | name?: InputHTMLAttributes['name']; 7 | onChange?: InputHTMLAttributes['onChange']; 8 | disabled?: InputHTMLAttributes['disabled']; 9 | checked?: InputHTMLAttributes['checked']; 10 | label?: string; 11 | }; 12 | 13 | const Toggle = ({ 14 | id, 15 | name, 16 | onChange, 17 | disabled = false, 18 | checked, 19 | label, 20 | }: ToggleProps) => ( 21 | 51 | ); 52 | 53 | export default Toggle; 54 | -------------------------------------------------------------------------------- /app/src/components/FormCard.tsx: -------------------------------------------------------------------------------- 1 | import { faArrowLeft } from '@fortawesome/free-solid-svg-icons'; 2 | import type { DOMAttributes, PropsWithChildren } from 'react'; 3 | import FullScreenCard from './FullscreenCard'; 4 | import LinkButton from './LinkButton'; 5 | 6 | type FormCard = PropsWithChildren<{ 7 | title: string; 8 | description: string; 9 | onSubmit: DOMAttributes['onSubmit']; 10 | buttons?: React.ReactNode; 11 | backHref?: string; 12 | }>; 13 | 14 | const FormCard = ({ 15 | title, 16 | description, 17 | onSubmit, 18 | backHref, 19 | children, 20 | buttons = null, 21 | }: FormCard) => ( 22 | 23 |
24 | {backHref && ( 25 |
26 | 32 | Back 33 | 34 |
35 | )} 36 |
37 |
38 |

{title}

39 |

{description}

40 |
41 |
{buttons}
42 |
43 | {children} 44 |
45 |
46 | ); 47 | 48 | export default FormCard; 49 | -------------------------------------------------------------------------------- /app/src/components/FullscreenCard.tsx: -------------------------------------------------------------------------------- 1 | import type { PropsWithChildren } from 'react'; 2 | 3 | const FullScreenCard = ({ children }: PropsWithChildren) => ( 4 |
5 |
6 | {children} 7 |
8 |
9 | ); 10 | 11 | export default FullScreenCard; 12 | -------------------------------------------------------------------------------- /app/src/components/IconButton.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { ComponentStory, ComponentMeta } from '@storybook/react'; 3 | import { 4 | faBoxOpen, 5 | faBriefcase, 6 | faFileInvoice, 7 | faUsers, 8 | } from '@fortawesome/free-solid-svg-icons'; 9 | import IconButton from './IconButton'; 10 | import GoogleIcon from './Icons/GoogleIcon'; 11 | 12 | const Icons = { 13 | none: undefined, 14 | faBriefcase, 15 | faUsers, 16 | faBoxOpen, 17 | faFileInvoice, 18 | GoogleIcon, 19 | }; 20 | const IconInputType = { 21 | options: Object.keys(Icons), 22 | mapping: Icons, 23 | control: { type: 'select' }, 24 | }; 25 | 26 | export default { 27 | title: 'IconButton', 28 | component: IconButton, 29 | argTypes: { 30 | disabled: { control: 'boolean' }, 31 | onClick: { action: 'clicked' }, 32 | icon: IconInputType, 33 | color: { 34 | control: { type: 'radio' }, 35 | options: ['primary', 'secondary', 'danger'], 36 | }, 37 | }, 38 | parameters: { 39 | layout: 'centered', 40 | }, 41 | } as ComponentMeta; 42 | 43 | const Template: ComponentStory = (args) => ( 44 | 45 | ); 46 | 47 | export const Default = Template.bind({}); 48 | Default.args = { 49 | color: 'primary', 50 | variant: 'solid', 51 | children: 'Button', 52 | icon: faBriefcase, 53 | disabled: false, 54 | size: 'md', 55 | fullWidth: false, 56 | }; 57 | -------------------------------------------------------------------------------- /app/src/components/Icons/GoogleIcon.tsx: -------------------------------------------------------------------------------- 1 | import type { CustomIcon } from './types'; 2 | 3 | const GoogleIcon: CustomIcon = ({ className }) => ( 4 | 10 | 11 | 15 | 19 | 23 | 27 | 28 | 29 | 30 | 36 | 37 | 38 | 39 | ); 40 | 41 | export default GoogleIcon; 42 | -------------------------------------------------------------------------------- /app/src/components/Icons/Icon.tsx: -------------------------------------------------------------------------------- 1 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 2 | import cn from 'classnames'; 3 | import type { IconProp } from './types'; 4 | 5 | type IconProps = { 6 | icon?: IconProp; 7 | className?: string; 8 | }; 9 | 10 | const Icon = ({ icon: Icon, className }: IconProps) => { 11 | const finalClassName = cn({ 12 | 'w-4 h-4': !className, 13 | ...(className && { [className]: true }), 14 | }); 15 | if (!Icon) { 16 | return null; 17 | } 18 | if (typeof Icon === 'function') { 19 | return ; 20 | } 21 | return ; 22 | }; 23 | 24 | export default Icon; 25 | -------------------------------------------------------------------------------- /app/src/components/Icons/Icons.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { ComponentStory, ComponentMeta } from '@storybook/react'; 3 | import type { CustomIcon } from './types'; 4 | import GoogleIcon from './GoogleIcon'; 5 | import UserPlaceholderIcon from './UserPlaceholderIcon'; 6 | 7 | export default { 8 | title: 'Icons', 9 | parameters: { 10 | layout: 'centered', 11 | }, 12 | } as ComponentMeta; 13 | 14 | const Template: ComponentStory = () => { 15 | const className = 'w-6 h-6'; 16 | return ( 17 |
18 | 19 | 20 |
21 | ); 22 | }; 23 | 24 | export const Default = Template.bind({}); 25 | -------------------------------------------------------------------------------- /app/src/components/Icons/UserPlaceholderIcon.tsx: -------------------------------------------------------------------------------- 1 | import type { CustomIcon } from './types'; 2 | 3 | const UserPlaceholderIcon: CustomIcon = ({ className }) => ( 4 | 10 | 15 | 16 | ); 17 | 18 | export default UserPlaceholderIcon; 19 | -------------------------------------------------------------------------------- /app/src/components/Icons/types.ts: -------------------------------------------------------------------------------- 1 | import type { IconDefinition } from '@fortawesome/free-solid-svg-icons'; 2 | 3 | export type IconProps = { 4 | className: string; 5 | }; 6 | 7 | export type CustomIcon = (props: IconProps) => JSX.Element; 8 | 9 | export type IconProp = IconDefinition | CustomIcon; 10 | -------------------------------------------------------------------------------- /app/src/components/InvoicePDF/Table.tsx: -------------------------------------------------------------------------------- 1 | import { View, Text } from '@react-pdf/renderer'; 2 | import type { PropsWithChildren } from 'react'; 3 | import { colors, fontSizes, sizes } from './styles'; 4 | 5 | export const Table = ({ children }: PropsWithChildren) => ( 6 | {children} 7 | ); 8 | 9 | export const TableHeader = ({ children }: PropsWithChildren) => ( 10 | 22 | {children} 23 | 24 | ); 25 | 26 | export const TableRow = ({ children }: PropsWithChildren) => ( 27 | 39 | {children} 40 | 41 | ); 42 | 43 | type TableCellProps = PropsWithChildren<{ 44 | weight?: number; 45 | textAlign?: 'center' | 'left' | 'right' | 'justify'; 46 | }>; 47 | 48 | export const TableCell = ({ 49 | weight = 1, 50 | textAlign = 'left', 51 | children, 52 | }: TableCellProps) => ( 53 | 59 | {children} 60 | 61 | ); 62 | -------------------------------------------------------------------------------- /app/src/components/InvoicePDF/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './InvoicePDF'; 2 | -------------------------------------------------------------------------------- /app/src/components/InvoicePDF/styles.ts: -------------------------------------------------------------------------------- 1 | import { Font } from '@react-pdf/renderer'; 2 | 3 | Font.register({ 4 | family: 'Inter', 5 | fonts: [ 6 | { src: '/fonts/Inter-Regular.ttf', fontWeight: 400 }, 7 | { src: '/fonts/Inter-Medium.ttf', fontWeight: 500 }, 8 | { src: '/fonts/Inter-SemiBold.ttf', fontWeight: 600 }, 9 | { src: '/fonts/Inter-Bold.ttf', fontWeight: 700 }, 10 | ], 11 | }); 12 | 13 | export const sizes = { 14 | '0.5': 2, 15 | 1: 4, 16 | 2: 8, 17 | 3: 12, 18 | 4: 16, 19 | 5: 20, 20 | 6: 24, 21 | 7: 28, 22 | 8: 32, 23 | 9: 36, 24 | 10: 40, 25 | 11: 44, 26 | 12: 48, 27 | 14: 56, 28 | 16: 64, 29 | }; 30 | 31 | export const fontSizes = { 32 | sm: 10, 33 | base: 12, 34 | lg: 20, 35 | }; 36 | 37 | export const colors = { 38 | primary: '#6d28d9', 39 | secondary: '#a1a1aa', 40 | white: '#ffffff', 41 | }; 42 | -------------------------------------------------------------------------------- /app/src/components/InvoiceTotalSection.tsx: -------------------------------------------------------------------------------- 1 | import { formatAmount } from '@lib/format'; 2 | import calculateTotal from '@lib/invoices/calculateTotal'; 3 | import type { Product } from '@server/products/types'; 4 | 5 | type LineItem = { 6 | product: Product; 7 | date: string; 8 | quantity: string; 9 | }; 10 | 11 | type InvoiceTotalSectionProps = { 12 | items: LineItem[]; 13 | }; 14 | 15 | const InvoiceTotalSection = ({ items }: InvoiceTotalSectionProps) => { 16 | const { exclVat, total, currency } = useInvoiceTotals(items); 17 | return ( 18 |
19 |
Total excl. VAT
20 |
21 | {formatAmount(exclVat, currency)} 22 |
23 |
Total amount due
24 |
25 | {formatAmount(total, currency)} 26 |
27 |
28 | ); 29 | }; 30 | 31 | const useInvoiceTotals = (items: LineItem[]) => 32 | calculateTotal( 33 | items.map(({ quantity, ...item }) => ({ 34 | ...item, 35 | quantity: parseFloat(quantity), 36 | })) 37 | ); 38 | 39 | export default InvoiceTotalSection; 40 | -------------------------------------------------------------------------------- /app/src/components/Layout/BlankLayout.tsx: -------------------------------------------------------------------------------- 1 | import type { PropsWithChildren } from 'react'; 2 | 3 | const BlankLayout = ({ children }: PropsWithChildren) => ( 4 |
5 | {children} 6 |
7 | ); 8 | 9 | export default BlankLayout; 10 | -------------------------------------------------------------------------------- /app/src/components/Layout/FullScreenSpinner.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { ComponentStory, ComponentMeta } from '@storybook/react'; 3 | import FullScreenSpinner from './FullScreenSpinner'; 4 | export default { 5 | title: 'FullScreenSpinner', 6 | component: FullScreenSpinner, 7 | parameters: { 8 | layout: 'centered', 9 | }, 10 | } as ComponentMeta; 11 | 12 | const Template: ComponentStory = () => ( 13 | 14 | ); 15 | 16 | export const Default = Template.bind({}); 17 | -------------------------------------------------------------------------------- /app/src/components/Layout/FullScreenSpinner.tsx: -------------------------------------------------------------------------------- 1 | import Spinner from '@components/Spinner'; 2 | 3 | const FullScreenSpinner = () => ( 4 |
5 | 6 |
7 | ); 8 | 9 | export default FullScreenSpinner; 10 | -------------------------------------------------------------------------------- /app/src/components/Layout/Layout.tsx: -------------------------------------------------------------------------------- 1 | import type { PropsWithChildren } from 'react'; 2 | import { useRouter } from 'next/router'; 3 | import { useSession } from 'next-auth/react'; 4 | import BlankLayout from './BlankLayout'; 5 | import SidebarLayout from './SidebarLayout'; 6 | import FullScreenSpinner from './FullScreenSpinner'; 7 | 8 | const BlankLayoutPaths = ['/auth', '/404', '/invoices/[invoiceId]/preview']; 9 | 10 | const Layout = ({ children }: PropsWithChildren) => { 11 | const { status } = useSession(); 12 | const { pathname } = useRouter(); 13 | 14 | if (status === 'loading') { 15 | return ; 16 | } 17 | 18 | const hasBlankLayout = 19 | status === 'unauthenticated' || 20 | pathname === '/' || 21 | BlankLayoutPaths.some((path) => pathname.startsWith(path)); 22 | 23 | return hasBlankLayout ? ( 24 | {children} 25 | ) : ( 26 | {children} 27 | ); 28 | }; 29 | 30 | export default Layout; 31 | -------------------------------------------------------------------------------- /app/src/components/Layout/SidebarLayout.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { ComponentStory, ComponentMeta } from '@storybook/react'; 3 | import { rest } from 'msw'; 4 | import Providers from '@components/Providers'; 5 | import SidebarLayout from './SidebarLayout'; 6 | 7 | export default { 8 | title: 'SidebarLayout', 9 | component: SidebarLayout, 10 | layout: 'fullscreen', 11 | } as ComponentMeta; 12 | 13 | const Template: ComponentStory = (args) => ( 14 | 15 | 16 |
17 |

Content goes here

18 |
19 |
20 |
21 | ); 22 | 23 | export const Default = Template.bind({}); 24 | Default.args = {}; 25 | Default.parameters = { 26 | nextRouter: { 27 | pathname: '/company', 28 | asPath: '/company', 29 | query: {}, 30 | }, 31 | msw: { 32 | handlers: [ 33 | rest.get('http://localhost:6006/api/auth/session', (_req, res, ctx) => 34 | res( 35 | ctx.json({ 36 | user: { 37 | name: 'David Saltares', 38 | email: 'david.saltares@gmail.com', 39 | }, 40 | userId: 'user_1', 41 | companyId: 'company_1', 42 | }) 43 | ) 44 | ), 45 | ], 46 | }, 47 | }; 48 | -------------------------------------------------------------------------------- /app/src/components/Layout/SidebarLayout.test.tsx: -------------------------------------------------------------------------------- 1 | import 'next'; 2 | import { setupServer } from 'msw/node'; 3 | import type { Session } from 'next-auth'; 4 | import { signOut } from 'next-auth/react'; 5 | import { render, screen, fireEvent, mockSession } from '@lib/testing'; 6 | import SidebarLayout from './SidebarLayout'; 7 | 8 | jest.mock('next-auth/react', () => ({ 9 | ...jest.requireActual('next-auth/react'), 10 | signOut: jest.fn(), 11 | })); 12 | 13 | const server = setupServer(); 14 | 15 | beforeAll(() => server.listen()); 16 | afterAll(() => server.close()); 17 | 18 | const companyId = 'company_1'; 19 | const session: Session = { 20 | user: { 21 | email: 'ada.lovelace@company.com', 22 | name: 'Ada Lovelace', 23 | }, 24 | userId: 'user_1', 25 | companyId: companyId, 26 | expires: '', 27 | }; 28 | const router = { 29 | pathname: '/', 30 | }; 31 | 32 | describe('SidebarLayout', () => { 33 | beforeEach(() => { 34 | jest.resetAllMocks(); 35 | server.resetHandlers(mockSession(session)); 36 | }); 37 | 38 | it('allows the user to sign out', async () => { 39 | render(, { session, router }); 40 | 41 | const menuButton = await screen.findByRole('button', { 42 | name: /Ada Lovelace/, 43 | }); 44 | await fireEvent.click(menuButton); 45 | await fireEvent.click(screen.getByRole('button', { name: 'Sign out' })); 46 | 47 | await screen.findByText('Sign out of Beet Bill'); 48 | await fireEvent.click(screen.getByRole('button', { name: 'Sign out' })); 49 | 50 | expect(signOut).toHaveBeenCalled(); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /app/src/components/Layout/SidebarLayout.tsx: -------------------------------------------------------------------------------- 1 | import { type PropsWithChildren, useEffect } from 'react'; 2 | import { useRouter } from 'next/router'; 3 | import Sidebar, { MobileSidebarOpenControls } from '@components/Sidebar'; 4 | import useDisclosure from '@lib/useDisclosure'; 5 | 6 | const SidebarLayout = ({ children }: PropsWithChildren) => { 7 | const { isOpen, onOpen, onClose } = useDisclosure(); 8 | const router = useRouter(); 9 | 10 | useEffect(() => { 11 | if (isOpen) { 12 | onClose(); 13 | } 14 | // eslint-disable-next-line react-hooks/exhaustive-deps 15 | }, [router.asPath]); 16 | return ( 17 |
18 | 19 |
20 | 21 |
22 | {children} 23 |
24 |
25 |
26 | ); 27 | }; 28 | 29 | export default SidebarLayout; 30 | -------------------------------------------------------------------------------- /app/src/components/Layout/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Layout'; 2 | -------------------------------------------------------------------------------- /app/src/components/LinkButton.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { ComponentStory, ComponentMeta } from '@storybook/react'; 3 | import { 4 | faBoxOpen, 5 | faBriefcase, 6 | faFileInvoice, 7 | faUsers, 8 | } from '@fortawesome/free-solid-svg-icons'; 9 | import LinkButton from './LinkButton'; 10 | import GoogleIcon from './Icons/GoogleIcon'; 11 | 12 | const Icons = { 13 | none: undefined, 14 | faBriefcase, 15 | faUsers, 16 | faBoxOpen, 17 | faFileInvoice, 18 | GoogleIcon, 19 | }; 20 | const IconInputType = { 21 | options: Object.keys(Icons), 22 | mapping: Icons, 23 | control: { type: 'select' }, 24 | }; 25 | 26 | export default { 27 | title: 'LinkButton', 28 | component: LinkButton, 29 | argTypes: { 30 | startIcon: IconInputType, 31 | endIcon: IconInputType, 32 | color: { 33 | control: { type: 'radio' }, 34 | options: ['primary', 'secondary', 'tertiary'], 35 | }, 36 | variant: { 37 | control: { 38 | type: 'radio', 39 | }, 40 | options: ['solid', 'light', 'outlined', 'borderless'], 41 | }, 42 | }, 43 | parameters: { 44 | layout: 'centered', 45 | }, 46 | } as ComponentMeta; 47 | 48 | const Template: ComponentStory = (args) => ( 49 | 50 | ); 51 | 52 | export const Default = Template.bind({}); 53 | Default.args = { 54 | color: 'primary', 55 | variant: 'solid', 56 | children: 'Button', 57 | startIcon: faBriefcase, 58 | size: 'md', 59 | fullWidth: false, 60 | href: '/', 61 | }; 62 | -------------------------------------------------------------------------------- /app/src/components/LinkIconButton.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { ComponentStory, ComponentMeta } from '@storybook/react'; 3 | import { 4 | faBoxOpen, 5 | faBriefcase, 6 | faFileInvoice, 7 | faUsers, 8 | } from '@fortawesome/free-solid-svg-icons'; 9 | import LinkIconButton from './LinkIconButton'; 10 | import GoogleIcon from './Icons/GoogleIcon'; 11 | 12 | const Icons = { 13 | none: undefined, 14 | faBriefcase, 15 | faUsers, 16 | faBoxOpen, 17 | faFileInvoice, 18 | GoogleIcon, 19 | }; 20 | const IconInputType = { 21 | options: Object.keys(Icons), 22 | mapping: Icons, 23 | control: { type: 'select' }, 24 | }; 25 | 26 | export default { 27 | title: 'LinkIconButton', 28 | component: LinkIconButton, 29 | argTypes: { 30 | disabled: { control: 'boolean' }, 31 | icon: IconInputType, 32 | color: { 33 | control: { type: 'radio' }, 34 | options: ['primary', 'secondary', 'danger'], 35 | }, 36 | }, 37 | parameters: { 38 | layout: 'centered', 39 | }, 40 | } as ComponentMeta; 41 | 42 | const Template: ComponentStory = (args) => ( 43 | 44 | ); 45 | 46 | export const Default = Template.bind({}); 47 | Default.args = { 48 | color: 'primary', 49 | variant: 'solid', 50 | children: 'Button', 51 | icon: faBriefcase, 52 | size: 'md', 53 | fullWidth: false, 54 | href: '/', 55 | }; 56 | -------------------------------------------------------------------------------- /app/src/components/Message.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { ComponentStory, ComponentMeta } from '@storybook/react'; 3 | import Message from './Message'; 4 | 5 | export default { 6 | title: 'Message', 7 | component: Message, 8 | argTypes: { 9 | onClose: { action: 'clicked' }, 10 | color: { 11 | control: { type: 'radio' }, 12 | options: ['primary', 'secondary', 'danger'], 13 | }, 14 | variant: { 15 | control: { type: 'radio' }, 16 | options: ['solid', 'light'], 17 | }, 18 | }, 19 | parameters: { 20 | layout: 'centered', 21 | }, 22 | } as ComponentMeta; 23 | 24 | const Template: ComponentStory = (args) => ( 25 | 26 | ); 27 | 28 | export const Default = Template.bind({}); 29 | Default.args = { 30 | color: 'primary', 31 | variant: 'solid', 32 | message: 'This is a message', 33 | }; 34 | -------------------------------------------------------------------------------- /app/src/components/Message.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | faCircleInfo, 3 | faTriangleExclamation, 4 | faXmark, 5 | } from '@fortawesome/free-solid-svg-icons'; 6 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 7 | import cn from 'classnames'; 8 | import React from 'react'; 9 | 10 | type MessageProps = { 11 | color?: 'primary' | 'secondary' | 'danger'; 12 | variant?: 'solid' | 'light'; 13 | message: string; 14 | onClose?: () => void; 15 | }; 16 | 17 | const Message = ({ color, variant, message, onClose }: MessageProps) => ( 18 |
34 |
35 | 39 | {message} 40 |
41 |
42 | 45 |
46 |
47 | ); 48 | 49 | export default Message; 50 | -------------------------------------------------------------------------------- /app/src/components/ProductsTable.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { ComponentStory, ComponentMeta } from '@storybook/react'; 3 | import type { Product } from '@server/products/types'; 4 | import ProductsTable from './ProductsTable'; 5 | export default { 6 | title: 'ProductsTable', 7 | component: ProductsTable, 8 | argTypes: { 9 | onDelete: { action: 'edit' }, 10 | }, 11 | parameters: { 12 | layout: 'fullscreen', 13 | }, 14 | } as ComponentMeta; 15 | 16 | const now = new Date().toISOString(); 17 | const products: Product[] = [ 18 | { 19 | id: 'product_1', 20 | name: 'Product 1', 21 | includesVat: false, 22 | price: 10, 23 | currency: 'GBP', 24 | vat: 15, 25 | unit: 'h', 26 | companyId: 'company_1', 27 | createdAt: now, 28 | updatedAt: now, 29 | }, 30 | { 31 | id: 'product_2', 32 | name: 'Product 2', 33 | includesVat: false, 34 | price: 17.65, 35 | currency: 'EUR', 36 | vat: 9, 37 | unit: 'h', 38 | companyId: 'company_1', 39 | createdAt: now, 40 | updatedAt: now, 41 | }, 42 | { 43 | id: 'product_3', 44 | name: 'Product 3', 45 | includesVat: false, 46 | price: 24.3, 47 | currency: 'USD', 48 | vat: 0, 49 | unit: 'h', 50 | companyId: 'company_1', 51 | createdAt: now, 52 | updatedAt: now, 53 | }, 54 | ]; 55 | 56 | const Template: ComponentStory = (args) => ( 57 |
58 | 59 |
60 | ); 61 | 62 | export const Default = Template.bind({}); 63 | -------------------------------------------------------------------------------- /app/src/components/Providers.tsx: -------------------------------------------------------------------------------- 1 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; 2 | import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; 3 | import { TRPCError } from '@trpc/server'; 4 | import type { Session } from 'next-auth'; 5 | import { SessionProvider } from 'next-auth/react'; 6 | import type { PropsWithChildren } from 'react'; 7 | import { useState } from 'react'; 8 | import { Toaster } from 'react-hot-toast'; 9 | 10 | type ProvidersProps = { 11 | session?: Session; 12 | }; 13 | 14 | const RetriableErrors = new Set([ 15 | 'INTERNAL_SERVER_ERROR', 16 | 'TIMEOUT', 17 | 'CONFLICT', 18 | 'TOO_MANY_REQUESTS', 19 | 'CLIENT_CLOSED_REQUEST', 20 | ]); 21 | 22 | const Providers = ({ 23 | session, 24 | children, 25 | }: PropsWithChildren) => { 26 | const [queryClient] = useState( 27 | () => 28 | new QueryClient({ 29 | defaultOptions: { 30 | queries: { 31 | staleTime: 1 * 60 * 1000, // 1 minute 32 | cacheTime: 12 * 60 * 60 * 1000, // 12 hours 33 | refetchOnWindowFocus: true, 34 | refetchOnReconnect: 'always', 35 | refetchOnMount: true, 36 | keepPreviousData: true, 37 | retry: (_count, error) => { 38 | if (error instanceof TRPCError) { 39 | return RetriableErrors.has(error.code); 40 | } 41 | return false; 42 | }, 43 | }, 44 | }, 45 | }) 46 | ); 47 | 48 | return ( 49 | 50 | 51 | {children} 52 | 53 | 54 | 55 | 56 | ); 57 | }; 58 | 59 | export default Providers; 60 | -------------------------------------------------------------------------------- /app/src/components/Sidebar/MobileSidebarOpenControls.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { ComponentStory, ComponentMeta } from '@storybook/react'; 3 | import MobileSidebarOpenControls from './MobileSidebarOpenControls'; 4 | 5 | export default { 6 | title: 'MobileSidebarOpenControls', 7 | component: MobileSidebarOpenControls, 8 | argTypes: { 9 | onOpenSidebar: { action: 'opened' }, 10 | }, 11 | } as ComponentMeta; 12 | 13 | const Template: ComponentStory = (args) => ( 14 |
15 | 16 |
17 | ); 18 | 19 | export const Default = Template.bind({}); 20 | -------------------------------------------------------------------------------- /app/src/components/Sidebar/MobileSidebarOpenControls.tsx: -------------------------------------------------------------------------------- 1 | import { faBars } from '@fortawesome/free-solid-svg-icons'; 2 | import Logo from '@components/Logo'; 3 | import useSticky from '@lib/useSticky'; 4 | import SidebarButton from './SidebarButton'; 5 | 6 | type MobileSidebarOpenControlsProps = { 7 | onOpenSidebar: () => void; 8 | }; 9 | 10 | const MobileSidebarOpenControls = ({ 11 | onOpenSidebar, 12 | }: MobileSidebarOpenControlsProps) => { 13 | const { ref, height } = useSticky(); 14 | return ( 15 | <> 16 |
20 | 25 | 26 |
27 |
33 | 34 | ); 35 | }; 36 | 37 | export default MobileSidebarOpenControls; 38 | -------------------------------------------------------------------------------- /app/src/components/Sidebar/NavLink.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { ComponentStory, ComponentMeta } from '@storybook/react'; 3 | import { 4 | faBoxOpen, 5 | faBriefcase, 6 | faFileInvoice, 7 | faUsers, 8 | } from '@fortawesome/free-solid-svg-icons'; 9 | import NavLink from './NavLink'; 10 | 11 | const Icons = { 12 | faBriefcase, 13 | faUsers, 14 | faBoxOpen, 15 | faFileInvoice, 16 | }; 17 | 18 | export default { 19 | title: 'NavLink', 20 | component: NavLink, 21 | argTypes: { 22 | icon: { 23 | options: Object.keys(Icons), 24 | mapping: Icons, 25 | control: { type: 'select' }, 26 | }, 27 | }, 28 | parameters: { 29 | layout: 'centered', 30 | }, 31 | } as ComponentMeta; 32 | 33 | const Template: ComponentStory = (args) => ( 34 | 35 | ); 36 | 37 | export const Default = Template.bind({}); 38 | Default.args = { 39 | label: 'Company', 40 | href: '/company', 41 | icon: faBriefcase, 42 | selected: false, 43 | }; 44 | -------------------------------------------------------------------------------- /app/src/components/Sidebar/NavLink.tsx: -------------------------------------------------------------------------------- 1 | import type { IconDefinition } from '@fortawesome/free-solid-svg-icons'; 2 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 3 | import Link from 'next/link'; 4 | import cn from 'classnames'; 5 | 6 | type NavLinkProps = { 7 | label: string; 8 | icon: IconDefinition; 9 | href: string; 10 | selected: boolean; 11 | }; 12 | 13 | const NavLink = ({ label, icon, href, selected }: NavLinkProps) => ( 14 |
  • 15 | 22 |
    23 | 24 | {label} 25 |
    26 | 27 |
  • 28 | ); 29 | 30 | export default NavLink; 31 | -------------------------------------------------------------------------------- /app/src/components/Sidebar/NavLinks.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { ComponentStory, ComponentMeta } from '@storybook/react'; 3 | import NavLinks from './NavLinks'; 4 | 5 | export default { 6 | title: 'NavLinks', 7 | component: NavLinks, 8 | parameters: { 9 | layout: 'centered', 10 | }, 11 | } as ComponentMeta; 12 | 13 | const Template: ComponentStory = () => ; 14 | 15 | export const Default = Template.bind({}); 16 | Default.args = {}; 17 | Default.parameters = { 18 | nextRouter: { 19 | pathname: '/company', 20 | asPath: '/company', 21 | query: {}, 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /app/src/components/Sidebar/NavLinks.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | faBoxOpen, 3 | faBriefcase, 4 | faFileInvoice, 5 | faUsers, 6 | } from '@fortawesome/free-solid-svg-icons'; 7 | import { useRouter } from 'next/router'; 8 | import Routes from '@lib/routes'; 9 | import NavLink from './NavLink'; 10 | 11 | const Links = [ 12 | { 13 | label: 'Company', 14 | href: Routes.company, 15 | icon: faBriefcase, 16 | }, 17 | { 18 | label: 'Clients', 19 | href: Routes.clients, 20 | icon: faUsers, 21 | }, 22 | { 23 | label: 'Products', 24 | href: Routes.products, 25 | icon: faBoxOpen, 26 | }, 27 | { 28 | label: 'Invoices', 29 | href: Routes.invoices, 30 | icon: faFileInvoice, 31 | }, 32 | ]; 33 | 34 | const isSelected = (href: string, pathname: string) => { 35 | const path = pathname.split('/'); 36 | const hrefPath = href.split('/'); 37 | return path[1] === hrefPath[1]; 38 | }; 39 | 40 | const NavLinks = () => { 41 | const { pathname } = useRouter(); 42 | return ( 43 |
      44 | {Links.map(({ label, href, icon }) => ( 45 | 52 | ))} 53 |
    54 | ); 55 | }; 56 | 57 | export default NavLinks; 58 | -------------------------------------------------------------------------------- /app/src/components/Sidebar/Sidebar.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { ComponentStory, ComponentMeta } from '@storybook/react'; 3 | import { rest } from 'msw'; 4 | import Providers from '@components/Providers'; 5 | import Sidebar from './Sidebar'; 6 | 7 | export default { 8 | title: 'Sidebar', 9 | component: Sidebar, 10 | layout: 'fullscreen', 11 | argTypes: { 12 | onClose: { action: 'closed' }, 13 | }, 14 | } as ComponentMeta; 15 | 16 | const Template: ComponentStory = (args) => ( 17 | 18 | 19 | 20 | ); 21 | 22 | export const Default = Template.bind({}); 23 | Default.args = {}; 24 | Default.parameters = { 25 | nextRouter: { 26 | pathname: '/company', 27 | asPath: '/company', 28 | query: {}, 29 | }, 30 | msw: { 31 | handlers: [ 32 | rest.get('http://localhost:6006/api/auth/session', (_req, res, ctx) => 33 | res( 34 | ctx.json({ 35 | user: { 36 | name: 'David Saltares', 37 | email: 'david.saltares@gmail.com', 38 | }, 39 | userId: 'user_1', 40 | companyId: 'company_1', 41 | }) 42 | ) 43 | ), 44 | ], 45 | }, 46 | }; 47 | -------------------------------------------------------------------------------- /app/src/components/Sidebar/Sidebar.tsx: -------------------------------------------------------------------------------- 1 | import { faXmark } from '@fortawesome/free-solid-svg-icons'; 2 | import cn from 'classnames'; 3 | import Logo from '@components/Logo'; 4 | import SidebarButton from './SidebarButton'; 5 | import NavLinks from './NavLinks'; 6 | import UserMenu from './UserMenu'; 7 | 8 | type SidebarProps = { 9 | isOpen: boolean; 10 | onClose: () => void; 11 | }; 12 | 13 | const Sidebar = ({ isOpen, onClose }: SidebarProps) => ( 14 | <> 15 | 40 |
    41 | 42 | ); 43 | 44 | export default Sidebar; 45 | -------------------------------------------------------------------------------- /app/src/components/Sidebar/SidebarButton.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { ComponentStory, ComponentMeta } from '@storybook/react'; 3 | import { faBars, faXmark } from '@fortawesome/free-solid-svg-icons'; 4 | import SidebarButton from './SidebarButton'; 5 | import GoogleIcon from './../Icons/GoogleIcon'; 6 | 7 | const Icons = { 8 | none: undefined, 9 | faBars, 10 | faXmark, 11 | GoogleIcon, 12 | }; 13 | const IconInputType = { 14 | options: Object.keys(Icons), 15 | mapping: Icons, 16 | control: { type: 'select' }, 17 | }; 18 | 19 | export default { 20 | title: 'SidebarButton', 21 | component: SidebarButton, 22 | argTypes: { 23 | onClick: { action: 'clicked' }, 24 | icon: IconInputType, 25 | }, 26 | parameters: { 27 | layout: 'centered', 28 | }, 29 | } as ComponentMeta; 30 | 31 | const Template: ComponentStory = (args) => ( 32 | 33 | ); 34 | 35 | export const Default = Template.bind({}); 36 | Default.args = { 37 | variant: 'primary', 38 | label: 'menu', 39 | icon: faBars, 40 | }; 41 | -------------------------------------------------------------------------------- /app/src/components/Sidebar/SidebarButton.tsx: -------------------------------------------------------------------------------- 1 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 2 | import cn from 'classnames'; 3 | import type { ButtonHTMLAttributes } from 'react'; 4 | import type { IconProp } from '../Icons/types'; 5 | 6 | type SidebarButtonProps = ButtonHTMLAttributes & { 7 | label: string; 8 | variant?: 'primary' | 'secondary'; 9 | icon: IconProp; 10 | }; 11 | 12 | const SidebarButton = ({ 13 | label, 14 | variant = 'primary', 15 | icon: Icon, 16 | ...buttonProps 17 | }: SidebarButtonProps) => { 18 | const iconClassName = cn('text-2xl w-8 h-8', {}); 19 | return ( 20 | 38 | ); 39 | }; 40 | 41 | export default SidebarButton; 42 | -------------------------------------------------------------------------------- /app/src/components/Sidebar/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Sidebar'; 2 | export { default as MobileSidebarOpenControls } from './MobileSidebarOpenControls'; 3 | -------------------------------------------------------------------------------- /app/src/components/SignInForm.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { ComponentStory, ComponentMeta } from '@storybook/react'; 3 | import type { ClientSafeProvider } from 'next-auth/react'; 4 | import SignInForm from './SignInForm'; 5 | 6 | export default { 7 | title: 'SignInForm', 8 | parameters: { 9 | layout: 'fullscreen', 10 | }, 11 | } as ComponentMeta; 12 | 13 | const providers: Record = { 14 | google: { 15 | id: 'google', 16 | name: 'Google', 17 | type: 'oauth', 18 | signinUrl: '/api/auth/signin/google', 19 | callbackUrl: '/api/auth/callback/google', 20 | }, 21 | email: { 22 | id: 'email', 23 | name: 'Email', 24 | type: 'email', 25 | signinUrl: '/api/auth/signin/email', 26 | callbackUrl: '/api/auth/callback/email', 27 | }, 28 | }; 29 | 30 | const Template: ComponentStory = () => ( 31 |
    32 | 33 |
    34 | ); 35 | 36 | export const Default = Template.bind({}); 37 | -------------------------------------------------------------------------------- /app/src/components/Spinner.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { ComponentStory, ComponentMeta } from '@storybook/react'; 3 | import Spinner from './Spinner'; 4 | export default { 5 | title: 'Spinner', 6 | component: Spinner, 7 | argTypes: { 8 | size: { 9 | control: { type: 'radio' }, 10 | options: ['sm', 'md', 'lg'], 11 | }, 12 | }, 13 | parameters: { 14 | layout: 'centered', 15 | }, 16 | } as ComponentMeta; 17 | 18 | const Template: ComponentStory = (args) => ( 19 | 20 | ); 21 | 22 | export const Default = Template.bind({ 23 | size: 'md', 24 | }); 25 | -------------------------------------------------------------------------------- /app/src/components/Spinner.tsx: -------------------------------------------------------------------------------- 1 | import cn from 'classnames'; 2 | 3 | type SpinnerProps = { 4 | size?: 'sm' | 'md' | 'lg'; 5 | }; 6 | 7 | const Spinner = ({ size = 'md' }: SpinnerProps) => ( 8 |
    9 | 19 | 23 | 27 | 28 | Loading... 29 |
    30 | ); 31 | 32 | export default Spinner; 33 | -------------------------------------------------------------------------------- /app/src/components/Toast.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { ComponentStory, ComponentMeta } from '@storybook/react'; 3 | import { Toaster } from 'react-hot-toast'; 4 | import Toast, { type ToastArgs } from './Toast'; 5 | import Button from './Button'; 6 | 7 | const ToastComponent = (_args: ToastArgs) =>

    hello

    ; 8 | 9 | export default { 10 | title: 'Toast', 11 | component: ToastComponent, 12 | argTypes: { 13 | color: { 14 | control: { type: 'radio' }, 15 | options: ['primary', 'secondary', 'danger'], 16 | }, 17 | }, 18 | parameters: { 19 | layout: 'centered', 20 | }, 21 | } as ComponentMeta; 22 | 23 | const Template: ComponentStory = (args) => ( 24 | <> 25 | 26 | 27 | 28 | ); 29 | 30 | export const Default = Template.bind({}); 31 | Default.args = { 32 | color: 'secondary', 33 | message: 'Toast message', 34 | }; 35 | -------------------------------------------------------------------------------- /app/src/components/Toast.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | faCircleInfo, 3 | faTriangleExclamation, 4 | faXmark, 5 | } from '@fortawesome/free-solid-svg-icons'; 6 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 7 | import cn from 'classnames'; 8 | import React from 'react'; 9 | import baseToast from 'react-hot-toast'; 10 | 11 | export type ToastArgs = { 12 | color?: 'primary' | 'secondary' | 'danger'; 13 | message: string; 14 | }; 15 | 16 | const toast = ({ color = 'primary', message }: ToastArgs) => { 17 | baseToast( 18 | (t) => ( 19 |
    20 |
    21 | 28 | {message} 29 |
    30 |
    31 | 34 |
    35 |
    36 | ), 37 | { 38 | className: 'min-w-[100%] sm:min-w-[400px]', 39 | style: { 40 | ...(color === 'primary' && { background: '#6B26D9' }), 41 | ...(color === 'secondary' && { background: '#27272A' }), 42 | ...(color === 'danger' && { background: '#B91C1C' }), 43 | borderRadius: 6, 44 | padding: 0, 45 | margin: 0, 46 | }, 47 | ariaProps: { 48 | role: 'status', 49 | 'aria-live': 'polite', 50 | }, 51 | duration: 10000, 52 | } 53 | ); 54 | }; 55 | 56 | export default toast; 57 | -------------------------------------------------------------------------------- /app/src/components/WithAuthentication.test.tsx: -------------------------------------------------------------------------------- 1 | import 'next'; 2 | import { setupServer } from 'msw/node'; 3 | import { render, screen, mockRouter, mockSession, waitFor } from '@lib/testing'; 4 | import WithAuthentication from './WithAuthentication'; 5 | 6 | const server = setupServer(); 7 | 8 | beforeAll(() => server.listen()); 9 | afterEach(() => server.resetHandlers()); 10 | afterAll(() => server.close()); 11 | 12 | const PageWithAuthentication = WithAuthentication(() => ( 13 |
    This page requires auth
    14 | )); 15 | 16 | describe('WithAuthentication', () => { 17 | it('redirects to the login page when the user is not authenticated', async () => { 18 | const session = undefined; 19 | server.resetHandlers(mockSession(session)); 20 | render(, { session }); 21 | 22 | await waitFor(() => { 23 | expect(mockRouter.push).toHaveBeenCalledWith('/api/auth/signin'); 24 | }); 25 | }); 26 | 27 | it('renders the page when the user is authenticated', async () => { 28 | const session = { 29 | user: {}, 30 | userId: 'user_1', 31 | companyId: 'company_1', 32 | expires: '', 33 | }; 34 | server.resetHandlers(mockSession(session)); 35 | render(, { 36 | session, 37 | }); 38 | 39 | await screen.findByText('This page requires auth'); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /app/src/components/WithAuthentication.tsx: -------------------------------------------------------------------------------- 1 | import { useSession } from 'next-auth/react'; 2 | import { useRouter } from 'next/router'; 3 | import { useEffect } from 'react'; 4 | import Routes from '@lib/routes'; 5 | 6 | const WithAuthentication =

    ( 7 | Component: React.ComponentType

    8 | ) => { 9 | function RequireAuthentication(props: P) { 10 | const router = useRouter(); 11 | const { status } = useSession(); 12 | 13 | useEffect(() => { 14 | if (status === 'unauthenticated') { 15 | void router.push(Routes.signIn); 16 | } 17 | }, [router, status]); 18 | 19 | return status === 'authenticated' ? : null; 20 | } 21 | 22 | return RequireAuthentication; 23 | }; 24 | 25 | export default WithAuthentication; 26 | -------------------------------------------------------------------------------- /app/src/components/WithNoAuthentication.test.tsx: -------------------------------------------------------------------------------- 1 | import 'next'; 2 | import { setupServer } from 'msw/node'; 3 | import { render, screen, mockRouter, mockSession, waitFor } from '@lib/testing'; 4 | import WithNoAuthentication from './WithNoAuthentication'; 5 | 6 | const server = setupServer(); 7 | 8 | beforeAll(() => server.listen()); 9 | afterEach(() => server.resetHandlers()); 10 | afterAll(() => server.close()); 11 | 12 | const PageWithNoAuthentication = WithNoAuthentication(() => ( 13 |

    This page requires an unauthenticated user
    14 | )); 15 | 16 | describe('WithAuthentication', () => { 17 | beforeEach(() => { 18 | jest.resetAllMocks(); 19 | }); 20 | 21 | it('renders the page when the user is not authenticated', async () => { 22 | const session = undefined; 23 | server.resetHandlers(mockSession(session)); 24 | render(, { session }); 25 | 26 | await screen.findByText('This page requires an unauthenticated user'); 27 | }); 28 | 29 | it('redirects to the home page when the user is authenticated', async () => { 30 | const session = { 31 | user: {}, 32 | userId: 'user_1', 33 | companyId: 'company_1', 34 | expires: '', 35 | }; 36 | server.resetHandlers(mockSession(session)); 37 | render(, { session }); 38 | 39 | await waitFor(() => { 40 | expect(mockRouter.push).toHaveBeenCalledWith('/'); 41 | }); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /app/src/components/WithNoAuthentication.tsx: -------------------------------------------------------------------------------- 1 | import { useSession } from 'next-auth/react'; 2 | import { useRouter } from 'next/router'; 3 | import { useEffect } from 'react'; 4 | import Routes from '@lib/routes'; 5 | 6 | const WithNoAuthentication =

    ( 7 | Component: React.ComponentType

    8 | ) => { 9 | function RequireNoAuthentication(props: P) { 10 | const router = useRouter(); 11 | const { status } = useSession(); 12 | 13 | useEffect(() => { 14 | if (status === 'authenticated') { 15 | void router.push(Routes.home); 16 | } 17 | }, [router, status]); 18 | 19 | return status === 'unauthenticated' ? : null; 20 | } 21 | 22 | return RequireNoAuthentication; 23 | }; 24 | 25 | export default WithNoAuthentication; 26 | -------------------------------------------------------------------------------- /app/src/lib/api.ts: -------------------------------------------------------------------------------- 1 | import { createTRPCProxyClient, httpBatchLink, httpLink } from '@trpc/client'; 2 | import type { AppRouter } from '@server/router'; 3 | 4 | export const getBaseUrl = (): string => { 5 | if (process.env.NEXT_PUBLIC_BASE_URL) { 6 | return process.env.NEXT_PUBLIC_BASE_URL; 7 | } 8 | if (process.env.NEXT_PUBLIC_VERCEL_URL) { 9 | return `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`; 10 | } 11 | if (process.env.RENDER_INTERNAL_HOSTNAME) { 12 | return `http://${process.env.RENDER_INTERNAL_HOSTNAME}:${process.env.PORT}`; 13 | } 14 | 15 | return `http://localhost:${process.env.PORT ?? 3000}`; 16 | }; 17 | 18 | const baseUrl = getBaseUrl(); 19 | 20 | const client = createTRPCProxyClient({ 21 | links: [ 22 | process.env.TEST_ENVIRONMENT === 'true' 23 | ? httpLink({ 24 | url: `${baseUrl}/api/trpc`, 25 | }) 26 | : httpBatchLink({ 27 | url: `${baseUrl}/api/trpc`, 28 | maxURLLength: 2083, 29 | }), 30 | ], 31 | }); 32 | 33 | export default client; 34 | -------------------------------------------------------------------------------- /app/src/lib/appName.ts: -------------------------------------------------------------------------------- 1 | const AppName = 'Beet Bill'; 2 | export default AppName; 3 | -------------------------------------------------------------------------------- /app/src/lib/capitalizeFirstLetter.ts: -------------------------------------------------------------------------------- 1 | const capitalizeFirstLetter = (string: string) => 2 | `${string.charAt(0).toUpperCase()}${string.slice(1)}`; 3 | 4 | export default capitalizeFirstLetter; 5 | -------------------------------------------------------------------------------- /app/src/lib/clients/queryKeys.ts: -------------------------------------------------------------------------------- 1 | const QueryKeys = { 2 | clients: ['clients'], 3 | client: (id: string) => ['clients', id], 4 | }; 5 | 6 | export default QueryKeys; 7 | -------------------------------------------------------------------------------- /app/src/lib/clients/useClient.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@tanstack/react-query'; 2 | import api from '@lib/api'; 3 | import QueryKeys from './queryKeys'; 4 | 5 | const useClient = (id: string) => 6 | useQuery(QueryKeys.client(id), () => api.getClient.query({ id })); 7 | 8 | export default useClient; 9 | -------------------------------------------------------------------------------- /app/src/lib/clients/useClients.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@tanstack/react-query'; 2 | import api from '@lib/api'; 3 | import QueryKeys from './queryKeys'; 4 | 5 | const useClients = () => 6 | useQuery(QueryKeys.clients, () => api.getClients.query()); 7 | 8 | export default useClients; 9 | -------------------------------------------------------------------------------- /app/src/lib/clients/useCreateClient.ts: -------------------------------------------------------------------------------- 1 | import cuid from 'cuid'; 2 | import api from '@lib/api'; 3 | import useMutation from '@lib/useMutation'; 4 | import type { 5 | CreateClientInput, 6 | Client, 7 | GetClientsOutput, 8 | } from '@server/clients/types'; 9 | import QueryKeys from './queryKeys'; 10 | 11 | type UseCreateClientArgs = 12 | | { 13 | onSuccess?: (client: Client) => void; 14 | } 15 | | undefined; 16 | 17 | const useCreateClient = ({ onSuccess }: UseCreateClientArgs = {}) => 18 | useMutation({ 19 | mutationFn: api.createClient.mutate, 20 | cacheKey: QueryKeys.clients, 21 | cacheUpdater: (clients, input) => { 22 | const now = new Date().toISOString(); 23 | clients.push({ 24 | id: `new-client${cuid()}`, 25 | companyId: '', 26 | number: null, 27 | createdAt: now, 28 | updatedAt: now, 29 | vatNumber: null, 30 | contactName: null, 31 | email: null, 32 | country: null, 33 | address: null, 34 | postCode: null, 35 | city: null, 36 | paymentTerms: 7, 37 | ...input, 38 | }); 39 | }, 40 | successMessage: () => 'Successfully created client!', 41 | errorMessage: () => 'Failed to create client', 42 | onSuccess, 43 | }); 44 | 45 | export default useCreateClient; 46 | -------------------------------------------------------------------------------- /app/src/lib/clients/useDeleteClient.ts: -------------------------------------------------------------------------------- 1 | import api from '@lib/api'; 2 | import useMutation from '@lib/useMutation'; 3 | import type { 4 | DeleteClientInput, 5 | GetClientsOutput, 6 | } from '@server/clients/types'; 7 | import QueryKeys from './queryKeys'; 8 | 9 | const useDeleteClient = () => 10 | useMutation({ 11 | mutationFn: api.deleteClient.mutate, 12 | cacheKey: QueryKeys.clients, 13 | cacheUpdater: (clients, input) => { 14 | const clientIndex = clients.findIndex(({ id }) => id === input.id); 15 | if (clientIndex !== -1) { 16 | clients.splice(clientIndex, 1); 17 | } 18 | }, 19 | successMessage: () => 'Successfully deleted client!', 20 | errorMessage: () => 'Failed to delete client', 21 | }); 22 | 23 | export default useDeleteClient; 24 | -------------------------------------------------------------------------------- /app/src/lib/clients/useUpdateClient.ts: -------------------------------------------------------------------------------- 1 | import api from '@lib/api'; 2 | import useMutation from '@lib/useMutation'; 3 | import type { 4 | UpdateClientInput, 5 | GetClientsOutput, 6 | Client, 7 | } from '@server/clients/types'; 8 | import QueryKeys from './queryKeys'; 9 | 10 | type UseUpdateClientArgs = 11 | | { 12 | onSuccess?: (client: Client) => void; 13 | } 14 | | undefined; 15 | 16 | const useUpdateClient = ({ onSuccess }: UseUpdateClientArgs = {}) => 17 | useMutation({ 18 | mutationFn: api.updateClient.mutate, 19 | cacheKey: QueryKeys.clients, 20 | cacheUpdater: (clients, input) => { 21 | const clientIndex = clients.findIndex(({ id }) => id === input.id); 22 | if (clientIndex !== -1) { 23 | clients[clientIndex] = { 24 | ...clients[clientIndex], 25 | ...input, 26 | }; 27 | } 28 | }, 29 | successMessage: () => 'Successfully updated client!', 30 | errorMessage: () => 'Failed to update client', 31 | onSuccess, 32 | }); 33 | 34 | export default useUpdateClient; 35 | -------------------------------------------------------------------------------- /app/src/lib/companies/queryKeys.ts: -------------------------------------------------------------------------------- 1 | const QueryKeys = { 2 | company: ['company'], 3 | }; 4 | 5 | export default QueryKeys; 6 | -------------------------------------------------------------------------------- /app/src/lib/companies/useCompany.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@tanstack/react-query'; 2 | import api from '@lib/api'; 3 | import QueryKeys from './queryKeys'; 4 | 5 | const useCompany = () => { 6 | const { data, ...rest } = useQuery(QueryKeys.company, () => 7 | api.getCompany.query() 8 | ); 9 | return { 10 | data, 11 | isValid: !!data && data.name && data.number, 12 | ...rest, 13 | }; 14 | }; 15 | 16 | export default useCompany; 17 | -------------------------------------------------------------------------------- /app/src/lib/companies/useUpdateCompany.ts: -------------------------------------------------------------------------------- 1 | import api from '@lib/api'; 2 | import useMutation from '@lib/useMutation'; 3 | import type { 4 | UpdateCompanyInput, 5 | GetCompanyOutput, 6 | } from '@server/company/types'; 7 | import QueryKeys from './queryKeys'; 8 | 9 | const useUpdateCompany = () => 10 | useMutation({ 11 | mutationFn: api.updateCompany.mutate, 12 | cacheKey: QueryKeys.company, 13 | cacheUpdater: (company, input) => { 14 | if (company) { 15 | Object.assign(company, input); 16 | } 17 | }, 18 | successMessage: () => 'Successfully updated company!', 19 | errorMessage: () => 'Failed to update company', 20 | }); 21 | 22 | export default useUpdateCompany; 23 | -------------------------------------------------------------------------------- /app/src/lib/emailRegexp.ts: -------------------------------------------------------------------------------- 1 | const EmailRegexp = 2 | /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; 3 | 4 | export default EmailRegexp; 5 | -------------------------------------------------------------------------------- /app/src/lib/format.ts: -------------------------------------------------------------------------------- 1 | import format from 'date-fns/format'; 2 | 3 | export const formatDate = (date: Date | string) => 4 | format(new Date(date), 'dd MMMM yyyy'); 5 | 6 | export const safeFormatDate = (date: Date | string) => { 7 | try { 8 | return formatDate(date); 9 | } catch (e) {} 10 | }; 11 | 12 | export const datePickerFormat = (date: Date) => format(date, 'yyyy-MM-dd'); 13 | 14 | export const formatNumber = (value: number) => DecimalFormatter.format(value); 15 | 16 | export const formatPercentage = (value: number) => 17 | PercentFormatter.format(value); 18 | 19 | export const formatAmount = (amount: number, currency: string | undefined) => 20 | isNaN(amount) 21 | ? '-' 22 | : new Intl.NumberFormat('en-UK', { 23 | style: 'currency', 24 | currency: currency || 'EUR', 25 | }).format(amount); 26 | 27 | const DecimalFormatter = new Intl.NumberFormat('en-UK', { style: 'decimal' }); 28 | const PercentFormatter = new Intl.NumberFormat('en-UK', { style: 'percent' }); 29 | -------------------------------------------------------------------------------- /app/src/lib/invoices/calculateTotal.ts: -------------------------------------------------------------------------------- 1 | import type { Product } from '@server/products/types'; 2 | 3 | interface LineItem { 4 | product: Product; 5 | date: string; 6 | quantity: number; 7 | } 8 | 9 | const calculateTotal = (items: LineItem[]) => { 10 | if (items.length === 0) { 11 | return { 12 | exclVat: 0, 13 | total: 0, 14 | currency: '', 15 | }; 16 | } 17 | const currency = items[0].product.currency; 18 | const [exclVat, total] = items.reduce( 19 | ([exclVatAcc, totalAcc], { product, quantity }) => { 20 | const basePrice = product.price * quantity; 21 | const priceExclVat = product.includesVat 22 | ? basePrice / (1 + product.vat / 100.0) 23 | : basePrice; 24 | const priceWithVat = priceExclVat * (1 + product.vat / 100.0); 25 | return [exclVatAcc + priceExclVat, totalAcc + priceWithVat]; 26 | }, 27 | [0, 0] as [number, number] 28 | ); 29 | return { 30 | exclVat, 31 | total, 32 | currency, 33 | }; 34 | }; 35 | 36 | export default calculateTotal; 37 | -------------------------------------------------------------------------------- /app/src/lib/invoices/downloadInvoice.tsx: -------------------------------------------------------------------------------- 1 | import saveAs from 'file-saver'; 2 | import { pdf } from '@react-pdf/renderer'; 3 | import InvoicePDF from '@components/InvoicePDF'; 4 | import type { Invoice } from '@server/invoices/types'; 5 | 6 | const downloadInvoice = async (invoice: Invoice) => { 7 | const title = `invoice ${invoice.company.name} ${[ 8 | invoice.prefix, 9 | invoice.number, 10 | ] 11 | .filter((part) => !!part) 12 | .join(' ')}`.replace(' ', '_'); 13 | const doc = ; 14 | const asPdf = pdf(); 15 | asPdf.updateContainer(doc); 16 | const blob = await asPdf.toBlob(); 17 | saveAs(blob, title); 18 | }; 19 | 20 | export default downloadInvoice; 21 | -------------------------------------------------------------------------------- /app/src/lib/invoices/getTitle.ts: -------------------------------------------------------------------------------- 1 | import type { Invoice } from '@server/invoices/types'; 2 | 3 | const getTitle = (invoice: Invoice) => { 4 | if (invoice.number && invoice.prefix) { 5 | return `${invoice.prefix} - ${invoice.number}`; 6 | } else if (invoice.number) { 7 | return invoice.number.toString(); 8 | } else if (invoice.prefix) { 9 | return `${invoice.prefix} -`; 10 | } 11 | return ''; 12 | }; 13 | 14 | export default getTitle; 15 | -------------------------------------------------------------------------------- /app/src/lib/invoices/queryKeys.ts: -------------------------------------------------------------------------------- 1 | const QueryKeys = { 2 | invoices: ['invoices'], 3 | invoice: (id: string) => ['invoices', id], 4 | }; 5 | 6 | export default QueryKeys; 7 | -------------------------------------------------------------------------------- /app/src/lib/invoices/useCreateInvoice.ts: -------------------------------------------------------------------------------- 1 | import cuid from 'cuid'; 2 | import api from '@lib/api'; 3 | import useMutation from '@lib/useMutation'; 4 | import type { 5 | Invoice, 6 | CreateInvoiceInput, 7 | GetInvoicesOutput, 8 | } from '@server/invoices/types'; 9 | import type { Company } from '@server/company/types'; 10 | import type { Client } from '@server/clients/types'; 11 | import QueryKeys from './queryKeys'; 12 | 13 | type UseCreateInvoicesArgs = 14 | | { 15 | onSuccess?: (invoice: Invoice) => void; 16 | } 17 | | undefined; 18 | 19 | type UseCreateInvoicesInput = CreateInvoiceInput & { 20 | company: Company; 21 | client: Client; 22 | }; 23 | 24 | const useCreateInvoice = ({ onSuccess }: UseCreateInvoicesArgs = {}) => 25 | useMutation({ 26 | mutationFn: ({ company: _company, client: _client, ...input }) => 27 | api.createInvoice.mutate(input), 28 | cacheKey: QueryKeys.invoices, 29 | cacheUpdater: (invoices, input) => { 30 | const now = new Date().toISOString(); 31 | invoices.push({ 32 | id: `new-invoice${cuid()}`, 33 | status: 'DRAFT', 34 | prefix: '', 35 | message: '', 36 | number: 0, 37 | date: now, 38 | createdAt: now, 39 | updatedAt: now, 40 | ...input, 41 | items: [] as Invoice['items'], 42 | }); 43 | }, 44 | successMessage: () => 'Successfully created invoice!', 45 | errorMessage: () => 'Failed to create invoice', 46 | onSuccess, 47 | }); 48 | 49 | export default useCreateInvoice; 50 | -------------------------------------------------------------------------------- /app/src/lib/invoices/useDeleteInvoice.ts: -------------------------------------------------------------------------------- 1 | import api from '@lib/api'; 2 | import useMutation from '@lib/useMutation'; 3 | import type { 4 | DeleteInvoiceInput, 5 | GetInvoicesOutput, 6 | } from '@server/invoices/types'; 7 | import QueryKeys from './queryKeys'; 8 | 9 | const useDeleteInvoice = () => 10 | useMutation({ 11 | mutationFn: api.deleteInvoice.mutate, 12 | cacheKey: QueryKeys.invoices, 13 | cacheUpdater: (invoices, input) => { 14 | const invoiceIndex = invoices.findIndex(({ id }) => id === input.id); 15 | if (invoiceIndex !== -1) { 16 | invoices.splice(invoiceIndex, 1); 17 | } 18 | }, 19 | successMessage: () => 'Successfully deleted invoice!', 20 | errorMessage: () => 'Failed to delete invoice', 21 | }); 22 | 23 | export default useDeleteInvoice; 24 | -------------------------------------------------------------------------------- /app/src/lib/invoices/useInvoice.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@tanstack/react-query'; 2 | import api from '@lib/api'; 3 | import QueryKeys from './queryKeys'; 4 | 5 | const useInvoice = (id: string) => 6 | useQuery(QueryKeys.invoice(id), () => api.getInvoice.query({ id })); 7 | 8 | export default useInvoice; 9 | -------------------------------------------------------------------------------- /app/src/lib/invoices/useInvoices.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@tanstack/react-query'; 2 | import api from '@lib/api'; 3 | import QueryKeys from './queryKeys'; 4 | 5 | const useInvoices = () => 6 | useQuery(QueryKeys.invoices, () => api.getInvoices.query()); 7 | 8 | export default useInvoices; 9 | -------------------------------------------------------------------------------- /app/src/lib/invoices/useLatestNumberByPrefix.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react'; 2 | import useInvoices from './useInvoices'; 3 | 4 | const useLatestNumberByPrefix = () => { 5 | const { data: invoices, ...rest } = useInvoices(); 6 | const numbersByPrefix = useMemo( 7 | () => 8 | (invoices || []).reduce((acc, invoice) => { 9 | const { prefix, number, status } = invoice; 10 | if (status === 'DRAFT') { 11 | return acc; 12 | } 13 | 14 | const current = acc[prefix] || 0; 15 | return { 16 | ...acc, 17 | [prefix]: Math.max(current, number || 0), 18 | }; 19 | }, {} as { [key: string]: number }), 20 | [invoices] 21 | ); 22 | 23 | return { 24 | ...rest, 25 | data: numbersByPrefix, 26 | }; 27 | }; 28 | 29 | export default useLatestNumberByPrefix; 30 | -------------------------------------------------------------------------------- /app/src/lib/invoices/useUpdateInvoice.ts: -------------------------------------------------------------------------------- 1 | import omit from 'lodash.omit'; 2 | import api from '@lib/api'; 3 | import useMutation from '@lib/useMutation'; 4 | import type { 5 | UpdateInvoiceInput, 6 | GetInvoicesOutput, 7 | Invoice, 8 | } from '@server/invoices/types'; 9 | import QueryKeys from './queryKeys'; 10 | 11 | type UseUpdateInvoiceArgs = 12 | | { 13 | onSuccess?: (invoice: Invoice) => void; 14 | } 15 | | undefined; 16 | 17 | const useUpdateInvoice = ({ onSuccess }: UseUpdateInvoiceArgs = {}) => 18 | useMutation({ 19 | mutationFn: api.updateInvoice.mutate, 20 | cacheKey: QueryKeys.invoices, 21 | cacheUpdater: (invoices, input) => { 22 | const invoiceIndex = invoices.findIndex(({ id }) => id === input.id); 23 | if (invoiceIndex !== -1) { 24 | invoices[invoiceIndex] = { 25 | ...invoices[invoiceIndex], 26 | ...omit(input, 'items'), 27 | }; 28 | } 29 | }, 30 | successMessage: () => 'Successfully updated invoice!', 31 | errorMessage: () => 'Failed to update invoice', 32 | onSuccess, 33 | }); 34 | 35 | export default useUpdateInvoice; 36 | -------------------------------------------------------------------------------- /app/src/lib/products/queryKeys.ts: -------------------------------------------------------------------------------- 1 | const QueryKeys = { 2 | products: ['products'], 3 | product: (id: string) => ['products', id], 4 | }; 5 | 6 | export default QueryKeys; 7 | -------------------------------------------------------------------------------- /app/src/lib/products/useCreateProduct.ts: -------------------------------------------------------------------------------- 1 | import cuid from 'cuid'; 2 | import api from '@lib/api'; 3 | import useMutation from '@lib/useMutation'; 4 | import type { 5 | CreateProductInput, 6 | GetProductsOutput, 7 | Product, 8 | } from '@server/products/types'; 9 | import QueryKeys from './queryKeys'; 10 | 11 | type UseCreateProductArgs = 12 | | { 13 | onSuccess?: (product: Product) => void; 14 | } 15 | | undefined; 16 | 17 | const useCreateProduct = ({ onSuccess }: UseCreateProductArgs = {}) => 18 | useMutation({ 19 | mutationFn: api.createProduct.mutate, 20 | cacheKey: QueryKeys.products, 21 | cacheUpdater: (products, input) => { 22 | const now = new Date().toISOString(); 23 | products.push({ 24 | id: `new-product${cuid()}`, 25 | companyId: '', 26 | createdAt: now, 27 | updatedAt: now, 28 | includesVat: false, 29 | price: 0, 30 | currency: 'EUR', 31 | vat: 0, 32 | unit: 'hours', 33 | ...input, 34 | }); 35 | }, 36 | successMessage: () => 'Successfully created product!', 37 | errorMessage: () => 'Failed to create product', 38 | onSuccess, 39 | }); 40 | 41 | export default useCreateProduct; 42 | -------------------------------------------------------------------------------- /app/src/lib/products/useDeleteProduct.ts: -------------------------------------------------------------------------------- 1 | import api from '@lib/api'; 2 | import useMutation from '@lib/useMutation'; 3 | import type { 4 | DeleteProductInput, 5 | GetProductsOutput, 6 | } from '@server/products/types'; 7 | import QueryKeys from './queryKeys'; 8 | 9 | const useDeleteProduct = () => 10 | useMutation({ 11 | mutationFn: api.deleteProduct.mutate, 12 | cacheKey: QueryKeys.products, 13 | cacheUpdater: (products, input) => { 14 | const productIndex = products.findIndex(({ id }) => id === input.id); 15 | if (productIndex !== -1) { 16 | products.splice(productIndex, 1); 17 | } 18 | }, 19 | successMessage: () => 'Successfully deleted product!', 20 | errorMessage: () => 'Failed to delete product', 21 | }); 22 | 23 | export default useDeleteProduct; 24 | -------------------------------------------------------------------------------- /app/src/lib/products/useProduct.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@tanstack/react-query'; 2 | import api from '@lib/api'; 3 | import QueryKeys from './queryKeys'; 4 | 5 | const useProduct = (id: string) => 6 | useQuery(QueryKeys.product(id), () => api.getProduct.query({ id })); 7 | 8 | export default useProduct; 9 | -------------------------------------------------------------------------------- /app/src/lib/products/useProducts.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@tanstack/react-query'; 2 | import api from '@lib/api'; 3 | import QueryKeys from './queryKeys'; 4 | 5 | const useProducts = () => 6 | useQuery(QueryKeys.products, () => api.getProducts.query()); 7 | 8 | export default useProducts; 9 | -------------------------------------------------------------------------------- /app/src/lib/products/useUpdateProduct.ts: -------------------------------------------------------------------------------- 1 | import api from '@lib/api'; 2 | import useMutation from '@lib/useMutation'; 3 | import type { 4 | UpdateProductInput, 5 | GetProductsOutput, 6 | Product, 7 | } from '@server/products/types'; 8 | import QueryKeys from './queryKeys'; 9 | 10 | type UseUpdateProductArgs = 11 | | { 12 | onSuccess?: (data: Product) => void; 13 | } 14 | | undefined; 15 | 16 | const useUpdateProduct = ({ onSuccess }: UseUpdateProductArgs = {}) => 17 | useMutation({ 18 | mutationFn: api.updateProduct.mutate, 19 | cacheKey: QueryKeys.products, 20 | cacheUpdater: (products, input) => { 21 | const productIndex = products.findIndex(({ id }) => id === input.id); 22 | if (productIndex !== -1) { 23 | products[productIndex] = { 24 | ...products[productIndex], 25 | ...input, 26 | }; 27 | } 28 | }, 29 | onSuccess, 30 | successMessage: () => 'Successfully updated product!', 31 | errorMessage: () => 'Failed to update product', 32 | }); 33 | 34 | export default useUpdateProduct; 35 | -------------------------------------------------------------------------------- /app/src/lib/routes.ts: -------------------------------------------------------------------------------- 1 | const Routes = { 2 | home: '/', 3 | company: '/company', 4 | products: '/products', 5 | createProduct: '/products/new', 6 | product: (productId: string) => `/products/${productId}`, 7 | clients: '/clients', 8 | createClient: '/clients/new', 9 | client: (clientId: string) => `/clients/${clientId}`, 10 | invoices: '/invoices', 11 | createInvoice: '/invoices/new', 12 | invoice: (invoiceId: string) => `/invoices/${invoiceId}`, 13 | invoicePreview: (invoiceId: string) => `/invoices/${invoiceId}/preview`, 14 | signIn: '/api/auth/signin', 15 | notFound: '/404', 16 | privacyPolicy: '/beetbill_privacy_policy.pdf', 17 | termsAndConditions: '/beetbill_terms_and_conditions.pdf', 18 | cookiePolicy: '/beetbill_cookie_policy.pdf', 19 | }; 20 | 21 | export default Routes; 22 | -------------------------------------------------------------------------------- /app/src/lib/useDisclosure.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useState } from 'react'; 2 | 3 | const useDisclosure = () => { 4 | const [isOpen, setOpen] = useState(false); 5 | const onOpen = useCallback(() => setOpen(true), [setOpen]); 6 | const onClose = useCallback(() => setOpen(false), [setOpen]); 7 | return { 8 | isOpen, 9 | onOpen, 10 | onClose, 11 | }; 12 | }; 13 | 14 | export default useDisclosure; 15 | -------------------------------------------------------------------------------- /app/src/lib/useDisclosureForId.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useState } from 'react'; 2 | 3 | const useDisclosureForId = () => { 4 | const [openFor, setOpenFor] = useState(); 5 | const onOpen = useCallback((id: string) => setOpenFor(id), [setOpenFor]); 6 | const onClose = useCallback(() => setOpenFor(undefined), [setOpenFor]); 7 | return { 8 | isOpen: !!openFor, 9 | openFor, 10 | onOpen, 11 | onClose, 12 | }; 13 | }; 14 | 15 | export default useDisclosureForId; 16 | -------------------------------------------------------------------------------- /app/src/lib/useDropdownAnchor.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useRef, useState } from 'react'; 2 | 3 | const useDropdownAnchor = () => { 4 | const anchorRef = useRef(null); 5 | const [rect, setRect] = useState(() => new DOMRect()); 6 | const handleUpdateRect = useCallback(() => { 7 | if (anchorRef.current) { 8 | setRect(anchorRef.current.getBoundingClientRect()); 9 | } 10 | }, [anchorRef, setRect]); 11 | useEffect(() => { 12 | handleUpdateRect(); 13 | window.addEventListener('scroll', handleUpdateRect); 14 | return () => window.removeEventListener('scroll', handleUpdateRect); 15 | }, [handleUpdateRect]); 16 | 17 | return { 18 | anchorRef, 19 | top: rect.top + rect.height, 20 | width: rect.width, 21 | }; 22 | }; 23 | 24 | export default useDropdownAnchor; 25 | -------------------------------------------------------------------------------- /app/src/lib/useFilterFromUrl.ts: -------------------------------------------------------------------------------- 1 | import { useRouter } from 'next/router'; 2 | import { useCallback } from 'react'; 3 | 4 | const useFilterFromUrl = (): [string, (newFilter: string) => void] => { 5 | const { query, replace } = useRouter(); 6 | const setFilter = useCallback( 7 | (newFilter: string) => { 8 | void replace({ query: { ...query, filter: newFilter } }, undefined, { 9 | shallow: true, 10 | }); 11 | }, 12 | [query, replace] 13 | ); 14 | 15 | return [(query.filter || '') as string, setFilter]; 16 | }; 17 | 18 | export default useFilterFromUrl; 19 | -------------------------------------------------------------------------------- /app/src/lib/useSortFromUrl.ts: -------------------------------------------------------------------------------- 1 | import type { ColumnSort } from '@tanstack/react-table'; 2 | import { useRouter } from 'next/router'; 3 | import { useCallback, useMemo } from 'react'; 4 | 5 | const useSortFromUrl = (defaultSort: ColumnSort | undefined = undefined) => { 6 | const { query, push } = useRouter(); 7 | const hasSort = 'sortBy' in query; 8 | const sortBy = query.sortBy as string | undefined; 9 | const sortDir = query.sortDir as string | undefined; 10 | const sorting: ColumnSort[] = useMemo(() => { 11 | if (hasSort) { 12 | return sortBy ? [{ id: sortBy, desc: sortDir === 'desc' }] : []; 13 | } 14 | return defaultSort ? [defaultSort] : []; 15 | }, [sortBy, sortDir, defaultSort, hasSort]); 16 | const toggleSort = useCallback( 17 | (id: string) => { 18 | const currentSortBy = sortBy ?? defaultSort?.id; 19 | const currentSortDir = sortDir ?? (defaultSort?.desc ? 'desc' : 'asc'); 20 | let newSortBy: string | undefined; 21 | let newSortDir: string | undefined; 22 | 23 | if (currentSortBy === id && currentSortDir === 'desc') { 24 | newSortBy = id; 25 | newSortDir = 'asc'; 26 | } else if (currentSortBy === id && currentSortDir === 'asc') { 27 | newSortBy = undefined; 28 | newSortDir = undefined; 29 | } else { 30 | newSortBy = id; 31 | newSortDir = 'desc'; 32 | } 33 | void push( 34 | { 35 | query: { 36 | ...query, 37 | sortBy: newSortBy, 38 | sortDir: newSortDir, 39 | }, 40 | }, 41 | undefined, 42 | { shallow: true } 43 | ); 44 | }, 45 | [push, query, sortBy, sortDir, defaultSort] 46 | ); 47 | return { 48 | sorting, 49 | toggleSort, 50 | }; 51 | }; 52 | 53 | export default useSortFromUrl; 54 | -------------------------------------------------------------------------------- /app/src/lib/useSticky.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from 'react'; 2 | 3 | const useSticky = () => { 4 | const ref = useRef(null); 5 | const [height, setHeight] = useState(0); 6 | 7 | useEffect(() => { 8 | setHeight(ref.current?.offsetHeight ?? 0); 9 | }, [ref]); 10 | 11 | return { 12 | ref, 13 | height, 14 | }; 15 | }; 16 | 17 | export default useSticky; 18 | -------------------------------------------------------------------------------- /app/src/lib/userName.ts: -------------------------------------------------------------------------------- 1 | import type { Session } from 'next-auth'; 2 | import capitalizeFirstLetter from './capitalizeFirstLetter'; 3 | 4 | export const getFullName = (user: NonNullable) => { 5 | if (user.name) { 6 | const [firstName, lastName] = user.name.split(' '); 7 | return firstName && lastName ? `${firstName} ${lastName}` : firstName; 8 | } 9 | if (user.email) { 10 | const [email] = user.email.split('@'); 11 | const [firstName, lastName] = email.split('.'); 12 | return firstName && lastName 13 | ? `${capitalizeFirstLetter(firstName)} ${capitalizeFirstLetter(lastName)}` 14 | : firstName; 15 | } 16 | return ''; 17 | }; 18 | 19 | export const getInitials = (user: NonNullable) => { 20 | if (user.name) { 21 | const [firstName, lastName] = user.name.split(' '); 22 | return firstName && lastName 23 | ? `${firstName[0]}${lastName[0]}` 24 | : firstName[0]; 25 | } 26 | if (user.email) { 27 | const [email] = user.email.split('@'); 28 | const [firstName, lastName] = email.split('.'); 29 | return firstName && lastName 30 | ? `${capitalizeFirstLetter(firstName)[0]}${ 31 | capitalizeFirstLetter(lastName)[0] 32 | }` 33 | : capitalizeFirstLetter(firstName[0]); 34 | } 35 | return ''; 36 | }; 37 | -------------------------------------------------------------------------------- /app/src/lib/utilityTypes.ts: -------------------------------------------------------------------------------- 1 | export type ArrayElement = 2 | ArrayType extends readonly (infer ElementType)[] ? ElementType : never; 3 | -------------------------------------------------------------------------------- /app/src/pages/404.page.test.tsx: -------------------------------------------------------------------------------- 1 | import 'next'; 2 | import { setupServer } from 'msw/node'; 3 | import { screen, render, mockSession } from '@lib/testing'; 4 | import NotFoundPage from './404.page'; 5 | 6 | const session = undefined; 7 | const server = setupServer(); 8 | 9 | beforeAll(() => server.listen()); 10 | beforeEach(() => server.resetHandlers(mockSession(session))); 11 | afterAll(() => server.close()); 12 | 13 | describe('NotFoundPage', () => { 14 | it('renders the page', async () => { 15 | render(, { session }); 16 | 17 | await screen.findByRole('link', { name: 'Home' }); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /app/src/pages/404.page.tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head'; 2 | import { faHouse } from '@fortawesome/free-solid-svg-icons'; 3 | import AppName from '@lib/appName'; 4 | import Routes from '@lib/routes'; 5 | import LinkButton from '@components/LinkButton'; 6 | import Card from '@components/Card'; 7 | 8 | const NotFoundPage = () => ( 9 | 10 | 11 | {`404 - ${AppName}`} 12 | 13 |

    14 |
    15 | 16 | Home 17 | 18 |
    19 | 20 | ); 21 | 22 | export default NotFoundPage; 23 | -------------------------------------------------------------------------------- /app/src/pages/_document.next.tsx: -------------------------------------------------------------------------------- 1 | import { Html, Head, Main, NextScript } from 'next/document'; 2 | 3 | const Document = () => ( 4 | 5 | 6 | 7 | 8 | 12 | 13 | 14 |
    15 | 16 | 17 | 18 | ); 19 | 20 | export default Document; 21 | -------------------------------------------------------------------------------- /app/src/pages/api/auth/[...nextauth].route.ts: -------------------------------------------------------------------------------- 1 | import NextAuth from 'next-auth'; 2 | import authOptions from '@server/auth/authOptions'; 3 | 4 | export default NextAuth(authOptions); 5 | -------------------------------------------------------------------------------- /app/src/pages/api/health.route.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | import prisma from '@server/prisma'; 3 | 4 | type SuccessResponse = { 5 | invoiceCount: number; 6 | }; 7 | 8 | type ErrorResponse = { 9 | message: string; 10 | }; 11 | 12 | type ResponseData = SuccessResponse | ErrorResponse; 13 | 14 | export default async function handler( 15 | req: NextApiRequest, 16 | res: NextApiResponse 17 | ) { 18 | if (req.method !== 'GET') { 19 | return res.status(405).json({ message: 'Method Not Allowed' }); 20 | } 21 | 22 | const apiKey = req.query.apiKey as string | undefined; 23 | if (apiKey !== process.env.API_KEY) { 24 | return res.status(401).json({ message: 'Unauthorized' }); 25 | } 26 | 27 | const invoiceCount = await prisma.invoice.count(); 28 | return res.status(200).json({ invoiceCount }); 29 | } 30 | -------------------------------------------------------------------------------- /app/src/pages/api/trpc/[trpc].route.ts: -------------------------------------------------------------------------------- 1 | import { createNextApiHandler } from '@trpc/server/adapters/next'; 2 | import createContext from '@server/createContext'; 3 | import router from '@server/router'; 4 | 5 | export default createNextApiHandler({ 6 | router, 7 | createContext, 8 | batching: { enabled: true }, 9 | }); 10 | -------------------------------------------------------------------------------- /app/src/pages/auth/error.page.test.tsx: -------------------------------------------------------------------------------- 1 | import 'next'; 2 | import { setupServer } from 'msw/node'; 3 | import { screen, render, mockSession } from '@lib/testing'; 4 | import ErrorPage from './error.page'; 5 | 6 | const session = undefined; 7 | const server = setupServer(); 8 | 9 | beforeAll(() => server.listen()); 10 | afterAll(() => server.close()); 11 | 12 | describe('ErrorPage', () => { 13 | beforeEach(() => { 14 | jest.resetAllMocks(); 15 | server.resetHandlers(mockSession(session)); 16 | }); 17 | 18 | it('renders the page and shows the correct error message', async () => { 19 | render(, { session }); 20 | 21 | await screen.findByRole('link', { name: 'Sign in' }); 22 | await screen.findByText( 23 | 'The sign in link is no longer valid. It may have been used or it may have expired.' 24 | ); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /app/src/pages/auth/error.page.tsx: -------------------------------------------------------------------------------- 1 | import type { GetServerSideProps, InferGetServerSidePropsType } from 'next'; 2 | import Head from 'next/head'; 3 | import { 4 | faArrowRight, 5 | faTriangleExclamation, 6 | } from '@fortawesome/free-solid-svg-icons'; 7 | import LinkButton from '@components/LinkButton'; 8 | import AppName from '@lib/appName'; 9 | import Routes from '@lib/routes'; 10 | import WithNoAuthentication from '@components/WithNoAuthentication'; 11 | import Card from '@components/Card'; 12 | 13 | const Messages: Record = { 14 | Configuration: 'The application is misconfigured, please contact support.', 15 | AccessDenied: 'Your account has been blocked, please contact support.', 16 | Verification: 17 | 'The sign in link is no longer valid. It may have been used or it may have expired.', 18 | Default: 'Something went wrong, please try again.', 19 | }; 20 | 21 | const ErrorPage = ({ 22 | error, 23 | }: InferGetServerSidePropsType) => { 24 | const message = Messages[error] || Messages.Default; 25 | return ( 26 | 27 | 28 | {`Log in - ${AppName}`} 29 | 30 |

    {message}

    31 |
    32 | 33 | Sign in 34 | 35 |
    36 |
    37 | ); 38 | }; 39 | 40 | export const getServerSideProps: GetServerSideProps = async (context) => ({ 41 | props: { 42 | error: context.query.error || '', 43 | }, 44 | }); 45 | 46 | export default WithNoAuthentication(ErrorPage); 47 | -------------------------------------------------------------------------------- /app/src/pages/auth/signin.page.tsx: -------------------------------------------------------------------------------- 1 | import type { GetServerSideProps, InferGetServerSidePropsType } from 'next'; 2 | import { getProviders } from 'next-auth/react'; 3 | import Head from 'next/head'; 4 | import SignInForm from '@components/SignInForm'; 5 | import WithNoAuthentication from '@components/WithNoAuthentication'; 6 | import AppName from '@lib/appName'; 7 | 8 | const SignInPage = ({ 9 | providers, 10 | callbackUrl, 11 | error, 12 | }: InferGetServerSidePropsType) => ( 13 | <> 14 | 15 | {`Log in - ${AppName}`} 16 | 17 | 18 | 19 | ); 20 | 21 | export const getServerSideProps: GetServerSideProps = async (context) => { 22 | const providers = await getProviders(); 23 | 24 | return { 25 | props: { 26 | providers, 27 | callbackUrl: context.query.callbackUrl || '', 28 | error: context.query.error || '', 29 | }, 30 | }; 31 | }; 32 | 33 | export default WithNoAuthentication(SignInPage); 34 | -------------------------------------------------------------------------------- /app/src/pages/auth/verify.page.test.tsx: -------------------------------------------------------------------------------- 1 | import 'next'; 2 | import { setupServer } from 'msw/node'; 3 | import { screen, render, mockSession } from '@lib/testing'; 4 | import VerifyPage from './verify.page'; 5 | 6 | const session = undefined; 7 | const server = setupServer(); 8 | 9 | beforeAll(() => server.listen()); 10 | afterEach(() => server.resetHandlers()); 11 | afterAll(() => server.close()); 12 | 13 | describe('VerifyPage', () => { 14 | beforeEach(() => { 15 | jest.resetAllMocks(); 16 | server.resetHandlers(mockSession(session)); 17 | }); 18 | 19 | it('renders the page', async () => { 20 | render(, { session }); 21 | 22 | await screen.findByRole('link', { name: 'Back' }); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /app/src/pages/auth/verify.page.tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head'; 2 | import { faArrowLeft } from '@fortawesome/free-solid-svg-icons'; 3 | import AppName from '@lib/appName'; 4 | import LinkButton from '@components/LinkButton'; 5 | import Routes from '@lib/routes'; 6 | import WithNoAuthentication from '@components/WithNoAuthentication'; 7 | import Card from '@components/Card'; 8 | 9 | const VerifyPage = () => ( 10 | 11 | 12 | {`Log in - ${AppName}`} 13 | 14 |

    15 | We just emailed you a link that will log you in securely. 16 |

    17 |
    18 | 19 | Back 20 | 21 |
    22 |
    23 | ); 24 | 25 | export default WithNoAuthentication(VerifyPage); 26 | -------------------------------------------------------------------------------- /app/src/pages/clients.page.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | import { faAdd } from '@fortawesome/free-solid-svg-icons'; 3 | import Head from 'next/head'; 4 | import EmptyContent from '@components/EmptyContent'; 5 | import WithAuthentication from '@components/WithAuthentication'; 6 | import Routes from '@lib/routes'; 7 | import FullScreenSpinner from '@components/Layout/FullScreenSpinner'; 8 | import LinkButton from '@components/LinkButton'; 9 | import useClients from '@lib/clients/useClients'; 10 | import useDeleteClient from '@lib/clients/useDeleteClient'; 11 | import ClientsTable from '@components/ClientsTable'; 12 | import AppName from '@lib/appName'; 13 | 14 | const ClientsPage = () => { 15 | const { data: clients, isLoading } = useClients(); 16 | const { mutate: deleteClient } = useDeleteClient(); 17 | const handleDelete = useCallback( 18 | (clientId: string) => deleteClient({ id: clientId }), 19 | [deleteClient] 20 | ); 21 | 22 | let content = null; 23 | 24 | if (isLoading) { 25 | content = ; 26 | } else if (!clients || clients.length === 0) { 27 | content = ( 28 | 33 | ); 34 | } else { 35 | content = ( 36 |
    37 |
    38 |

    Your clients

    39 | 40 | Add client 41 | 42 |
    43 | 44 |
    45 | ); 46 | } 47 | 48 | return ( 49 | <> 50 | 51 | {`Clients - ${AppName}`} 52 | 53 | {content} 54 | 55 | ); 56 | }; 57 | 58 | export default WithAuthentication(ClientsPage); 59 | -------------------------------------------------------------------------------- /app/src/pages/clients/[clientId].page.tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from 'next/router'; 2 | import { useEffect } from 'react'; 3 | import Head from 'next/head'; 4 | import FullScreenSpinner from '@components/Layout/FullScreenSpinner'; 5 | import WithAuthentication from '@components/WithAuthentication'; 6 | import Routes from '@lib/routes'; 7 | import CreateEditClientForm from '@components/CreateEditClientForm'; 8 | import useClient from '@lib/clients/useClient'; 9 | import AppName from '@lib/appName'; 10 | 11 | const EditClientPage = () => { 12 | const router = useRouter(); 13 | const { data: client, isError } = useClient(router.query.clientId as string); 14 | 15 | useEffect(() => { 16 | if (isError) { 17 | void router.push(Routes.notFound); 18 | } 19 | }, [router, isError]); 20 | 21 | const content = client ? ( 22 | 23 | ) : ( 24 | 25 | ); 26 | 27 | return ( 28 | <> 29 | 30 | {`${client?.name ?? 'Client'} - ${AppName}`} 31 | 32 | {content} 33 | 34 | ); 35 | }; 36 | 37 | export default WithAuthentication(EditClientPage); 38 | -------------------------------------------------------------------------------- /app/src/pages/clients/new.page.tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head'; 2 | import CreateEditClientForm from '@components/CreateEditClientForm'; 3 | import WithAuthentication from '@components/WithAuthentication'; 4 | import AppName from '@lib/appName'; 5 | 6 | const NewClientPage = () => ( 7 | <> 8 | 9 | {`New client - ${AppName}`} 10 | 11 | 12 | 13 | ); 14 | 15 | export default WithAuthentication(NewClientPage); 16 | -------------------------------------------------------------------------------- /app/src/pages/company.page.tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head'; 2 | import EditCompanyForm from '@components/EditCompanyForm'; 3 | import FullScreenSpinner from '@components/Layout/FullScreenSpinner'; 4 | import WithAuthentication from '@components/WithAuthentication'; 5 | import useCompany from '@lib/companies/useCompany'; 6 | import AppName from '@lib/appName'; 7 | 8 | const CompanyPage = () => { 9 | const { data: company } = useCompany(); 10 | const content = company ? ( 11 | 12 | ) : ( 13 | 14 | ); 15 | 16 | return ( 17 | <> 18 | 19 | {`${company?.name ?? 'Company'} - ${AppName}`} 20 | 21 | {content} 22 | 23 | ); 24 | }; 25 | 26 | export default WithAuthentication(CompanyPage); 27 | -------------------------------------------------------------------------------- /app/src/pages/index.page.tsx: -------------------------------------------------------------------------------- 1 | import type { GetServerSideProps, NextPage } from 'next'; 2 | 3 | const Home: NextPage = () => null; 4 | 5 | export default Home; 6 | 7 | export const getServerSideProps: GetServerSideProps = async () => ({ 8 | redirect: { 9 | destination: '/invoices', 10 | permantent: false, 11 | }, 12 | props: {}, 13 | }); 14 | -------------------------------------------------------------------------------- /app/src/pages/index.test.tsx: -------------------------------------------------------------------------------- 1 | import 'next'; 2 | 3 | import { useSession } from 'next-auth/react'; 4 | import { render, screen } from '@lib/testing'; 5 | import IndexPage from './index.page'; 6 | 7 | jest.mock('next-auth/react'); 8 | 9 | const useSessionFn = useSession as jest.Mock; 10 | 11 | describe('IndexPage', () => { 12 | it('renders correctly', async () => { 13 | useSessionFn.mockReturnValue({ 14 | status: 'unauthenticated', 15 | data: null, 16 | }); 17 | 18 | render(); 19 | screen.queryByText('Not signed in'); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /app/src/pages/invoices.page.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | import Head from 'next/head'; 3 | import { faAdd } from '@fortawesome/free-solid-svg-icons'; 4 | import EmptyContent from '@components/EmptyContent'; 5 | import WithAuthentication from '@components/WithAuthentication'; 6 | import Routes from '@lib/routes'; 7 | import AppName from '@lib/appName'; 8 | import useInvoices from '@lib/invoices/useInvoices'; 9 | import useDeleteInvoice from '@lib/invoices/useDeleteInvoice'; 10 | import FullScreenSpinner from '@components/Layout/FullScreenSpinner'; 11 | import LinkButton from '@components/LinkButton'; 12 | import InvoicesTable from '@components/InvoicesTable'; 13 | 14 | const InvoicesPage = () => { 15 | const { data: invoices, isLoading } = useInvoices(); 16 | const { mutate: deleteInvoice } = useDeleteInvoice(); 17 | const handleDelete = useCallback( 18 | (invoiceId: string) => deleteInvoice({ id: invoiceId }), 19 | [deleteInvoice] 20 | ); 21 | 22 | let content = null; 23 | if (isLoading) { 24 | content = ; 25 | } else if (!invoices || invoices.length === 0) { 26 | content = ( 27 | 32 | ); 33 | } else { 34 | content = ( 35 |
    36 |
    37 |

    Your invoices

    38 | 39 | Add invoice 40 | 41 |
    42 | 43 |
    44 | ); 45 | } 46 | 47 | return ( 48 | <> 49 | 50 | {`Invoices - ${AppName}`} 51 | 52 | {content} 53 | 54 | ); 55 | }; 56 | 57 | export default WithAuthentication(InvoicesPage); 58 | -------------------------------------------------------------------------------- /app/src/pages/invoices/[invoiceId]/preview.page.tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from 'next/router'; 2 | import { useEffect } from 'react'; 3 | import Head from 'next/head'; 4 | import FullScreenSpinner from '@components/Layout/FullScreenSpinner'; 5 | import WithAuthentication from '@components/WithAuthentication'; 6 | import Routes from '@lib/routes'; 7 | import AppName from '@lib/appName'; 8 | import useInvoice from '@lib/invoices/useInvoice'; 9 | import InvoicePreview from '@components/InvoicePreview'; 10 | 11 | const PreviewInvoicePage = () => { 12 | const router = useRouter(); 13 | const { data: invoice, isError } = useInvoice( 14 | router.query.invoiceId as string 15 | ); 16 | 17 | useEffect(() => { 18 | if (isError) { 19 | void router.push(Routes.notFound); 20 | } 21 | }, [router, isError]); 22 | 23 | const content = invoice ? ( 24 | 25 | ) : ( 26 | 27 | ); 28 | 29 | return ( 30 | <> 31 | 32 | {`Invoice preview - ${AppName}`} 33 | 34 | {content} 35 | 36 | ); 37 | }; 38 | 39 | export default WithAuthentication(PreviewInvoicePage); 40 | -------------------------------------------------------------------------------- /app/src/pages/products.page.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | import { faAdd } from '@fortawesome/free-solid-svg-icons'; 3 | import Head from 'next/head'; 4 | import EmptyContent from '@components/EmptyContent'; 5 | import WithAuthentication from '@components/WithAuthentication'; 6 | import useDeleteProduct from '@lib/products/useDeleteProduct'; 7 | import useProducts from '@lib/products/useProducts'; 8 | import Routes from '@lib/routes'; 9 | import FullScreenSpinner from '@components/Layout/FullScreenSpinner'; 10 | import ProductsTable from '@components/ProductsTable'; 11 | import LinkButton from '@components/LinkButton'; 12 | import AppName from '@lib/appName'; 13 | 14 | const ProductsPage = () => { 15 | const { data: products, isLoading } = useProducts(); 16 | const { mutate: deleteProduct } = useDeleteProduct(); 17 | const handleDelete = useCallback( 18 | (productId: string) => deleteProduct({ id: productId }), 19 | [deleteProduct] 20 | ); 21 | 22 | let content = null; 23 | if (isLoading) { 24 | content = ; 25 | } else if (!products || products.length === 0) { 26 | content = ( 27 | 32 | ); 33 | } else { 34 | content = ( 35 |
    36 |
    37 |

    Your products

    38 | 39 | Add product 40 | 41 |
    42 | 43 |
    44 | ); 45 | } 46 | 47 | return ( 48 | <> 49 | 50 | {`Products - ${AppName}`} 51 | 52 | {content} 53 | 54 | ); 55 | }; 56 | 57 | export default WithAuthentication(ProductsPage); 58 | -------------------------------------------------------------------------------- /app/src/pages/products/[productId].page.tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from 'next/router'; 2 | import { useEffect } from 'react'; 3 | import Head from 'next/head'; 4 | import CreateEditProductForm from '@components/CreateEditProductForm'; 5 | import FullScreenSpinner from '@components/Layout/FullScreenSpinner'; 6 | import WithAuthentication from '@components/WithAuthentication'; 7 | import useProduct from '@lib/products/useProduct'; 8 | import Routes from '@lib/routes'; 9 | import AppName from '@lib/appName'; 10 | 11 | const EditProductPage = () => { 12 | const router = useRouter(); 13 | const { data: product, isError } = useProduct( 14 | router.query.productId as string 15 | ); 16 | 17 | useEffect(() => { 18 | if (isError) { 19 | void router.push(Routes.notFound); 20 | } 21 | }, [router, isError]); 22 | 23 | const content = product ? ( 24 | 25 | ) : ( 26 | 27 | ); 28 | 29 | return ( 30 | <> 31 | 32 | {`${product?.name ?? 'Product'} - ${AppName}`} 33 | 34 | {content} 35 | 36 | ); 37 | }; 38 | 39 | export default WithAuthentication(EditProductPage); 40 | -------------------------------------------------------------------------------- /app/src/pages/products/new.page.tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head'; 2 | import CreateEditProductForm from '@components/CreateEditProductForm'; 3 | import WithAuthentication from '@components/WithAuthentication'; 4 | import AppName from '@lib/appName'; 5 | 6 | const NewProductPage = () => ( 7 | <> 8 | 9 | {`New product - ${AppName}`} 10 | 11 | 12 | 13 | ); 14 | 15 | export default WithAuthentication(NewProductPage); 16 | -------------------------------------------------------------------------------- /app/src/server/auth/authOptions.ts: -------------------------------------------------------------------------------- 1 | import { PrismaAdapter } from '@next-auth/prisma-adapter'; 2 | import EmailProvider from 'next-auth/providers/email'; 3 | import GoogleProvider from 'next-auth/providers/google'; 4 | import prisma from '@server/prisma'; 5 | import { sendVerificationRequest } from './email'; 6 | import callbacks from './callbacks'; 7 | import events from './events'; 8 | 9 | const authOptions = { 10 | adapter: PrismaAdapter(prisma), 11 | providers: [ 12 | EmailProvider({ 13 | server: { 14 | host: process.env.EMAIL_SERVER_HOST, 15 | port: parseInt(process.env.EMAIL_SERVER_PORT || '1025'), 16 | auth: { 17 | user: process.env.EMAIL_SERVER_USER, 18 | pass: process.env.EMAIL_SERVER_PASSWORD, 19 | }, 20 | }, 21 | from: process.env.EMAIL_FROM, 22 | sendVerificationRequest, 23 | }), 24 | ...(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET 25 | ? [ 26 | GoogleProvider({ 27 | clientId: process.env.GOOGLE_CLIENT_ID, 28 | clientSecret: process.env.GOOGLE_CLIENT_SECRET, 29 | allowDangerousEmailAccountLinking: true, 30 | }), 31 | ] 32 | : []), 33 | ], 34 | secret: process.env.NEXT_AUTH_SECRET, 35 | callbacks, 36 | events, 37 | pages: { 38 | signIn: '/auth/signin', 39 | verifyRequest: '/auth/verify', 40 | error: '/auth/error', 41 | }, 42 | }; 43 | 44 | export default authOptions; 45 | -------------------------------------------------------------------------------- /app/src/server/auth/callbacks.ts: -------------------------------------------------------------------------------- 1 | import type { CallbacksOptions } from 'next-auth'; 2 | import prisma from '@server/prisma'; 3 | 4 | const session: CallbacksOptions['session'] = async ({ session, user }) => { 5 | const company = await prisma.company.findUnique({ 6 | where: { userId: user.id }, 7 | }); 8 | session.userId = user.id; 9 | session.companyId = company?.id as string; 10 | return session; 11 | }; 12 | 13 | const callbacks = { 14 | session, 15 | }; 16 | 17 | export default callbacks; 18 | -------------------------------------------------------------------------------- /app/src/server/auth/email.test.tsx: -------------------------------------------------------------------------------- 1 | import { generateHtmlEmail } from './email'; 2 | 3 | describe('generateHtmlEmail', () => { 4 | it('generates a valid mjml template', () => { 5 | const url = 'https://invoicing.saltares.com/auth/magic-link'; 6 | const host = 'invoicing.saltares.com'; 7 | const email = 'ada@lovelace.com'; 8 | 9 | const html = generateHtmlEmail({ url, host, email }); 10 | 11 | expect(typeof html).toBe('string'); 12 | expect(html).toContain(url); 13 | expect(html).toContain(email); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /app/src/server/auth/events.ts: -------------------------------------------------------------------------------- 1 | import type { EventCallbacks } from 'next-auth'; 2 | import prisma from '@server/prisma'; 3 | 4 | const createUser: EventCallbacks['createUser'] = async ({ user }) => { 5 | await prisma.company.create({ 6 | data: { 7 | userId: user.id, 8 | states: { 9 | create: { 10 | name: '', 11 | number: '', 12 | vatNumber: '', 13 | email: '', 14 | website: '', 15 | country: '', 16 | address: '', 17 | postCode: '', 18 | city: '', 19 | iban: '', 20 | }, 21 | }, 22 | }, 23 | }); 24 | }; 25 | 26 | const events = { 27 | createUser, 28 | }; 29 | 30 | export default events; 31 | -------------------------------------------------------------------------------- /app/src/server/clients/createClient.ts: -------------------------------------------------------------------------------- 1 | import { type Procedure, procedure } from '@server/trpc'; 2 | import prisma from '@server/prisma'; 3 | import { CreateClientOutput, CreateClientInput } from './types'; 4 | import mapClientEntity from './mapClientEntity'; 5 | 6 | export const createClient: Procedure< 7 | CreateClientInput, 8 | CreateClientOutput 9 | > = async ({ ctx: { session }, input }) => { 10 | const client = await prisma.client.create({ 11 | data: { 12 | companyId: session?.companyId as string, 13 | states: { create: input }, 14 | }, 15 | include: { states: true }, 16 | }); 17 | return mapClientEntity(client, []); 18 | }; 19 | 20 | export default procedure 21 | .input(CreateClientInput) 22 | .output(CreateClientOutput) 23 | .mutation(createClient); 24 | -------------------------------------------------------------------------------- /app/src/server/clients/deleteClient.ts: -------------------------------------------------------------------------------- 1 | import { TRPCError } from '@trpc/server'; 2 | import prisma from '@server/prisma'; 3 | import { type Procedure, procedure } from '@server/trpc'; 4 | import { DeleteClientOutput, DeleteClientInput } from './types'; 5 | 6 | export const deleteClient: Procedure< 7 | DeleteClientInput, 8 | DeleteClientOutput 9 | > = async ({ ctx: { session }, input: { id } }) => { 10 | const clientInNonDraftInvoice = await prisma.client.findFirst({ 11 | where: { 12 | id, 13 | companyId: session?.companyId as string, 14 | states: { 15 | some: { 16 | invoices: { 17 | some: { 18 | deletedAt: null, 19 | }, 20 | }, 21 | }, 22 | }, 23 | }, 24 | }); 25 | if (clientInNonDraftInvoice) { 26 | throw new TRPCError({ 27 | code: 'PRECONDITION_FAILED', 28 | message: 'Clients associated to approved invoices cannot be deleted.', 29 | }); 30 | } 31 | 32 | const existingClient = await prisma.client.findFirst({ 33 | where: { id, companyId: session?.companyId as string }, 34 | }); 35 | 36 | if (existingClient) { 37 | await prisma.client.update({ 38 | where: { id }, 39 | data: { deletedAt: new Date() }, 40 | }); 41 | } 42 | 43 | return id; 44 | }; 45 | 46 | export default procedure 47 | .input(DeleteClientInput) 48 | .output(DeleteClientOutput) 49 | .mutation(deleteClient); 50 | -------------------------------------------------------------------------------- /app/src/server/clients/getClient.ts: -------------------------------------------------------------------------------- 1 | import { TRPCError } from '@trpc/server'; 2 | import { type Procedure, procedure } from '@server/trpc'; 3 | import prisma, { Prisma } from '@server/prisma'; 4 | import { GetClientInput, GetClientOutput } from './types'; 5 | import mapClientEntity from './mapClientEntity'; 6 | import { getInvoicesForClient } from './utils'; 7 | 8 | export const getClient: Procedure = async ({ 9 | ctx: { session }, 10 | input: { id }, 11 | }) => { 12 | try { 13 | const [client, invoices] = await Promise.all([ 14 | prisma.client.findFirstOrThrow({ 15 | where: { id, companyId: session?.companyId, deletedAt: null }, 16 | include: { 17 | states: { 18 | orderBy: { createdAt: 'desc' }, 19 | take: 1, 20 | }, 21 | }, 22 | }), 23 | getInvoicesForClient(id), 24 | ]); 25 | return mapClientEntity(client, invoices); 26 | } catch (e) { 27 | if ( 28 | e instanceof Prisma.PrismaClientKnownRequestError && 29 | e.code === 'P2025' 30 | ) { 31 | throw new TRPCError({ 32 | code: 'NOT_FOUND', 33 | message: 'The client does not exist.', 34 | }); 35 | } 36 | throw e; 37 | } 38 | }; 39 | 40 | export default procedure 41 | .input(GetClientInput) 42 | .output(GetClientOutput) 43 | .query(getClient); 44 | -------------------------------------------------------------------------------- /app/src/server/clients/getClients.ts: -------------------------------------------------------------------------------- 1 | import { type Procedure, procedure } from '@server/trpc'; 2 | import prisma from '@server/prisma'; 3 | import { getInvoices } from '@server/invoices/getInvoices'; 4 | import type { Invoice } from '@server/invoices/types'; 5 | import { GetClientsOutput } from './types'; 6 | import mapClientEntity from './mapClientEntity'; 7 | 8 | export const getClients: Procedure = async ({ 9 | ctx, 10 | }) => { 11 | const clients = await prisma.client.findMany({ 12 | where: { 13 | companyId: ctx.session?.companyId, 14 | deletedAt: null, 15 | }, 16 | include: { 17 | states: { 18 | orderBy: { createdAt: 'desc' }, 19 | take: 1, 20 | }, 21 | }, 22 | }); 23 | const invoices = await getInvoices({ ctx, input: {} }); 24 | const invoicesByClientId = invoices.reduce( 25 | (acc, invoice) => ({ 26 | ...acc, 27 | [invoice.client.id]: [...(acc[invoice.client.id] || []), invoice], 28 | }), 29 | {} as Record 30 | ); 31 | return clients.map((client) => 32 | mapClientEntity(client, invoicesByClientId[client.id] || []) 33 | ); 34 | }; 35 | 36 | export default procedure.output(GetClientsOutput).query(getClients); 37 | -------------------------------------------------------------------------------- /app/src/server/clients/mapClientEntity.ts: -------------------------------------------------------------------------------- 1 | import type { Client, ClientState } from '@prisma/client'; 2 | import calculateTotal from '@lib/invoices/calculateTotal'; 3 | import type { Invoice } from '@server/invoices/types'; 4 | import type { Client as APIClient } from './types'; 5 | 6 | type Entity = Client & { states: ClientState[] }; 7 | const mapClientEntity = ( 8 | { states, ...client }: Entity, 9 | invoices: Invoice[] 10 | ): APIClient => { 11 | const { toBePaid, paid } = getAmounts(invoices); 12 | 13 | return { 14 | ...states[0], 15 | ...client, 16 | createdAt: client.createdAt.toISOString(), 17 | updatedAt: client.updatedAt.toISOString(), 18 | toBePaid, 19 | paid, 20 | }; 21 | }; 22 | 23 | const getAmounts = (invoices: Invoice[]) => { 24 | if ( 25 | invoices.length === 0 || 26 | invoices[0].items.length === 0 || 27 | !hasSameCurrency(invoices) 28 | ) { 29 | return { 30 | toBePaid: undefined, 31 | paid: undefined, 32 | }; 33 | } 34 | 35 | const currency = invoices[0].items[0].product.currency; 36 | const { toBePaid, paid } = invoices.reduce( 37 | (acc, invoice) => { 38 | const { total } = calculateTotal(invoice.items); 39 | if (invoice.status === 'PAID') { 40 | return { ...acc, paid: acc.paid + total }; 41 | } else if (invoice.status === 'SENT') { 42 | return { ...acc, toBePaid: acc.toBePaid + total }; 43 | } 44 | return acc; 45 | }, 46 | { 47 | toBePaid: 0, 48 | paid: 0, 49 | } 50 | ); 51 | return { 52 | toBePaid: { currency, value: toBePaid }, 53 | paid: { currency, value: paid }, 54 | }; 55 | }; 56 | 57 | const hasSameCurrency = (invoices: Invoice[]) => { 58 | const currencies = invoices.map( 59 | (invoice) => invoice.items[0]?.product.currency 60 | ); 61 | return currencies.every((currency) => currency === currencies[0]); 62 | }; 63 | 64 | export default mapClientEntity; 65 | -------------------------------------------------------------------------------- /app/src/server/clients/updateClient.ts: -------------------------------------------------------------------------------- 1 | import { TRPCError } from '@trpc/server'; 2 | import omit from 'lodash.omit'; 3 | import { type Procedure, procedure } from '@server/trpc'; 4 | import prisma from '@server/prisma'; 5 | import { UpdateClientOutput, UpdateClientInput } from './types'; 6 | import mapClientEntity from './mapClientEntity'; 7 | import { getInvoicesForClient } from './utils'; 8 | 9 | export const updateClient: Procedure< 10 | UpdateClientInput, 11 | UpdateClientOutput 12 | > = async ({ ctx: { session }, input: { id, ...data } }) => { 13 | const [existingClient, invoices] = await Promise.all([ 14 | prisma.client.findFirst({ 15 | where: { id, companyId: session?.companyId as string }, 16 | include: { 17 | states: { 18 | orderBy: { createdAt: 'desc' }, 19 | take: 1, 20 | }, 21 | }, 22 | }), 23 | getInvoicesForClient(id), 24 | ]); 25 | if (!existingClient) { 26 | throw new TRPCError({ 27 | code: 'NOT_FOUND', 28 | message: 'The client does not exist.', 29 | }); 30 | } 31 | 32 | const stateData = { 33 | ...omit(existingClient.states[0], 'id', 'createdAt'), 34 | ...data, 35 | clientId: id, 36 | }; 37 | const newState = await prisma.clientState.create({ data: stateData }); 38 | return mapClientEntity( 39 | { 40 | ...existingClient, 41 | states: [newState], 42 | }, 43 | invoices 44 | ); 45 | }; 46 | 47 | export default procedure 48 | .input(UpdateClientInput) 49 | .output(UpdateClientOutput) 50 | .mutation(updateClient); 51 | -------------------------------------------------------------------------------- /app/src/server/clients/utils.ts: -------------------------------------------------------------------------------- 1 | import mapInvoiceEntity from '@server/invoices/mapInvoiceEntity'; 2 | import prisma from '@server/prisma'; 3 | 4 | export const getInvoicesForClient = async (id: string) => { 5 | const invoices = await prisma.invoice.findMany({ 6 | where: { 7 | deletedAt: null, 8 | clientState: { 9 | clientId: id, 10 | }, 11 | }, 12 | include: { 13 | companyState: { 14 | include: { 15 | company: true, 16 | }, 17 | }, 18 | clientState: { 19 | include: { 20 | client: true, 21 | }, 22 | }, 23 | items: { 24 | include: { 25 | productState: { 26 | include: { 27 | product: true, 28 | }, 29 | }, 30 | }, 31 | }, 32 | }, 33 | }); 34 | return invoices.map(mapInvoiceEntity); 35 | }; 36 | -------------------------------------------------------------------------------- /app/src/server/company/getCompany.ts: -------------------------------------------------------------------------------- 1 | import { type Procedure, procedure } from '@server/trpc'; 2 | import prisma from '@server/prisma'; 3 | import { GetCompanyOutput } from './types'; 4 | import mapCompanyEntity from './mapCompanyEntity'; 5 | 6 | export const getCompany: Procedure = async ({ 7 | ctx, 8 | }) => { 9 | const company = await prisma.company.findUniqueOrThrow({ 10 | where: { 11 | id: ctx.session?.companyId, 12 | }, 13 | include: { 14 | states: { 15 | orderBy: { createdAt: 'desc' }, 16 | take: 1, 17 | }, 18 | }, 19 | }); 20 | return mapCompanyEntity(company); 21 | }; 22 | 23 | export default procedure.output(GetCompanyOutput).query(getCompany); 24 | -------------------------------------------------------------------------------- /app/src/server/company/mapCompanyEntity.ts: -------------------------------------------------------------------------------- 1 | import type { Company, CompanyState } from '@prisma/client'; 2 | import type { Company as APICompany } from './types'; 3 | 4 | type Entity = Company & { states: CompanyState[] }; 5 | 6 | const mapCompanyEntity = ({ 7 | states, 8 | createdAt, 9 | ...company 10 | }: Entity): APICompany => ({ 11 | ...states[0], 12 | ...company, 13 | createdAt: createdAt.toISOString(), 14 | }); 15 | 16 | export default mapCompanyEntity; 17 | -------------------------------------------------------------------------------- /app/src/server/company/types.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | export const Company = z.object({ 4 | id: z.string(), 5 | name: z.string(), 6 | number: z.string(), 7 | vatNumber: z.string().nullable(), 8 | contactName: z.string().nullable(), 9 | email: z.string().nullable(), 10 | website: z.string().nullable(), 11 | country: z.string().nullable(), 12 | address: z.string().nullable(), 13 | postCode: z.string().nullable(), 14 | city: z.string().nullable(), 15 | iban: z.string().nullable(), 16 | message: z.string().nullable(), 17 | userId: z.string(), 18 | createdAt: z.string(), 19 | }); 20 | 21 | export const GetCompanyOutput = Company.nullish(); 22 | export const UpdateCompanyInput = Company.omit({ 23 | createdAt: true, 24 | userId: true, 25 | }).partial(); 26 | export const UpdateCompanyOutput = Company; 27 | 28 | export type Company = z.infer; 29 | export type GetCompanyOutput = z.infer; 30 | export type UpdateCompanyInput = z.infer; 31 | export type UpdateCompanyOutput = z.infer; 32 | -------------------------------------------------------------------------------- /app/src/server/company/updateCompany.ts: -------------------------------------------------------------------------------- 1 | import { TRPCError } from '@trpc/server'; 2 | import omit from 'lodash.omit'; 3 | import { type Procedure, procedure } from '@server/trpc'; 4 | import prisma from '@server/prisma'; 5 | import { UpdateCompanyOutput, UpdateCompanyInput } from './types'; 6 | import mapCompanyEntity from './mapCompanyEntity'; 7 | 8 | export const updateCompany: Procedure< 9 | UpdateCompanyInput, 10 | UpdateCompanyOutput 11 | > = async ({ ctx: { session }, input: { id: _id, ...data } }) => { 12 | const existingCompany = await prisma.company.findUnique({ 13 | where: { id: session?.companyId as string }, 14 | include: { 15 | states: { 16 | orderBy: { createdAt: 'desc' }, 17 | take: 1, 18 | }, 19 | }, 20 | }); 21 | if (!existingCompany) { 22 | throw new TRPCError({ 23 | code: 'NOT_FOUND', 24 | message: 'The company does not exist.', 25 | }); 26 | } 27 | const stateData = { 28 | ...omit(existingCompany.states[0], 'id', 'createdAt'), 29 | ...data, 30 | companyId: existingCompany.id, 31 | }; 32 | const newState = await prisma.companyState.create({ data: stateData }); 33 | return mapCompanyEntity({ 34 | ...existingCompany, 35 | states: [newState], 36 | }); 37 | }; 38 | 39 | export default procedure 40 | .input(UpdateCompanyInput) 41 | .output(UpdateCompanyOutput) 42 | .mutation(updateCompany); 43 | -------------------------------------------------------------------------------- /app/src/server/createContext.ts: -------------------------------------------------------------------------------- 1 | import type { inferAsyncReturnType } from '@trpc/server'; 2 | import type { CreateNextContextOptions } from '@trpc/server/adapters/next'; 3 | import type { Session } from 'next-auth'; 4 | import { getServerSession } from 'next-auth'; 5 | import authOptions from './auth/authOptions'; 6 | 7 | const createContext = async ({ req, res }: CreateNextContextOptions) => ({ 8 | session: (await getServerSession(req, res, authOptions)) as Session | null, 9 | }); 10 | 11 | export default createContext; 12 | 13 | export type Context = inferAsyncReturnType; 14 | -------------------------------------------------------------------------------- /app/src/server/invoices/deleteInvoice.ts: -------------------------------------------------------------------------------- 1 | import { TRPCError } from '@trpc/server'; 2 | import prisma from '@server/prisma'; 3 | import { type Procedure, procedure } from '@server/trpc'; 4 | import { DeleteInvoiceOutput, DeleteInvoiceInput } from './types'; 5 | 6 | export const deleteInvoice: Procedure< 7 | DeleteInvoiceInput, 8 | DeleteInvoiceOutput 9 | > = async ({ ctx: { session }, input: { id } }) => { 10 | const invoice = await prisma.invoice.findFirst({ 11 | where: { 12 | id, 13 | deletedAt: null, 14 | companyState: { 15 | companyId: session?.companyId as string, 16 | }, 17 | }, 18 | }); 19 | 20 | if (!invoice) { 21 | return id; 22 | } 23 | 24 | if (invoice.status !== 'DRAFT') { 25 | throw new TRPCError({ 26 | code: 'PRECONDITION_FAILED', 27 | message: 'Appoved invoices cannot be deleted.', 28 | }); 29 | } 30 | 31 | await prisma.invoice.update({ 32 | where: { id }, 33 | data: { deletedAt: new Date() }, 34 | }); 35 | 36 | return id; 37 | }; 38 | 39 | export default procedure 40 | .input(DeleteInvoiceInput) 41 | .output(DeleteInvoiceOutput) 42 | .mutation(deleteInvoice); 43 | -------------------------------------------------------------------------------- /app/src/server/invoices/getInvoice.ts: -------------------------------------------------------------------------------- 1 | import { TRPCError } from '@trpc/server'; 2 | import { type Procedure, procedure } from '@server/trpc'; 3 | import prisma, { Prisma } from '@server/prisma'; 4 | import { GetInvoiceInput, GetInvoiceOutput } from './types'; 5 | import mapInvoiceEntity from './mapInvoiceEntity'; 6 | 7 | export const getInvoice: Procedure = async ({ 8 | ctx: { session }, 9 | input: { id }, 10 | }) => { 11 | try { 12 | const invoice = await prisma.invoice.findFirstOrThrow({ 13 | where: { 14 | id, 15 | deletedAt: null, 16 | companyState: { companyId: session?.companyId as string }, 17 | }, 18 | orderBy: { createdAt: 'desc' }, 19 | include: { 20 | companyState: { 21 | include: { 22 | company: true, 23 | }, 24 | }, 25 | clientState: { 26 | include: { 27 | client: true, 28 | }, 29 | }, 30 | items: { 31 | include: { 32 | productState: { 33 | include: { 34 | product: true, 35 | }, 36 | }, 37 | }, 38 | orderBy: { order: 'asc' }, 39 | }, 40 | }, 41 | }); 42 | return mapInvoiceEntity(invoice); 43 | } catch (e) { 44 | if ( 45 | e instanceof Prisma.PrismaClientKnownRequestError && 46 | e.code === 'P2025' 47 | ) { 48 | throw new TRPCError({ 49 | code: 'NOT_FOUND', 50 | message: 'The invoice does not exist.', 51 | }); 52 | } 53 | throw e; 54 | } 55 | }; 56 | 57 | export default procedure 58 | .input(GetInvoiceInput) 59 | .output(GetInvoiceOutput) 60 | .query(getInvoice); 61 | -------------------------------------------------------------------------------- /app/src/server/invoices/getInvoices.ts: -------------------------------------------------------------------------------- 1 | import { type Procedure, procedure } from '@server/trpc'; 2 | import prisma from '@server/prisma'; 3 | import { GetInvoicesInput, GetInvoicesOutput } from './types'; 4 | import mapInvoiceEntity from './mapInvoiceEntity'; 5 | 6 | export const getInvoices: Procedure< 7 | GetInvoicesInput, 8 | GetInvoicesOutput 9 | > = async ({ ctx: { session } }) => { 10 | const invoices = await prisma.invoice.findMany({ 11 | where: { 12 | deletedAt: null, 13 | companyState: { companyId: session?.companyId as string }, 14 | }, 15 | include: { 16 | companyState: { 17 | include: { 18 | company: true, 19 | }, 20 | }, 21 | clientState: { 22 | include: { 23 | client: true, 24 | }, 25 | }, 26 | items: { 27 | include: { 28 | productState: { 29 | include: { 30 | product: true, 31 | }, 32 | }, 33 | }, 34 | orderBy: { order: 'asc' }, 35 | }, 36 | }, 37 | }); 38 | return invoices.map(mapInvoiceEntity); 39 | }; 40 | 41 | export default procedure 42 | .input(GetInvoicesInput) 43 | .output(GetInvoicesOutput) 44 | .query(getInvoices); 45 | -------------------------------------------------------------------------------- /app/src/server/invoices/mapInvoiceEntity.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | Client, 3 | ClientState, 4 | Company, 5 | CompanyState, 6 | Invoice, 7 | LineItem, 8 | Product, 9 | ProductState, 10 | } from '@prisma/client'; 11 | import mapCompanyEntity from '@server/company/mapCompanyEntity'; 12 | import mapClientEntity from '@server/clients/mapClientEntity'; 13 | import mapProductEntity from '@server/products/mapProductEntity'; 14 | import type { Invoice as APIInvoice } from './types'; 15 | 16 | type Entity = Invoice & { 17 | companyState: CompanyState & { 18 | company: Company; 19 | }; 20 | clientState: ClientState & { 21 | client: Client; 22 | }; 23 | items: (LineItem & { 24 | productState: ProductState & { 25 | product: Product; 26 | }; 27 | })[]; 28 | }; 29 | 30 | const mapInvoiceEntity = ({ 31 | id, 32 | status, 33 | prefix, 34 | number, 35 | date, 36 | message, 37 | createdAt, 38 | updatedAt, 39 | companyState: { company, ...companyState }, 40 | clientState: { client, ...clientState }, 41 | items, 42 | }: Entity): APIInvoice => ({ 43 | id, 44 | status, 45 | prefix, 46 | number, 47 | date: date.toISOString(), 48 | message, 49 | createdAt: createdAt.toISOString(), 50 | updatedAt: updatedAt.toISOString(), 51 | company: mapCompanyEntity({ 52 | ...company, 53 | states: [companyState], 54 | }), 55 | client: mapClientEntity( 56 | { 57 | ...client, 58 | states: [clientState], 59 | }, 60 | [] 61 | ), 62 | items: items.map( 63 | ({ 64 | id, 65 | invoiceId, 66 | quantity, 67 | date, 68 | createdAt, 69 | updatedAt, 70 | productState: { product, ...productState }, 71 | }) => ({ 72 | id, 73 | invoiceId, 74 | quantity, 75 | date: date.toISOString(), 76 | createdAt: createdAt.toISOString(), 77 | updatedAt: updatedAt.toISOString(), 78 | product: mapProductEntity({ 79 | ...product, 80 | states: [productState], 81 | }), 82 | }) 83 | ), 84 | }); 85 | 86 | export default mapInvoiceEntity; 87 | -------------------------------------------------------------------------------- /app/src/server/invoices/utils.ts: -------------------------------------------------------------------------------- 1 | import prisma from '@server/prisma'; 2 | 3 | type GetLastInvoiceNumberArgs = { 4 | companyId: string; 5 | prefix?: string; 6 | }; 7 | 8 | export const getLastInvoiceNumber = async ({ 9 | companyId, 10 | prefix, 11 | }: GetLastInvoiceNumberArgs) => { 12 | const aggregate = await prisma.invoice.aggregate({ 13 | where: { 14 | companyState: { 15 | companyId, 16 | }, 17 | prefix, 18 | deletedAt: null, 19 | }, 20 | _max: { 21 | number: true, 22 | }, 23 | }); 24 | 25 | return aggregate._max.number || 0; 26 | }; 27 | -------------------------------------------------------------------------------- /app/src/server/prisma.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client'; 2 | export { Prisma } from '@prisma/client'; 3 | 4 | const prisma = global._prisma || new PrismaClient(); 5 | 6 | if (process.env.NODE_ENV !== 'production') { 7 | global._prisma = prisma; 8 | } 9 | 10 | export default prisma; 11 | -------------------------------------------------------------------------------- /app/src/server/products/createProduct.ts: -------------------------------------------------------------------------------- 1 | import { type Procedure, procedure } from '@server/trpc'; 2 | import prisma from '@server/prisma'; 3 | import { CreateProductOutput, CreateProductInput } from './types'; 4 | import mapProductEntity from './mapProductEntity'; 5 | 6 | export const createProduct: Procedure< 7 | CreateProductInput, 8 | CreateProductOutput 9 | > = async ({ ctx: { session }, input }) => { 10 | const product = await prisma.product.create({ 11 | data: { 12 | companyId: session?.companyId as string, 13 | states: { create: input }, 14 | }, 15 | include: { states: true }, 16 | }); 17 | return mapProductEntity(product); 18 | }; 19 | 20 | export default procedure 21 | .input(CreateProductInput) 22 | .output(CreateProductOutput) 23 | .mutation(createProduct); 24 | -------------------------------------------------------------------------------- /app/src/server/products/deleteProduct.ts: -------------------------------------------------------------------------------- 1 | import { TRPCError } from '@trpc/server'; 2 | import prisma from '@server/prisma'; 3 | import { type Procedure, procedure } from '@server/trpc'; 4 | import { DeleteProductOutput, DeleteProductInput } from './types'; 5 | 6 | export const deleteProduct: Procedure< 7 | DeleteProductInput, 8 | DeleteProductOutput 9 | > = async ({ ctx: { session }, input: { id } }) => { 10 | const productInNonDraftInvoice = await prisma.product.findFirst({ 11 | where: { 12 | id, 13 | companyId: session?.companyId as string, 14 | states: { 15 | some: { 16 | lineItems: { 17 | some: { 18 | invoice: { deletedAt: null }, 19 | }, 20 | }, 21 | }, 22 | }, 23 | }, 24 | }); 25 | if (productInNonDraftInvoice) { 26 | throw new TRPCError({ 27 | code: 'PRECONDITION_FAILED', 28 | message: 'Products associated to approve invoices cannot be deleted.', 29 | }); 30 | } 31 | 32 | const existingProduct = await prisma.product.findFirst({ 33 | where: { id, companyId: session?.companyId as string }, 34 | }); 35 | 36 | if (!existingProduct) { 37 | return id; 38 | } 39 | 40 | await prisma.product.update({ 41 | where: { id }, 42 | data: { deletedAt: new Date() }, 43 | }); 44 | 45 | return id; 46 | }; 47 | 48 | export default procedure 49 | .input(DeleteProductInput) 50 | .output(DeleteProductOutput) 51 | .mutation(deleteProduct); 52 | -------------------------------------------------------------------------------- /app/src/server/products/getProduct.ts: -------------------------------------------------------------------------------- 1 | import { TRPCError } from '@trpc/server'; 2 | import { type Procedure, procedure } from '@server/trpc'; 3 | import prisma, { Prisma } from '@server/prisma'; 4 | import { GetProductInput, GetProductOutput } from './types'; 5 | import mapProductEntity from './mapProductEntity'; 6 | 7 | export const getProduct: Procedure = async ({ 8 | ctx: { session }, 9 | input: { id }, 10 | }) => { 11 | try { 12 | const product = await prisma.product.findFirstOrThrow({ 13 | where: { id, companyId: session?.companyId, deletedAt: null }, 14 | include: { 15 | states: { 16 | orderBy: { createdAt: 'desc' }, 17 | take: 1, 18 | }, 19 | }, 20 | }); 21 | return mapProductEntity(product); 22 | } catch (e) { 23 | if ( 24 | e instanceof Prisma.PrismaClientKnownRequestError && 25 | e.code === 'P2025' 26 | ) { 27 | throw new TRPCError({ 28 | code: 'NOT_FOUND', 29 | message: 'The product does not exist.', 30 | }); 31 | } 32 | throw e; 33 | } 34 | }; 35 | 36 | export default procedure 37 | .input(GetProductInput) 38 | .output(GetProductOutput) 39 | .query(getProduct); 40 | -------------------------------------------------------------------------------- /app/src/server/products/getProducts.ts: -------------------------------------------------------------------------------- 1 | import { type Procedure, procedure } from '@server/trpc'; 2 | import prisma from '@server/prisma'; 3 | import { GetProductsOutput } from './types'; 4 | import mapProductEntity from './mapProductEntity'; 5 | 6 | export const getProducts: Procedure = async ({ 7 | ctx, 8 | }) => { 9 | const products = await prisma.product.findMany({ 10 | where: { companyId: ctx.session?.companyId, deletedAt: null }, 11 | include: { 12 | states: { 13 | orderBy: { createdAt: 'desc' }, 14 | take: 1, 15 | }, 16 | }, 17 | }); 18 | return products.map(mapProductEntity); 19 | }; 20 | 21 | export default procedure.output(GetProductsOutput).query(getProducts); 22 | -------------------------------------------------------------------------------- /app/src/server/products/mapProductEntity.ts: -------------------------------------------------------------------------------- 1 | import type { Product, ProductState } from '@prisma/client'; 2 | import type { Product as APIProduct } from './types'; 3 | 4 | type Entity = Product & { states: ProductState[] }; 5 | 6 | const mapProductEntity = ({ 7 | states, 8 | createdAt, 9 | updatedAt, 10 | ...product 11 | }: Entity): APIProduct => ({ 12 | ...states[0], 13 | ...product, 14 | createdAt: createdAt.toISOString(), 15 | updatedAt: updatedAt.toISOString(), 16 | }); 17 | 18 | export default mapProductEntity; 19 | -------------------------------------------------------------------------------- /app/src/server/products/types.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | export const Product = z.object({ 4 | id: z.string(), 5 | name: z.string(), 6 | includesVat: z.boolean(), 7 | price: z.number(), 8 | currency: z.string(), 9 | vat: z.number(), 10 | unit: z.string(), 11 | companyId: z.string(), 12 | createdAt: z.string(), 13 | updatedAt: z.string(), 14 | }); 15 | export const GetProductInput = z.object({ 16 | id: z.string(), 17 | }); 18 | export const GetProductOutput = Product; 19 | export const GetProductsOutput = Product.array(); 20 | export const CreateProductInput = Product.omit({ 21 | id: true, 22 | companyId: true, 23 | createdAt: true, 24 | updatedAt: true, 25 | }).partial({ 26 | includesVat: true, 27 | price: true, 28 | currency: true, 29 | vat: true, 30 | unit: true, 31 | }); 32 | export const CreateProductOutput = Product; 33 | export const DeleteProductInput = z.object({ id: z.string() }); 34 | export const DeleteProductOutput = z.string(); 35 | export const UpdateProductInput = Product.omit({ 36 | companyId: true, 37 | createdAt: true, 38 | updatedAt: true, 39 | }) 40 | .partial() 41 | .merge(z.object({ id: z.string() })); 42 | export const UpdateProductOutput = Product; 43 | 44 | export type Product = z.infer; 45 | export type GetProductInput = z.infer; 46 | export type GetProductOutput = z.infer; 47 | export type GetProductsOutput = z.infer; 48 | export type CreateProductInput = z.infer; 49 | export type CreateProductOutput = z.infer; 50 | export type DeleteProductInput = z.infer; 51 | export type DeleteProductOutput = z.infer; 52 | export type UpdateProductInput = z.infer; 53 | export type UpdateProductOutput = z.infer; 54 | -------------------------------------------------------------------------------- /app/src/server/products/updateProduct.ts: -------------------------------------------------------------------------------- 1 | import { TRPCError } from '@trpc/server'; 2 | import omit from 'lodash.omit'; 3 | import { type Procedure, procedure } from '@server/trpc'; 4 | import prisma from '@server/prisma'; 5 | import { UpdateProductOutput, UpdateProductInput } from './types'; 6 | import mapProductEntity from './mapProductEntity'; 7 | 8 | export const updateProduct: Procedure< 9 | UpdateProductInput, 10 | UpdateProductOutput 11 | > = async ({ ctx: { session }, input: { id, ...data } }) => { 12 | const existingProduct = await prisma.product.findFirst({ 13 | where: { id, companyId: session?.companyId as string }, 14 | include: { 15 | states: { 16 | orderBy: { createdAt: 'desc' }, 17 | take: 1, 18 | }, 19 | }, 20 | }); 21 | if (!existingProduct) { 22 | throw new TRPCError({ 23 | code: 'NOT_FOUND', 24 | message: 'The product does not exist.', 25 | }); 26 | } 27 | 28 | const stateData = { 29 | ...omit(existingProduct.states[0], 'id', 'createdAt'), 30 | ...data, 31 | productId: id, 32 | }; 33 | const newState = await prisma.productState.create({ data: stateData }); 34 | return mapProductEntity({ 35 | ...existingProduct, 36 | states: [newState], 37 | }); 38 | }; 39 | 40 | export default procedure 41 | .input(UpdateProductInput) 42 | .output(UpdateProductOutput) 43 | .mutation(updateProduct); 44 | -------------------------------------------------------------------------------- /app/src/server/router.ts: -------------------------------------------------------------------------------- 1 | import getCompany from './company/getCompany'; 2 | import updateCompany from './company/updateCompany'; 3 | import createClient from './clients/createClient'; 4 | import deleteClient from './clients/deleteClient'; 5 | import getClient from './clients/getClient'; 6 | import getClients from './clients/getClients'; 7 | import updateClient from './clients/updateClient'; 8 | import createProduct from './products/createProduct'; 9 | import deleteProduct from './products/deleteProduct'; 10 | import getProduct from './products/getProduct'; 11 | import getProducts from './products/getProducts'; 12 | import updateProduct from './products/updateProduct'; 13 | import getInvoice from './invoices/getInvoice'; 14 | import getInvoices from './invoices/getInvoices'; 15 | import createInvoice from './invoices/createInvoice'; 16 | import updateInvoice from './invoices/updateInvoice'; 17 | import deleteInvoice from './invoices/deleteInvoice'; 18 | import trpc from './trpc'; 19 | 20 | const router = trpc.router({ 21 | getProduct, 22 | getProducts, 23 | createProduct, 24 | updateProduct, 25 | deleteProduct, 26 | getClient, 27 | getClients, 28 | createClient, 29 | updateClient, 30 | deleteClient, 31 | getCompany, 32 | updateCompany, 33 | getInvoice, 34 | getInvoices, 35 | createInvoice, 36 | updateInvoice, 37 | deleteInvoice, 38 | }); 39 | 40 | export default router; 41 | 42 | export type AppRouter = typeof router; 43 | -------------------------------------------------------------------------------- /app/src/server/trpc.ts: -------------------------------------------------------------------------------- 1 | import { initTRPC, TRPCError } from '@trpc/server'; 2 | import type { Session } from 'next-auth'; 3 | import type { Context } from './createContext'; 4 | 5 | interface Meta { 6 | withoutAuth: boolean; 7 | [key: string]: unknown; 8 | } 9 | 10 | export type ProcedureArgs = { 11 | ctx: { 12 | session: Session | null; 13 | }; 14 | meta?: Meta; 15 | input: TInput; 16 | }; 17 | export type Procedure = ( 18 | args: ProcedureArgs 19 | ) => Promise; 20 | 21 | const trpc = initTRPC.context().meta().create(); 22 | 23 | const isAuthed = trpc.middleware(async ({ meta, next, ctx }) => { 24 | if (!meta?.withoutAuth && !ctx.session) { 25 | throw new TRPCError({ 26 | code: 'UNAUTHORIZED', 27 | message: 'You are not logged in.', 28 | }); 29 | } 30 | return next({ ctx }); 31 | }); 32 | 33 | export const procedure = trpc.procedure.use(isAuthed); 34 | 35 | export default trpc; 36 | -------------------------------------------------------------------------------- /app/src/styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer utilities { 6 | .focus-base { 7 | @apply outline-none ring-1 ring-violet-500 ring-offset-0; 8 | } 9 | 10 | .focus-ring { 11 | @apply focus-visible:focus-base focus:focus-base; 12 | } 13 | 14 | .width-without-sidebar { 15 | width: calc(100% - 242px); 16 | } 17 | 18 | .main-content { 19 | @apply w-full lg:width-without-sidebar 20 | } 21 | } 22 | 23 | html { 24 | @apply h-full; 25 | } 26 | 27 | body { 28 | @apply h-full; 29 | } 30 | 31 | #__next { 32 | @apply h-full; 33 | } 34 | 35 | .sb-show-main.sb-main-centered #root { 36 | display: flex; 37 | align-items: center; 38 | justify-content: center; 39 | width: 100%; 40 | padding: 0; 41 | } 42 | -------------------------------------------------------------------------------- /app/src/tests-integration/clients/createClient.test.ts: -------------------------------------------------------------------------------- 1 | import type { Company, User } from '@prisma/client'; 2 | import type { Session } from 'next-auth'; 3 | import cuid from 'cuid'; 4 | import omit from 'lodash.omit'; 5 | import prisma from '@server/prisma'; 6 | import { createTestCompany, createTestUser } from '../testData'; 7 | import { createClient } from '@server/clients/createClient'; 8 | 9 | let user: User; 10 | let company: Company; 11 | let session: Session; 12 | 13 | describe('createClient', () => { 14 | beforeEach(async () => { 15 | user = await createTestUser(); 16 | company = await createTestCompany(user.id); 17 | session = { userId: user.id, companyId: company.id, expires: '' }; 18 | }); 19 | 20 | it('creates a client for the company in the session', async () => { 21 | const input = { 22 | name: 'Test Client', 23 | number: cuid(), 24 | }; 25 | const result = await createClient({ 26 | ctx: { session }, 27 | input, 28 | }); 29 | const dbClient = await prisma.client.findUniqueOrThrow({ 30 | where: { id: result.id }, 31 | include: { states: { orderBy: { createdAt: 'desc' }, take: 1 } }, 32 | }); 33 | expect(result).toMatchObject(input); 34 | expect(result).toMatchObject(omit(dbClient.states[0], 'id', 'createdAt')); 35 | }); 36 | 37 | it('throws when the company does not exist', async () => { 38 | await expect( 39 | createClient({ 40 | ctx: { session: { ...session, companyId: 'invalid_company' } }, 41 | input: { 42 | name: 'Test Client', 43 | number: cuid(), 44 | }, 45 | }) 46 | ).rejects.toThrow(); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /app/src/tests-integration/company/getCompany.test.ts: -------------------------------------------------------------------------------- 1 | import type { User } from '@prisma/client'; 2 | import omit from 'lodash.omit'; 3 | import { getCompany } from '@server/company/getCompany'; 4 | import { createTestCompany, createTestUser } from '../testData'; 5 | 6 | let user: User; 7 | let company: Awaited>; 8 | 9 | describe('getCompany', () => { 10 | beforeEach(async () => { 11 | user = await createTestUser(); 12 | company = await createTestCompany(user.id); 13 | }); 14 | 15 | it('throws when the company does not exist', async () => { 16 | await expect( 17 | getCompany({ 18 | ctx: { 19 | session: { 20 | userId: 'invalid_user', 21 | companyId: 'invalid_company', 22 | expires: '', 23 | }, 24 | }, 25 | input: {}, 26 | }) 27 | ).rejects.toThrow(); 28 | }); 29 | 30 | it('returns the company for the user in the session', async () => { 31 | const result = await getCompany({ 32 | ctx: { 33 | session: { 34 | userId: user.id, 35 | companyId: company.id, 36 | expires: '', 37 | }, 38 | }, 39 | input: {}, 40 | }); 41 | expect(result).toMatchObject(omit(company.states[0], 'id', 'createdAt')); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /app/src/tests-integration/company/updateCompany.test.ts: -------------------------------------------------------------------------------- 1 | import type { User } from '@prisma/client'; 2 | import { TRPCError } from '@trpc/server'; 3 | import omit from 'lodash.omit'; 4 | import prisma from '@server/prisma'; 5 | import { updateCompany } from '@server/company/updateCompany'; 6 | import { createTestCompany, createTestUser } from '../testData'; 7 | 8 | let user: User; 9 | let company: Awaited>; 10 | 11 | describe('updateCompany', () => { 12 | beforeEach(async () => { 13 | user = await createTestUser(); 14 | company = await createTestCompany(user.id); 15 | }); 16 | 17 | it('returns updated company', async () => { 18 | const newName = 'new company name'; 19 | const result = await updateCompany({ 20 | ctx: { session: { userId: user.id, companyId: company.id, expires: '' } }, 21 | input: { 22 | name: newName, 23 | }, 24 | }); 25 | const dbCompany = await prisma.company.findUniqueOrThrow({ 26 | where: { id: company.id }, 27 | include: { 28 | states: { 29 | orderBy: { createdAt: 'desc' }, 30 | take: 1, 31 | }, 32 | }, 33 | }); 34 | expect(result.name).toEqual(newName); 35 | expect(result).toMatchObject(omit(dbCompany.states[0], 'id', 'createdAt')); 36 | }); 37 | 38 | it('throws when the company does not exist', async () => { 39 | const newName = 'new company name'; 40 | await expect( 41 | updateCompany({ 42 | ctx: { 43 | session: { 44 | userId: user.id, 45 | companyId: 'invalid_company', 46 | expires: '', 47 | }, 48 | }, 49 | input: { 50 | name: newName, 51 | }, 52 | }) 53 | ).rejects.toEqual( 54 | new TRPCError({ 55 | code: 'NOT_FOUND', 56 | message: 'The company does not exist.', 57 | }) 58 | ); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /app/src/tests-integration/products/createProduct.test.ts: -------------------------------------------------------------------------------- 1 | import type { User } from '@prisma/client'; 2 | import type { Session } from 'next-auth'; 3 | import omit from 'lodash.omit'; 4 | import prisma from '@server/prisma'; 5 | import { createTestCompany, createTestUser } from '../testData'; 6 | import { createProduct } from '@server/products/createProduct'; 7 | 8 | let user: User; 9 | let company: Awaited>; 10 | let session: Session; 11 | 12 | describe('createProduct', () => { 13 | beforeEach(async () => { 14 | user = await createTestUser(); 15 | company = await createTestCompany(user.id); 16 | session = { userId: user.id, companyId: company.id, expires: '' }; 17 | }); 18 | 19 | it('creates a product for the company in the session', async () => { 20 | const input = { 21 | name: 'Test Product', 22 | }; 23 | const result = await createProduct({ 24 | ctx: { session }, 25 | input, 26 | }); 27 | const dbClient = await prisma.product.findUnique({ 28 | where: { id: result.id }, 29 | include: { 30 | states: true, 31 | }, 32 | }); 33 | expect(result).toMatchObject(input); 34 | expect(result).toMatchObject(omit(dbClient?.states[0], 'id', 'createdAt')); 35 | }); 36 | 37 | it('throws when the company does not exist', async () => { 38 | await expect( 39 | createProduct({ 40 | ctx: { session: { ...session, companyId: 'invalid_company' } }, 41 | input: { 42 | name: 'Test Product', 43 | }, 44 | }) 45 | ).rejects.toThrow(); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /app/src/tests-integration/products/getProduct.test.ts: -------------------------------------------------------------------------------- 1 | import type { User } from '@prisma/client'; 2 | import type { Session } from 'next-auth'; 3 | import { TRPCError } from '@trpc/server'; 4 | import omit from 'lodash.omit'; 5 | import { getProduct } from '@server/products/getProduct'; 6 | import { 7 | createTestCompany, 8 | createTestProduct, 9 | createTestUser, 10 | } from '../testData'; 11 | 12 | let user1: User; 13 | let user2: User; 14 | let company1: Awaited>; 15 | let company2: Awaited>; 16 | let session: Session; 17 | 18 | describe('getProduct', () => { 19 | beforeEach(async () => { 20 | [user1, user2] = await Promise.all([createTestUser(), createTestUser()]); 21 | [company1, company2] = await Promise.all([ 22 | createTestCompany(user1.id), 23 | createTestCompany(user2.id), 24 | ]); 25 | session = { userId: user1.id, companyId: company1.id, expires: '' }; 26 | }); 27 | 28 | it('returns the product', async () => { 29 | const dbProduct = await createTestProduct(company1.id); 30 | 31 | const product = await getProduct({ 32 | ctx: { session }, 33 | input: { id: dbProduct.id }, 34 | }); 35 | expect(product).toMatchObject(omit(dbProduct.states[0], 'id', 'createdAt')); 36 | }); 37 | 38 | it('throws a NOT_FOUND error when the product does not exist', async () => { 39 | await expect( 40 | getProduct({ ctx: { session }, input: { id: 'invalid' } }) 41 | ).rejects.toEqual( 42 | new TRPCError({ 43 | code: 'NOT_FOUND', 44 | message: 'The product does not exist.', 45 | }) 46 | ); 47 | }); 48 | 49 | it('throws a NOT_FOUND error when the product belongs to a different user', async () => { 50 | const dbProduct = await createTestProduct(company2.id); 51 | 52 | await expect( 53 | getProduct({ ctx: { session }, input: { id: dbProduct.id } }) 54 | ).rejects.toEqual( 55 | new TRPCError({ 56 | code: 'NOT_FOUND', 57 | message: 'The product does not exist.', 58 | }) 59 | ); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /app/src/tests-integration/products/getProducts.test.ts: -------------------------------------------------------------------------------- 1 | import type { User } from '@prisma/client'; 2 | import type { Session } from 'next-auth'; 3 | import omit from 'lodash.omit'; 4 | import { getProducts } from '@server/products/getProducts'; 5 | import { 6 | createTestCompany, 7 | createTestProduct, 8 | createTestUser, 9 | } from '../testData'; 10 | 11 | let user1: User; 12 | let user2: User; 13 | let company1: Awaited>; 14 | let company2: Awaited>; 15 | let session: Session; 16 | 17 | describe('getProducts', () => { 18 | beforeEach(async () => { 19 | [user1, user2] = await Promise.all([createTestUser(), createTestUser()]); 20 | [company1, company2] = await Promise.all([ 21 | createTestCompany(user1.id), 22 | createTestCompany(user2.id), 23 | ]); 24 | session = { userId: user1.id, companyId: company1.id, expires: '' }; 25 | }); 26 | 27 | it('returns an empty array when there are no products', async () => { 28 | await createTestProduct(company2.id); 29 | 30 | const products = await getProducts({ ctx: { session }, input: {} }); 31 | expect(products).toEqual([]); 32 | }); 33 | 34 | it('returns the products for the company in the session', async () => { 35 | await createTestProduct(company2.id); 36 | const product1 = await createTestProduct(company1.id); 37 | const product2 = await createTestProduct(company1.id); 38 | 39 | const products = await getProducts({ ctx: { session }, input: {} }); 40 | expect(products).toEqual( 41 | expect.arrayContaining([ 42 | expect.objectContaining(omit(product1.states[0], 'id', 'createdAt')), 43 | expect.objectContaining(omit(product2.states[0], 'id', 'createdAt')), 44 | ]) 45 | ); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /app/src/tests-integration/products/updateProduct.test.ts: -------------------------------------------------------------------------------- 1 | import type { User } from '@prisma/client'; 2 | import type { Session } from 'next-auth'; 3 | import { TRPCError } from '@trpc/server'; 4 | import omit from 'lodash.omit'; 5 | import { updateProduct } from '@server/products/updateProduct'; 6 | import { 7 | createTestCompany, 8 | createTestProduct, 9 | createTestUser, 10 | } from '../testData'; 11 | import prisma from '@server/prisma'; 12 | 13 | let user: User; 14 | let company: Awaited>; 15 | let session: Session; 16 | 17 | describe('updateProduct', () => { 18 | beforeEach(async () => { 19 | user = await createTestUser(); 20 | company = await createTestCompany(user.id); 21 | session = { userId: user.id, companyId: company.id, expires: '' }; 22 | }); 23 | 24 | it('throws when trying to update a non existing product', async () => { 25 | await expect( 26 | updateProduct({ 27 | ctx: { session }, 28 | input: { 29 | id: 'invalid_product', 30 | name: 'Test Product', 31 | }, 32 | }) 33 | ).rejects.toEqual( 34 | new TRPCError({ 35 | code: 'NOT_FOUND', 36 | message: 'The product does not exist.', 37 | }) 38 | ); 39 | }); 40 | 41 | it('updates the product', async () => { 42 | const product = await createTestProduct(company.id); 43 | const newName = 'Updated Product'; 44 | const updatedProduct = await updateProduct({ 45 | ctx: { session }, 46 | input: { 47 | id: product.id, 48 | name: newName, 49 | }, 50 | }); 51 | const dbProduct = await prisma.product.findUnique({ 52 | where: { id: product.id }, 53 | }); 54 | 55 | expect(updatedProduct.name).toEqual(newName); 56 | expect(updatedProduct).toMatchObject( 57 | omit(dbProduct, 'id', 'createdAt', 'updatedAt') 58 | ); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /app/src/tests-integration/setup.ts: -------------------------------------------------------------------------------- 1 | import 'tsconfig-paths/register'; 2 | import prisma from '@server/prisma'; 3 | 4 | const setup = async () => { 5 | await prisma.invoice.deleteMany(); 6 | 7 | await Promise.all([ 8 | prisma.account.deleteMany(), 9 | prisma.user.deleteMany(), 10 | prisma.session.deleteMany(), 11 | prisma.verificationToken.deleteMany(), 12 | prisma.company.deleteMany(), 13 | prisma.product.deleteMany(), 14 | prisma.client.deleteMany(), 15 | ]); 16 | }; 17 | 18 | export default setup; 19 | -------------------------------------------------------------------------------- /app/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: [ 4 | './src/pages/**/*.{js,ts,jsx,tsx}', 5 | './src/components/**/*.{js,ts,jsx,tsx}', 6 | './.storybook/**/*.{js,ts,jsx,tsx}', 7 | ], 8 | theme: { 9 | extend: { 10 | fontFamily: { 11 | sans: ['Inter', 'sans-serif'], 12 | }, 13 | }, 14 | }, 15 | plugins: [], 16 | }; 17 | -------------------------------------------------------------------------------- /app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": false, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "baseUrl": ".", 18 | "paths": { 19 | "@lib/*": ["./src/lib/*"], 20 | "@components/*": ["./src/components/*"], 21 | "@pages/*": ["./src/pages/*"], 22 | "@server/*": ["./src/server/*"], 23 | }, 24 | "types": ["@types/jest"] 25 | }, 26 | "include": ["next-env.d.ts", "./jest-setup.tsx", "**/*.ts", "**/*.tsx"], 27 | "exclude": ["node_modules"] 28 | } 29 | -------------------------------------------------------------------------------- /app/types.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-var */ 2 | import 'next-auth'; 3 | import type { PrismaClient, Company, User } from '@prisma/client'; 4 | 5 | declare global { 6 | var _prisma: PrismaClient | undefined; 7 | } 8 | 9 | declare module 'next-auth' { 10 | export interface Session { 11 | userId: User['id']; 12 | companyId: Company['id']; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /app/vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "github": { 3 | "enabled": false 4 | } 5 | } 6 | --------------------------------------------------------------------------------