mirror of
https://github.com/VladSez/easy-invoice-pdf
synced 2026-04-21 13:37:40 +00:00
feat: add e2e tests with playwright and other improvements (#79)
* feat: Add language attribute to date input fields in invoice form components - Include `lang="en"` attribute in date input fields of `InvoiceForm` and `GeneralInformation` components for improved accessibility and localization support. * fix: Update language attribute for date input fields in invoice form components * refactor: Improve layout and organization of invoice components - Remove unnecessary margin from the main container in the Home component. - Wrap the share invoice button and PDF download link in a fragment for better structure. - Adjust margins for the ProjectInfo and action button container for improved spacing. - Update the InvoicePDFViewer height to use full height for better responsiveness. - Remove the deprecated RegenerateInvoiceButton component to streamline the codebase. - Update the InvoiceClientPage to accept handleShareInvoice prop for better functionality. - Clean up unused language attributes in date input fields across invoice form components. * feat: Integrate Playwright for end-to-end testing and enhance invoice form components - Add Playwright configuration and dependencies for E2E testing. - Create GitHub Actions workflow for automated E2E tests on deployment. - Implement initial E2E tests for the Invoice Generator Page, verifying UI elements and form functionality. - Refactor invoice form components to include data-testid attributes for better testability. - Update .gitignore to exclude Playwright-related files and directories. * chore: Update GitHub Actions workflow for E2E testing and enhance test coverage - Upgrade pnpm version from 8 to 10 in the E2E workflow for improved package management. - Add new test case to verify header buttons and links on the Invoice Generator Page, ensuring UI elements are displayed correctly and have the expected attributes. * chore: Enhance ESLint configuration for Playwright integration - Add Playwright ESLint plugin to package.json for improved E2E testing support. - Update .eslintrc.json to include overrides for E2E test files. - Clean up GitHub Actions workflow by removing unnecessary pnpm version specification. * chore: Update Playwright configuration and improve test assertions - Increase timeout for expect assertions and test execution from 15 seconds to 30 seconds for better stability in E2E tests. - Comment out mobile viewport tests to streamline configuration and focus on desktop testing. * chore: Update configuration and refactor invoice form components - Add compiler options to remove console logs in production and enhance logging for fetch requests in next.config.mjs. - Update package.json to include new type definitions for ua-parser-js and add ua-parser-js as a dependency. - Refactor invoice form components to remove form prefix IDs, simplifying data-testid attributes for better testability. - Introduce DeviceContext for managing device type state and improve responsiveness in invoice form components. - Implement server-side device detection using user agent parsing for better rendering on mobile and desktop views. - Update media query hooks to streamline device type checks across components. * chore: Update Playwright configuration and enhance invoice form tests - Reduce timeout for expect assertions from 30 seconds to 15 seconds for improved test performance. - Add new test for handling currency switching in the Invoice Generator Page, verifying correct currency display and calculations. - Refactor buyer and seller information components to include tooltip messages and improve accessibility with aria attributes. - Update BuyerDialog and BuyerManagement components to enhance user experience with better visibility and edit functionality for buyer details. * chore: Update Playwright installation command in GitHub Actions workflow - Modify Playwright installation command to remove explicit browser specification, allowing for default browser installation with dependencies. * chore: Update GitHub Actions E2E workflow for Playwright report handling - Change condition for uploading Playwright report to ensure it uploads regardless of test outcome. - Reduce retention days for uploaded reports from 5 to 3 for better resource management. * chore: Update Playwright installation command in GitHub Actions workflow - Specify installation of Chromium and WebKit browsers along with dependencies for enhanced testing capabilities. * chore: Enhance E2E tests for seller and buyer management functionality - Add tests to verify the deletion process for sellers and buyers, including confirmation dialogs and success messages. - Ensure localStorage data is correctly saved and parsed for both seller and buyer information. - Introduce default data constants for sellers and buyers to streamline test setup. - Improve accessibility by adding screen reader text for delete buttons in the seller management component. * chore: Pin versions of GitHub Actions in E2E workflow for stability - Update actions/checkout, pnpm/action-setup, actions/setup-node, and actions/upload-artifact to specific versions for improved reliability and security. - Comment added to clarify the rationale for using pinned versions. * chore: Add E2E test for accordion items visibility and localStorage state management - Implement test to verify that accordion items are visible, collapsible, and their state is correctly saved in localStorage. - Ensure state persistence across page reloads and validate updated states after toggling sections. - Introduce ACCORDION_STATE_LOCAL_STORAGE_KEY and AccordionState type for better type safety and clarity. * chore: Update Playwright configuration and add comprehensive E2E tests for seller and buyer management - Increase timeout for expect assertions and test execution from 30 seconds to 60 seconds for improved stability in E2E tests. - Introduce new E2E tests for seller and buyer management, covering creation, editing, and deletion processes, including confirmation dialogs and success messages. - Ensure localStorage data is correctly saved and parsed for both seller and buyer information. - Implement detailed validation for form fields and visibility toggles in seller and buyer management dialogs. - Enhance accessibility by adding screen reader text for buttons and tooltips in the management components. * chore: Refactor Playwright configuration and enhance invoice item validation tests - Introduce a constant for timeout values in Playwright configuration for consistency and maintainability. - Add comprehensive validation tests for amount, net price, and VAT fields in the invoice items section, ensuring proper error messages for invalid inputs. - Update expected error messages in the schema to match the new formatting for better clarity. - Improve test structure by utilizing descriptive variable names and modularizing input handling for better readability. * chore: add pdf e2e tests - Add `pdf-parse` and its type definitions to package.json for PDF handling capabilities. - Increase Playwright timeout from 30 seconds to 60 seconds for improved test stability. - Introduce comprehensive E2E tests for PDF generation, verifying content in both English and Polish. - Implement cleanup procedures for test downloads to ensure a clean testing environment. - Validate invoice data updates in the generated PDF, ensuring accurate content reflects user inputs. * chore: add eslint, knip, lint-staged * chore: run prettier * chore: minor improvements * chore: add more test and improved e2e config * minor fixes * minor fixes * chore: add new test
This commit is contained in:
parent
ccefb5e301
commit
e6f54d5dfc
53 changed files with 9583 additions and 3764 deletions
|
|
@ -1,6 +1,7 @@
|
|||
---
|
||||
description: Shared rules across the project
|
||||
globs: **/*
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
You are an expert in TypeScript, Node.js, Next.js App Router, React, Shadcn UI, Radix UI and Tailwind.
|
||||
|
|
@ -45,5 +46,7 @@ Key Conventions
|
|||
- Limit 'use client':
|
||||
- Favor server components and Next.js SSR.
|
||||
- Use only for Web API access in small components.
|
||||
- Use pnpm
|
||||
- Use `console.log({})` syntax for debugging
|
||||
|
||||
Follow Next.js docs for Data Fetching, Rendering, and Routing.
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
{
|
||||
"extends": ["next/core-web-vitals", "next/typescript"],
|
||||
"rules": {
|
||||
"no-console": ["warn", { "allow": ["error"] }]
|
||||
}
|
||||
}
|
||||
47
.github/workflows/e2e.yml
vendored
Normal file
47
.github/workflows/e2e.yml
vendored
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
name: 🎭 Playwright E2E tests
|
||||
on:
|
||||
deployment_status:
|
||||
|
||||
jobs:
|
||||
e2e:
|
||||
# https://vercel.com/guides/how-can-i-run-end-to-end-tests-after-my-vercel-preview-deployment
|
||||
if: github.event_name == 'deployment_status' && github.event.deployment_status.state == 'success'
|
||||
|
||||
name: Run e2e tests via ${{ github.event_name == 'deployment_status' && 'Vercel deployment' }}
|
||||
timeout-minutes: 10
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: |
|
||||
echo "Environment URL - ${{ github.event_name == 'deployment_status' && github.event.deployment_status.environment_url }}"
|
||||
# we use pinned versions because there are safer to use: https://x.com/paulmillr/status/1900948425325031448
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
name: 🛎️ Checkout repository
|
||||
|
||||
- uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||
name: 📦 Setup pnpm
|
||||
|
||||
- uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # 4.3.0
|
||||
name: 📚 Setup Node.js
|
||||
with:
|
||||
node-version: lts/*
|
||||
cache: "pnpm"
|
||||
|
||||
- name: 🚚 Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: 📦 Install Playwright browser binaries & OS dependencies
|
||||
run: pnpm exec playwright install chromium webkit --with-deps
|
||||
|
||||
- name: 🎭 Run Playwright tests
|
||||
id: playwright
|
||||
run: pnpm exec playwright test
|
||||
env:
|
||||
BASE_URL: ${{ github.event_name == 'deployment_status' && github.event.deployment_status.environment_url }}
|
||||
|
||||
- uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 #4.6.1
|
||||
name: 🎲 Upload Playwright report
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 3
|
||||
11
.gitignore
vendored
11
.gitignore
vendored
|
|
@ -37,3 +37,14 @@ next-env.d.ts
|
|||
|
||||
# Sentry Config File
|
||||
.env.sentry-build-plugin
|
||||
|
||||
# Playwright
|
||||
node_modules/
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
/playwright-output/
|
||||
|
||||
# eslint
|
||||
.eslintcache
|
||||
|
|
|
|||
1
.husky/pre-commit
Normal file
1
.husky/pre-commit
Normal file
|
|
@ -0,0 +1 @@
|
|||
pnpm run lint-stage --verbose
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
/** @type {import('prettier').Config} */
|
||||
/** @typedef {import("@ianvs/prettier-plugin-sort-imports").PluginConfig} SortImportsConfig*/
|
||||
|
||||
const config = {
|
||||
trailingComma: "es5",
|
||||
plugins: [require.resolve("prettier-plugin-tailwindcss")],
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ If you found this project helpful, you can support my work here: [Buy Me a Coffe
|
|||
|
||||
<img width="1440" alt="image" src="https://github.com/user-attachments/assets/9bc70941-4c2b-4c13-b264-38e3483a82c4" />
|
||||
|
||||
|
||||
## Technologies we use
|
||||
|
||||
- [React](https://react.dev/)
|
||||
|
|
|
|||
309
e2e/buyer.test.ts
Normal file
309
e2e/buyer.test.ts
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
import { DEFAULT_BUYER_DATA, type BuyerData } from "@/app/schema";
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.describe("Buyer management", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/");
|
||||
});
|
||||
|
||||
test("create/edit buyer", async ({ page }) => {
|
||||
// Open buyer management dialog
|
||||
await page.getByRole("button", { name: "New Buyer" }).click();
|
||||
|
||||
// Fill in all buyer details
|
||||
const testData = {
|
||||
name: "New Test Client",
|
||||
address: "456 Client Avenue\nClient City, 54321\nClient Country",
|
||||
|
||||
vatNoFieldIsVisible: true,
|
||||
vatNo: "987654321",
|
||||
|
||||
email: "client@example.com",
|
||||
} as const satisfies BuyerData;
|
||||
|
||||
const manageBuyerDialog = page.getByTestId(`manage-buyer-dialog`);
|
||||
|
||||
// Fill in form fields
|
||||
await manageBuyerDialog
|
||||
.getByRole("textbox", { name: "Name" })
|
||||
.fill(testData.name);
|
||||
await manageBuyerDialog
|
||||
.getByRole("textbox", { name: "Address" })
|
||||
.fill(testData.address);
|
||||
await manageBuyerDialog
|
||||
.getByRole("textbox", { name: "VAT Number" })
|
||||
.fill(testData.vatNo);
|
||||
await manageBuyerDialog
|
||||
.getByRole("textbox", { name: "Email" })
|
||||
.fill(testData.email);
|
||||
|
||||
// Verify VAT visibility switch is checked by default
|
||||
await expect(
|
||||
manageBuyerDialog.getByRole("switch", { name: "Show in PDF" }).nth(0)
|
||||
).toBeChecked();
|
||||
|
||||
// Toggle VAT visibility switch
|
||||
await manageBuyerDialog
|
||||
.getByRole("switch", { name: "Show in PDF" })
|
||||
.nth(0)
|
||||
.click(); // Toggle VAT Number visibility
|
||||
|
||||
// Verify "Apply to Current Invoice" switch is checked by default
|
||||
await expect(
|
||||
manageBuyerDialog.getByRole("switch", {
|
||||
name: "Apply to Current Invoice",
|
||||
})
|
||||
).toBeChecked();
|
||||
|
||||
// Cancel button is shown
|
||||
await expect(
|
||||
manageBuyerDialog.getByRole("button", { name: "Cancel" })
|
||||
).toBeVisible();
|
||||
|
||||
// Save buyer
|
||||
await manageBuyerDialog.getByRole("button", { name: "Save Buyer" }).click();
|
||||
|
||||
// Verify success toast message is visible
|
||||
await expect(
|
||||
page.getByText("Buyer added successfully", { exact: true })
|
||||
).toBeVisible();
|
||||
|
||||
// Verify buyer data is actually saved in localStorage
|
||||
const storedData = (await page.evaluate(() => {
|
||||
return localStorage.getItem("EASY_INVOICE_PDF_BUYERS");
|
||||
})) as string;
|
||||
expect(storedData).toBeTruthy();
|
||||
|
||||
const parsedData = JSON.parse(storedData) as BuyerData[];
|
||||
|
||||
expect(parsedData[0]).toMatchObject({
|
||||
name: testData.name,
|
||||
address: testData.address,
|
||||
|
||||
vatNo: testData.vatNo,
|
||||
vatNoFieldIsVisible: false,
|
||||
|
||||
email: testData.email,
|
||||
} satisfies BuyerData);
|
||||
|
||||
// Verify all saved details in the Buyer Information section form
|
||||
const buyerForm = page.getByTestId(`buyer-information-section`);
|
||||
|
||||
// Try to find desktop tooltip icon first
|
||||
const desktopTooltipExists =
|
||||
(await buyerForm
|
||||
.getByTestId("form-section-tooltip-info-icon-desktop")
|
||||
.count()) > 0;
|
||||
|
||||
// If desktop tooltip exists, hover over it; otherwise find and click mobile tooltip
|
||||
// eslint-disable-next-line playwright/no-conditional-in-test
|
||||
if (desktopTooltipExists) {
|
||||
// Get desktop tooltip icons and hover over the first one because we use tooltip
|
||||
const desktopTooltips = buyerForm.getByTestId(
|
||||
"form-section-tooltip-info-icon-desktop"
|
||||
);
|
||||
await desktopTooltips.first().hover();
|
||||
} else {
|
||||
// Get mobile tooltip icons and click the first one because we use popover
|
||||
const mobileTooltips = buyerForm.getByTestId(
|
||||
"form-section-tooltip-info-icon-mobile"
|
||||
);
|
||||
await mobileTooltips.first().click();
|
||||
}
|
||||
|
||||
// Check that HTML title attributes contain the tooltip message on input fields
|
||||
const nameInput = buyerForm.getByRole("textbox", { name: "Name" });
|
||||
await expect(nameInput).toHaveAttribute(
|
||||
"title",
|
||||
"Buyer details are locked. Click the edit buyer button to modify."
|
||||
);
|
||||
|
||||
// Buyer Name
|
||||
await expect(nameInput).toHaveAttribute("aria-readonly", "true");
|
||||
await expect(nameInput).toHaveValue(testData.name);
|
||||
|
||||
// Buyer Address
|
||||
await expect(
|
||||
buyerForm.getByRole("textbox", { name: "Address" })
|
||||
).toHaveAttribute("aria-readonly", "true");
|
||||
await expect(
|
||||
buyerForm.getByRole("textbox", { name: "Address" })
|
||||
).toHaveValue(testData.address);
|
||||
|
||||
// Buyer VAT Number
|
||||
await expect(
|
||||
buyerForm.getByRole("textbox", { name: "VAT Number" })
|
||||
).toHaveAttribute("aria-readonly", "true");
|
||||
await expect(
|
||||
buyerForm.getByRole("textbox", { name: "VAT Number" })
|
||||
).toHaveValue(testData.vatNo);
|
||||
|
||||
const vatNumberSwitch = buyerForm.getByTestId(`buyerVatNoFieldIsVisible`);
|
||||
// Verify VAT Number switch is not checked as we toggled it off
|
||||
await expect(vatNumberSwitch).not.toBeChecked();
|
||||
await expect(vatNumberSwitch).toBeDisabled();
|
||||
|
||||
// Buyer Email
|
||||
await expect(
|
||||
buyerForm.getByRole("textbox", { name: "Email" })
|
||||
).toHaveAttribute("aria-readonly", "true");
|
||||
await expect(buyerForm.getByRole("textbox", { name: "Email" })).toHaveValue(
|
||||
testData.email
|
||||
);
|
||||
|
||||
// Verify the buyer appears in the dropdown
|
||||
await expect(
|
||||
buyerForm.getByRole("combobox", { name: "Select Buyer" })
|
||||
).toContainText(testData.name);
|
||||
|
||||
// Test edit functionality
|
||||
await buyerForm.getByRole("button", { name: "Edit buyer" }).click();
|
||||
|
||||
// Verify all fields are populated in edit dialog
|
||||
await expect(
|
||||
manageBuyerDialog.getByRole("textbox", { name: "Name" })
|
||||
).toHaveValue(testData.name);
|
||||
await expect(
|
||||
manageBuyerDialog.getByRole("textbox", { name: "Address" })
|
||||
).toHaveValue(testData.address);
|
||||
await expect(
|
||||
manageBuyerDialog.getByRole("textbox", { name: "VAT Number" })
|
||||
).toHaveValue(testData.vatNo);
|
||||
await expect(
|
||||
manageBuyerDialog.getByRole("textbox", { name: "Email" })
|
||||
).toHaveValue(testData.email);
|
||||
|
||||
// Verify visibility switch state persisted in edit dialog
|
||||
await expect(
|
||||
manageBuyerDialog.getByRole("switch", { name: "Show in PDF" }).nth(0)
|
||||
).not.toBeChecked();
|
||||
|
||||
// Update some data in edit mode
|
||||
const updatedName = "Updated Client Corp";
|
||||
await manageBuyerDialog
|
||||
.getByRole("textbox", { name: "Name" })
|
||||
.fill(updatedName);
|
||||
|
||||
// Re-enable VAT visibility
|
||||
await manageBuyerDialog
|
||||
.getByRole("switch", { name: "Show in PDF" })
|
||||
.nth(0)
|
||||
.click();
|
||||
|
||||
// Save updated buyer
|
||||
await manageBuyerDialog.getByRole("button", { name: "Save Buyer" }).click();
|
||||
|
||||
// Verify success toast for update
|
||||
await expect(
|
||||
page.getByText("Buyer updated successfully", { exact: true })
|
||||
).toBeVisible();
|
||||
|
||||
// Verify updated information is displayed
|
||||
await expect(buyerForm.getByRole("textbox", { name: "Name" })).toHaveValue(
|
||||
updatedName
|
||||
);
|
||||
|
||||
// Verify VAT visibility is now enabled
|
||||
await expect(
|
||||
buyerForm.getByTestId(`buyerVatNoFieldIsVisible`)
|
||||
).toBeChecked();
|
||||
});
|
||||
|
||||
test("delete buyer", async ({ page }) => {
|
||||
// First add a buyer
|
||||
await page.getByRole("button", { name: "New Buyer" }).click();
|
||||
|
||||
const testData = {
|
||||
name: "Test Delete Buyer",
|
||||
address: "456 Delete Avenue",
|
||||
email: "delete@buyer.com",
|
||||
|
||||
vatNoFieldIsVisible: true,
|
||||
vatNo: "123456789",
|
||||
} as const satisfies BuyerData;
|
||||
|
||||
const manageBuyerDialog = page.getByTestId(`manage-buyer-dialog`);
|
||||
|
||||
// Fill in basic buyer details
|
||||
await manageBuyerDialog
|
||||
.getByRole("textbox", { name: "Name" })
|
||||
.fill(testData.name);
|
||||
await manageBuyerDialog
|
||||
.getByRole("textbox", { name: "Address" })
|
||||
.fill(testData.address);
|
||||
await manageBuyerDialog
|
||||
.getByRole("textbox", { name: "Email" })
|
||||
.fill(testData.email);
|
||||
|
||||
// Save buyer
|
||||
await manageBuyerDialog.getByRole("button", { name: "Save Buyer" }).click();
|
||||
|
||||
// Verify buyer was added
|
||||
const buyerForm = page.getByTestId(`buyer-information-section`);
|
||||
await expect(
|
||||
buyerForm.getByRole("combobox", { name: "Select Buyer" })
|
||||
).toContainText(testData.name);
|
||||
|
||||
// Click delete button
|
||||
await buyerForm.getByRole("button", { name: "Delete buyer" }).click();
|
||||
|
||||
// Verify delete confirmation dialog appears
|
||||
await expect(page.getByRole("alertdialog")).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(
|
||||
`Are you sure you want to delete "${testData.name}" buyer?`
|
||||
)
|
||||
).toBeVisible();
|
||||
|
||||
// Cancel button is shown
|
||||
await expect(page.getByRole("button", { name: "Cancel" })).toBeVisible();
|
||||
|
||||
// Click cancel button
|
||||
await page.getByRole("button", { name: "Cancel" }).click();
|
||||
|
||||
// Verify dialog is closed
|
||||
await expect(page.getByRole("alertdialog")).toBeHidden();
|
||||
|
||||
// Click delete button once again to open the dialog
|
||||
await buyerForm.getByRole("button", { name: "Delete buyer" }).click();
|
||||
|
||||
// Verify delete confirmation dialog appears
|
||||
await expect(page.getByRole("alertdialog")).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(
|
||||
`Are you sure you want to delete "${testData.name}" buyer?`
|
||||
)
|
||||
).toBeVisible();
|
||||
|
||||
// Confirm deletion
|
||||
await page.getByRole("button", { name: "Delete" }).click();
|
||||
|
||||
// Verify success message
|
||||
await expect(
|
||||
page.getByText("Buyer deleted successfully", { exact: true })
|
||||
).toBeVisible();
|
||||
|
||||
// Verify buyer is removed from dropdown
|
||||
// because we have only one buyer, dropdown will be completely hidden
|
||||
await expect(
|
||||
buyerForm.getByRole("combobox", { name: "Select Buyer" })
|
||||
).toBeHidden();
|
||||
|
||||
// Verify form is reset to default values
|
||||
await expect(buyerForm.getByRole("textbox", { name: "Name" })).toHaveValue(
|
||||
DEFAULT_BUYER_DATA.name
|
||||
);
|
||||
|
||||
await expect(
|
||||
buyerForm.getByRole("textbox", { name: "Address" })
|
||||
).toHaveValue(DEFAULT_BUYER_DATA.address);
|
||||
|
||||
await expect(buyerForm.getByRole("textbox", { name: "Email" })).toHaveValue(
|
||||
DEFAULT_BUYER_DATA.email
|
||||
);
|
||||
|
||||
await expect(
|
||||
buyerForm.getByRole("textbox", { name: "VAT Number" })
|
||||
).toHaveValue(DEFAULT_BUYER_DATA.vatNo);
|
||||
});
|
||||
});
|
||||
980
e2e/invoice-form.test.ts
Normal file
980
e2e/invoice-form.test.ts
Normal file
|
|
@ -0,0 +1,980 @@
|
|||
import {
|
||||
ACCORDION_STATE_LOCAL_STORAGE_KEY,
|
||||
type AccordionState,
|
||||
type InvoiceData,
|
||||
} from "@/app/schema";
|
||||
import { expect, test } from "@playwright/test";
|
||||
import dayjs from "dayjs";
|
||||
import { INITIAL_INVOICE_DATA } from "../src/app/constants";
|
||||
|
||||
test.describe("Invoice Generator Page", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/");
|
||||
});
|
||||
|
||||
test("has correct title and branding", async ({ page }) => {
|
||||
await expect(page).toHaveTitle(
|
||||
"Invoice PDF Generator with Live Preview | No Sign-Up"
|
||||
);
|
||||
await expect(
|
||||
page.getByRole("link", { name: "EasyInvoicePDF.com" })
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByText("Invoice PDF generator with live preview")
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("displays correct buttons and links in header", async ({ page }) => {
|
||||
// Check title and branding
|
||||
await expect(page).toHaveTitle(
|
||||
"Invoice PDF Generator with Live Preview | No Sign-Up"
|
||||
);
|
||||
await expect(
|
||||
page.getByRole("link", { name: "EasyInvoicePDF.com" })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText("Invoice PDF generator with live preview")
|
||||
).toBeVisible();
|
||||
|
||||
// Check main action buttons
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Support Project" })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Generate a link to invoice" })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("link", { name: "Download PDF in English" })
|
||||
).toBeVisible();
|
||||
|
||||
// Check footer links
|
||||
await expect(page.getByText("Made by")).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("link", { name: "Vlad Sazonau" })
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Open Source" })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("link", { name: "Share your feedback" })
|
||||
).toBeVisible();
|
||||
|
||||
// Verify links have correct href attributes
|
||||
await expect(
|
||||
page.getByRole("link", { name: "Vlad Sazonau" })
|
||||
).toHaveAttribute("href", "https://dub.sh/vldzn.me");
|
||||
await expect(
|
||||
page.getByRole("link", { name: "Open Source" })
|
||||
).toHaveAttribute(
|
||||
"href",
|
||||
"https://github.com/VladSez/pdf-invoice-generator"
|
||||
);
|
||||
|
||||
// Verify buttons are enabled
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Generate a link to invoice" })
|
||||
).toBeEnabled();
|
||||
await expect(
|
||||
page.getByRole("link", { name: "Download PDF in English" })
|
||||
).toBeEnabled();
|
||||
});
|
||||
|
||||
test("handles mobile/desktop views", async ({ page }) => {
|
||||
// Test mobile view
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
|
||||
// check that tabs are visible in mobile view
|
||||
await expect(page.getByRole("tab", { name: "Edit Invoice" })).toBeVisible();
|
||||
await expect(page.getByRole("tab", { name: "Preview PDF" })).toBeVisible();
|
||||
|
||||
// Test desktop view
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
|
||||
// check that tabs are not visible in desktop view
|
||||
await expect(page.getByRole("tab", { name: "Edit Invoice" })).toBeHidden();
|
||||
await expect(page.getByRole("tab", { name: "Preview PDF" })).toBeHidden();
|
||||
});
|
||||
|
||||
test("displays initial form state correctly", async ({ page }) => {
|
||||
// **CHECK GENERAL INFORMATION SECTION**
|
||||
const generalInfoSection = page.getByTestId(`general-information-section`);
|
||||
await expect(
|
||||
generalInfoSection.getByText("General Information", { exact: true })
|
||||
).toBeVisible();
|
||||
|
||||
// Language selection
|
||||
await expect(
|
||||
generalInfoSection.getByRole("combobox", { name: "Invoice PDF Language" })
|
||||
).toHaveValue(INITIAL_INVOICE_DATA.language);
|
||||
|
||||
// Currency selection
|
||||
await expect(
|
||||
generalInfoSection.getByRole("combobox", { name: "Currency" })
|
||||
).toHaveValue(INITIAL_INVOICE_DATA.currency);
|
||||
|
||||
// Date Format selection
|
||||
await expect(
|
||||
generalInfoSection.getByRole("combobox", { name: "Date Format" })
|
||||
).toHaveValue(INITIAL_INVOICE_DATA.dateFormat);
|
||||
|
||||
// Invoice Number
|
||||
await expect(
|
||||
generalInfoSection.getByRole("textbox", { name: "Invoice Number" })
|
||||
).toHaveValue(INITIAL_INVOICE_DATA.invoiceNumber);
|
||||
|
||||
// Date of Issue
|
||||
await expect(
|
||||
generalInfoSection.getByRole("textbox", { name: "Date of Issue" })
|
||||
).toHaveValue(INITIAL_INVOICE_DATA.dateOfIssue);
|
||||
|
||||
// Date of Service
|
||||
await expect(
|
||||
generalInfoSection.getByRole("textbox", { name: "Date of Service" })
|
||||
).toHaveValue(INITIAL_INVOICE_DATA.dateOfService);
|
||||
|
||||
// Invoice Type
|
||||
await expect(
|
||||
generalInfoSection.getByRole("textbox", { name: "Invoice Type" })
|
||||
).toHaveValue(INITIAL_INVOICE_DATA.invoiceType);
|
||||
|
||||
// Visibility toggles
|
||||
await expect(
|
||||
generalInfoSection.getByRole("switch", { name: "Show in PDF" })
|
||||
).toBeChecked();
|
||||
|
||||
// **CHECK SELLER INFORMATION SECTION**
|
||||
const sellerSection = page.getByTestId(`seller-information-section`);
|
||||
await expect(
|
||||
sellerSection.getByText("Seller Information", { exact: true })
|
||||
).toBeVisible();
|
||||
|
||||
// Name field
|
||||
await expect(
|
||||
sellerSection.getByRole("textbox", { name: "Name" })
|
||||
).toHaveValue(INITIAL_INVOICE_DATA.seller.name);
|
||||
|
||||
// Address field
|
||||
await expect(
|
||||
sellerSection.getByRole("textbox", { name: "Address" })
|
||||
).toHaveValue(INITIAL_INVOICE_DATA.seller.address);
|
||||
|
||||
// VAT Number field and visibility toggle
|
||||
await expect(
|
||||
sellerSection.getByRole("textbox", { name: "VAT Number" })
|
||||
).toHaveValue(INITIAL_INVOICE_DATA.seller.vatNo);
|
||||
await expect(
|
||||
sellerSection.getByRole("switch", { name: /Show in PDF/i }).nth(0)
|
||||
).toBeChecked();
|
||||
|
||||
// Email field
|
||||
await expect(
|
||||
sellerSection.getByRole("textbox", { name: "Email" })
|
||||
).toHaveValue(INITIAL_INVOICE_DATA.seller.email);
|
||||
|
||||
// Account Number field and visibility toggle
|
||||
await expect(
|
||||
sellerSection.getByRole("textbox", { name: "Account Number" })
|
||||
).toHaveValue(INITIAL_INVOICE_DATA.seller.accountNumber);
|
||||
await expect(
|
||||
sellerSection.getByRole("switch", { name: /Show in PDF/i }).nth(1)
|
||||
).toBeChecked();
|
||||
|
||||
// SWIFT/BIC field and visibility toggle
|
||||
await expect(
|
||||
sellerSection.getByRole("textbox", { name: "SWIFT/BIC" })
|
||||
).toHaveValue(INITIAL_INVOICE_DATA.seller.swiftBic);
|
||||
await expect(
|
||||
sellerSection.getByRole("switch", { name: /Show in PDF/i }).nth(2)
|
||||
).toBeChecked();
|
||||
|
||||
// Verify Seller Management button is present
|
||||
await expect(
|
||||
sellerSection.getByRole("button", { name: "New Seller" })
|
||||
).toBeVisible();
|
||||
|
||||
// **CHECK BUYER INFORMATION SECTION**
|
||||
const buyerSection = page.getByTestId(`buyer-information-section`);
|
||||
await expect(
|
||||
buyerSection.getByText("Buyer Information", { exact: true })
|
||||
).toBeVisible();
|
||||
|
||||
// Name field
|
||||
await expect(
|
||||
buyerSection.getByRole("textbox", { name: "Name" })
|
||||
).toHaveValue(INITIAL_INVOICE_DATA.buyer.name);
|
||||
|
||||
// Address field
|
||||
await expect(
|
||||
buyerSection.getByRole("textbox", { name: "Address" })
|
||||
).toHaveValue(INITIAL_INVOICE_DATA.buyer.address);
|
||||
|
||||
// VAT Number field and visibility toggle
|
||||
await expect(
|
||||
buyerSection.getByRole("textbox", { name: "VAT Number" })
|
||||
).toHaveValue(INITIAL_INVOICE_DATA.buyer.vatNo);
|
||||
await expect(
|
||||
buyerSection.getByRole("switch", { name: /Show in PDF/i })
|
||||
).toBeChecked();
|
||||
|
||||
// Email field
|
||||
await expect(
|
||||
buyerSection.getByRole("textbox", { name: "Email" })
|
||||
).toHaveValue(INITIAL_INVOICE_DATA.buyer.email);
|
||||
|
||||
// Verify Buyer Management button is present
|
||||
await expect(
|
||||
buyerSection.getByRole("button", { name: "New Buyer" })
|
||||
).toBeVisible();
|
||||
|
||||
// **Check INVOICE ITEMS section**
|
||||
const invoiceItemsSection = page.getByTestId(`invoice-items-section`);
|
||||
await expect(
|
||||
invoiceItemsSection.getByText("Invoice Items", { exact: true })
|
||||
).toBeVisible();
|
||||
|
||||
// Check visibility toggles in settings
|
||||
await expect(
|
||||
invoiceItemsSection.getByRole("switch", { name: /Show "Number" Column/i })
|
||||
).toBeChecked();
|
||||
|
||||
await expect(
|
||||
invoiceItemsSection.getByRole("switch", {
|
||||
name: /Show "VAT Table Summary"/i,
|
||||
})
|
||||
).toBeChecked();
|
||||
|
||||
// Check first invoice item fields
|
||||
const firstItem = INITIAL_INVOICE_DATA.items[0];
|
||||
|
||||
// Name field and visibility toggle
|
||||
await expect(
|
||||
invoiceItemsSection.getByRole("textbox", { name: "Name" })
|
||||
).toHaveValue(firstItem.name);
|
||||
await expect(
|
||||
invoiceItemsSection.getByRole("switch", { name: /Show in PDF/i }).nth(0)
|
||||
).toBeChecked();
|
||||
|
||||
// Type of GTU field and visibility toggle
|
||||
await expect(
|
||||
invoiceItemsSection.getByRole("textbox", { name: "Type of GTU" })
|
||||
).toHaveValue(firstItem.typeOfGTU);
|
||||
await expect(
|
||||
invoiceItemsSection.getByRole("switch", { name: /Show in PDF/i }).nth(1)
|
||||
).toBeChecked();
|
||||
|
||||
// Amount field and visibility toggle
|
||||
await expect(
|
||||
invoiceItemsSection.getByRole("spinbutton", { name: "Amount" })
|
||||
).toHaveValue(firstItem.amount.toString());
|
||||
await expect(
|
||||
invoiceItemsSection.getByRole("switch", { name: /Show in PDF/i }).nth(2)
|
||||
).toBeChecked();
|
||||
|
||||
// Unit field and visibility toggle
|
||||
await expect(
|
||||
invoiceItemsSection.getByRole("textbox", { name: "Unit" })
|
||||
).toHaveValue(firstItem.unit);
|
||||
await expect(
|
||||
invoiceItemsSection.getByRole("switch", { name: /Show in PDF/i }).nth(3)
|
||||
).toBeChecked();
|
||||
|
||||
// Net Price field and visibility toggle
|
||||
await expect(
|
||||
invoiceItemsSection.getByRole("spinbutton", { name: "Net Price" })
|
||||
).toHaveValue(firstItem.netPrice.toString());
|
||||
await expect(
|
||||
invoiceItemsSection.getByRole("switch", { name: /Show in PDF/i }).nth(4)
|
||||
).toBeChecked();
|
||||
|
||||
// VAT field and visibility toggle
|
||||
await expect(
|
||||
invoiceItemsSection.getByRole("textbox", { name: "VAT", exact: true })
|
||||
).toHaveValue(firstItem.vat);
|
||||
await expect(
|
||||
invoiceItemsSection.getByRole("switch", { name: /Show in PDF/i }).nth(5)
|
||||
).toBeChecked();
|
||||
|
||||
// Net Amount field (read-only) and visibility toggle
|
||||
await expect(
|
||||
invoiceItemsSection.getByRole("textbox", {
|
||||
name: "Net Amount",
|
||||
exact: true,
|
||||
})
|
||||
).toHaveValue(
|
||||
firstItem.netAmount.toLocaleString("en-US", {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})
|
||||
);
|
||||
await expect(
|
||||
invoiceItemsSection.getByRole("switch", { name: /Show in PDF/i }).nth(6)
|
||||
).toBeChecked();
|
||||
|
||||
// VAT Amount field (read-only) and visibility toggle
|
||||
await expect(
|
||||
invoiceItemsSection.getByRole("textbox", {
|
||||
name: "VAT Amount",
|
||||
exact: true,
|
||||
})
|
||||
).toHaveValue(
|
||||
firstItem.vatAmount.toLocaleString("en-US", {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})
|
||||
);
|
||||
await expect(
|
||||
invoiceItemsSection.getByRole("switch", { name: /Show in PDF/i }).nth(7)
|
||||
).toBeChecked();
|
||||
|
||||
// Pre-tax Amount field (read-only) and visibility toggle
|
||||
await expect(
|
||||
invoiceItemsSection.getByRole("textbox", {
|
||||
name: "Pre-tax Amount",
|
||||
exact: true,
|
||||
})
|
||||
).toHaveValue(
|
||||
firstItem.preTaxAmount.toLocaleString("en-US", {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})
|
||||
);
|
||||
await expect(
|
||||
invoiceItemsSection.getByRole("switch", { name: /Show in PDF/i }).nth(8)
|
||||
).toBeChecked();
|
||||
|
||||
// Verify Add Invoice Item button is present
|
||||
await expect(
|
||||
invoiceItemsSection.getByRole("button", { name: "Add invoice item" })
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("can add and remove invoice items", async ({ page }) => {
|
||||
const invoiceItemsSection = page.getByTestId(`invoice-items-section`);
|
||||
|
||||
// Add new invoice item
|
||||
await invoiceItemsSection
|
||||
.getByRole("button", { name: "Add invoice item" })
|
||||
.click();
|
||||
await expect(
|
||||
invoiceItemsSection.getByText("Item 2", { exact: true })
|
||||
).toBeVisible();
|
||||
|
||||
// Fill in new item details
|
||||
const itemNameInput = invoiceItemsSection
|
||||
.getByRole("textbox", { name: "Name" })
|
||||
.nth(1);
|
||||
await itemNameInput.fill("TEST INVOICE ITEM");
|
||||
await expect(itemNameInput).toHaveValue("TEST INVOICE ITEM");
|
||||
|
||||
// Remove the added item
|
||||
await invoiceItemsSection
|
||||
.getByRole("button", { name: "Delete Invoice Item 2" })
|
||||
.click();
|
||||
await expect(
|
||||
invoiceItemsSection.getByText("Item 2", { exact: true })
|
||||
).toBeHidden();
|
||||
});
|
||||
|
||||
test("calculates totals correctly", async ({ page }) => {
|
||||
const invoiceItemsSection = page.getByTestId(`invoice-items-section`);
|
||||
|
||||
// Fill in item details
|
||||
await invoiceItemsSection
|
||||
.getByRole("spinbutton", { name: "Amount", exact: true })
|
||||
.fill("2");
|
||||
await invoiceItemsSection
|
||||
.getByRole("spinbutton", { name: "Net Price", exact: true })
|
||||
.fill("100");
|
||||
await invoiceItemsSection
|
||||
.getByRole("textbox", { name: "VAT", exact: true })
|
||||
.fill("23");
|
||||
|
||||
// Check calculated values
|
||||
await expect(
|
||||
invoiceItemsSection.getByRole("textbox", {
|
||||
name: "Net Amount",
|
||||
exact: true,
|
||||
})
|
||||
).toHaveValue("200.00");
|
||||
await expect(
|
||||
invoiceItemsSection.getByRole("textbox", {
|
||||
name: "VAT Amount",
|
||||
exact: true,
|
||||
})
|
||||
).toHaveValue("46.00");
|
||||
|
||||
const finalSection = page.getByTestId(`final-section`);
|
||||
await expect(
|
||||
finalSection.getByRole("textbox", {
|
||||
name: "Total",
|
||||
exact: true,
|
||||
})
|
||||
).toHaveValue("246.00");
|
||||
});
|
||||
|
||||
test("handles form validation", async ({ page }) => {
|
||||
// Clear required fields
|
||||
await page.getByRole("textbox", { name: "Invoice Number" }).clear();
|
||||
await page.getByRole("textbox", { name: "Date of Issue" }).clear();
|
||||
|
||||
// Try to generate PDF
|
||||
await page.getByRole("link", { name: "Download PDF in English" }).click();
|
||||
|
||||
// Check for error messages
|
||||
await expect(
|
||||
page.getByText("Invoice number is required", { exact: true })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText("Date of issue is required", { exact: true })
|
||||
).toBeVisible();
|
||||
|
||||
const dateOfIssue = dayjs().format("YYYY-MM-DD");
|
||||
|
||||
// Fill in required fields
|
||||
await page
|
||||
.getByRole("textbox", { name: "Invoice Number" })
|
||||
.fill("1/03-2025");
|
||||
await page
|
||||
.getByRole("textbox", { name: "Date of Issue" })
|
||||
.fill(dateOfIssue);
|
||||
|
||||
// Check if the date of issue is filled in correctly
|
||||
await expect(
|
||||
page.getByRole("textbox", { name: "Date of Issue" })
|
||||
).toHaveValue(dateOfIssue);
|
||||
|
||||
// Try to generate PDF again
|
||||
await page.getByRole("link", { name: "Download PDF in English" }).click();
|
||||
|
||||
// Check for error messages to be hidden
|
||||
await expect(
|
||||
page.getByText("Invoice number is required", { exact: true })
|
||||
).toBeHidden();
|
||||
await expect(
|
||||
page.getByText("Date of issue is required", { exact: true })
|
||||
).toBeHidden();
|
||||
});
|
||||
|
||||
test("persists data in local storage", async ({ page }) => {
|
||||
// Fill in some data
|
||||
await page
|
||||
.getByRole("textbox", { name: "Invoice Number" })
|
||||
.fill("TEST/2024");
|
||||
await page
|
||||
.getByRole("textbox", { name: "Notes", exact: true })
|
||||
.fill("Test note");
|
||||
|
||||
// Wait a moment for any debounced localStorage updates
|
||||
// eslint-disable-next-line playwright/no-wait-for-timeout
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Verify data is actually saved in localStorage
|
||||
const storedData = (await page.evaluate(() => {
|
||||
return localStorage.getItem("EASY_INVOICE_PDF_DATA");
|
||||
})) as string;
|
||||
expect(storedData).toBeTruthy();
|
||||
|
||||
const parsedData = JSON.parse(storedData) as InvoiceData;
|
||||
expect(parsedData).toMatchObject({
|
||||
invoiceNumber: "TEST/2024",
|
||||
notes: "Test note",
|
||||
});
|
||||
|
||||
// Reload page
|
||||
await page.reload();
|
||||
|
||||
// Check if data persists in UI
|
||||
await expect(
|
||||
page.getByRole("textbox", { name: "Invoice Number" })
|
||||
).toHaveValue("TEST/2024");
|
||||
await expect(
|
||||
page.getByRole("textbox", { name: "Notes", exact: true })
|
||||
).toHaveValue("Test note");
|
||||
});
|
||||
|
||||
test("handles currency switching", async ({ page }) => {
|
||||
const invoiceItemsSection = page.getByTestId(`invoice-items-section`);
|
||||
|
||||
const netPriceFormElement =
|
||||
invoiceItemsSection.getByTestId(`itemNetPrice0`);
|
||||
|
||||
const netAmountFormElement =
|
||||
invoiceItemsSection.getByTestId(`itemNetAmount0`);
|
||||
|
||||
// Verify initial currency
|
||||
await expect(netPriceFormElement).toHaveText("€EUR");
|
||||
await expect(netAmountFormElement).toHaveText("€EUR");
|
||||
|
||||
await expect(
|
||||
invoiceItemsSection.getByText("Preview: €0.00 (zero EUR 00/100)")
|
||||
).toBeVisible();
|
||||
|
||||
const currencySelect = page.getByRole("combobox", { name: "Currency" });
|
||||
|
||||
// Switch currency
|
||||
await currencySelect.selectOption("USD");
|
||||
await expect(currencySelect).toHaveValue("USD");
|
||||
|
||||
// Verify calculations with new currency
|
||||
await invoiceItemsSection
|
||||
.getByRole("spinbutton", { name: "Amount", exact: true })
|
||||
.fill("2");
|
||||
await invoiceItemsSection
|
||||
.getByRole("spinbutton", { name: "Net Price", exact: true })
|
||||
.fill("100.75");
|
||||
|
||||
await expect(netPriceFormElement).toHaveText("$USD");
|
||||
await expect(netAmountFormElement).toHaveText("$USD");
|
||||
|
||||
await expect(
|
||||
invoiceItemsSection.getByText(
|
||||
"Preview: $100.75 (one hundred USD 75/100)",
|
||||
{
|
||||
exact: true,
|
||||
}
|
||||
)
|
||||
).toBeVisible();
|
||||
|
||||
const finalSection = page.getByTestId(`final-section`);
|
||||
await expect(
|
||||
finalSection.getByRole("textbox", {
|
||||
name: "Total",
|
||||
exact: true,
|
||||
})
|
||||
).toHaveValue("201.50");
|
||||
});
|
||||
|
||||
test("accordion items are visible, collapsible and saved in the local storage", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Define sections with their labels
|
||||
const sections = [
|
||||
{ id: "general-information-section", label: "General Information" },
|
||||
{ id: "seller-information-section", label: "Seller Information" },
|
||||
{ id: "buyer-information-section", label: "Buyer Information" },
|
||||
{ id: "invoice-items-section", label: "Invoice Items" },
|
||||
] as const;
|
||||
|
||||
// Verify all sections are initially visible and expanded
|
||||
for (const section of sections) {
|
||||
const sectionElement = page.getByTestId(section.id);
|
||||
await expect(sectionElement).toBeVisible();
|
||||
await expect(
|
||||
sectionElement.getByRole("region", { name: section.label })
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
// Collapse specific sections to create mixed state
|
||||
await page
|
||||
.getByTestId("seller-information-section")
|
||||
.getByRole("button", { name: "Seller Information" })
|
||||
.click();
|
||||
|
||||
await page
|
||||
.getByTestId("invoice-items-section")
|
||||
.getByRole("button", { name: "Invoice Items" })
|
||||
.click();
|
||||
|
||||
// Verify mixed state: general and buyer expanded, seller and items collapsed
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("general-information-section")
|
||||
.getByRole("region", { name: "General Information" })
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("seller-information-section")
|
||||
.getByRole("region", { name: "Seller Information" })
|
||||
).toBeHidden();
|
||||
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("buyer-information-section")
|
||||
.getByRole("region", { name: "Buyer Information" })
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("invoice-items-section")
|
||||
.getByRole("region", { name: "Invoice Items" })
|
||||
).toBeHidden();
|
||||
|
||||
// Verify the state is saved in localStorage
|
||||
const storedState = (await page.evaluate((key) => {
|
||||
return localStorage.getItem(key);
|
||||
}, ACCORDION_STATE_LOCAL_STORAGE_KEY)) as string;
|
||||
|
||||
expect(storedState).toBeTruthy();
|
||||
|
||||
const parsedState = JSON.parse(storedState) as AccordionState;
|
||||
|
||||
expect(parsedState).toEqual({
|
||||
general: true,
|
||||
seller: false,
|
||||
buyer: true,
|
||||
invoiceItems: false,
|
||||
} as const satisfies AccordionState);
|
||||
|
||||
// Reload the page and verify state persistence
|
||||
await page.reload();
|
||||
|
||||
// Verify state persists after reload
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("general-information-section")
|
||||
.getByRole("region", { name: "General Information" })
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("seller-information-section")
|
||||
.getByRole("region", { name: "Seller Information" })
|
||||
).toBeHidden();
|
||||
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("buyer-information-section")
|
||||
.getByRole("region", { name: "Buyer Information" })
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("invoice-items-section")
|
||||
.getByRole("region", { name: "Invoice Items" })
|
||||
).toBeHidden();
|
||||
|
||||
// Toggle states after reload
|
||||
await page
|
||||
.getByTestId("general-information-section")
|
||||
.getByRole("button", { name: "General Information" })
|
||||
.click();
|
||||
|
||||
await page
|
||||
.getByTestId("seller-information-section")
|
||||
.getByRole("button", { name: "Seller Information" })
|
||||
.click();
|
||||
|
||||
// Verify new toggled state
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("general-information-section")
|
||||
.getByRole("region", { name: "General Information" })
|
||||
).toBeHidden();
|
||||
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("seller-information-section")
|
||||
.getByRole("region", { name: "Seller Information" })
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("buyer-information-section")
|
||||
.getByRole("region", { name: "Buyer Information" })
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("invoice-items-section")
|
||||
.getByRole("region", { name: "Invoice Items" })
|
||||
).toBeHidden();
|
||||
|
||||
// Verify updated state is saved in localStorage
|
||||
const updatedStoredState = (await page.evaluate((key) => {
|
||||
return localStorage.getItem(key);
|
||||
}, ACCORDION_STATE_LOCAL_STORAGE_KEY)) as string;
|
||||
|
||||
expect(updatedStoredState).toBeTruthy();
|
||||
|
||||
const updatedParsedState = JSON.parse(updatedStoredState) as AccordionState;
|
||||
expect(updatedParsedState).toEqual({
|
||||
general: false,
|
||||
seller: true,
|
||||
buyer: true,
|
||||
invoiceItems: false,
|
||||
} as const satisfies AccordionState);
|
||||
});
|
||||
|
||||
test("validates amount, net price and VAT fields in invoice items section", async ({
|
||||
page,
|
||||
}) => {
|
||||
const invoiceItemsSection = page.getByTestId(`invoice-items-section`);
|
||||
|
||||
// **AMOUNT FIELD**
|
||||
const amountInput = invoiceItemsSection.getByRole("spinbutton", {
|
||||
name: "Amount",
|
||||
});
|
||||
|
||||
// Test invalid values
|
||||
await amountInput.fill("-1");
|
||||
await expect(page.getByText("Amount must be positive")).toBeVisible();
|
||||
|
||||
await amountInput.fill("0");
|
||||
await expect(page.getByText("Amount must be positive")).toBeVisible();
|
||||
|
||||
await amountInput.fill("1000000000000"); // 1 trillion
|
||||
await expect(
|
||||
page.getByText("Amount must not exceed 9 999 999 999.99")
|
||||
).toBeVisible();
|
||||
|
||||
// Test valid values
|
||||
await amountInput.fill("1");
|
||||
await expect(page.getByText("Amount must be positive")).toBeHidden();
|
||||
await expect(
|
||||
page.getByText("Amount must not exceed 9 999 999 999.99")
|
||||
).toBeHidden();
|
||||
|
||||
await amountInput.fill("9999999999.99"); // Maximum valid value
|
||||
await expect(page.getByText("Amount must be positive")).toBeHidden();
|
||||
await expect(
|
||||
page.getByText("Amount must not exceed 9 999 999 999.99")
|
||||
).toBeHidden();
|
||||
|
||||
// **NET PRICE FIELD**
|
||||
|
||||
const netPriceInput = invoiceItemsSection.getByRole("spinbutton", {
|
||||
name: "Net Price",
|
||||
});
|
||||
|
||||
// Test negative value
|
||||
await netPriceInput.fill("-100");
|
||||
await expect(page.getByText("Net price must be >= 0")).toBeVisible();
|
||||
|
||||
// Test exceeding maximum value
|
||||
await netPriceInput.fill("1000000000000"); // 1 trillion
|
||||
await expect(
|
||||
page.getByText("Net price must not exceed 100 billion")
|
||||
).toBeVisible();
|
||||
|
||||
// Test zero value
|
||||
await netPriceInput.fill("0");
|
||||
await expect(page.getByText("Net price must be >= 0")).toBeHidden();
|
||||
|
||||
// Test valid value
|
||||
await netPriceInput.fill("1");
|
||||
await expect(page.getByText("Net price must be >= 0")).toBeHidden();
|
||||
await expect(
|
||||
page.getByText("Net price must not exceed 100 billion")
|
||||
).toBeHidden();
|
||||
|
||||
// **VAT FIELD**
|
||||
|
||||
const vatInput = invoiceItemsSection.getByRole("textbox", {
|
||||
name: "VAT",
|
||||
exact: true,
|
||||
});
|
||||
|
||||
// Try invalid values
|
||||
await vatInput.fill("101");
|
||||
await expect(page.getByText("VAT must be between 0 and 100")).toBeVisible();
|
||||
|
||||
await vatInput.fill("-1");
|
||||
await expect(page.getByText("VAT must be between 0 and 100")).toBeVisible();
|
||||
|
||||
await vatInput.fill("abc");
|
||||
await expect(
|
||||
page.getByText("Must be a valid number (0-100) or NP or OO")
|
||||
).toBeVisible();
|
||||
|
||||
// Try valid values
|
||||
await vatInput.fill("23");
|
||||
await expect(page.getByText("VAT must be between 0 and 100")).toBeHidden();
|
||||
|
||||
await vatInput.fill("NP");
|
||||
await expect(
|
||||
page.getByText("Must be a valid number (0-100) or NP or OO")
|
||||
).toBeHidden();
|
||||
|
||||
await vatInput.fill("OO");
|
||||
await expect(
|
||||
page.getByText("Must be a valid number (0-100) or NP or OO")
|
||||
).toBeHidden();
|
||||
});
|
||||
|
||||
test("handles VAT calculations for different rates", async ({ page }) => {
|
||||
// Test with different VAT rates
|
||||
const testCases = [
|
||||
{
|
||||
vat: "23",
|
||||
amount: "100",
|
||||
netPrice: "100",
|
||||
expected: {
|
||||
net: "10,000.00",
|
||||
vatAmount: "2,300.00",
|
||||
total: "12,300.00",
|
||||
},
|
||||
},
|
||||
{
|
||||
vat: "8",
|
||||
amount: "100",
|
||||
netPrice: "100",
|
||||
expected: { net: "10,000.00", vatAmount: "800.00", total: "10,800.00" },
|
||||
},
|
||||
{
|
||||
vat: "0",
|
||||
amount: "100",
|
||||
netPrice: "100",
|
||||
expected: { net: "10,000.00", vatAmount: "0.00", total: "10,000.00" },
|
||||
},
|
||||
{
|
||||
vat: "NP",
|
||||
amount: "100",
|
||||
netPrice: "100",
|
||||
expected: { net: "10,000.00", vatAmount: "0.00", total: "10,000.00" },
|
||||
},
|
||||
{
|
||||
vat: "OO",
|
||||
amount: "3",
|
||||
netPrice: "100",
|
||||
expected: { net: "300.00", vatAmount: "0.00", total: "300.00" },
|
||||
},
|
||||
] as const satisfies {
|
||||
vat: string;
|
||||
amount: string;
|
||||
netPrice: string;
|
||||
expected: { net: string; vatAmount: string; total: string };
|
||||
}[];
|
||||
|
||||
const invoiceItemsSection = page.getByTestId(`invoice-items-section`);
|
||||
const amountInput = invoiceItemsSection.getByRole("spinbutton", {
|
||||
name: "Amount",
|
||||
exact: true,
|
||||
});
|
||||
const netPriceInput = invoiceItemsSection.getByRole("spinbutton", {
|
||||
name: "Net Price",
|
||||
exact: true,
|
||||
});
|
||||
const vatInput = invoiceItemsSection.getByRole("textbox", {
|
||||
name: "VAT",
|
||||
exact: true,
|
||||
});
|
||||
|
||||
for (const testCase of testCases) {
|
||||
// Fill in values
|
||||
await amountInput.fill(testCase.amount);
|
||||
await netPriceInput.fill(testCase.netPrice);
|
||||
await vatInput.fill(testCase.vat);
|
||||
|
||||
// Check calculations
|
||||
await expect(
|
||||
invoiceItemsSection.getByRole("textbox", {
|
||||
name: "Net Amount",
|
||||
exact: true,
|
||||
})
|
||||
).toHaveValue(testCase.expected.net);
|
||||
|
||||
await expect(
|
||||
invoiceItemsSection.getByRole("textbox", {
|
||||
name: "VAT Amount",
|
||||
exact: true,
|
||||
})
|
||||
).toHaveValue(testCase.expected.vatAmount);
|
||||
|
||||
await expect(
|
||||
invoiceItemsSection.getByRole("textbox", {
|
||||
name: "Pre-tax Amount",
|
||||
exact: true,
|
||||
})
|
||||
).toHaveValue(testCase.expected.total);
|
||||
|
||||
await expect(
|
||||
page.getByRole("textbox", {
|
||||
name: "Total",
|
||||
exact: true,
|
||||
})
|
||||
).toHaveValue(testCase.expected.total);
|
||||
}
|
||||
});
|
||||
|
||||
test("generates shareable URL that persists invoice data between tabs", async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
// Fill in some test data
|
||||
await page
|
||||
.getByRole("textbox", { name: "Invoice Number" })
|
||||
.fill("SHARE-TEST-001");
|
||||
await page
|
||||
.getByRole("textbox", { name: "Notes", exact: true })
|
||||
.fill("Test note for sharing");
|
||||
|
||||
// Fill in seller information
|
||||
const sellerSection = page.getByTestId("seller-information-section");
|
||||
await sellerSection
|
||||
.getByRole("textbox", { name: "Name" })
|
||||
.fill("Test Seller");
|
||||
await sellerSection
|
||||
.getByRole("textbox", { name: "Address" })
|
||||
.fill("123 Test St");
|
||||
await sellerSection
|
||||
.getByRole("textbox", { name: "Email" })
|
||||
.fill("seller@test.com");
|
||||
|
||||
// Fill in an invoice item
|
||||
const invoiceItemsSection = page.getByTestId("invoice-items-section");
|
||||
await invoiceItemsSection
|
||||
.getByRole("spinbutton", { name: "Amount" })
|
||||
.fill("5");
|
||||
await invoiceItemsSection
|
||||
.getByRole("spinbutton", { name: "Net Price" })
|
||||
.fill("100");
|
||||
await invoiceItemsSection
|
||||
.getByRole("textbox", { name: "VAT", exact: true })
|
||||
.fill("23");
|
||||
|
||||
// wait for debounce timeout
|
||||
// eslint-disable-next-line playwright/no-wait-for-timeout
|
||||
await page.waitForTimeout(600);
|
||||
|
||||
// Generate share link
|
||||
await page
|
||||
.getByRole("button", { name: "Generate a link to invoice" })
|
||||
.click();
|
||||
|
||||
// Wait for URL to update with share data
|
||||
await page.waitForURL((url) => url.searchParams.has("data"));
|
||||
|
||||
// Get the current URL which should now contain the share data
|
||||
const sharedUrl = page.url();
|
||||
expect(sharedUrl).toContain("?data=");
|
||||
|
||||
// Open URL in new tab
|
||||
const newPage = await context.newPage();
|
||||
await newPage.goto(sharedUrl);
|
||||
|
||||
// Verify data is loaded in new tab
|
||||
await expect(
|
||||
newPage.getByRole("textbox", { name: "Invoice Number" })
|
||||
).toHaveValue("SHARE-TEST-001");
|
||||
await expect(
|
||||
newPage.getByRole("textbox", { name: "Notes", exact: true })
|
||||
).toHaveValue("Test note for sharing");
|
||||
|
||||
// Verify seller information
|
||||
const newSellerSection = newPage.getByTestId("seller-information-section");
|
||||
await expect(
|
||||
newSellerSection.getByRole("textbox", { name: "Name" })
|
||||
).toHaveValue("Test Seller");
|
||||
await expect(
|
||||
newSellerSection.getByRole("textbox", { name: "Address" })
|
||||
).toHaveValue("123 Test St");
|
||||
await expect(
|
||||
newSellerSection.getByRole("textbox", { name: "Email" })
|
||||
).toHaveValue("seller@test.com");
|
||||
|
||||
// Verify invoice item
|
||||
const newInvoiceItemsSection = newPage.getByTestId("invoice-items-section");
|
||||
await expect(
|
||||
newInvoiceItemsSection.getByRole("spinbutton", { name: "Amount" })
|
||||
).toHaveValue("5");
|
||||
await expect(
|
||||
newInvoiceItemsSection.getByRole("spinbutton", { name: "Net Price" })
|
||||
).toHaveValue("100");
|
||||
await expect(
|
||||
newInvoiceItemsSection.getByRole("textbox", { name: "VAT", exact: true })
|
||||
).toHaveValue("23");
|
||||
|
||||
// Close the new page
|
||||
await newPage.close();
|
||||
});
|
||||
});
|
||||
474
e2e/pdf.test.ts
Normal file
474
e2e/pdf.test.ts
Normal file
|
|
@ -0,0 +1,474 @@
|
|||
import { expect, test } from "@playwright/test";
|
||||
import dayjs from "dayjs";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import pdf from "pdf-parse";
|
||||
|
||||
const TEST_DOWNLOADS_DIR = "test-downloads";
|
||||
|
||||
/**
|
||||
* We can't test the PDF preview because it's not supported in Playwright.
|
||||
* https://github.com/microsoft/playwright/issues/7822
|
||||
*
|
||||
* we download pdf file and parse the content to verify the invoice data
|
||||
*/
|
||||
test.describe("PDF Preview", () => {
|
||||
test.beforeAll(async () => {
|
||||
// Ensure test-downloads directory exists
|
||||
try {
|
||||
await fs.promises.mkdir(TEST_DOWNLOADS_DIR);
|
||||
} catch (error) {
|
||||
// Handle specific error cases if needed
|
||||
if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
// Clean up all files in test-downloads directory
|
||||
try {
|
||||
const files = await fs.promises.readdir(TEST_DOWNLOADS_DIR);
|
||||
|
||||
await Promise.all(
|
||||
files.map((file) =>
|
||||
fs.promises.unlink(path.join(TEST_DOWNLOADS_DIR, file))
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
// Handle specific error cases if needed
|
||||
// ENOENT means "no such file or directory" - ignore this expected error
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
// Remove the test-downloads directory itself
|
||||
try {
|
||||
await fs.promises.rmdir(TEST_DOWNLOADS_DIR);
|
||||
} catch (error) {
|
||||
// Handle specific error cases if needed
|
||||
// ENOENT means "no such file or directory" - ignore this expected error
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/");
|
||||
});
|
||||
|
||||
test("downloads PDF in English and verifies content", async ({ page }) => {
|
||||
// Set up download handler
|
||||
const downloadPromise = page.waitForEvent("download");
|
||||
|
||||
const downloadButton = page.getByRole("link", {
|
||||
name: "Download PDF in English",
|
||||
});
|
||||
// Wait for download button to be visible and enabled
|
||||
await expect(downloadButton).toBeVisible();
|
||||
await expect(downloadButton).toBeEnabled();
|
||||
|
||||
// Click the download button
|
||||
await downloadButton.click();
|
||||
|
||||
// Wait for the download to start
|
||||
const download = await downloadPromise;
|
||||
|
||||
// Get the suggested filename
|
||||
const suggestedFilename = download.suggestedFilename();
|
||||
|
||||
// Save the file to a temporary location
|
||||
const tmpPath = path.join(TEST_DOWNLOADS_DIR, suggestedFilename);
|
||||
await download.saveAs(tmpPath);
|
||||
|
||||
// Read and verify PDF content using pdf-parse
|
||||
const dataBuffer = await fs.promises.readFile(tmpPath);
|
||||
const pdfData = await pdf(dataBuffer);
|
||||
|
||||
// Verify PDF content
|
||||
expect(pdfData.text).toContain("Invoice No. of:");
|
||||
expect(pdfData.text).toContain("Date of issue:");
|
||||
|
||||
expect(pdfData.text).toContain("Seller");
|
||||
expect(pdfData.text).toContain("Buyer");
|
||||
|
||||
const lastDayOfCurrentMonth = dayjs().endOf("month").format("YYYY-MM-DD");
|
||||
|
||||
expect(pdfData.text).toContain(
|
||||
`Date of sales/of executing the service: ${lastDayOfCurrentMonth}`
|
||||
);
|
||||
expect(pdfData.text).toContain(`To pay: 0.00 EUR
|
||||
Paid: 0.00 EUR
|
||||
Left to pay: 0.00 EUR
|
||||
Amount in words: zero EUR 00/100`);
|
||||
|
||||
expect(pdfData.text).toContain(`Reverse charge
|
||||
Created with https://easyinvoicepdf.com`);
|
||||
});
|
||||
|
||||
test("downloads PDF in Polish and verifies translated content", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Switch to Polish
|
||||
await page
|
||||
.getByRole("combobox", { name: "Invoice PDF Language" })
|
||||
.selectOption("pl");
|
||||
|
||||
// we wait until this button is visible and enabled, that means that the PDF preview has been regenerated
|
||||
const downloadButton = page.getByRole("link", {
|
||||
name: "Download PDF in Polish",
|
||||
});
|
||||
|
||||
await expect(downloadButton).toBeVisible();
|
||||
await expect(downloadButton).toBeEnabled();
|
||||
|
||||
// Set up download handler
|
||||
const downloadPromise = page.waitForEvent("download");
|
||||
|
||||
// Click the download button
|
||||
await downloadButton.click();
|
||||
|
||||
// Wait for the download to start
|
||||
const download = await downloadPromise;
|
||||
|
||||
// Get the suggested filename
|
||||
const suggestedFilename = download.suggestedFilename();
|
||||
|
||||
// Save the file to a temporary location
|
||||
const tmpPath = path.join(TEST_DOWNLOADS_DIR, suggestedFilename);
|
||||
await download.saveAs(tmpPath);
|
||||
|
||||
// Read and verify PDF content using pdf-parse
|
||||
const dataBuffer = await fs.promises.readFile(tmpPath);
|
||||
const pdfData = await pdf(dataBuffer);
|
||||
|
||||
// Verify PDF content
|
||||
expect(pdfData.text).toContain("Faktura nr");
|
||||
expect(pdfData.text).toContain("Data wystawienia");
|
||||
|
||||
expect(pdfData.text).toContain("Sprzedawca");
|
||||
expect(pdfData.text).toContain("Nabywca");
|
||||
|
||||
expect(pdfData.text).toContain("Data wystawienia:");
|
||||
|
||||
const lastDayOfCurrentMonth = dayjs().endOf("month").format("YYYY-MM-DD");
|
||||
expect(pdfData.text).toContain(
|
||||
`Data sprzedaży / wykonania usługi: ${lastDayOfCurrentMonth}`
|
||||
);
|
||||
|
||||
expect(pdfData.text).toContain(`Razem do zapłaty: 0.00 EUR
|
||||
Wpłacono: 0.00 EUR
|
||||
Pozostało do zapłaty: 0.00 EUR
|
||||
Kwota słownie: zero EUR 00/100`);
|
||||
|
||||
expect(pdfData.text).toContain("Created with https://easyinvoicepdf.com");
|
||||
});
|
||||
|
||||
test("update pdf when invoice data changes", async ({ page }) => {
|
||||
// Switch to another currency
|
||||
await page.getByRole("combobox", { name: "Currency" }).selectOption("GBP");
|
||||
|
||||
// Switch to another date format
|
||||
await page
|
||||
.getByRole("combobox", { name: "Date format" })
|
||||
.selectOption("MMMM D, YYYY");
|
||||
|
||||
await page
|
||||
.getByRole("textbox", { name: "Invoice Type" })
|
||||
.fill("HELLO FROM PLAYWRIGHT TEST!");
|
||||
|
||||
const sellerSection = page.getByTestId(`seller-information-section`);
|
||||
|
||||
// Name field
|
||||
await sellerSection
|
||||
.getByRole("textbox", { name: "Name" })
|
||||
.fill("PLAYWRIGHT SELLER TEST");
|
||||
|
||||
// Toggle VAT Number visibility off
|
||||
await sellerSection
|
||||
.getByRole("switch", { name: /Show in PDF/i })
|
||||
.nth(0)
|
||||
.click();
|
||||
|
||||
// Toggle Account Number visibility off
|
||||
await sellerSection
|
||||
.getByRole("switch", { name: /Show in PDF/i })
|
||||
.nth(1)
|
||||
.click();
|
||||
|
||||
// Toggle SWIFT visibility off
|
||||
await sellerSection
|
||||
.getByRole("switch", { name: /Show in PDF/i })
|
||||
.nth(2)
|
||||
.click();
|
||||
|
||||
const buyerSection = page.getByTestId(`buyer-information-section`);
|
||||
|
||||
// Name field
|
||||
await buyerSection
|
||||
.getByRole("textbox", { name: "Name" })
|
||||
.fill("PLAYWRIGHT BUYER TEST");
|
||||
|
||||
// // Address field
|
||||
await buyerSection
|
||||
.getByRole("textbox", { name: "Address" })
|
||||
.fill("PLAYWRIGHT BUYER ADDRESS TEST");
|
||||
|
||||
// // Email field
|
||||
await buyerSection
|
||||
.getByRole("textbox", { name: "Email" })
|
||||
.fill("TEST_BUYER_EMAIL@mail.com");
|
||||
|
||||
const invoiceSection = page.getByTestId(`invoice-items-section`);
|
||||
|
||||
// Amount field
|
||||
await invoiceSection.getByRole("spinbutton", { name: "Amount" }).fill("3");
|
||||
|
||||
// Net price field
|
||||
await invoiceSection
|
||||
.getByRole("spinbutton", { name: "Net price" })
|
||||
.fill("1000");
|
||||
|
||||
// Toggle VAT Table Summary visibility off
|
||||
await page
|
||||
.getByRole("switch", { name: `Show "VAT Table Summary" in the PDF` })
|
||||
.click();
|
||||
|
||||
// Wait for PDF preview to regenerate after language change (debounce timeout)
|
||||
// eslint-disable-next-line playwright/no-wait-for-timeout
|
||||
await page.waitForTimeout(600);
|
||||
|
||||
// Set up download handler
|
||||
const downloadPromise = page.waitForEvent("download");
|
||||
|
||||
const downloadButton = page.getByRole("link", {
|
||||
name: "Download PDF in English",
|
||||
});
|
||||
|
||||
await expect(downloadButton).toBeVisible();
|
||||
await expect(downloadButton).toBeEnabled();
|
||||
|
||||
// Click the download button
|
||||
await downloadButton.click();
|
||||
|
||||
// Wait for the download to start
|
||||
const download = await downloadPromise;
|
||||
|
||||
// Get the suggested filename
|
||||
const suggestedFilename = download.suggestedFilename();
|
||||
|
||||
// Save the file to a temporary location
|
||||
const tmpPath = path.join(TEST_DOWNLOADS_DIR, suggestedFilename);
|
||||
await download.saveAs(tmpPath);
|
||||
|
||||
// Read and verify PDF content using pdf-parse
|
||||
const dataBuffer = await fs.promises.readFile(tmpPath);
|
||||
const pdfData = await pdf(dataBuffer);
|
||||
|
||||
const lastDayOfCurrentMonth = dayjs().endOf("month").format("MMMM D, YYYY");
|
||||
|
||||
// Verify PDF content
|
||||
expect(pdfData.text).toContain(
|
||||
`Date of sales/of executing the service: ${lastDayOfCurrentMonth}`
|
||||
);
|
||||
|
||||
expect(pdfData.text).toContain("HELLO FROM PLAYWRIGHT TEST!");
|
||||
expect(pdfData.text).toContain("PLAYWRIGHT SELLER TEST");
|
||||
expect(pdfData.text).toContain(`PLAYWRIGHT BUYER TEST`);
|
||||
expect(pdfData.text).toContain(`PLAYWRIGHT BUYER ADDRESS TEST`);
|
||||
expect(pdfData.text).toContain(`TEST_BUYER_EMAIL@mail.com`);
|
||||
|
||||
// Check that the PDF does NOT contain the seller's VAT number, account number, or SWIFT/BIC number
|
||||
// because we toggled the visibility off
|
||||
expect(pdfData.text).not.toContain(`VAT no: Seller vat number
|
||||
Account Number - Seller account number
|
||||
SWIFT/BIC number: Seller swift bic`);
|
||||
|
||||
// Check that the PDF does NOT contain the VAT table summary
|
||||
// because we toggled the visibility off
|
||||
expect(pdfData.text).not.toContain(`VAT rateNetVATPre-tax
|
||||
NP3 000.000.003 000.00
|
||||
Total3 000.000.003 000.00`);
|
||||
|
||||
expect(pdfData.text).toContain(`To pay: 3 000.00 GBP
|
||||
Paid: 0.00 GBP
|
||||
Left to pay: 3 000.00 GBP
|
||||
Amount in words: three thousand GBP 00/100`);
|
||||
|
||||
expect(pdfData.text).toContain(`Reverse charge
|
||||
Created with https://easyinvoicepdf.com`);
|
||||
});
|
||||
|
||||
test("completes full invoice flow on mobile: tabs navigation, form editing and PDF download", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Set mobile viewport
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
|
||||
// Verify tabs are visible in mobile view
|
||||
await expect(page.getByRole("tab", { name: "Edit Invoice" })).toBeVisible();
|
||||
await expect(page.getByRole("tab", { name: "Preview PDF" })).toBeVisible();
|
||||
|
||||
// Download button in English is visible and enabled
|
||||
const downloadButtonEnglish = page.getByRole("link", {
|
||||
name: "Download PDF in English",
|
||||
});
|
||||
// Wait for download button to be visible
|
||||
await expect(downloadButtonEnglish).toBeVisible();
|
||||
// Wait for download button to be enabled
|
||||
await expect(downloadButtonEnglish).toBeEnabled();
|
||||
|
||||
// Switch to Polish
|
||||
await page
|
||||
.getByRole("combobox", { name: "Invoice PDF Language" })
|
||||
.selectOption("pl");
|
||||
|
||||
// Fill in some invoice data
|
||||
await page
|
||||
.getByRole("textbox", { name: "Invoice Number" })
|
||||
.fill("MOBILE-TEST-001");
|
||||
await page
|
||||
.getByRole("textbox", { name: "Notes", exact: true })
|
||||
.fill("Mobile test note");
|
||||
|
||||
// Fill in seller information
|
||||
const sellerSection = page.getByTestId("seller-information-section");
|
||||
await sellerSection
|
||||
.getByRole("textbox", { name: "Name" })
|
||||
.fill("Mobile Test Seller");
|
||||
await sellerSection
|
||||
.getByRole("textbox", { name: "Address" })
|
||||
.fill("456 Mobile St");
|
||||
|
||||
// Fill in an invoice item
|
||||
const invoiceItemsSection = page.getByTestId("invoice-items-section");
|
||||
await invoiceItemsSection
|
||||
.getByRole("spinbutton", { name: "Amount" })
|
||||
.fill("3");
|
||||
await invoiceItemsSection
|
||||
.getByRole("spinbutton", { name: "Net Price" })
|
||||
.fill("50");
|
||||
await invoiceItemsSection
|
||||
.getByRole("textbox", { name: "VAT", exact: true })
|
||||
.fill("23");
|
||||
|
||||
// we wait until this button is visible and enabled, that means that the PDF preview has been regenerated
|
||||
const downloadButtonPolish = page.getByRole("link", {
|
||||
name: "Download PDF in Polish",
|
||||
});
|
||||
// Wait for download button to be visible and enabled
|
||||
await expect(downloadButtonPolish).toBeVisible();
|
||||
await expect(downloadButtonPolish).toBeEnabled();
|
||||
|
||||
// Switch to preview tab
|
||||
await page.getByRole("tab", { name: "Preview PDF" }).click();
|
||||
|
||||
// Verify preview tab is selected
|
||||
await expect(
|
||||
page.getByRole("tabpanel", { name: "Preview PDF" })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("tabpanel", { name: "Edit Invoice" })
|
||||
).toBeHidden();
|
||||
|
||||
// Set up download handler
|
||||
const downloadPromise = page.waitForEvent("download");
|
||||
|
||||
// Click the download button
|
||||
await downloadButtonPolish.click();
|
||||
|
||||
// Wait for the download to start
|
||||
const download = await downloadPromise;
|
||||
|
||||
// Get the suggested filename
|
||||
const suggestedFilename = download.suggestedFilename();
|
||||
|
||||
// Save the file to a temporary location
|
||||
const tmpPath = path.join(TEST_DOWNLOADS_DIR, suggestedFilename);
|
||||
await download.saveAs(tmpPath);
|
||||
|
||||
// Read and verify PDF content using pdf-parse
|
||||
const dataBuffer = await fs.promises.readFile(tmpPath);
|
||||
const pdfData = await pdf(dataBuffer);
|
||||
|
||||
// Verify PDF content in Polish
|
||||
expect(pdfData.text).toContain("Faktura nr");
|
||||
expect(pdfData.text).toContain("Data wystawienia");
|
||||
expect(pdfData.text).toContain("Sprzedawca");
|
||||
expect(pdfData.text).toContain("Nabywca");
|
||||
expect(pdfData.text).toContain("Mobile Test Seller");
|
||||
expect(pdfData.text).toContain("456 Mobile St");
|
||||
|
||||
const lastDayOfCurrentMonth = dayjs().endOf("month").format("YYYY-MM-DD");
|
||||
expect(pdfData.text).toContain(
|
||||
`Data sprzedaży / wykonania usługi: ${lastDayOfCurrentMonth}`
|
||||
);
|
||||
|
||||
// Verify calculations in Polish
|
||||
expect(pdfData.text).toContain(`Razem do zapłaty: 184.50 EUR
|
||||
Wpłacono: 0.00 EUR
|
||||
Pozostało do zapłaty: 184.50 EUR`);
|
||||
|
||||
// Switch back to form tab
|
||||
await page.getByRole("tab", { name: "Edit Invoice" }).click();
|
||||
|
||||
// Verify form tab is selected and data persists
|
||||
await expect(
|
||||
page.getByRole("tabpanel", { name: "Edit Invoice" })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("tabpanel", { name: "Preview PDF" })
|
||||
).toBeHidden();
|
||||
|
||||
// Verify form data persists
|
||||
await expect(
|
||||
page.getByRole("textbox", { name: "Invoice Number" })
|
||||
).toHaveValue("MOBILE-TEST-001");
|
||||
await expect(
|
||||
page.getByRole("textbox", { name: "Notes", exact: true })
|
||||
).toHaveValue("Mobile test note");
|
||||
|
||||
// Verify seller information persists
|
||||
await expect(
|
||||
sellerSection.getByRole("textbox", { name: "Name" })
|
||||
).toHaveValue("Mobile Test Seller");
|
||||
await expect(
|
||||
sellerSection.getByRole("textbox", { name: "Address" })
|
||||
).toHaveValue("456 Mobile St");
|
||||
|
||||
// Verify invoice item persists
|
||||
await expect(
|
||||
invoiceItemsSection.getByRole("spinbutton", { name: "Amount" })
|
||||
).toHaveValue("3");
|
||||
await expect(
|
||||
invoiceItemsSection.getByRole("spinbutton", { name: "Net Price" })
|
||||
).toHaveValue("50");
|
||||
await expect(
|
||||
invoiceItemsSection.getByRole("textbox", { name: "VAT", exact: true })
|
||||
).toHaveValue("23");
|
||||
|
||||
// Verify calculations are correct
|
||||
await expect(
|
||||
invoiceItemsSection.getByRole("textbox", {
|
||||
name: "Net Amount",
|
||||
exact: true,
|
||||
})
|
||||
).toHaveValue("150.00");
|
||||
await expect(
|
||||
invoiceItemsSection.getByRole("textbox", {
|
||||
name: "VAT Amount",
|
||||
exact: true,
|
||||
})
|
||||
).toHaveValue("34.50");
|
||||
await expect(
|
||||
invoiceItemsSection.getByRole("textbox", {
|
||||
name: "Pre-tax Amount",
|
||||
exact: true,
|
||||
})
|
||||
).toHaveValue("184.50");
|
||||
});
|
||||
});
|
||||
365
e2e/seller.test.ts
Normal file
365
e2e/seller.test.ts
Normal file
|
|
@ -0,0 +1,365 @@
|
|||
import { DEFAULT_SELLER_DATA, type SellerData } from "@/app/schema";
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.describe("Seller management", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/");
|
||||
});
|
||||
|
||||
test("create/edit seller", async ({ page }) => {
|
||||
// Open seller management dialog
|
||||
await page.getByRole("button", { name: "New Seller" }).click();
|
||||
|
||||
// Fill in all seller details
|
||||
const testData = {
|
||||
name: "New Test Company",
|
||||
address: "123 Test Street\nTest City, 12345\nTest Country",
|
||||
|
||||
vatNoFieldIsVisible: true,
|
||||
vatNo: "123456789",
|
||||
email: "test@company.com",
|
||||
|
||||
accountNumberFieldIsVisible: true,
|
||||
accountNumber: "1234-5678-9012-3456",
|
||||
|
||||
swiftBicFieldIsVisible: true,
|
||||
swiftBic: "TESTBICX",
|
||||
} as const satisfies SellerData;
|
||||
|
||||
const manageSellerDialog = page.getByTestId(`manage-seller-dialog`);
|
||||
|
||||
// Fill in form fields
|
||||
await manageSellerDialog
|
||||
.getByRole("textbox", { name: "Name" })
|
||||
.fill(testData.name);
|
||||
await manageSellerDialog
|
||||
.getByRole("textbox", { name: "Address" })
|
||||
.fill(testData.address);
|
||||
await manageSellerDialog
|
||||
.getByRole("textbox", { name: "VAT Number" })
|
||||
.fill(testData.vatNo);
|
||||
await manageSellerDialog
|
||||
.getByRole("textbox", { name: "Email" })
|
||||
.fill(testData.email);
|
||||
await manageSellerDialog
|
||||
.getByRole("textbox", { name: "Account Number" })
|
||||
.fill(testData.accountNumber);
|
||||
await manageSellerDialog
|
||||
.getByRole("textbox", { name: "SWIFT/BIC" })
|
||||
.fill(testData.swiftBic);
|
||||
|
||||
// Verify all switches are checked by default
|
||||
await expect(
|
||||
manageSellerDialog.getByRole("switch", { name: "Show in PDF" }).nth(0)
|
||||
).toBeChecked();
|
||||
await expect(
|
||||
manageSellerDialog.getByRole("switch", { name: "Show in PDF" }).nth(1)
|
||||
).toBeChecked();
|
||||
await expect(
|
||||
manageSellerDialog.getByRole("switch", { name: "Show in PDF" }).nth(2)
|
||||
).toBeChecked();
|
||||
|
||||
// Toggle some visibility switches
|
||||
await manageSellerDialog
|
||||
.getByRole("switch", { name: "Show in PDF" })
|
||||
.nth(1)
|
||||
.click(); // Toggle Account Number visibility
|
||||
await manageSellerDialog
|
||||
.getByRole("switch", { name: "Show in PDF" })
|
||||
.nth(2)
|
||||
.click(); // Toggle SWIFT/BIC visibility
|
||||
|
||||
// Verify "Apply to Current Invoice" switch is checked by default
|
||||
await expect(
|
||||
manageSellerDialog.getByRole("switch", {
|
||||
name: "Apply to Current Invoice",
|
||||
})
|
||||
).toBeChecked();
|
||||
|
||||
// Cancel button is shown
|
||||
await expect(
|
||||
manageSellerDialog.getByRole("button", { name: "Cancel" })
|
||||
).toBeVisible();
|
||||
|
||||
// Save seller
|
||||
await manageSellerDialog
|
||||
.getByRole("button", { name: "Save Seller" })
|
||||
.click();
|
||||
|
||||
// Verify seller data is actually saved in localStorage
|
||||
const storedData = (await page.evaluate(() => {
|
||||
return localStorage.getItem("EASY_INVOICE_PDF_SELLERS");
|
||||
})) as string;
|
||||
expect(storedData).toBeTruthy();
|
||||
|
||||
const parsedData = JSON.parse(storedData) as SellerData[];
|
||||
|
||||
expect(parsedData[0]).toMatchObject({
|
||||
name: testData.name,
|
||||
address: testData.address,
|
||||
|
||||
vatNo: testData.vatNo,
|
||||
vatNoFieldIsVisible: testData.vatNoFieldIsVisible,
|
||||
|
||||
email: testData.email,
|
||||
|
||||
accountNumber: testData.accountNumber,
|
||||
accountNumberFieldIsVisible: false,
|
||||
|
||||
swiftBic: testData.swiftBic,
|
||||
swiftBicFieldIsVisible: false,
|
||||
} satisfies SellerData);
|
||||
|
||||
// Verify success toast message is visible
|
||||
await expect(
|
||||
page.getByText("Seller added successfully", { exact: true })
|
||||
).toBeVisible();
|
||||
|
||||
// Verify all saved details in the Seller Information section form
|
||||
const sellerForm = page.getByTestId(`seller-information-section`);
|
||||
|
||||
// Try to find desktop tooltip icon first
|
||||
const desktopTooltipExists =
|
||||
(await sellerForm
|
||||
.getByTestId("form-section-tooltip-info-icon-desktop")
|
||||
.count()) > 0;
|
||||
|
||||
// If desktop tooltip exists, hover over it; otherwise find and click mobile tooltip
|
||||
// eslint-disable-next-line playwright/no-conditional-in-test
|
||||
if (desktopTooltipExists) {
|
||||
// Get desktop tooltip icons and hover over the first one because we use tooltip
|
||||
const desktopTooltips = sellerForm.getByTestId(
|
||||
"form-section-tooltip-info-icon-desktop"
|
||||
);
|
||||
await desktopTooltips.first().hover();
|
||||
} else {
|
||||
// Get mobile tooltip icons and click the first one because we use popover
|
||||
const mobileTooltips = sellerForm.getByTestId(
|
||||
"form-section-tooltip-info-icon-mobile"
|
||||
);
|
||||
await mobileTooltips.first().click();
|
||||
}
|
||||
|
||||
// Verify the tooltip content appears
|
||||
await expect(
|
||||
page.getByText(
|
||||
"Seller details are locked. Click the edit button next to the 'Select Seller' dropdown to modify seller details. Any changes will be automatically saved.",
|
||||
{ exact: true }
|
||||
)
|
||||
).toBeVisible();
|
||||
|
||||
// Check that HTML title attributes contain the tooltip message on input fields
|
||||
const nameInput = sellerForm.getByRole("textbox", { name: "Name" });
|
||||
await expect(nameInput).toHaveAttribute(
|
||||
"title",
|
||||
"Seller details are locked. Click the edit seller button to modify."
|
||||
);
|
||||
|
||||
// Seller Name
|
||||
await expect(nameInput).toHaveAttribute("aria-readonly", "true");
|
||||
await expect(nameInput).toHaveValue(testData.name);
|
||||
|
||||
// Seller Address
|
||||
await expect(
|
||||
sellerForm.getByRole("textbox", { name: "Address" })
|
||||
).toHaveAttribute("aria-readonly", "true");
|
||||
await expect(
|
||||
sellerForm.getByRole("textbox", { name: "Address" })
|
||||
).toHaveValue(testData.address);
|
||||
|
||||
// Seller VAT Number
|
||||
await expect(
|
||||
sellerForm.getByRole("textbox", { name: "VAT Number" })
|
||||
).toHaveAttribute("aria-readonly", "true");
|
||||
await expect(
|
||||
sellerForm.getByRole("textbox", { name: "VAT Number" })
|
||||
).toHaveValue(testData.vatNo);
|
||||
|
||||
const vatNumberSwitch = sellerForm.getByTestId(`sellerVatNoFieldIsVisible`);
|
||||
// Verify VAT Number switch is visible
|
||||
await expect(vatNumberSwitch).toBeChecked();
|
||||
await expect(vatNumberSwitch).toBeDisabled();
|
||||
|
||||
// Seller Email
|
||||
await expect(
|
||||
sellerForm.getByRole("textbox", { name: "Email" })
|
||||
).toHaveAttribute("aria-readonly", "true");
|
||||
await expect(
|
||||
sellerForm.getByRole("textbox", { name: "Email" })
|
||||
).toHaveValue(testData.email);
|
||||
|
||||
// Seller Account Number
|
||||
await expect(
|
||||
sellerForm.getByRole("textbox", { name: "Account Number" })
|
||||
).toHaveAttribute("aria-readonly", "true");
|
||||
await expect(
|
||||
sellerForm.getByRole("textbox", { name: "Account Number" })
|
||||
).toHaveValue(testData.accountNumber);
|
||||
|
||||
const accountNumberSwitch = sellerForm.getByTestId(
|
||||
`sellerAccountNumberFieldIsVisible`
|
||||
);
|
||||
// Verify Account Number switch is visible
|
||||
await expect(accountNumberSwitch).not.toBeChecked();
|
||||
await expect(accountNumberSwitch).toBeDisabled();
|
||||
|
||||
// Seller SWIFT/BIC
|
||||
await expect(
|
||||
sellerForm.getByRole("textbox", { name: "SWIFT/BIC" })
|
||||
).toHaveAttribute("aria-readonly", "true");
|
||||
await expect(
|
||||
sellerForm.getByRole("textbox", { name: "SWIFT/BIC" })
|
||||
).toHaveValue(testData.swiftBic);
|
||||
|
||||
const swiftBicSwitch = sellerForm.getByTestId(
|
||||
`sellerSwiftBicFieldIsVisible`
|
||||
);
|
||||
// Verify SWIFT/BIC switch is visible
|
||||
await expect(swiftBicSwitch).not.toBeChecked();
|
||||
await expect(swiftBicSwitch).toBeDisabled();
|
||||
|
||||
// Verify the seller appears in the dropdown
|
||||
await expect(
|
||||
sellerForm.getByRole("combobox", { name: "Select Seller" })
|
||||
).toContainText(testData.name);
|
||||
|
||||
// Test edit functionality
|
||||
await sellerForm.getByRole("button", { name: "Edit seller" }).click();
|
||||
|
||||
// Verify all fields are populated in edit dialog
|
||||
await expect(
|
||||
manageSellerDialog.getByRole("textbox", { name: "Name" })
|
||||
).toHaveValue(testData.name);
|
||||
await expect(
|
||||
manageSellerDialog.getByRole("textbox", { name: "Address" })
|
||||
).toHaveValue(testData.address);
|
||||
await expect(
|
||||
manageSellerDialog.getByRole("textbox", { name: "VAT Number" })
|
||||
).toHaveValue(testData.vatNo);
|
||||
await expect(
|
||||
manageSellerDialog.getByRole("textbox", { name: "Email" })
|
||||
).toHaveValue(testData.email);
|
||||
await expect(
|
||||
manageSellerDialog.getByRole("textbox", { name: "Account Number" })
|
||||
).toHaveValue(testData.accountNumber);
|
||||
await expect(
|
||||
manageSellerDialog.getByRole("textbox", { name: "SWIFT/BIC" })
|
||||
).toHaveValue(testData.swiftBic);
|
||||
|
||||
// Verify visibility switches state persisted in edit dialog
|
||||
await expect(
|
||||
manageSellerDialog.getByRole("switch", { name: "Show in PDF" }).nth(0)
|
||||
).toBeChecked();
|
||||
await expect(
|
||||
manageSellerDialog.getByRole("switch", { name: "Show in PDF" }).nth(1)
|
||||
).not.toBeChecked();
|
||||
await expect(
|
||||
manageSellerDialog.getByRole("switch", { name: "Show in PDF" }).nth(2)
|
||||
).not.toBeChecked();
|
||||
});
|
||||
|
||||
test("delete seller", async ({ page }) => {
|
||||
// First add a seller
|
||||
await page.getByRole("button", { name: "New Seller" }).click();
|
||||
|
||||
const testData = {
|
||||
name: "Test Delete Seller",
|
||||
address: "123 Delete Street",
|
||||
email: "delete@test.com",
|
||||
|
||||
vatNoFieldIsVisible: true,
|
||||
vatNo: "123456789",
|
||||
|
||||
accountNumberFieldIsVisible: true,
|
||||
accountNumber: "123456789",
|
||||
|
||||
swiftBicFieldIsVisible: true,
|
||||
swiftBic: "123456789",
|
||||
} as const satisfies SellerData;
|
||||
|
||||
const manageSellerDialog = page.getByTestId(`manage-seller-dialog`);
|
||||
|
||||
// Fill in basic seller details
|
||||
await manageSellerDialog
|
||||
.getByRole("textbox", { name: "Name" })
|
||||
.fill(testData.name);
|
||||
await manageSellerDialog
|
||||
.getByRole("textbox", { name: "Address" })
|
||||
.fill(testData.address);
|
||||
await manageSellerDialog
|
||||
.getByRole("textbox", { name: "Email" })
|
||||
.fill(testData.email);
|
||||
|
||||
// Save seller
|
||||
await manageSellerDialog
|
||||
.getByRole("button", { name: "Save Seller" })
|
||||
.click();
|
||||
|
||||
// Verify seller was added
|
||||
const sellerForm = page.getByTestId(`seller-information-section`);
|
||||
await expect(
|
||||
sellerForm.getByRole("combobox", { name: "Select Seller" })
|
||||
).toContainText(testData.name);
|
||||
|
||||
// Click delete button
|
||||
await sellerForm.getByRole("button", { name: "Delete seller" }).click();
|
||||
|
||||
// Verify delete confirmation dialog appears
|
||||
await expect(page.getByRole("alertdialog")).toBeVisible();
|
||||
|
||||
// Cancel button is shown
|
||||
await expect(page.getByRole("button", { name: "Cancel" })).toBeVisible();
|
||||
|
||||
// Click cancel button
|
||||
await page.getByRole("button", { name: "Cancel" }).click();
|
||||
|
||||
// Verify dialog is closed
|
||||
await expect(page.getByRole("alertdialog")).toBeHidden();
|
||||
|
||||
// Click delete button once again to open the dialog
|
||||
await sellerForm.getByRole("button", { name: "Delete seller" }).click();
|
||||
|
||||
// Verify delete confirmation dialog appears
|
||||
await expect(page.getByRole("alertdialog")).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByText(
|
||||
`Are you sure you want to delete "${testData.name}" seller?`
|
||||
)
|
||||
).toBeVisible();
|
||||
|
||||
// Confirm deletion
|
||||
await page.getByRole("button", { name: "Delete" }).click();
|
||||
|
||||
// Verify success message
|
||||
await expect(
|
||||
page.getByText("Seller deleted successfully", { exact: true })
|
||||
).toBeVisible();
|
||||
|
||||
// Verify seller is removed from dropdown
|
||||
// because we have only one seller, dropdown will be completely hidden
|
||||
await expect(
|
||||
sellerForm.getByRole("combobox", { name: "Select Seller" })
|
||||
).toBeHidden();
|
||||
|
||||
// Verify form is reset to default values
|
||||
await expect(sellerForm.getByRole("textbox", { name: "Name" })).toHaveValue(
|
||||
DEFAULT_SELLER_DATA.name
|
||||
);
|
||||
await expect(
|
||||
sellerForm.getByRole("textbox", { name: "Address" })
|
||||
).toHaveValue(DEFAULT_SELLER_DATA.address);
|
||||
await expect(
|
||||
sellerForm.getByRole("textbox", { name: "Email" })
|
||||
).toHaveValue(DEFAULT_SELLER_DATA.email);
|
||||
await expect(
|
||||
sellerForm.getByRole("textbox", { name: "VAT Number" })
|
||||
).toHaveValue(DEFAULT_SELLER_DATA.vatNo);
|
||||
await expect(
|
||||
sellerForm.getByRole("textbox", { name: "Account Number" })
|
||||
).toHaveValue(DEFAULT_SELLER_DATA.accountNumber);
|
||||
await expect(
|
||||
sellerForm.getByRole("textbox", { name: "SWIFT/BIC" })
|
||||
).toHaveValue(DEFAULT_SELLER_DATA.swiftBic);
|
||||
});
|
||||
});
|
||||
70
eslint.config.mjs
Normal file
70
eslint.config.mjs
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
// @ts-check
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
import tseslint from "typescript-eslint";
|
||||
import playwright from "eslint-plugin-playwright";
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: import.meta.dirname,
|
||||
});
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: [".next", "playwright-output"],
|
||||
},
|
||||
// next config
|
||||
...compat.extends("next/core-web-vitals"),
|
||||
{
|
||||
files: ["**/*.ts", "**/*.tsx"],
|
||||
extends: [
|
||||
...tseslint.configs.recommended,
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
...tseslint.configs.stylisticTypeChecked,
|
||||
],
|
||||
rules: {
|
||||
"@typescript-eslint/array-type": "off",
|
||||
"@typescript-eslint/consistent-type-definitions": "off",
|
||||
"@typescript-eslint/consistent-type-imports": [
|
||||
"warn",
|
||||
{ prefer: "type-imports", fixStyle: "inline-type-imports" },
|
||||
],
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{ argsIgnorePattern: "^_" },
|
||||
],
|
||||
"@typescript-eslint/require-await": "off",
|
||||
"@typescript-eslint/no-misused-promises": [
|
||||
"error",
|
||||
{ checksVoidReturn: { attributes: false } },
|
||||
],
|
||||
"@typescript-eslint/prefer-nullish-coalescing": "off",
|
||||
"@typescript-eslint/unbound-method": "off",
|
||||
"@typescript-eslint/consistent-indexed-object-style": "off",
|
||||
"@typescript-eslint/non-nullable-type-assertion-style": "off",
|
||||
},
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
"no-console": ["warn", { allow: ["error"] }],
|
||||
},
|
||||
},
|
||||
// Playwright config for e2e tests only
|
||||
{
|
||||
...playwright.configs["flat/recommended"],
|
||||
files: ["e2e/**"],
|
||||
rules: {
|
||||
...playwright.configs["flat/recommended"].rules,
|
||||
// Customize Playwright rules
|
||||
// ...
|
||||
},
|
||||
},
|
||||
{
|
||||
linterOptions: {
|
||||
reportUnusedDisableDirectives: true,
|
||||
},
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
28
knip.ts
Normal file
28
knip.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import type { KnipConfig } from "knip";
|
||||
|
||||
// https://knip.dev/reference/configuration#_top
|
||||
const config: KnipConfig = {
|
||||
ignoreDependencies: [
|
||||
"@radix-ui/react-separator",
|
||||
"@types/ua-parser-js",
|
||||
"cmdk",
|
||||
"eslint-plugin-react-hooks",
|
||||
"file-saver",
|
||||
"jszip",
|
||||
"@next/eslint-plugin-next",
|
||||
"@types/file-saver",
|
||||
"eslint-config-next",
|
||||
"@ianvs/prettier-plugin-sort-imports",
|
||||
],
|
||||
ignore: [
|
||||
"lint-staged.config.js",
|
||||
"src/app/components/invoice-pdf-download-multiple-languages.tsx",
|
||||
"src/components/ui/**/*.tsx",
|
||||
],
|
||||
includeEntryExports: true,
|
||||
// ignore tags
|
||||
// https://knip.dev/reference/configuration#tags
|
||||
tags: ["-@lintignore"],
|
||||
};
|
||||
|
||||
export default config;
|
||||
10
lint-staged.config.js
Normal file
10
lint-staged.config.js
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
module.exports = {
|
||||
// Run type-check on all changes to files
|
||||
// https://github.com/okonet/lint-staged
|
||||
"*": () => [
|
||||
`pnpm run type-check`,
|
||||
`pnpm run lint`,
|
||||
`pnpm run knip`,
|
||||
`pnpm run prettify --write`,
|
||||
],
|
||||
};
|
||||
|
|
@ -8,6 +8,14 @@ const nextConfig = {
|
|||
eslint: {
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
compiler: {
|
||||
removeConsole: process.env.VERCEL_ENV === "production",
|
||||
},
|
||||
logging: {
|
||||
fetches: {
|
||||
fullUrl: true,
|
||||
},
|
||||
},
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
|
|
|
|||
32
package.json
32
package.json
|
|
@ -2,7 +2,7 @@
|
|||
"name": "pdf-invoice-generator",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.5.0",
|
||||
"packageManager": "pnpm@10.6.5",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
|
|
@ -10,11 +10,17 @@
|
|||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"type-check": "tsc --noEmit",
|
||||
"lint": "pnpm eslint . --cache",
|
||||
"lint:fix": "pnpm eslint . --fix",
|
||||
"prettify": "prettier --write --cache '**/*.{ts?(x),json,js,mjs,yml,yaml,md}'",
|
||||
"knip": "knip",
|
||||
"update-deps": "pnpm upgrade --interactive --latest",
|
||||
"dedupe": "pnpm dedupe"
|
||||
"e2e": "pnpm exec playwright test",
|
||||
"e2e:ui": "pnpm exec playwright test --ui",
|
||||
"dedupe": "pnpm dedupe",
|
||||
"lint-stage": "npx lint-staged --verbose",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "3.9.0",
|
||||
|
|
@ -31,11 +37,13 @@
|
|||
"@radix-ui/react-tooltip": "1.1.8",
|
||||
"@react-pdf/renderer": "4.3.0",
|
||||
"@sentry/nextjs": "9.3.0",
|
||||
"@types/ua-parser-js": "0.7.39",
|
||||
"@vercel/speed-insights": "1.2.0",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
"cmdk": "1.0.0",
|
||||
"dayjs": "1.11.13",
|
||||
"eslint-plugin-react-hooks": "5.2.0",
|
||||
"file-saver": "2.0.5",
|
||||
"jszip": "3.10.1",
|
||||
"lucide-react": "0.477.0",
|
||||
|
|
@ -43,26 +51,38 @@
|
|||
"n2words": "1.21.0",
|
||||
"next": "14.2.15",
|
||||
"nuqs": "2.1.0",
|
||||
"pdf-parse": "1.1.1",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-hook-form": "7.53.1",
|
||||
"sonner": "1.7.4",
|
||||
"tailwind-merge": "2.6.0",
|
||||
"tailwindcss-animate": "1.0.7",
|
||||
"ua-parser-js": "2.0.3",
|
||||
"use-debounce": "10.0.4",
|
||||
"zod": "3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "3.3.1",
|
||||
"@next/eslint-plugin-next": "15.2.3",
|
||||
"@playwright/test": "1.51.1",
|
||||
"@types/file-saver": "2.0.7",
|
||||
"@types/node": "22.8.1",
|
||||
"@types/pdf-parse": "1.1.4",
|
||||
"@types/react": "18.3.12",
|
||||
"@types/react-dom": "18.3.1",
|
||||
"eslint": "^8",
|
||||
"eslint-config-next": "14.2.15",
|
||||
"autoprefixer": "10.4.21",
|
||||
"eslint": "9.23.0",
|
||||
"eslint-config-next": "15.2.3",
|
||||
"eslint-plugin-playwright": "2.2.0",
|
||||
"husky": "9.1.7",
|
||||
"knip": "5.46.0",
|
||||
"lint-staged": "15.5.0",
|
||||
"postcss": "^8",
|
||||
"prettier": "3.5.3",
|
||||
"prettier-plugin-tailwindcss": "0.6.11",
|
||||
"tailwindcss": "3.4.14",
|
||||
"typescript": "5.8.2"
|
||||
"typescript": "5.8.2",
|
||||
"typescript-eslint": "8.28.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
94
playwright.config.ts
Normal file
94
playwright.config.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
*/
|
||||
// import dotenv from 'dotenv';
|
||||
// import path from 'path';
|
||||
// dotenv.config({ path: path.resolve(__dirname, '.env') });
|
||||
|
||||
// Use process.env.PORT by default and fallback to port 3000
|
||||
const PORT = process.env.PORT ?? 3000;
|
||||
|
||||
// Set webServer.url and use.baseURL with the location of the WebServer respecting the correct set port
|
||||
const BASE_URL = process.env.BASE_URL ?? `http://localhost:${PORT}`;
|
||||
|
||||
const TIMEOUT = 70 * 1000;
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: true,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: !!process.env.CI,
|
||||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
/* timeout for expect assertions */
|
||||
expect: {
|
||||
timeout: TIMEOUT,
|
||||
},
|
||||
|
||||
// /* timeout for test execution */
|
||||
timeout: TIMEOUT,
|
||||
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: [["html", { outputFolder: "playwright-output/report" }]],
|
||||
|
||||
/* Output directory for test artifacts */
|
||||
outputDir: "playwright-output/test-results",
|
||||
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
// Use baseURL so to make navigations relative.
|
||||
// More information: https://playwright.dev/docs/api/class-testoptions#test-options-base-url
|
||||
baseURL: BASE_URL,
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: "on-first-retry",
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
// {
|
||||
// name: "webkit",
|
||||
// use: { ...devices["Desktop Safari"] },
|
||||
// },
|
||||
|
||||
// /* Test against mobile viewports. */
|
||||
{
|
||||
name: "Mobile Chrome",
|
||||
use: { ...devices["Pixel 5"] },
|
||||
},
|
||||
{
|
||||
name: "Mobile Safari",
|
||||
use: { ...devices["iPhone 12"] },
|
||||
},
|
||||
|
||||
/* Test against branded browsers. */
|
||||
// {
|
||||
// name: 'Microsoft Edge',
|
||||
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
|
||||
// },
|
||||
// {
|
||||
// name: 'Google Chrome',
|
||||
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
|
||||
// },
|
||||
],
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
// webServer: {
|
||||
// command: 'npm run start',
|
||||
// url: 'http://127.0.0.1:3000',
|
||||
// reuseExistingServer: !process.env.CI,
|
||||
// },
|
||||
});
|
||||
9735
pnpm-lock.yaml
9735
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
|
|
@ -1,7 +1,7 @@
|
|||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ Sentry.init({
|
|||
|
||||
// Recommended production settings
|
||||
debug: false,
|
||||
enabled: process.env.NODE_ENV === "production",
|
||||
enabled: process.env.VERCEL_ENV === "production",
|
||||
|
||||
// Performance settings
|
||||
replaysSessionSampleRate: 0.1, // Sample 10% of sessions
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ Sentry.init({
|
|||
|
||||
// Recommended production settings
|
||||
debug: false,
|
||||
enabled: process.env.NODE_ENV === "production",
|
||||
enabled: process.env.VERCEL_ENV === "production",
|
||||
|
||||
// Performance settings
|
||||
replaysSessionSampleRate: 0.1, // Sample 10% of sessions
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ Sentry.init({
|
|||
|
||||
// Recommended production settings
|
||||
debug: false,
|
||||
enabled: process.env.NODE_ENV === "production",
|
||||
enabled: process.env.VERCEL_ENV === "production",
|
||||
|
||||
// Performance settings
|
||||
replaysSessionSampleRate: 0.1, // Sample 10% of sessions
|
||||
|
|
|
|||
|
|
@ -6,17 +6,9 @@ import dynamic from "next/dynamic";
|
|||
import { InvoiceForm } from "./invoice-form";
|
||||
import { InvoicePDFDownloadLink } from "./invoice-pdf-download-link";
|
||||
import { InvoicePdfTemplate } from "./invoice-pdf-template";
|
||||
import { RegenerateInvoiceButton } from "./regenerate-invoice-button";
|
||||
import type { InvoiceData } from "../schema";
|
||||
import { useState } from "react";
|
||||
|
||||
export const FORM_PREFIX_IDS = {
|
||||
MOBILE: "mobile-invoice-form",
|
||||
DESKTOP: "desktop-invoice-form",
|
||||
} as const satisfies Record<string, string>;
|
||||
|
||||
export type FormPrefixId =
|
||||
(typeof FORM_PREFIX_IDS)[keyof typeof FORM_PREFIX_IDS];
|
||||
import { CustomTooltip } from "@/components/ui/tooltip";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const InvoicePDFViewer = dynamic(
|
||||
() => import("./invoice-pdf-viewer").then((mod) => mod.InvoicePDFViewer),
|
||||
|
|
@ -35,7 +27,6 @@ const InvoicePDFViewer = dynamic(
|
|||
);
|
||||
|
||||
const TABS_VALUES = ["invoice-form", "invoice-preview"] as const;
|
||||
type TabValue = (typeof TABS_VALUES)[number];
|
||||
|
||||
const TAB_INVOICE_FORM = TABS_VALUES[0];
|
||||
const TAB_INVOICE_PREVIEW = TABS_VALUES[1];
|
||||
|
|
@ -43,90 +34,83 @@ const TAB_INVOICE_PREVIEW = TABS_VALUES[1];
|
|||
export function InvoiceClientPage({
|
||||
invoiceDataState,
|
||||
handleInvoiceDataChange,
|
||||
handleShareInvoice,
|
||||
isMobile,
|
||||
}: {
|
||||
invoiceDataState: InvoiceData;
|
||||
handleInvoiceDataChange: (invoiceData: InvoiceData) => void;
|
||||
handleShareInvoice: () => void;
|
||||
isMobile: boolean;
|
||||
}) {
|
||||
const [activeTab, setActiveTab] = useState<TabValue>(TAB_INVOICE_FORM);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile View with Tabs START */}
|
||||
<div className="block w-full lg:hidden">
|
||||
<Tabs
|
||||
defaultValue={TAB_INVOICE_FORM}
|
||||
className="w-full"
|
||||
onValueChange={(value) => setActiveTab(value as TabValue)}
|
||||
>
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value={TAB_INVOICE_FORM} className="flex-1">
|
||||
<span className="flex items-center gap-1">
|
||||
<PencilIcon className="h-4 w-4" />
|
||||
Edit Invoice
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value={TAB_INVOICE_PREVIEW} className="flex-1">
|
||||
<span className="flex items-center gap-1">
|
||||
<FileTextIcon className="h-4 w-4" />
|
||||
Preview PDF
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value={TAB_INVOICE_FORM} className="mt-4">
|
||||
<div className="h-[400px] overflow-auto rounded-lg border-b px-3 shadow-sm">
|
||||
<InvoiceForm
|
||||
invoiceData={invoiceDataState}
|
||||
onInvoiceDataChange={handleInvoiceDataChange}
|
||||
formPrefixId={FORM_PREFIX_IDS.MOBILE}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value={TAB_INVOICE_PREVIEW} className="mt-4">
|
||||
<div className="h-[580px] w-full">
|
||||
<InvoicePDFViewer>
|
||||
<InvoicePdfTemplate invoiceData={invoiceDataState} />
|
||||
</InvoicePDFViewer>
|
||||
</div>
|
||||
</TabsContent>
|
||||
{/* Action buttons visible based on active tab */}
|
||||
<div className="sticky bottom-0 mt-4 flex flex-col gap-3 rounded-lg border border-t border-gray-200 bg-white px-3 pt-3 shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.1),0_-2px_4px_-2px_rgba(0,0,0,0.05)]">
|
||||
<RegenerateInvoiceButton
|
||||
invoiceData={invoiceDataState}
|
||||
formPrefixId={
|
||||
// we need to pass the correct form prefix id based on the active tab, because on invoice preview tab, the mobile form is not rendered
|
||||
activeTab === TAB_INVOICE_FORM
|
||||
? FORM_PREFIX_IDS.MOBILE
|
||||
: FORM_PREFIX_IDS.DESKTOP
|
||||
{isMobile ? (
|
||||
<div>
|
||||
<Tabs defaultValue={TAB_INVOICE_FORM} className="w-full">
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value={TAB_INVOICE_FORM} className="flex-1">
|
||||
<span className="flex items-center gap-1">
|
||||
<PencilIcon className="h-4 w-4" />
|
||||
Edit Invoice
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value={TAB_INVOICE_PREVIEW} className="flex-1">
|
||||
<span className="flex items-center gap-1">
|
||||
<FileTextIcon className="h-4 w-4" />
|
||||
Preview PDF
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value={TAB_INVOICE_FORM} className="mt-1">
|
||||
<div className="h-[460px] overflow-auto rounded-lg border-b px-3 shadow-sm">
|
||||
<InvoiceForm
|
||||
invoiceData={invoiceDataState}
|
||||
onInvoiceDataChange={handleInvoiceDataChange}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value={TAB_INVOICE_PREVIEW} className="mt-1">
|
||||
<div className="h-[445px] w-full">
|
||||
<InvoicePDFViewer>
|
||||
<InvoicePdfTemplate invoiceData={invoiceDataState} />
|
||||
</InvoicePDFViewer>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<div className="sticky bottom-0 mt-2 flex flex-col items-center justify-center gap-3 rounded-lg border border-t border-gray-200 bg-white px-3 py-3 shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.1),0_-2px_4px_-2px_rgba(0,0,0,0.05)]">
|
||||
<CustomTooltip
|
||||
trigger={
|
||||
<Button
|
||||
onClick={handleShareInvoice}
|
||||
_variant="outline"
|
||||
className="mx-2 w-full"
|
||||
>
|
||||
Generate a link to invoice
|
||||
</Button>
|
||||
}
|
||||
content="Generate a shareable link to this invoice. Share it with your clients to allow them to view the invoice online."
|
||||
/>
|
||||
<InvoicePDFDownloadLink invoiceData={invoiceDataState} />
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
{/* Mobile View with Tabs END */}
|
||||
|
||||
{/* Desktop View - Side by Side START */}
|
||||
<div className="hidden lg:col-span-4 lg:block">
|
||||
<div className="h-[580px] overflow-auto px-3 pl-0">
|
||||
<InvoiceForm
|
||||
invoiceData={invoiceDataState}
|
||||
onInvoiceDataChange={handleInvoiceDataChange}
|
||||
formPrefixId={FORM_PREFIX_IDS.DESKTOP}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 border-t border-gray-200 bg-white">
|
||||
<RegenerateInvoiceButton
|
||||
invoiceData={invoiceDataState}
|
||||
formPrefixId={FORM_PREFIX_IDS.DESKTOP}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden h-[580px] w-full max-w-full lg:col-span-8 lg:block">
|
||||
<InvoicePDFViewer>
|
||||
<InvoicePdfTemplate invoiceData={invoiceDataState} />
|
||||
</InvoicePDFViewer>
|
||||
</div>
|
||||
{/* Desktop View - Side by Side END */}
|
||||
) : (
|
||||
// Desktop View
|
||||
<>
|
||||
<div className="col-span-4">
|
||||
<div className="h-[620px] overflow-auto border-b px-3 pl-0">
|
||||
<InvoiceForm
|
||||
invoiceData={invoiceDataState}
|
||||
onInvoiceDataChange={handleInvoiceDataChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-8 h-[620px] w-full max-w-full">
|
||||
<InvoicePDFViewer>
|
||||
<InvoicePdfTemplate invoiceData={invoiceDataState} />
|
||||
</InvoicePDFViewer>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
accordionSchema,
|
||||
invoiceItemSchema,
|
||||
invoiceSchema,
|
||||
type AccordionState,
|
||||
type InvoiceData,
|
||||
type InvoiceItemData,
|
||||
} from "@/app/schema";
|
||||
|
|
@ -33,17 +34,15 @@ import { Controller, useFieldArray, useForm, useWatch } from "react-hook-form";
|
|||
import { toast } from "sonner";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import { z } from "zod";
|
||||
import type { FormPrefixId } from "..";
|
||||
import { BuyerInformation } from "./sections/buyer-information";
|
||||
import { GeneralInformation } from "./sections/general-information";
|
||||
import { InvoiceItems } from "./sections/invoice-items";
|
||||
import { SellerInformation } from "./sections/seller-information";
|
||||
|
||||
export const PDF_DATA_LOCAL_STORAGE_KEY = "EASY_INVOICE_PDF_DATA";
|
||||
export const INVOICE_PDF_HTML_FORM_ID = "pdfInvoiceForm";
|
||||
export const DEBOUNCE_TIMEOUT = 500;
|
||||
export const LOADING_BUTTON_TIMEOUT = 400;
|
||||
export const LOADING_BUTTON_TEXT = "Generating Document...";
|
||||
const DEBOUNCE_TIMEOUT = 500;
|
||||
|
||||
const DEFAULT_ACCORDION_VALUES = [
|
||||
"general",
|
||||
|
|
@ -91,9 +90,14 @@ const ErrorMessage = ({ children }: { children: React.ReactNode }) => {
|
|||
return <p className="mt-1 text-xs text-red-600">{children}</p>;
|
||||
};
|
||||
|
||||
const Legend = ({ children }: { children: React.ReactNode }) => {
|
||||
const Legend = ({
|
||||
children,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLLegendElement>) => {
|
||||
return (
|
||||
<legend className="text-lg font-semibold text-gray-900">{children}</legend>
|
||||
<legend className="text-lg font-semibold text-gray-900" {...props}>
|
||||
{children}
|
||||
</legend>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -110,17 +114,11 @@ type Prettify<T> = {
|
|||
interface InvoiceFormProps {
|
||||
invoiceData: InvoiceData;
|
||||
onInvoiceDataChange: (updatedData: InvoiceData) => void;
|
||||
/**
|
||||
* we need this to generate unique ids for the form fields for mobile and desktop views
|
||||
* otherwise the ids will be the same and the form will not work correctly + accessibility issues
|
||||
*/
|
||||
formPrefixId: FormPrefixId;
|
||||
}
|
||||
|
||||
export const InvoiceForm = memo(function InvoiceForm({
|
||||
invoiceData,
|
||||
onInvoiceDataChange,
|
||||
formPrefixId,
|
||||
}: InvoiceFormProps) {
|
||||
const openPanel = useOpenPanel();
|
||||
|
||||
|
|
@ -211,9 +209,9 @@ export const InvoiceForm = memo(function InvoiceForm({
|
|||
|
||||
// regenerate pdf on every input change with debounce
|
||||
const debouncedRegeneratePdfOnFormChange = useDebouncedCallback(
|
||||
(data) => {
|
||||
(data: InvoiceData) => {
|
||||
// submit form e.g. regenerates pdf and run form validations
|
||||
handleSubmit(onSubmit)(data);
|
||||
void handleSubmit(onSubmit)(data as unknown as React.BaseSyntheticEvent);
|
||||
|
||||
// data should be already validated
|
||||
const stringifiedData = JSON.stringify(data);
|
||||
|
|
@ -233,7 +231,7 @@ export const InvoiceForm = memo(function InvoiceForm({
|
|||
// subscribe to form changes to regenerate pdf on every input change
|
||||
useEffect(() => {
|
||||
const subscription = watch((value) => {
|
||||
debouncedRegeneratePdfOnFormChange(value);
|
||||
debouncedRegeneratePdfOnFormChange(value as unknown as InvoiceData);
|
||||
});
|
||||
|
||||
return () => subscription.unsubscribe();
|
||||
|
|
@ -275,13 +273,12 @@ export const InvoiceForm = memo(function InvoiceForm({
|
|||
);
|
||||
|
||||
if (savedState) {
|
||||
const parsedState = JSON.parse(savedState);
|
||||
const parsedState = JSON.parse(savedState) as AccordionState;
|
||||
|
||||
const validatedState = accordionSchema.safeParse(parsedState);
|
||||
|
||||
if (validatedState.success) {
|
||||
const arrayOfOpenSections = Object.entries(validatedState.data)
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
.filter(([_, isOpen]) => isOpen)
|
||||
.map(([section]) => section) as Prettify<AccordionKeys>;
|
||||
|
||||
|
|
@ -326,7 +323,6 @@ export const InvoiceForm = memo(function InvoiceForm({
|
|||
|
||||
return (
|
||||
<form
|
||||
id={formPrefixId}
|
||||
className="mb-4 space-y-3.5"
|
||||
onSubmit={handleSubmit(onSubmit, (errors) => {
|
||||
console.error("Form validation errors:", errors);
|
||||
|
|
@ -353,7 +349,7 @@ export const InvoiceForm = memo(function InvoiceForm({
|
|||
if (Array.isArray(error)) {
|
||||
return error.map((item, index) =>
|
||||
Object.entries(
|
||||
item as Record<string, { message?: string }>
|
||||
item as { [key: string]: { message?: string } }
|
||||
).map(([fieldName, fieldError]) => (
|
||||
<li
|
||||
key={`${key}.${index}.${fieldName}`}
|
||||
|
|
@ -367,13 +363,15 @@ export const InvoiceForm = memo(function InvoiceForm({
|
|||
|
||||
// Handle nested object errors
|
||||
if (error && typeof error === "object") {
|
||||
return Object.entries(error).map(
|
||||
([nestedKey, nestedError]) => (
|
||||
return Object.entries(
|
||||
error as { [key: string]: { message?: string } }
|
||||
).map(([nestedKey, nestedError]) => {
|
||||
return (
|
||||
<li key={`${key}.${nestedKey}`} className="text-sm">
|
||||
{nestedError?.message || "Unknown error"}
|
||||
</li>
|
||||
)
|
||||
);
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
|
|
@ -397,6 +395,7 @@ export const InvoiceForm = memo(function InvoiceForm({
|
|||
<AccordionItem
|
||||
value={ACCORDION_GENERAL}
|
||||
className="rounded-lg border shadow"
|
||||
data-testid={`general-information-section`}
|
||||
>
|
||||
<AccordionTrigger className="px-4 py-3">
|
||||
<Legend>General Information</Legend>
|
||||
|
|
@ -406,7 +405,6 @@ export const InvoiceForm = memo(function InvoiceForm({
|
|||
control={control}
|
||||
errors={errors}
|
||||
setValue={setValue}
|
||||
formPrefixId={formPrefixId}
|
||||
dateOfIssue={dateOfIssue}
|
||||
/>
|
||||
</AccordionContent>
|
||||
|
|
@ -416,6 +414,7 @@ export const InvoiceForm = memo(function InvoiceForm({
|
|||
<AccordionItem
|
||||
value={ACCORDION_SELLER}
|
||||
className="rounded-lg border shadow"
|
||||
data-testid={`seller-information-section`}
|
||||
>
|
||||
<AccordionTrigger className="px-4 py-3">
|
||||
<Legend>Seller Information</Legend>
|
||||
|
|
@ -425,7 +424,6 @@ export const InvoiceForm = memo(function InvoiceForm({
|
|||
control={control}
|
||||
errors={errors}
|
||||
setValue={setValue}
|
||||
formPrefixId={formPrefixId}
|
||||
invoiceData={invoiceData}
|
||||
/>
|
||||
</AccordionContent>
|
||||
|
|
@ -435,6 +433,7 @@ export const InvoiceForm = memo(function InvoiceForm({
|
|||
<AccordionItem
|
||||
value={ACCORDION_BUYER}
|
||||
className="rounded-lg border shadow"
|
||||
data-testid={`buyer-information-section`}
|
||||
>
|
||||
<AccordionTrigger className="px-4 py-3">
|
||||
<Legend>Buyer Information</Legend>
|
||||
|
|
@ -444,7 +443,6 @@ export const InvoiceForm = memo(function InvoiceForm({
|
|||
control={control}
|
||||
errors={errors}
|
||||
setValue={setValue}
|
||||
formPrefixId={formPrefixId}
|
||||
invoiceData={invoiceData}
|
||||
/>
|
||||
</AccordionContent>
|
||||
|
|
@ -454,6 +452,7 @@ export const InvoiceForm = memo(function InvoiceForm({
|
|||
<AccordionItem
|
||||
value={ACCORDION_ITEMS}
|
||||
className="rounded-lg border shadow"
|
||||
data-testid={`invoice-items-section`}
|
||||
>
|
||||
<AccordionTrigger className="px-4 py-3">
|
||||
<Legend>Invoice Items</Legend>
|
||||
|
|
@ -461,7 +460,6 @@ export const InvoiceForm = memo(function InvoiceForm({
|
|||
<AccordionContent className="px-4 pb-4">
|
||||
<InvoiceItems
|
||||
control={control}
|
||||
formPrefixId={formPrefixId}
|
||||
fields={fields}
|
||||
handleRemoveItem={handleRemoveItem}
|
||||
errors={errors}
|
||||
|
|
@ -474,11 +472,11 @@ export const InvoiceForm = memo(function InvoiceForm({
|
|||
</Accordion>
|
||||
|
||||
{/* Final section */}
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4" data-testid={`final-section`}>
|
||||
<div className="">
|
||||
{/* Total field (with currency) */}
|
||||
<div className="mt-5" />
|
||||
<Label htmlFor={`${formPrefixId}-total`} className="mb-1">
|
||||
<Label htmlFor={`total`} className="mb-1">
|
||||
Total
|
||||
</Label>
|
||||
<div className="relative mt-1 rounded-md shadow-sm">
|
||||
|
|
@ -488,7 +486,7 @@ export const InvoiceForm = memo(function InvoiceForm({
|
|||
render={({ field }) => (
|
||||
<ReadOnlyMoneyInput
|
||||
{...field}
|
||||
id={`${formPrefixId}-total`}
|
||||
id={`total`}
|
||||
currency={currency}
|
||||
value={field.value.toLocaleString("en-US", {
|
||||
minimumFractionDigits: 2,
|
||||
|
|
@ -511,7 +509,7 @@ export const InvoiceForm = memo(function InvoiceForm({
|
|||
{/* Payment Method */}
|
||||
<div>
|
||||
<div className="relative mb-2 mt-6 flex items-center justify-between">
|
||||
<Label htmlFor={`${formPrefixId}-paymentMethod`} className="">
|
||||
<Label htmlFor={`paymentMethod`} className="">
|
||||
Payment Method
|
||||
</Label>
|
||||
|
||||
|
|
@ -523,7 +521,7 @@ export const InvoiceForm = memo(function InvoiceForm({
|
|||
render={({ field: { value, onChange, ...field } }) => (
|
||||
<Switch
|
||||
{...field}
|
||||
id={`${formPrefixId}-paymentMethodFieldIsVisible`}
|
||||
id={`paymentMethodFieldIsVisible`}
|
||||
checked={value}
|
||||
onCheckedChange={onChange}
|
||||
className="h-5 w-8 [&_span]:size-4 [&_span]:data-[state=checked]:translate-x-3 rtl:[&_span]:data-[state=checked]:-translate-x-3"
|
||||
|
|
@ -532,9 +530,7 @@ export const InvoiceForm = memo(function InvoiceForm({
|
|||
/>
|
||||
<CustomTooltip
|
||||
trigger={
|
||||
<Label
|
||||
htmlFor={`${formPrefixId}-paymentMethodFieldIsVisible`}
|
||||
>
|
||||
<Label htmlFor={`paymentMethodFieldIsVisible`}>
|
||||
Show in PDF
|
||||
</Label>
|
||||
}
|
||||
|
|
@ -549,7 +545,7 @@ export const InvoiceForm = memo(function InvoiceForm({
|
|||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
id={`${formPrefixId}-paymentMethod`}
|
||||
id={`paymentMethod`}
|
||||
type="text"
|
||||
className="mt-1"
|
||||
/>
|
||||
|
|
@ -563,19 +559,14 @@ export const InvoiceForm = memo(function InvoiceForm({
|
|||
{/* Payment Due */}
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<Label htmlFor={`${formPrefixId}-paymentDue`} className="mb-1">
|
||||
<Label htmlFor={`paymentDue`} className="mb-1">
|
||||
Payment Due
|
||||
</Label>
|
||||
<Controller
|
||||
name="paymentDue"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
id={`${formPrefixId}-paymentDue`}
|
||||
type="date"
|
||||
className=""
|
||||
/>
|
||||
<Input {...field} id={`paymentDue`} type="date" className="" />
|
||||
)}
|
||||
/>
|
||||
{errors.paymentDue && (
|
||||
|
|
@ -626,7 +617,7 @@ export const InvoiceForm = memo(function InvoiceForm({
|
|||
{/* Notes */}
|
||||
<div className="">
|
||||
<div className="relative mb-2 flex items-center justify-between">
|
||||
<Label htmlFor={`${formPrefixId}-notes`} className="">
|
||||
<Label htmlFor={`notes`} className="">
|
||||
Notes
|
||||
</Label>
|
||||
|
||||
|
|
@ -638,7 +629,7 @@ export const InvoiceForm = memo(function InvoiceForm({
|
|||
render={({ field: { value, onChange, ...field } }) => (
|
||||
<Switch
|
||||
{...field}
|
||||
id={`${formPrefixId}-notesFieldIsVisible`}
|
||||
id={`notesFieldIsVisible`}
|
||||
checked={value}
|
||||
onCheckedChange={onChange}
|
||||
className="h-5 w-8 [&_span]:size-4 [&_span]:data-[state=checked]:translate-x-3 rtl:[&_span]:data-[state=checked]:-translate-x-3"
|
||||
|
|
@ -647,9 +638,7 @@ export const InvoiceForm = memo(function InvoiceForm({
|
|||
/>
|
||||
<CustomTooltip
|
||||
trigger={
|
||||
<Label htmlFor={`${formPrefixId}-notesFieldIsVisible`}>
|
||||
Show in PDF
|
||||
</Label>
|
||||
<Label htmlFor={`notesFieldIsVisible`}>Show in PDF</Label>
|
||||
}
|
||||
content='Show/Hide the "Notes" Field in the PDF'
|
||||
/>
|
||||
|
|
@ -661,9 +650,10 @@ export const InvoiceForm = memo(function InvoiceForm({
|
|||
render={({ field }) => (
|
||||
<Textarea
|
||||
{...field}
|
||||
id={`${formPrefixId}-notes`}
|
||||
id={`notes`}
|
||||
rows={3}
|
||||
className=""
|
||||
data-testid="notes"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
|
@ -676,9 +666,7 @@ export const InvoiceForm = memo(function InvoiceForm({
|
|||
<div className="relative mt-5 space-y-4">
|
||||
{/* Show/hide Person Authorized to Receive field in PDF switch */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label
|
||||
htmlFor={`${formPrefixId}-personAuthorizedToReceiveFieldIsVisible`}
|
||||
>
|
||||
<Label htmlFor={`personAuthorizedToReceiveFieldIsVisible`}>
|
||||
Show "Person Authorized to Receive" Signature Field in
|
||||
the PDF
|
||||
</Label>
|
||||
|
|
@ -689,7 +677,7 @@ export const InvoiceForm = memo(function InvoiceForm({
|
|||
render={({ field: { value, onChange, ...field } }) => (
|
||||
<Switch
|
||||
{...field}
|
||||
id={`${formPrefixId}-personAuthorizedToReceiveFieldIsVisible`}
|
||||
id={`personAuthorizedToReceiveFieldIsVisible`}
|
||||
checked={value}
|
||||
onCheckedChange={onChange}
|
||||
className="h-5 w-8 [&_span]:size-4 [&_span]:data-[state=checked]:translate-x-3 rtl:[&_span]:data-[state=checked]:-translate-x-3"
|
||||
|
|
@ -700,9 +688,7 @@ export const InvoiceForm = memo(function InvoiceForm({
|
|||
|
||||
{/* Show/hide Person Authorized to Issue field in PDF switch */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label
|
||||
htmlFor={`${formPrefixId}-personAuthorizedToIssueFieldIsVisible`}
|
||||
>
|
||||
<Label htmlFor={`personAuthorizedToIssueFieldIsVisible`}>
|
||||
Show "Person Authorized to Issue" Signature Field in
|
||||
the PDF
|
||||
</Label>
|
||||
|
|
@ -713,7 +699,7 @@ export const InvoiceForm = memo(function InvoiceForm({
|
|||
render={({ field: { value, onChange, ...field } }) => (
|
||||
<Switch
|
||||
{...field}
|
||||
id={`${formPrefixId}-personAuthorizedToIssueFieldIsVisible`}
|
||||
id={`personAuthorizedToIssueFieldIsVisible`}
|
||||
checked={value}
|
||||
onCheckedChange={onChange}
|
||||
className="h-5 w-8 [&_span]:size-4 [&_span]:data-[state=checked]:translate-x-3 rtl:[&_span]:data-[state=checked]:-translate-x-3"
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import {
|
||||
Control,
|
||||
type Control,
|
||||
Controller,
|
||||
FieldErrors,
|
||||
UseFormSetValue,
|
||||
type FieldErrors,
|
||||
type UseFormSetValue,
|
||||
} from "react-hook-form";
|
||||
import { InvoiceData, type BuyerData } from "@/app/schema";
|
||||
import { type InvoiceData, type BuyerData } from "@/app/schema";
|
||||
import { BuyerManagement } from "@/components/buyer-management";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
|
@ -12,7 +12,6 @@ import { Switch } from "@/components/ui/switch";
|
|||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { CustomTooltip } from "@/components/ui/tooltip";
|
||||
import { memo, useState } from "react";
|
||||
import type { FormPrefixId } from "../..";
|
||||
import { LabelWithEditIcon } from "@/components/label-with-edit-icon";
|
||||
|
||||
const ErrorMessage = ({ children }: { children: React.ReactNode }) => {
|
||||
|
|
@ -20,13 +19,12 @@ const ErrorMessage = ({ children }: { children: React.ReactNode }) => {
|
|||
};
|
||||
|
||||
const BUYER_TOOLTIP_CONTENT =
|
||||
"Click the edit button next to the 'Select Buyer' dropdown to modify buyer details. Any changes will be automatically saved.";
|
||||
"Buyer details are locked. Click the edit button next to the 'Select Buyer' dropdown to modify buyer details. Any changes will be automatically saved.";
|
||||
|
||||
interface BuyerInformationProps {
|
||||
control: Control<InvoiceData>;
|
||||
errors: FieldErrors<InvoiceData>;
|
||||
setValue: UseFormSetValue<InvoiceData>;
|
||||
formPrefixId: FormPrefixId;
|
||||
invoiceData: InvoiceData;
|
||||
}
|
||||
|
||||
|
|
@ -34,12 +32,15 @@ export const BuyerInformation = memo(function BuyerInformation({
|
|||
control,
|
||||
errors,
|
||||
setValue,
|
||||
formPrefixId,
|
||||
invoiceData,
|
||||
}: BuyerInformationProps) {
|
||||
const [selectedBuyerId, setSelectedBuyerId] = useState("");
|
||||
const isBuyerSelected = !!selectedBuyerId;
|
||||
|
||||
const HTML_TITLE_CONTENT = isBuyerSelected
|
||||
? "Buyer details are locked. Click the edit buyer button to modify."
|
||||
: "";
|
||||
|
||||
// Get current form values to pass to BuyerManagement
|
||||
const currentFormValues = {
|
||||
name: invoiceData.buyer.name,
|
||||
|
|
@ -64,13 +65,13 @@ export const BuyerInformation = memo(function BuyerInformation({
|
|||
<div>
|
||||
{isBuyerSelected ? (
|
||||
<LabelWithEditIcon
|
||||
htmlFor={`${formPrefixId}-buyerName`}
|
||||
htmlFor={`buyerName`}
|
||||
content={BUYER_TOOLTIP_CONTENT}
|
||||
>
|
||||
Name
|
||||
</LabelWithEditIcon>
|
||||
) : (
|
||||
<Label htmlFor={`${formPrefixId}-buyerName`} className="mb-1">
|
||||
<Label htmlFor={`buyerName`} className="mb-1">
|
||||
Name
|
||||
</Label>
|
||||
)}
|
||||
|
|
@ -80,9 +81,12 @@ export const BuyerInformation = memo(function BuyerInformation({
|
|||
render={({ field }) => (
|
||||
<Textarea
|
||||
{...field}
|
||||
id={`${formPrefixId}-buyerName`}
|
||||
id={`buyerName`}
|
||||
rows={3}
|
||||
className=""
|
||||
readOnly={isBuyerSelected}
|
||||
aria-readonly={isBuyerSelected}
|
||||
title={HTML_TITLE_CONTENT}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
|
@ -94,13 +98,13 @@ export const BuyerInformation = memo(function BuyerInformation({
|
|||
<div>
|
||||
{isBuyerSelected ? (
|
||||
<LabelWithEditIcon
|
||||
htmlFor={`${formPrefixId}-buyerAddress`}
|
||||
htmlFor={`buyerAddress`}
|
||||
content={BUYER_TOOLTIP_CONTENT}
|
||||
>
|
||||
Address
|
||||
</LabelWithEditIcon>
|
||||
) : (
|
||||
<Label htmlFor={`${formPrefixId}-buyerAddress`} className="mb-1">
|
||||
<Label htmlFor={`buyerAddress`} className="mb-1">
|
||||
Address
|
||||
</Label>
|
||||
)}
|
||||
|
|
@ -110,9 +114,12 @@ export const BuyerInformation = memo(function BuyerInformation({
|
|||
render={({ field }) => (
|
||||
<Textarea
|
||||
{...field}
|
||||
id={`${formPrefixId}-buyerAddress`}
|
||||
id={`buyerAddress`}
|
||||
rows={3}
|
||||
className=""
|
||||
readOnly={isBuyerSelected}
|
||||
aria-readonly={isBuyerSelected}
|
||||
title={HTML_TITLE_CONTENT}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
|
@ -125,34 +132,39 @@ export const BuyerInformation = memo(function BuyerInformation({
|
|||
<div className="relative mb-2 flex items-center justify-between">
|
||||
{isBuyerSelected ? (
|
||||
<LabelWithEditIcon
|
||||
htmlFor={`${formPrefixId}-buyerVatNo`}
|
||||
htmlFor={`buyerVatNo`}
|
||||
content={BUYER_TOOLTIP_CONTENT}
|
||||
>
|
||||
VAT Number
|
||||
</LabelWithEditIcon>
|
||||
) : (
|
||||
<Label htmlFor={`${formPrefixId}-buyerVatNo`} className="">
|
||||
<Label htmlFor={`buyerVatNo`} className="">
|
||||
VAT Number
|
||||
</Label>
|
||||
)}
|
||||
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<div
|
||||
className="inline-flex items-center gap-2"
|
||||
title={HTML_TITLE_CONTENT}
|
||||
>
|
||||
<Controller
|
||||
name={`buyer.vatNoFieldIsVisible`}
|
||||
control={control}
|
||||
render={({ field: { value, onChange, ...field } }) => (
|
||||
<Switch
|
||||
{...field}
|
||||
id={`${formPrefixId}-buyerVatNoFieldIsVisible`}
|
||||
id={`buyerVatNoFieldIsVisible`}
|
||||
checked={value}
|
||||
onCheckedChange={onChange}
|
||||
className="h-5 w-8 [&_span]:size-4 [&_span]:data-[state=checked]:translate-x-3 rtl:[&_span]:data-[state=checked]:-translate-x-3"
|
||||
disabled={isBuyerSelected}
|
||||
data-testid={`buyerVatNoFieldIsVisible`}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<CustomTooltip
|
||||
trigger={
|
||||
<Label htmlFor={`${formPrefixId}-buyerVatNoFieldIsVisible`}>
|
||||
<Label htmlFor={`buyerVatNoFieldIsVisible`}>
|
||||
Show in PDF
|
||||
</Label>
|
||||
}
|
||||
|
|
@ -170,9 +182,12 @@ export const BuyerInformation = memo(function BuyerInformation({
|
|||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
id={`${formPrefixId}-buyerVatNo`}
|
||||
id={`buyerVatNo`}
|
||||
type="text"
|
||||
className=""
|
||||
readOnly={isBuyerSelected}
|
||||
aria-readonly={isBuyerSelected}
|
||||
title={HTML_TITLE_CONTENT}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
|
@ -184,13 +199,13 @@ export const BuyerInformation = memo(function BuyerInformation({
|
|||
<div>
|
||||
{isBuyerSelected ? (
|
||||
<LabelWithEditIcon
|
||||
htmlFor={`${formPrefixId}-buyerEmail`}
|
||||
htmlFor={`buyerEmail`}
|
||||
content={BUYER_TOOLTIP_CONTENT}
|
||||
>
|
||||
Email
|
||||
</LabelWithEditIcon>
|
||||
) : (
|
||||
<Label htmlFor={`${formPrefixId}-buyerEmail`} className="mb-1">
|
||||
<Label htmlFor={`buyerEmail`} className="mb-1">
|
||||
Email
|
||||
</Label>
|
||||
)}
|
||||
|
|
@ -200,9 +215,12 @@ export const BuyerInformation = memo(function BuyerInformation({
|
|||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
id={`${formPrefixId}-buyerEmail`}
|
||||
id={`buyerEmail`}
|
||||
type="email"
|
||||
className=""
|
||||
readOnly={isBuyerSelected}
|
||||
aria-readonly={isBuyerSelected}
|
||||
title={HTML_TITLE_CONTENT}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import {
|
||||
Control,
|
||||
type Control,
|
||||
Controller,
|
||||
FieldErrors,
|
||||
UseFormSetValue,
|
||||
type FieldErrors,
|
||||
type UseFormSetValue,
|
||||
useWatch,
|
||||
} from "react-hook-form";
|
||||
import { InvoiceData } from "@/app/schema";
|
||||
import { type InvoiceData } from "@/app/schema";
|
||||
import {
|
||||
SUPPORTED_CURRENCIES,
|
||||
SUPPORTED_DATE_FORMATS,
|
||||
|
|
@ -23,7 +23,6 @@ import { CustomTooltip } from "@/components/ui/tooltip";
|
|||
import dayjs from "dayjs";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { memo } from "react";
|
||||
import type { FormPrefixId } from "../..";
|
||||
|
||||
const AlertIcon = () => {
|
||||
return <AlertTriangle className="mr-1 inline-block h-3 w-3 text-amber-500" />;
|
||||
|
|
@ -37,7 +36,6 @@ interface GeneralInformationProps {
|
|||
control: Control<InvoiceData>;
|
||||
errors: FieldErrors<InvoiceData>;
|
||||
setValue: UseFormSetValue<InvoiceData>;
|
||||
formPrefixId: FormPrefixId;
|
||||
dateOfIssue: string;
|
||||
}
|
||||
|
||||
|
|
@ -45,7 +43,6 @@ export const GeneralInformation = memo(function GeneralInformation({
|
|||
control,
|
||||
errors,
|
||||
setValue,
|
||||
formPrefixId,
|
||||
dateOfIssue,
|
||||
}: GeneralInformationProps) {
|
||||
const invoiceNumber = useWatch({ control, name: "invoiceNumber" });
|
||||
|
|
@ -67,18 +64,14 @@ export const GeneralInformation = memo(function GeneralInformation({
|
|||
<div className="space-y-4">
|
||||
{/* Language PDF Select */}
|
||||
<div>
|
||||
<Label htmlFor={`${formPrefixId}-language`} className="mb-1">
|
||||
<Label htmlFor={`language`} className="mb-1">
|
||||
Invoice PDF Language
|
||||
</Label>
|
||||
<Controller
|
||||
name="language"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<SelectNative
|
||||
{...field}
|
||||
id={`${formPrefixId}-language`}
|
||||
className="block"
|
||||
>
|
||||
<SelectNative {...field} id={`language`} className="block">
|
||||
{SUPPORTED_LANGUAGES.map((lang) => (
|
||||
<option key={lang} value={lang}>
|
||||
{lang === "en" ? "English" : "Polish"}
|
||||
|
|
@ -98,7 +91,7 @@ export const GeneralInformation = memo(function GeneralInformation({
|
|||
|
||||
{/* Currency Select */}
|
||||
<div>
|
||||
<Label htmlFor={`${formPrefixId}-currency`} className="mb-1">
|
||||
<Label htmlFor={`currency`} className="mb-1">
|
||||
Currency
|
||||
</Label>
|
||||
<Controller
|
||||
|
|
@ -106,11 +99,7 @@ export const GeneralInformation = memo(function GeneralInformation({
|
|||
control={control}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<SelectNative
|
||||
{...field}
|
||||
id={`${formPrefixId}-currency`}
|
||||
className="block"
|
||||
>
|
||||
<SelectNative {...field} id={`currency`} className="block">
|
||||
{SUPPORTED_CURRENCIES.map((currency) => {
|
||||
const currencySymbol = CURRENCY_SYMBOLS[currency] || null;
|
||||
|
||||
|
|
@ -140,18 +129,14 @@ export const GeneralInformation = memo(function GeneralInformation({
|
|||
|
||||
{/* Date Format */}
|
||||
<div>
|
||||
<Label htmlFor={`${formPrefixId}-dateFormat`} className="mb-1">
|
||||
<Label htmlFor={`dateFormat`} className="mb-1">
|
||||
Date Format
|
||||
</Label>
|
||||
<Controller
|
||||
name="dateFormat"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<SelectNative
|
||||
{...field}
|
||||
id={`${formPrefixId}-dateFormat`}
|
||||
className="block"
|
||||
>
|
||||
<SelectNative {...field} id={`dateFormat`} className="block">
|
||||
{SUPPORTED_DATE_FORMATS.map((format) => {
|
||||
const preview = dayjs().format(format);
|
||||
const isDefault = format === SUPPORTED_DATE_FORMATS[0];
|
||||
|
|
@ -177,7 +162,7 @@ export const GeneralInformation = memo(function GeneralInformation({
|
|||
|
||||
{/* Invoice Number */}
|
||||
<div>
|
||||
<Label htmlFor={`${formPrefixId}-invoiceNumber`} className="mb-1">
|
||||
<Label htmlFor={`invoiceNumber`} className="mb-1">
|
||||
Invoice Number
|
||||
</Label>
|
||||
<Controller
|
||||
|
|
@ -187,7 +172,7 @@ export const GeneralInformation = memo(function GeneralInformation({
|
|||
<Input
|
||||
{...field}
|
||||
type="text"
|
||||
id={`${formPrefixId}-invoiceNumber`}
|
||||
id={`invoiceNumber`}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm"
|
||||
/>
|
||||
)}
|
||||
|
|
@ -217,19 +202,14 @@ export const GeneralInformation = memo(function GeneralInformation({
|
|||
|
||||
{/* Date of Issue */}
|
||||
<div>
|
||||
<Label htmlFor={`${formPrefixId}-dateOfIssue`} className="mb-1">
|
||||
<Label htmlFor={`dateOfIssue`} className="mb-1">
|
||||
Date of Issue
|
||||
</Label>
|
||||
<Controller
|
||||
name="dateOfIssue"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
type="date"
|
||||
id={`${formPrefixId}-dateOfIssue`}
|
||||
className=""
|
||||
/>
|
||||
<Input {...field} type="date" id={`dateOfIssue`} className="" />
|
||||
)}
|
||||
/>
|
||||
{errors.dateOfIssue && (
|
||||
|
|
@ -257,19 +237,14 @@ export const GeneralInformation = memo(function GeneralInformation({
|
|||
|
||||
{/* Date of Service */}
|
||||
<div>
|
||||
<Label htmlFor={`${formPrefixId}-dateOfService`} className="mb-1">
|
||||
<Label htmlFor={`dateOfService`} className="mb-1">
|
||||
Date of Service
|
||||
</Label>
|
||||
<Controller
|
||||
name="dateOfService"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
type="date"
|
||||
id={`${formPrefixId}-dateOfService`}
|
||||
className=""
|
||||
/>
|
||||
<Input {...field} type="date" id={`dateOfService`} className="" />
|
||||
)}
|
||||
/>
|
||||
{errors.dateOfService && (
|
||||
|
|
@ -302,7 +277,7 @@ export const GeneralInformation = memo(function GeneralInformation({
|
|||
{/* Invoice Type */}
|
||||
<div>
|
||||
<div className="relative mb-2 flex items-center justify-between">
|
||||
<Label htmlFor={`${formPrefixId}-invoiceType`} className="">
|
||||
<Label htmlFor={`invoiceType`} className="">
|
||||
Invoice Type
|
||||
</Label>
|
||||
|
||||
|
|
@ -314,7 +289,7 @@ export const GeneralInformation = memo(function GeneralInformation({
|
|||
render={({ field: { value, onChange, ...field } }) => (
|
||||
<Switch
|
||||
{...field}
|
||||
id={`${formPrefixId}-invoiceTypeFieldIsVisible`}
|
||||
id={`invoiceTypeFieldIsVisible`}
|
||||
checked={value}
|
||||
onCheckedChange={onChange}
|
||||
className="h-5 w-8 [&_span]:size-4 [&_span]:data-[state=checked]:translate-x-3 rtl:[&_span]:data-[state=checked]:-translate-x-3"
|
||||
|
|
@ -323,9 +298,7 @@ export const GeneralInformation = memo(function GeneralInformation({
|
|||
/>
|
||||
<CustomTooltip
|
||||
trigger={
|
||||
<Label htmlFor={`${formPrefixId}-invoiceTypeFieldIsVisible`}>
|
||||
Show in PDF
|
||||
</Label>
|
||||
<Label htmlFor={`invoiceTypeFieldIsVisible`}>Show in PDF</Label>
|
||||
}
|
||||
content='Show/Hide the "Invoice Type" Field in the PDF'
|
||||
/>
|
||||
|
|
@ -338,7 +311,7 @@ export const GeneralInformation = memo(function GeneralInformation({
|
|||
render={({ field }) => (
|
||||
<Textarea
|
||||
{...field}
|
||||
id={`${formPrefixId}-invoiceType`}
|
||||
id={`invoiceType`}
|
||||
rows={2}
|
||||
className=""
|
||||
placeholder="Enter invoice type"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import {
|
||||
InvoiceData,
|
||||
type InvoiceData,
|
||||
type SupportedCurrencies,
|
||||
type SupportedLanguages,
|
||||
} from "@/app/schema";
|
||||
|
|
@ -7,13 +7,12 @@ import { Label } from "@/components/ui/label";
|
|||
import { Switch } from "@/components/ui/switch";
|
||||
import { memo } from "react";
|
||||
import {
|
||||
Control,
|
||||
type Control,
|
||||
Controller,
|
||||
type FieldArrayWithId,
|
||||
type FieldErrors,
|
||||
type UseFieldArrayAppend,
|
||||
} from "react-hook-form";
|
||||
import type { FormPrefixId } from "../..";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { InputHelperMessage } from "@/components/ui/input-helper-message";
|
||||
|
|
@ -37,7 +36,6 @@ const ErrorMessage = ({ children }: { children: React.ReactNode }) => {
|
|||
|
||||
interface InvoiceItemsSettingsProps {
|
||||
control: Control<InvoiceData>;
|
||||
formPrefixId: FormPrefixId;
|
||||
fields: FieldArrayWithId<InvoiceData, "items", "id">[];
|
||||
handleRemoveItem: (index: number) => void;
|
||||
append: UseFieldArrayAppend<InvoiceData, "items">;
|
||||
|
|
@ -48,7 +46,6 @@ interface InvoiceItemsSettingsProps {
|
|||
|
||||
export const InvoiceItems = memo(function InvoiceItems({
|
||||
control,
|
||||
formPrefixId,
|
||||
fields,
|
||||
handleRemoveItem,
|
||||
errors,
|
||||
|
|
@ -57,12 +54,13 @@ export const InvoiceItems = memo(function InvoiceItems({
|
|||
append,
|
||||
}: InvoiceItemsSettingsProps) {
|
||||
const openPanel = useOpenPanel();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-3 space-y-4">
|
||||
{/* Show Number column on PDF switch */}
|
||||
<div className="relative flex items-center justify-between">
|
||||
<Label htmlFor={`${formPrefixId}-itemInvoiceItemNumberIsVisible0`}>
|
||||
<Label htmlFor={`itemInvoiceItemNumberIsVisible0`}>
|
||||
Show "Number" Column in the Invoice Items Table
|
||||
</Label>
|
||||
|
||||
|
|
@ -72,7 +70,7 @@ export const InvoiceItems = memo(function InvoiceItems({
|
|||
render={({ field: { value, onChange, ...field } }) => (
|
||||
<Switch
|
||||
{...field}
|
||||
id={`${formPrefixId}-itemInvoiceItemNumberIsVisible0`}
|
||||
id={`itemInvoiceItemNumberIsVisible0`}
|
||||
checked={value}
|
||||
onCheckedChange={onChange}
|
||||
className="h-5 w-8 [&_span]:size-4 [&_span]:data-[state=checked]:translate-x-3 rtl:[&_span]:data-[state=checked]:-translate-x-3"
|
||||
|
|
@ -83,7 +81,7 @@ export const InvoiceItems = memo(function InvoiceItems({
|
|||
|
||||
{/* Show VAT Table Summary in PDF switch */}
|
||||
<div className="relative flex items-center justify-between">
|
||||
<Label htmlFor={`${formPrefixId}-vatTableSummaryIsVisible`}>
|
||||
<Label htmlFor={`vatTableSummaryIsVisible`}>
|
||||
Show "VAT Table Summary" in the PDF
|
||||
</Label>
|
||||
|
||||
|
|
@ -93,7 +91,7 @@ export const InvoiceItems = memo(function InvoiceItems({
|
|||
render={({ field: { value, onChange, ...field } }) => (
|
||||
<Switch
|
||||
{...field}
|
||||
id={`${formPrefixId}-vatTableSummaryIsVisible`}
|
||||
id={`vatTableSummaryIsVisible`}
|
||||
checked={value}
|
||||
onCheckedChange={onChange}
|
||||
className="h-5 w-8 [&_span]:size-4 [&_span]:data-[state=checked]:translate-x-3 rtl:[&_span]:data-[state=checked]:-translate-x-3"
|
||||
|
|
@ -121,6 +119,9 @@ export const InvoiceItems = memo(function InvoiceItems({
|
|||
onClick={() => handleRemoveItem(index)}
|
||||
className="flex items-center justify-center rounded-full bg-red-600 p-2 transition-colors hover:bg-red-700"
|
||||
>
|
||||
<span className="sr-only">
|
||||
Delete Invoice Item {index + 1}
|
||||
</span>
|
||||
<Trash2 className="h-4 w-4 text-white" />
|
||||
</button>
|
||||
}
|
||||
|
|
@ -133,10 +134,7 @@ export const InvoiceItems = memo(function InvoiceItems({
|
|||
<div>
|
||||
{/* Invoice Item Name */}
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<Label
|
||||
htmlFor={`${formPrefixId}-itemName${index}`}
|
||||
className=""
|
||||
>
|
||||
<Label htmlFor={`itemName${index}`} className="">
|
||||
Name
|
||||
</Label>
|
||||
|
||||
|
|
@ -149,7 +147,7 @@ export const InvoiceItems = memo(function InvoiceItems({
|
|||
render={({ field: { value, onChange, ...field } }) => (
|
||||
<Switch
|
||||
{...field}
|
||||
id={`${formPrefixId}-itemNameFieldIsVisible${index}`}
|
||||
id={`itemNameFieldIsVisible${index}`}
|
||||
checked={value}
|
||||
onCheckedChange={onChange}
|
||||
className="h-5 w-8 [&_span]:size-4 [&_span]:data-[state=checked]:translate-x-3 rtl:[&_span]:data-[state=checked]:-translate-x-3"
|
||||
|
|
@ -158,9 +156,7 @@ export const InvoiceItems = memo(function InvoiceItems({
|
|||
/>
|
||||
<CustomTooltip
|
||||
trigger={
|
||||
<Label
|
||||
htmlFor={`${formPrefixId}-itemNameFieldIsVisible${index}`}
|
||||
>
|
||||
<Label htmlFor={`itemNameFieldIsVisible${index}`}>
|
||||
Show in PDF
|
||||
</Label>
|
||||
}
|
||||
|
|
@ -178,7 +174,7 @@ export const InvoiceItems = memo(function InvoiceItems({
|
|||
<Textarea
|
||||
{...field}
|
||||
rows={4}
|
||||
id={`${formPrefixId}-itemName${index}`}
|
||||
id={`itemName${index}`}
|
||||
className=""
|
||||
/>
|
||||
)}
|
||||
|
|
@ -193,10 +189,7 @@ export const InvoiceItems = memo(function InvoiceItems({
|
|||
{/* Invoice Item Type of GTU */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<Label
|
||||
htmlFor={`${formPrefixId}-itemTypeOfGTU${index}`}
|
||||
className=""
|
||||
>
|
||||
<Label htmlFor={`itemTypeOfGTU${index}`} className="">
|
||||
Type of GTU
|
||||
</Label>
|
||||
|
||||
|
|
@ -209,7 +202,7 @@ export const InvoiceItems = memo(function InvoiceItems({
|
|||
render={({ field: { value, onChange, ...field } }) => (
|
||||
<Switch
|
||||
{...field}
|
||||
id={`${formPrefixId}-itemTypeOfGTUFieldIsVisible${index}`}
|
||||
id={`itemTypeOfGTUFieldIsVisible${index}`}
|
||||
checked={value}
|
||||
onCheckedChange={onChange}
|
||||
className="h-5 w-8 [&_span]:size-4 [&_span]:data-[state=checked]:translate-x-3 rtl:[&_span]:data-[state=checked]:-translate-x-3"
|
||||
|
|
@ -219,7 +212,7 @@ export const InvoiceItems = memo(function InvoiceItems({
|
|||
<CustomTooltip
|
||||
trigger={
|
||||
<Label
|
||||
htmlFor={`${formPrefixId}-itemTypeOfGTUFieldIsVisible${index}`}
|
||||
htmlFor={`itemTypeOfGTUFieldIsVisible${index}`}
|
||||
>
|
||||
Show in PDF
|
||||
</Label>
|
||||
|
|
@ -237,7 +230,7 @@ export const InvoiceItems = memo(function InvoiceItems({
|
|||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
id={`${formPrefixId}-itemTypeOfGTU${index}`}
|
||||
id={`itemTypeOfGTU${index}`}
|
||||
className=""
|
||||
type="text"
|
||||
/>
|
||||
|
|
@ -253,10 +246,7 @@ export const InvoiceItems = memo(function InvoiceItems({
|
|||
{/* Invoice Item Amount */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<Label
|
||||
htmlFor={`${formPrefixId}-itemAmount${index}`}
|
||||
className=""
|
||||
>
|
||||
<Label htmlFor={`itemAmount${index}`} className="">
|
||||
Amount
|
||||
</Label>
|
||||
|
||||
|
|
@ -269,7 +259,7 @@ export const InvoiceItems = memo(function InvoiceItems({
|
|||
render={({ field: { value, onChange, ...field } }) => (
|
||||
<Switch
|
||||
{...field}
|
||||
id={`${formPrefixId}-itemAmountFieldIsVisible${index}`}
|
||||
id={`itemAmountFieldIsVisible${index}`}
|
||||
checked={value}
|
||||
onCheckedChange={onChange}
|
||||
className="h-5 w-8 [&_span]:size-4 [&_span]:data-[state=checked]:translate-x-3 rtl:[&_span]:data-[state=checked]:-translate-x-3"
|
||||
|
|
@ -278,9 +268,7 @@ export const InvoiceItems = memo(function InvoiceItems({
|
|||
/>
|
||||
<CustomTooltip
|
||||
trigger={
|
||||
<Label
|
||||
htmlFor={`${formPrefixId}-itemAmountFieldIsVisible${index}`}
|
||||
>
|
||||
<Label htmlFor={`itemAmountFieldIsVisible${index}`}>
|
||||
Show in PDF
|
||||
</Label>
|
||||
}
|
||||
|
|
@ -306,7 +294,7 @@ export const InvoiceItems = memo(function InvoiceItems({
|
|||
<>
|
||||
<Input
|
||||
{...field}
|
||||
id={`${formPrefixId}-itemAmount${index}`}
|
||||
id={`itemAmount${index}`}
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
|
|
@ -331,10 +319,7 @@ export const InvoiceItems = memo(function InvoiceItems({
|
|||
{/* Invoice Item Unit */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<Label
|
||||
htmlFor={`${formPrefixId}-itemUnit${index}`}
|
||||
className=""
|
||||
>
|
||||
<Label htmlFor={`itemUnit${index}`} className="">
|
||||
Unit
|
||||
</Label>
|
||||
|
||||
|
|
@ -347,7 +332,7 @@ export const InvoiceItems = memo(function InvoiceItems({
|
|||
render={({ field: { value, onChange, ...field } }) => (
|
||||
<Switch
|
||||
{...field}
|
||||
id={`${formPrefixId}-itemUnitFieldIsVisible${index}`}
|
||||
id={`itemUnitFieldIsVisible${index}`}
|
||||
checked={value}
|
||||
onCheckedChange={onChange}
|
||||
className="h-5 w-8 [&_span]:size-4 [&_span]:data-[state=checked]:translate-x-3 rtl:[&_span]:data-[state=checked]:-translate-x-3"
|
||||
|
|
@ -356,9 +341,7 @@ export const InvoiceItems = memo(function InvoiceItems({
|
|||
/>
|
||||
<CustomTooltip
|
||||
trigger={
|
||||
<Label
|
||||
htmlFor={`${formPrefixId}-itemUnitFieldIsVisible${index}`}
|
||||
>
|
||||
<Label htmlFor={`itemUnitFieldIsVisible${index}`}>
|
||||
Show in PDF
|
||||
</Label>
|
||||
}
|
||||
|
|
@ -373,11 +356,7 @@ export const InvoiceItems = memo(function InvoiceItems({
|
|||
name={`items.${index}.unit`}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
id={`${formPrefixId}-itemUnit${index}`}
|
||||
type="text"
|
||||
/>
|
||||
<Input {...field} id={`itemUnit${index}`} type="text" />
|
||||
)}
|
||||
/>
|
||||
{errors.items?.[index]?.unit && (
|
||||
|
|
@ -390,10 +369,7 @@ export const InvoiceItems = memo(function InvoiceItems({
|
|||
{/* Invoice Item Net Price */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<Label
|
||||
htmlFor={`${formPrefixId}-itemNetPrice${index}`}
|
||||
className=""
|
||||
>
|
||||
<Label htmlFor={`itemNetPrice${index}`} className="">
|
||||
Net Price
|
||||
</Label>
|
||||
|
||||
|
|
@ -406,7 +382,7 @@ export const InvoiceItems = memo(function InvoiceItems({
|
|||
render={({ field: { value, onChange, ...field } }) => (
|
||||
<Switch
|
||||
{...field}
|
||||
id={`${formPrefixId}-itemNetPriceFieldIsVisible${index}`}
|
||||
id={`itemNetPriceFieldIsVisible${index}`}
|
||||
checked={value}
|
||||
onCheckedChange={onChange}
|
||||
className="h-5 w-8 [&_span]:size-4 [&_span]:data-[state=checked]:translate-x-3 rtl:[&_span]:data-[state=checked]:-translate-x-3"
|
||||
|
|
@ -415,9 +391,7 @@ export const InvoiceItems = memo(function InvoiceItems({
|
|||
/>
|
||||
<CustomTooltip
|
||||
trigger={
|
||||
<Label
|
||||
htmlFor={`${formPrefixId}-itemNetPriceFieldIsVisible${index}`}
|
||||
>
|
||||
<Label htmlFor={`itemNetPriceFieldIsVisible${index}`}>
|
||||
Show in PDF
|
||||
</Label>
|
||||
}
|
||||
|
|
@ -455,12 +429,13 @@ export const InvoiceItems = memo(function InvoiceItems({
|
|||
<div className="flex w-full flex-col">
|
||||
<MoneyInput
|
||||
{...field}
|
||||
id={`${formPrefixId}-itemNetPrice${index}`}
|
||||
id={`itemNetPrice${index}`}
|
||||
currency={currency}
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
className="w-full"
|
||||
dataTestId={`itemNetPrice${index}`}
|
||||
/>
|
||||
{!errors.items?.[index]?.netPrice && (
|
||||
<InputHelperMessage>
|
||||
|
|
@ -474,6 +449,7 @@ export const InvoiceItems = memo(function InvoiceItems({
|
|||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{errors.items?.[index]?.netPrice && (
|
||||
<ErrorMessage>
|
||||
{errors.items[index].netPrice.message}
|
||||
|
|
@ -484,10 +460,7 @@ export const InvoiceItems = memo(function InvoiceItems({
|
|||
{/* Invoice Item VAT */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<Label
|
||||
htmlFor={`${formPrefixId}-itemVat${index}`}
|
||||
className=""
|
||||
>
|
||||
<Label htmlFor={`itemVat${index}`} className="">
|
||||
VAT
|
||||
</Label>
|
||||
|
||||
|
|
@ -500,7 +473,7 @@ export const InvoiceItems = memo(function InvoiceItems({
|
|||
render={({ field: { value, onChange, ...field } }) => (
|
||||
<Switch
|
||||
{...field}
|
||||
id={`${formPrefixId}-itemVatFieldIsVisible${index}`}
|
||||
id={`itemVatFieldIsVisible${index}`}
|
||||
checked={value}
|
||||
onCheckedChange={onChange}
|
||||
className="h-5 w-8 [&_span]:size-4 [&_span]:data-[state=checked]:translate-x-3 rtl:[&_span]:data-[state=checked]:-translate-x-3"
|
||||
|
|
@ -509,9 +482,7 @@ export const InvoiceItems = memo(function InvoiceItems({
|
|||
/>
|
||||
<CustomTooltip
|
||||
trigger={
|
||||
<Label
|
||||
htmlFor={`${formPrefixId}-itemVatFieldIsVisible${index}`}
|
||||
>
|
||||
<Label htmlFor={`itemVatFieldIsVisible${index}`}>
|
||||
Show in PDF
|
||||
</Label>
|
||||
}
|
||||
|
|
@ -528,7 +499,7 @@ export const InvoiceItems = memo(function InvoiceItems({
|
|||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
id={`${formPrefixId}-itemVat${index}`}
|
||||
id={`itemVat${index}`}
|
||||
type="text"
|
||||
className=""
|
||||
/>
|
||||
|
|
@ -547,10 +518,7 @@ export const InvoiceItems = memo(function InvoiceItems({
|
|||
{/* Invoice Item Net Amount */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<Label
|
||||
htmlFor={`${formPrefixId}-itemNetAmount${index}`}
|
||||
className=""
|
||||
>
|
||||
<Label htmlFor={`itemNetAmount${index}`} className="">
|
||||
Net Amount
|
||||
</Label>
|
||||
|
||||
|
|
@ -563,7 +531,7 @@ export const InvoiceItems = memo(function InvoiceItems({
|
|||
render={({ field: { value, onChange, ...field } }) => (
|
||||
<Switch
|
||||
{...field}
|
||||
id={`${formPrefixId}-itemNetAmountFieldIsVisible${index}`}
|
||||
id={`itemNetAmountFieldIsVisible${index}`}
|
||||
checked={value}
|
||||
onCheckedChange={onChange}
|
||||
className="h-5 w-8 [&_span]:size-4 [&_span]:data-[state=checked]:translate-x-3 rtl:[&_span]:data-[state=checked]:-translate-x-3"
|
||||
|
|
@ -573,7 +541,7 @@ export const InvoiceItems = memo(function InvoiceItems({
|
|||
<CustomTooltip
|
||||
trigger={
|
||||
<Label
|
||||
htmlFor={`${formPrefixId}-itemNetAmountFieldIsVisible${index}`}
|
||||
htmlFor={`itemNetAmountFieldIsVisible${index}`}
|
||||
>
|
||||
Show in PDF
|
||||
</Label>
|
||||
|
|
@ -592,12 +560,13 @@ export const InvoiceItems = memo(function InvoiceItems({
|
|||
return (
|
||||
<ReadOnlyMoneyInput
|
||||
{...field}
|
||||
id={`${formPrefixId}-itemNetAmount${index}`}
|
||||
id={`itemNetAmount${index}`}
|
||||
currency={currency}
|
||||
value={field.value.toLocaleString("en-US", {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}
|
||||
dataTestId={`itemNetAmount${index}`}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
|
|
@ -617,10 +586,7 @@ export const InvoiceItems = memo(function InvoiceItems({
|
|||
{/* Invoice Item VAT Amount (calculated automatically) */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<Label
|
||||
htmlFor={`${formPrefixId}-itemVatAmount${index}`}
|
||||
className=""
|
||||
>
|
||||
<Label htmlFor={`itemVatAmount${index}`} className="">
|
||||
VAT Amount
|
||||
</Label>
|
||||
|
||||
|
|
@ -633,7 +599,7 @@ export const InvoiceItems = memo(function InvoiceItems({
|
|||
render={({ field: { value, onChange, ...field } }) => (
|
||||
<Switch
|
||||
{...field}
|
||||
id={`${formPrefixId}-itemVatAmountFieldIsVisible${index}`}
|
||||
id={`itemVatAmountFieldIsVisible${index}`}
|
||||
checked={value}
|
||||
onCheckedChange={onChange}
|
||||
className="h-5 w-8 [&_span]:size-4 [&_span]:data-[state=checked]:translate-x-3 rtl:[&_span]:data-[state=checked]:-translate-x-3"
|
||||
|
|
@ -643,7 +609,7 @@ export const InvoiceItems = memo(function InvoiceItems({
|
|||
<CustomTooltip
|
||||
trigger={
|
||||
<Label
|
||||
htmlFor={`${formPrefixId}-itemVatAmountFieldIsVisible${index}`}
|
||||
htmlFor={`itemVatAmountFieldIsVisible${index}`}
|
||||
>
|
||||
Show in PDF
|
||||
</Label>
|
||||
|
|
@ -658,17 +624,20 @@ export const InvoiceItems = memo(function InvoiceItems({
|
|||
<Controller
|
||||
name={`items.${index}.vatAmount`}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<ReadOnlyMoneyInput
|
||||
{...field}
|
||||
id={`${formPrefixId}-itemVatAmount${index}`}
|
||||
currency={currency}
|
||||
value={field.value.toLocaleString("en-US", {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<ReadOnlyMoneyInput
|
||||
{...field}
|
||||
id={`itemVatAmount${index}`}
|
||||
currency={currency}
|
||||
value={field.value.toLocaleString("en-US", {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}
|
||||
data-testid="vat-amount"
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
{errors.items?.[index]?.vatAmount ? (
|
||||
|
|
@ -685,10 +654,7 @@ export const InvoiceItems = memo(function InvoiceItems({
|
|||
{/* Pre-tax Amount field */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<Label
|
||||
htmlFor={`${formPrefixId}-itemPreTaxAmount${index}`}
|
||||
className=""
|
||||
>
|
||||
<Label htmlFor={`itemPreTaxAmount${index}`} className="">
|
||||
Pre-tax Amount
|
||||
</Label>
|
||||
|
||||
|
|
@ -701,7 +667,7 @@ export const InvoiceItems = memo(function InvoiceItems({
|
|||
render={({ field: { value, onChange, ...field } }) => (
|
||||
<Switch
|
||||
{...field}
|
||||
id={`${formPrefixId}-itemPreTaxAmountFieldIsVisible${index}`}
|
||||
id={`itemPreTaxAmountFieldIsVisible${index}`}
|
||||
checked={value}
|
||||
onCheckedChange={onChange}
|
||||
className="h-5 w-8 [&_span]:size-4 [&_span]:data-[state=checked]:translate-x-3 rtl:[&_span]:data-[state=checked]:-translate-x-3"
|
||||
|
|
@ -711,7 +677,7 @@ export const InvoiceItems = memo(function InvoiceItems({
|
|||
<CustomTooltip
|
||||
trigger={
|
||||
<Label
|
||||
htmlFor={`${formPrefixId}-itemPreTaxAmountFieldIsVisible${index}`}
|
||||
htmlFor={`itemPreTaxAmountFieldIsVisible${index}`}
|
||||
>
|
||||
Show in PDF
|
||||
</Label>
|
||||
|
|
@ -726,17 +692,19 @@ export const InvoiceItems = memo(function InvoiceItems({
|
|||
<Controller
|
||||
name={`items.${index}.preTaxAmount`}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<ReadOnlyMoneyInput
|
||||
{...field}
|
||||
id={`${formPrefixId}-itemPreTaxAmount${index}`}
|
||||
currency={currency}
|
||||
value={field.value.toLocaleString("en-US", {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<ReadOnlyMoneyInput
|
||||
{...field}
|
||||
id={`itemPreTaxAmount${index}`}
|
||||
currency={currency}
|
||||
value={field.value.toLocaleString("en-US", {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
{errors.items?.[index]?.preTaxAmount ? (
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import {
|
||||
Control,
|
||||
type Control,
|
||||
Controller,
|
||||
FieldErrors,
|
||||
UseFormSetValue,
|
||||
type FieldErrors,
|
||||
type UseFormSetValue,
|
||||
} from "react-hook-form";
|
||||
import { InvoiceData, type SellerData } from "@/app/schema";
|
||||
import { type InvoiceData, type SellerData } from "@/app/schema";
|
||||
import { SellerManagement } from "@/components/seller-management";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
|
@ -12,7 +12,6 @@ import { Switch } from "@/components/ui/switch";
|
|||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { CustomTooltip } from "@/components/ui/tooltip";
|
||||
import { memo, useState } from "react";
|
||||
import type { FormPrefixId } from "../..";
|
||||
import { LabelWithEditIcon } from "@/components/label-with-edit-icon";
|
||||
|
||||
const ErrorMessage = ({ children }: { children: React.ReactNode }) => {
|
||||
|
|
@ -20,13 +19,12 @@ const ErrorMessage = ({ children }: { children: React.ReactNode }) => {
|
|||
};
|
||||
|
||||
const SELLER_TOOLTIP_CONTENT =
|
||||
"Click the edit button next to the 'Select Seller' dropdown to modify seller details. Any changes will be automatically saved.";
|
||||
"Seller details are locked. Click the edit button next to the 'Select Seller' dropdown to modify seller details. Any changes will be automatically saved.";
|
||||
|
||||
interface SellerInformationProps {
|
||||
control: Control<InvoiceData>;
|
||||
errors: FieldErrors<InvoiceData>;
|
||||
setValue: UseFormSetValue<InvoiceData>;
|
||||
formPrefixId: FormPrefixId;
|
||||
invoiceData: InvoiceData;
|
||||
}
|
||||
|
||||
|
|
@ -34,12 +32,15 @@ export const SellerInformation = memo(function SellerInformation({
|
|||
control,
|
||||
errors,
|
||||
setValue,
|
||||
formPrefixId,
|
||||
invoiceData,
|
||||
}: SellerInformationProps) {
|
||||
const [selectedSellerId, setSelectedSellerId] = useState("");
|
||||
const isSellerSelected = !!selectedSellerId;
|
||||
|
||||
const HTML_TITLE_CONTENT = isSellerSelected
|
||||
? "Seller details are locked. Click the edit seller button to modify."
|
||||
: "";
|
||||
|
||||
// Get current form values to pass to SellerManagement
|
||||
const currentFormValues = {
|
||||
name: invoiceData.seller.name,
|
||||
|
|
@ -64,17 +65,17 @@ export const SellerInformation = memo(function SellerInformation({
|
|||
formValues={currentFormValues}
|
||||
/>
|
||||
</div>
|
||||
<fieldset className="mt-5 space-y-4" disabled={isSellerSelected}>
|
||||
<fieldset className="mt-5 space-y-4">
|
||||
<div>
|
||||
{isSellerSelected ? (
|
||||
<LabelWithEditIcon
|
||||
htmlFor={`${formPrefixId}-sellerName`}
|
||||
htmlFor={`sellerName`}
|
||||
content={SELLER_TOOLTIP_CONTENT}
|
||||
>
|
||||
Name
|
||||
</LabelWithEditIcon>
|
||||
) : (
|
||||
<Label htmlFor={`${formPrefixId}-sellerName`} className="mb-1">
|
||||
<Label htmlFor={`sellerName`} className="mb-1">
|
||||
Name
|
||||
</Label>
|
||||
)}
|
||||
|
|
@ -84,9 +85,12 @@ export const SellerInformation = memo(function SellerInformation({
|
|||
render={({ field }) => (
|
||||
<Textarea
|
||||
{...field}
|
||||
id={`${formPrefixId}-sellerName`}
|
||||
id={`sellerName`}
|
||||
rows={3}
|
||||
className=""
|
||||
readOnly={isSellerSelected}
|
||||
aria-readonly={isSellerSelected}
|
||||
title={HTML_TITLE_CONTENT}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
|
@ -98,13 +102,13 @@ export const SellerInformation = memo(function SellerInformation({
|
|||
<div>
|
||||
{isSellerSelected ? (
|
||||
<LabelWithEditIcon
|
||||
htmlFor={`${formPrefixId}-sellerAddress`}
|
||||
htmlFor={`sellerAddress`}
|
||||
content={SELLER_TOOLTIP_CONTENT}
|
||||
>
|
||||
Address
|
||||
</LabelWithEditIcon>
|
||||
) : (
|
||||
<Label htmlFor={`${formPrefixId}-sellerAddress`} className="mb-1">
|
||||
<Label htmlFor={`sellerAddress`} className="mb-1">
|
||||
Address
|
||||
</Label>
|
||||
)}
|
||||
|
|
@ -114,10 +118,12 @@ export const SellerInformation = memo(function SellerInformation({
|
|||
render={({ field }) => (
|
||||
<Textarea
|
||||
{...field}
|
||||
id={`${formPrefixId}-sellerAddress`}
|
||||
id={`sellerAddress`}
|
||||
rows={3}
|
||||
className=""
|
||||
disabled={!!selectedSellerId}
|
||||
readOnly={isSellerSelected}
|
||||
aria-readonly={isSellerSelected}
|
||||
title={HTML_TITLE_CONTENT}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
|
@ -130,35 +136,40 @@ export const SellerInformation = memo(function SellerInformation({
|
|||
<div className="relative mb-2 flex items-center justify-between">
|
||||
{isSellerSelected ? (
|
||||
<LabelWithEditIcon
|
||||
htmlFor={`${formPrefixId}-sellerVatNo`}
|
||||
htmlFor={`sellerVatNo`}
|
||||
content={SELLER_TOOLTIP_CONTENT}
|
||||
>
|
||||
VAT Number
|
||||
</LabelWithEditIcon>
|
||||
) : (
|
||||
<Label htmlFor={`${formPrefixId}-sellerVatNo`} className="">
|
||||
<Label htmlFor={`sellerVatNo`} className="">
|
||||
VAT Number
|
||||
</Label>
|
||||
)}
|
||||
|
||||
{/* Show/hide Seller VAT Number field in PDF switch */}
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<div
|
||||
className="inline-flex items-center gap-2"
|
||||
title={HTML_TITLE_CONTENT}
|
||||
>
|
||||
<Controller
|
||||
name={`seller.vatNoFieldIsVisible`}
|
||||
control={control}
|
||||
render={({ field: { value, onChange, ...field } }) => (
|
||||
<Switch
|
||||
{...field}
|
||||
id={`${formPrefixId}-sellerVatNoFieldIsVisible`}
|
||||
id={`sellerVatNoFieldIsVisible`}
|
||||
checked={value}
|
||||
onCheckedChange={onChange}
|
||||
className="h-5 w-8 [&_span]:size-4 [&_span]:data-[state=checked]:translate-x-3 rtl:[&_span]:data-[state=checked]:-translate-x-3"
|
||||
disabled={isSellerSelected}
|
||||
data-testid={`sellerVatNoFieldIsVisible`}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<CustomTooltip
|
||||
trigger={
|
||||
<Label htmlFor={`${formPrefixId}-sellerVatNoFieldIsVisible`}>
|
||||
<Label htmlFor={`sellerVatNoFieldIsVisible`}>
|
||||
Show in PDF
|
||||
</Label>
|
||||
}
|
||||
|
|
@ -176,9 +187,12 @@ export const SellerInformation = memo(function SellerInformation({
|
|||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
id={`${formPrefixId}-sellerVatNo`}
|
||||
id={`sellerVatNo`}
|
||||
type="text"
|
||||
className=""
|
||||
readOnly={isSellerSelected}
|
||||
aria-readonly={isSellerSelected}
|
||||
title={HTML_TITLE_CONTENT}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
|
@ -190,13 +204,13 @@ export const SellerInformation = memo(function SellerInformation({
|
|||
<div>
|
||||
{isSellerSelected ? (
|
||||
<LabelWithEditIcon
|
||||
htmlFor={`${formPrefixId}-sellerEmail`}
|
||||
htmlFor={`sellerEmail`}
|
||||
content={SELLER_TOOLTIP_CONTENT}
|
||||
>
|
||||
Email
|
||||
</LabelWithEditIcon>
|
||||
) : (
|
||||
<Label htmlFor={`${formPrefixId}-sellerEmail`} className="mb-1">
|
||||
<Label htmlFor={`sellerEmail`} className="mb-1">
|
||||
Email
|
||||
</Label>
|
||||
)}
|
||||
|
|
@ -206,9 +220,12 @@ export const SellerInformation = memo(function SellerInformation({
|
|||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
id={`${formPrefixId}-sellerEmail`}
|
||||
id={`sellerEmail`}
|
||||
type="email"
|
||||
className=""
|
||||
readOnly={isSellerSelected}
|
||||
aria-readonly={isSellerSelected}
|
||||
title={HTML_TITLE_CONTENT}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
|
@ -222,40 +239,40 @@ export const SellerInformation = memo(function SellerInformation({
|
|||
<div className="relative mb-2 flex items-center justify-between">
|
||||
{isSellerSelected ? (
|
||||
<LabelWithEditIcon
|
||||
htmlFor={`${formPrefixId}-sellerAccountNumber`}
|
||||
htmlFor={`sellerAccountNumber`}
|
||||
content={SELLER_TOOLTIP_CONTENT}
|
||||
>
|
||||
Account Number
|
||||
</LabelWithEditIcon>
|
||||
) : (
|
||||
<Label
|
||||
htmlFor={`${formPrefixId}-sellerAccountNumber`}
|
||||
className=""
|
||||
>
|
||||
<Label htmlFor={`sellerAccountNumber`} className="">
|
||||
Account Number
|
||||
</Label>
|
||||
)}
|
||||
|
||||
{/* Show/hide Account Number field in PDF switch */}
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<div
|
||||
className="inline-flex items-center gap-2"
|
||||
title={HTML_TITLE_CONTENT}
|
||||
>
|
||||
<Controller
|
||||
name={`seller.accountNumberFieldIsVisible`}
|
||||
control={control}
|
||||
render={({ field: { value, onChange, ...field } }) => (
|
||||
<Switch
|
||||
{...field}
|
||||
id={`${formPrefixId}-sellerAccountNumberFieldIsVisible`}
|
||||
id={`sellerAccountNumberFieldIsVisible`}
|
||||
checked={value}
|
||||
onCheckedChange={onChange}
|
||||
className="h-5 w-8 [&_span]:size-4 [&_span]:data-[state=checked]:translate-x-3 rtl:[&_span]:data-[state=checked]:-translate-x-3"
|
||||
disabled={isSellerSelected}
|
||||
data-testid={`sellerAccountNumberFieldIsVisible`}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<CustomTooltip
|
||||
trigger={
|
||||
<Label
|
||||
htmlFor={`${formPrefixId}-sellerAccountNumberFieldIsVisible`}
|
||||
>
|
||||
<Label htmlFor={`sellerAccountNumberFieldIsVisible`}>
|
||||
Show in PDF
|
||||
</Label>
|
||||
}
|
||||
|
|
@ -270,14 +287,19 @@ export const SellerInformation = memo(function SellerInformation({
|
|||
<Controller
|
||||
name="seller.accountNumber"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Textarea
|
||||
{...field}
|
||||
id="sellerAccountNumber"
|
||||
rows={3}
|
||||
className=""
|
||||
/>
|
||||
)}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Textarea
|
||||
{...field}
|
||||
id={`sellerAccountNumber`}
|
||||
rows={3}
|
||||
className=""
|
||||
readOnly={isSellerSelected}
|
||||
aria-readonly={isSellerSelected}
|
||||
title={HTML_TITLE_CONTENT}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{errors.seller?.accountNumber && (
|
||||
<ErrorMessage>{errors.seller.accountNumber.message}</ErrorMessage>
|
||||
|
|
@ -289,37 +311,40 @@ export const SellerInformation = memo(function SellerInformation({
|
|||
<div className="relative mb-2 flex items-center justify-between">
|
||||
{isSellerSelected ? (
|
||||
<LabelWithEditIcon
|
||||
htmlFor={`${formPrefixId}-sellerSwiftBic`}
|
||||
htmlFor={`sellerSwiftBic`}
|
||||
content={SELLER_TOOLTIP_CONTENT}
|
||||
>
|
||||
SWIFT/BIC
|
||||
</LabelWithEditIcon>
|
||||
) : (
|
||||
<Label htmlFor={`${formPrefixId}-sellerSwiftBic`} className="">
|
||||
<Label htmlFor={`sellerSwiftBic`} className="">
|
||||
SWIFT/BIC
|
||||
</Label>
|
||||
)}
|
||||
|
||||
{/* Show/hide SWIFT/BIC field in PDF switch */}
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<div
|
||||
className="inline-flex items-center gap-2"
|
||||
title={HTML_TITLE_CONTENT}
|
||||
>
|
||||
<Controller
|
||||
name={`seller.swiftBicFieldIsVisible`}
|
||||
control={control}
|
||||
render={({ field: { value, onChange, ...field } }) => (
|
||||
<Switch
|
||||
{...field}
|
||||
id={`${formPrefixId}-sellerSwiftBicFieldIsVisible`}
|
||||
id={`sellerSwiftBicFieldIsVisible`}
|
||||
checked={value}
|
||||
onCheckedChange={onChange}
|
||||
className="h-5 w-8 [&_span]:size-4 [&_span]:data-[state=checked]:translate-x-3 rtl:[&_span]:data-[state=checked]:-translate-x-3"
|
||||
disabled={isSellerSelected}
|
||||
data-testid={`sellerSwiftBicFieldIsVisible`}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<CustomTooltip
|
||||
trigger={
|
||||
<Label
|
||||
htmlFor={`${formPrefixId}-sellerSwiftBicFieldIsVisible`}
|
||||
>
|
||||
<Label htmlFor={`sellerSwiftBicFieldIsVisible`}>
|
||||
Show in PDF
|
||||
</Label>
|
||||
}
|
||||
|
|
@ -335,9 +360,19 @@ export const SellerInformation = memo(function SellerInformation({
|
|||
<Controller
|
||||
name="seller.swiftBic"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Textarea {...field} id="sellerSwiftBic" rows={3} className="" />
|
||||
)}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Textarea
|
||||
{...field}
|
||||
id={`sellerSwiftBic`}
|
||||
rows={3}
|
||||
className=""
|
||||
readOnly={isSellerSelected}
|
||||
aria-readonly={isSellerSelected}
|
||||
title={HTML_TITLE_CONTENT}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{errors.seller?.swiftBic && (
|
||||
<ErrorMessage>{errors.seller.swiftBic.message}</ErrorMessage>
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ const DONATION_TOAST_CONFIG = {
|
|||
closeButton: true,
|
||||
} as const;
|
||||
|
||||
const DONATION_URL = "https://dub.sh/easyinvoice-donate" as const;
|
||||
const DONATION_URL = "https://dub.sh/easyinvoice-donate";
|
||||
|
||||
// Separate button states into a memoized component
|
||||
const ButtonContent = ({
|
||||
|
|
@ -138,7 +138,7 @@ export function InvoicePDFDownloadLink({
|
|||
download={filename}
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
"mb-4 h-[36px] w-full rounded-lg bg-slate-900 px-4 py-2 text-center text-sm font-medium text-slate-50",
|
||||
"h-[36px] w-full rounded-lg bg-slate-900 px-4 py-2 text-center text-sm font-medium text-slate-50",
|
||||
"shadow-sm shadow-black/5 outline-offset-2 hover:bg-slate-900/90",
|
||||
"focus-visible:border-indigo-500 focus-visible:ring focus-visible:ring-indigo-200 focus-visible:ring-opacity-50",
|
||||
"dark:bg-slate-50 dark:text-slate-900 dark:hover:bg-slate-50/90 lg:mb-0 lg:w-[210px]",
|
||||
|
|
|
|||
|
|
@ -4,8 +4,9 @@ export function InvoicePDFViewer({ children }: { children: React.ReactNode }) {
|
|||
return (
|
||||
<PDFViewer
|
||||
width="100%"
|
||||
className="mb-4 h-[580px] w-full lg:h-[620px]"
|
||||
className="mb-4 h-full w-full lg:h-[620px]"
|
||||
title="Invoice PDF Viewer"
|
||||
data-testid="pdf-preview"
|
||||
>
|
||||
{/* @ts-expect-error PR with fix?: https://github.com/diegomura/react-pdf/pull/2888 */}
|
||||
{children}
|
||||
|
|
|
|||
|
|
@ -1,89 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CustomTooltip } from "@/components/ui/tooltip";
|
||||
import { LOADING_BUTTON_TEXT, LOADING_BUTTON_TIMEOUT } from "./invoice-form";
|
||||
import { InvoicePdfTemplate } from "./invoice-pdf-template";
|
||||
import { usePDF } from "@react-pdf/renderer";
|
||||
import type { InvoiceData } from "../schema";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { umamiTrackEvent } from "@/lib/umami-analytics-track-event";
|
||||
import { useOpenPanel } from "@openpanel/nextjs";
|
||||
import { ErrorGeneratingPdfToast } from "@/components/ui/toasts/error-generating-pdf-toast";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import type { FormPrefixId } from ".";
|
||||
|
||||
export function RegenerateInvoiceButton({
|
||||
invoiceData,
|
||||
formPrefixId,
|
||||
}: {
|
||||
invoiceData: InvoiceData;
|
||||
formPrefixId: FormPrefixId;
|
||||
}) {
|
||||
const [{ loading: pdfLoading, error }] = usePDF({
|
||||
document: <InvoicePdfTemplate invoiceData={invoiceData} />,
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const openPanel = useOpenPanel();
|
||||
|
||||
useEffect(() => {
|
||||
if (pdfLoading) {
|
||||
setIsLoading(true);
|
||||
} else {
|
||||
// When PDF loading completes, wait for 0.5 second before hiding the loader
|
||||
const timer = setTimeout(() => {
|
||||
setIsLoading(false);
|
||||
}, LOADING_BUTTON_TIMEOUT);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [pdfLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
ErrorGeneratingPdfToast();
|
||||
|
||||
openPanel.track("error_generating_pdf_regenerate_button", {
|
||||
data: {
|
||||
error: error,
|
||||
},
|
||||
});
|
||||
umamiTrackEvent("error_generating_pdf_regenerate_button", {
|
||||
data: {
|
||||
error: error,
|
||||
},
|
||||
});
|
||||
|
||||
Sentry.captureException(error);
|
||||
}
|
||||
}, [error, openPanel]);
|
||||
|
||||
return (
|
||||
<CustomTooltip
|
||||
trigger={
|
||||
<Button
|
||||
type="submit"
|
||||
form={formPrefixId}
|
||||
_variant="outline"
|
||||
className="mt-2 w-full"
|
||||
disabled={isLoading}
|
||||
onClick={() => {
|
||||
// analytics events
|
||||
openPanel.track("regenerate_invoice");
|
||||
umamiTrackEvent("regenerate_invoice");
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="inline-flex items-center">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
<span className="animate-pulse">{LOADING_BUTTON_TEXT}</span>
|
||||
</span>
|
||||
) : (
|
||||
"Regenerate Invoice"
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
content={isLoading ? null : "Click to regenerate the invoice PDF preview"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -6,6 +6,8 @@ import { SpeedInsights } from "@vercel/speed-insights/next";
|
|||
|
||||
import "./globals.css";
|
||||
import { Toaster } from "sonner";
|
||||
import { checkDeviceUserAgent } from "@/lib/check-device.server";
|
||||
import { DeviceContextProvider } from "@/contexts/device-context";
|
||||
|
||||
export const viewport: Viewport = {
|
||||
initialScale: 1, // Sets the default zoom level to 1 (100%)
|
||||
|
|
@ -51,46 +53,53 @@ export const metadata: Metadata = {
|
|||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const { isDesktop: isDesktopServer } = await checkDeviceUserAgent();
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
{process.env.VERCEL_ENV === "development" && (
|
||||
{/* React-scan is a tool for detecting and fixing issues with React components
|
||||
https://github.com/aidenybai/react-scan#readme
|
||||
Uncomment if needed
|
||||
*/}
|
||||
{/* {process.env.VERCEL_ENV === "development" && (
|
||||
<head>
|
||||
{/* eslint-disable-next-line @next/next/no-sync-scripts */}
|
||||
<script
|
||||
crossOrigin="anonymous"
|
||||
src="//unpkg.com/react-scan/dist/auto.global.js"
|
||||
/>
|
||||
</head>
|
||||
)}
|
||||
)} */}
|
||||
<body className={`antialiased`}>
|
||||
<NuqsAdapter>{children}</NuqsAdapter>
|
||||
|
||||
{/* https://vercel.com/vladsazon27s-projects/pdf-invoice-generator/speed-insights */}
|
||||
{process.env.VERCEL_ENV === "production" && <SpeedInsights />}
|
||||
<DeviceContextProvider isDesktop={isDesktopServer}>
|
||||
<NuqsAdapter>{children}</NuqsAdapter>
|
||||
</DeviceContextProvider>
|
||||
|
||||
{/* https://sonner.emilkowal.ski/ */}
|
||||
<Toaster visibleToasts={1} richColors />
|
||||
|
||||
{/* https://openpanel.dev/docs */}
|
||||
{/* should only be enabled in production */}
|
||||
{process.env.VERCEL_ENV === "production" && (
|
||||
<OpenPanelComponent
|
||||
clientId="34cab0b1-c372-4d2d-9646-9a4cea67faf9"
|
||||
trackScreenViews={true}
|
||||
/>
|
||||
)}
|
||||
|
||||
{process.env.VERCEL_ENV === "production" && (
|
||||
<Script
|
||||
// we proxy umami check next.config.mjs rewrites
|
||||
src="/stats/script.js"
|
||||
data-website-id="1914352c-5ebb-4806-bfc3-f494712bb4a4"
|
||||
defer
|
||||
/>
|
||||
<>
|
||||
{/* https://vercel.com/vladsazon27s-projects/pdf-invoice-generator/speed-insights */}
|
||||
<SpeedInsights />
|
||||
{/* https://openpanel.dev/docs */}
|
||||
<OpenPanelComponent
|
||||
clientId="34cab0b1-c372-4d2d-9646-9a4cea67faf9"
|
||||
trackScreenViews={true}
|
||||
/>
|
||||
{/* https://eu.umami.is/dashboard */}
|
||||
<Script
|
||||
// we proxy umami check next.config.mjs rewrites
|
||||
src="/stats/script.js"
|
||||
data-website-id="1914352c-5ebb-4806-bfc3-f494712bb4a4"
|
||||
defer
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ export default function Loading() {
|
|||
|
||||
{/* Form fields skeleton */}
|
||||
<div className="space-y-4">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<div className="h-4 w-32 animate-pulse rounded bg-gray-200" />
|
||||
<div className="h-10 animate-pulse rounded bg-gray-200" />
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
import { invoiceSchema, type InvoiceData } from "@/app/schema";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CustomTooltip, TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { isLocalStorageAvailable } from "@/lib/check-local-storage";
|
||||
import { umamiTrackEvent } from "@/lib/umami-analytics-track-event";
|
||||
import { useOpenPanel } from "@openpanel/nextjs";
|
||||
|
|
@ -20,13 +19,16 @@ import { PDF_DATA_LOCAL_STORAGE_KEY } from "./components/invoice-form";
|
|||
import { InvoicePDFDownloadLink } from "./components/invoice-pdf-download-link";
|
||||
import { INITIAL_INVOICE_DATA } from "./constants";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { InvoicePDFDownloadMultipleLanguages } from "./components/invoice-pdf-download-multiple-languages";
|
||||
import { useDeviceContext } from "@/contexts/device-context";
|
||||
// import { InvoicePDFDownloadMultipleLanguages } from "./components/invoice-pdf-download-multiple-languages";
|
||||
|
||||
export default function Home() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const isDesktop = useMediaQuery("(min-width: 1024px)");
|
||||
|
||||
const openPanel = useOpenPanel();
|
||||
const { isDesktop } = useDeviceContext();
|
||||
const isMobile = !isDesktop;
|
||||
|
||||
const [invoiceDataState, setInvoiceDataState] = useState<InvoiceData | null>(
|
||||
null
|
||||
|
|
@ -42,7 +44,7 @@ export default function Home() {
|
|||
const decompressed = decompressFromEncodedURIComponent(
|
||||
compressedInvoiceDataInUrl
|
||||
);
|
||||
const parsed = JSON.parse(decompressed);
|
||||
const parsed: unknown = JSON.parse(decompressed);
|
||||
const validated = invoiceSchema.parse(parsed);
|
||||
|
||||
setInvoiceDataState(validated);
|
||||
|
|
@ -64,7 +66,7 @@ export default function Home() {
|
|||
try {
|
||||
const savedData = localStorage.getItem(PDF_DATA_LOCAL_STORAGE_KEY);
|
||||
if (savedData) {
|
||||
const json = JSON.parse(savedData);
|
||||
const json: unknown = JSON.parse(savedData);
|
||||
const parsedData = invoiceSchema.parse(json);
|
||||
|
||||
setInvoiceDataState(parsedData);
|
||||
|
|
@ -99,7 +101,7 @@ export default function Home() {
|
|||
if (urlData) {
|
||||
try {
|
||||
const decompressed = decompressFromEncodedURIComponent(urlData);
|
||||
const urlParsed = JSON.parse(decompressed);
|
||||
const urlParsed: unknown = JSON.parse(decompressed);
|
||||
|
||||
const urlValidated = invoiceSchema.parse(urlParsed);
|
||||
|
||||
|
|
@ -186,7 +188,7 @@ export default function Home() {
|
|||
return (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<div className="flex min-h-screen flex-col items-center justify-center bg-gray-100 sm:p-4">
|
||||
<div className="mb-4 w-full max-w-7xl bg-white p-3 shadow-lg sm:mb-0 sm:rounded-lg sm:p-6">
|
||||
<div className="w-full max-w-7xl bg-white p-3 shadow-lg sm:mb-0 sm:rounded-lg sm:p-6">
|
||||
<div className="flex w-full flex-row flex-wrap items-center justify-between lg:flex-nowrap">
|
||||
<div className="relative bottom-3 mt-3 flex flex-col items-center justify-center sm:mt-0">
|
||||
<div className="flex items-center">
|
||||
|
|
@ -227,20 +229,23 @@ export default function Home() {
|
|||
<span>Support Project</span>
|
||||
</span>
|
||||
</Button>
|
||||
<CustomTooltip
|
||||
trigger={
|
||||
<Button
|
||||
onClick={handleShareInvoice}
|
||||
_variant="outline"
|
||||
className="mx-2 mb-2 w-full lg:mx-0 lg:mb-0 lg:w-auto"
|
||||
>
|
||||
Generate a link to invoice
|
||||
</Button>
|
||||
}
|
||||
content="Generate a shareable link to this invoice. Share it with your clients to allow them to view the invoice online."
|
||||
/>
|
||||
|
||||
{isDesktop ? (
|
||||
<InvoicePDFDownloadLink invoiceData={invoiceDataState} />
|
||||
<>
|
||||
<CustomTooltip
|
||||
trigger={
|
||||
<Button
|
||||
onClick={handleShareInvoice}
|
||||
_variant="outline"
|
||||
className="mx-2 mb-2 w-full lg:mx-0 lg:mb-0 lg:w-auto"
|
||||
>
|
||||
Generate a link to invoice
|
||||
</Button>
|
||||
}
|
||||
content="Generate a shareable link to this invoice. Share it with your clients to allow them to view the invoice online."
|
||||
/>
|
||||
<InvoicePDFDownloadLink invoiceData={invoiceDataState} />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{/* TODO: add later when PRO version is released, this is PRO FEATURE =) */}
|
||||
|
|
@ -251,7 +256,7 @@ export default function Home() {
|
|||
) : null} */}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-4 flex flex-row items-center justify-center lg:mb-0 lg:mt-4 lg:justify-start xl:mt-0">
|
||||
<div className="mb-3 mt-2 flex flex-row items-center justify-center lg:mb-0 lg:mt-4 lg:justify-start xl:mt-0">
|
||||
<ProjectInfo />
|
||||
</div>
|
||||
|
||||
|
|
@ -259,6 +264,8 @@ export default function Home() {
|
|||
<InvoiceClientPage
|
||||
invoiceDataState={invoiceDataState}
|
||||
handleInvoiceDataChange={handleInvoiceDataChange}
|
||||
handleShareInvoice={handleShareInvoice}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -22,6 +22,14 @@ export const SUPPORTED_DATE_FORMATS = [
|
|||
"YYYY.MM.DD", // 2024.03.20
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Supported date formats
|
||||
*
|
||||
* This is the list of date formats that are supported by the invoice form
|
||||
*
|
||||
*
|
||||
* @lintignore ignore for now in knip
|
||||
*/
|
||||
export type SupportedDateFormat = (typeof SUPPORTED_DATE_FORMATS)[number];
|
||||
|
||||
export const invoiceItemSchema = z
|
||||
|
|
@ -54,7 +62,7 @@ export const invoiceItemSchema = z
|
|||
message: "Amount must be positive",
|
||||
})
|
||||
.refine((val) => val <= 9_999_999_999.99, {
|
||||
message: "Amount must not exceed 9.999.999.999",
|
||||
message: "Amount must not exceed 9 999 999 999.99",
|
||||
}),
|
||||
amountFieldIsVisible: z.boolean().default(true),
|
||||
|
||||
|
|
@ -223,18 +231,11 @@ export const invoiceSchema = z.object({
|
|||
|
||||
export type InvoiceData = z.infer<typeof invoiceSchema>;
|
||||
|
||||
// https://github.com/colinhacks/zod/discussions/2814#discussioncomment-7121769
|
||||
// const zodInputStringPipe = (zodPipe: ZodTypeAny) =>
|
||||
// z
|
||||
// .string()
|
||||
// .transform((value) => (value === "" ? null : value))
|
||||
// .nullable()
|
||||
// .refine((value) => value === null || !isNaN(Number(value)), {
|
||||
// message: "Nombre Invalide",
|
||||
// })
|
||||
// .transform((value) => (value === null ? 0 : Number(value)))
|
||||
// .pipe(zodPipe);
|
||||
|
||||
/**
|
||||
* Default seller data
|
||||
*
|
||||
* This is the default data that will be used if the user doesn't provide their own data
|
||||
*/
|
||||
export const DEFAULT_SELLER_DATA = {
|
||||
name: "Seller name",
|
||||
address: "Seller address",
|
||||
|
|
@ -251,6 +252,11 @@ export const DEFAULT_SELLER_DATA = {
|
|||
swiftBicFieldIsVisible: true,
|
||||
} as const satisfies Omit<SellerData, "id">;
|
||||
|
||||
/**
|
||||
* Default buyer data
|
||||
*
|
||||
* This is the default data that will be used if the user doesn't provide their own data
|
||||
*/
|
||||
export const DEFAULT_BUYER_DATA = {
|
||||
name: "Buyer name",
|
||||
address: "Buyer address",
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ export function BuyerDialog({
|
|||
|
||||
// Get existing buyers or initialize empty array
|
||||
const buyers = localStorage.getItem(BUYERS_LOCAL_STORAGE_KEY);
|
||||
const existingBuyers = buyers ? JSON.parse(buyers) : [];
|
||||
const existingBuyers: unknown = buyers ? JSON.parse(buyers) : [];
|
||||
|
||||
// Validate existing buyers array with Zod
|
||||
const existingBuyersValidationResult = z
|
||||
|
|
@ -131,7 +131,7 @@ export function BuyerDialog({
|
|||
}
|
||||
|
||||
// Validate buyer data against existing buyers
|
||||
const isDuplicateName = existingBuyers.some(
|
||||
const isDuplicateName = existingBuyersValidationResult.data.some(
|
||||
(buyer: BuyerData) =>
|
||||
buyer.name === formValues.name && buyer.id !== formValues.id
|
||||
);
|
||||
|
|
@ -188,7 +188,10 @@ export function BuyerDialog({
|
|||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="flex flex-col gap-0 overflow-y-visible p-0 sm:max-w-lg [&>button:last-child]:top-3.5">
|
||||
<DialogContent
|
||||
className="flex flex-col gap-0 overflow-y-visible p-0 sm:max-w-lg [&>button:last-child]:top-3.5"
|
||||
data-testid={`manage-buyer-dialog`}
|
||||
>
|
||||
<DialogHeader className="border-b border-slate-200 px-6 py-4 dark:border-slate-800">
|
||||
<DialogTitle className="text-base">
|
||||
{isEditMode ? "Edit Buyer" : "Add New Buyer"}
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ export function BuyerManagement({
|
|||
useEffect(() => {
|
||||
try {
|
||||
const savedBuyers = localStorage.getItem(BUYERS_LOCAL_STORAGE_KEY);
|
||||
const parsedBuyers = savedBuyers ? JSON.parse(savedBuyers) : [];
|
||||
const parsedBuyers: unknown = savedBuyers ? JSON.parse(savedBuyers) : [];
|
||||
|
||||
// Validate buyers array with Zod
|
||||
const buyersSchema = z.array(buyerSchema);
|
||||
|
|
@ -284,6 +284,7 @@ export function BuyerManagement({
|
|||
}}
|
||||
className="h-8 px-2"
|
||||
>
|
||||
<span className="sr-only">Edit buyer</span>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
}
|
||||
|
|
@ -302,6 +303,7 @@ export function BuyerManagement({
|
|||
}}
|
||||
className="h-8 px-2"
|
||||
>
|
||||
<span className="sr-only">Delete buyer</span>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
} from "@/components/ui/popover";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { CustomTooltip } from "@/components/ui/tooltip";
|
||||
import { useIsDesktop } from "@/hooks/use-media-query";
|
||||
|
||||
/**
|
||||
* A component that displays a label with an edit icon and a tooltip.
|
||||
|
|
@ -19,29 +20,37 @@ export const LabelWithEditIcon = ({
|
|||
children: React.ReactNode;
|
||||
content: string;
|
||||
}) => {
|
||||
const isDesktop = useIsDesktop();
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<Label htmlFor={htmlFor} className="mb-1">
|
||||
{children}
|
||||
</Label>
|
||||
<div className="hidden md:block">
|
||||
{isDesktop ? (
|
||||
<CustomTooltip
|
||||
trigger={
|
||||
<Info className="mb-1 h-3.5 w-3.5 cursor-pointer text-gray-800" />
|
||||
<Info
|
||||
className="mb-1 h-3.5 w-3.5 cursor-pointer text-gray-800"
|
||||
data-testid="form-section-tooltip-info-icon-desktop"
|
||||
/>
|
||||
}
|
||||
content={content}
|
||||
/>
|
||||
</div>
|
||||
<div className="block md:hidden">
|
||||
) : (
|
||||
// Mobile view
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Info className="mb-1 h-3.5 w-3.5 cursor-pointer text-gray-800" />
|
||||
<Info
|
||||
className="mb-1 h-3.5 w-3.5 cursor-pointer text-gray-800"
|
||||
data-testid="form-section-tooltip-info-icon-mobile"
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] text-sm">
|
||||
{content}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -117,7 +117,7 @@ export function SellerDialog({
|
|||
|
||||
// Get existing sellers or initialize empty array
|
||||
const sellers = localStorage.getItem(SELLERS_LOCAL_STORAGE_KEY);
|
||||
const existingSellers = sellers ? JSON.parse(sellers) : [];
|
||||
const existingSellers: unknown = sellers ? JSON.parse(sellers) : [];
|
||||
|
||||
// Validate existing sellers array with Zod
|
||||
const existingSellersValidationResult = z
|
||||
|
|
@ -145,7 +145,7 @@ export function SellerDialog({
|
|||
// we don't need to validate the name if we are editing an existing seller
|
||||
|
||||
// Validate seller data against existing sellers
|
||||
const isDuplicateName = existingSellers.some(
|
||||
const isDuplicateName = existingSellersValidationResult.data.some(
|
||||
(seller: SellerData) =>
|
||||
seller.name === formValues.name && seller.id !== formValues.id
|
||||
);
|
||||
|
|
@ -202,7 +202,10 @@ export function SellerDialog({
|
|||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="flex flex-col gap-0 overflow-y-visible p-0 sm:max-w-lg [&>button:last-child]:top-3.5">
|
||||
<DialogContent
|
||||
className="flex flex-col gap-0 overflow-y-visible p-0 sm:max-w-lg [&>button:last-child]:top-3.5"
|
||||
data-testid={`manage-seller-dialog`}
|
||||
>
|
||||
<DialogHeader className="border-b border-slate-200 px-6 py-4 dark:border-slate-800">
|
||||
<DialogTitle className="text-base">
|
||||
{isEditMode ? "Edit Seller" : "Add New Seller"}
|
||||
|
|
|
|||
|
|
@ -72,7 +72,9 @@ export function SellerManagement({
|
|||
useEffect(() => {
|
||||
try {
|
||||
const savedSellers = localStorage.getItem(SELLERS_LOCAL_STORAGE_KEY);
|
||||
const parsedSellers = savedSellers ? JSON.parse(savedSellers) : [];
|
||||
const parsedSellers: unknown = savedSellers
|
||||
? JSON.parse(savedSellers)
|
||||
: [];
|
||||
|
||||
// Validate sellers array with Zod
|
||||
const sellersSchema = z.array(sellerSchema);
|
||||
|
|
@ -296,6 +298,7 @@ export function SellerManagement({
|
|||
}}
|
||||
className="h-8 px-2"
|
||||
>
|
||||
<span className="sr-only">Edit seller</span>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
}
|
||||
|
|
@ -314,6 +317,7 @@ export function SellerManagement({
|
|||
}}
|
||||
className="h-8 px-2"
|
||||
>
|
||||
<span className="sr-only">Delete seller</span>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border border-slate-200 px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-slate-950 focus:ring-offset-2 dark:border-slate-800 dark:focus:ring-slate-300",
|
||||
|
|
@ -21,7 +21,7 @@ const badgeVariants = cva(
|
|||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
|
|
@ -30,7 +30,7 @@ export interface BadgeProps
|
|||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
export { Badge, badgeVariants };
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import type * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import {
|
||||
Controller,
|
||||
ControllerProps,
|
||||
FieldPath,
|
||||
FieldValues,
|
||||
type ControllerProps,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
} from "react-hook-form";
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ const Input = React.memo(
|
|||
type={type}
|
||||
autoComplete="off"
|
||||
className={cn(
|
||||
"h-8 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-slate-950 shadow-sm shadow-black/5 transition-shadow placeholder:text-slate-500/70 focus-visible:border-indigo-500 focus-visible:ring focus-visible:ring-indigo-200 focus-visible:ring-opacity-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-50 dark:placeholder:text-slate-400/70 dark:focus-visible:border-slate-300 dark:focus-visible:ring-slate-300/20",
|
||||
"h-8 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-slate-950 shadow-sm shadow-black/5 transition-shadow placeholder:text-slate-500/70 focus-visible:border-indigo-500 focus-visible:ring focus-visible:ring-indigo-200 focus-visible:ring-opacity-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-50 dark:placeholder:text-slate-400/70 dark:focus-visible:border-slate-300 dark:focus-visible:ring-slate-300/20 [&[aria-readonly=true]]:cursor-not-allowed [&[aria-readonly=true]]:bg-slate-50 [&[aria-readonly=true]]:opacity-50 [&[aria-readonly=true]]:dark:bg-slate-900",
|
||||
type === "search" &&
|
||||
"[&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none [&::-webkit-search-results-button]:appearance-none [&::-webkit-search-results-decoration]:appearance-none",
|
||||
type === "file" &&
|
||||
|
|
|
|||
|
|
@ -13,14 +13,20 @@ export const CURRENCY_SYMBOLS = {
|
|||
const MoneyInput = React.memo(
|
||||
React.forwardRef<
|
||||
HTMLInputElement,
|
||||
React.ComponentProps<"input"> & { currency: InvoiceData["currency"] }
|
||||
>(({ currency, ...props }, ref) => {
|
||||
React.ComponentProps<"input"> & {
|
||||
currency: InvoiceData["currency"];
|
||||
dataTestId?: string;
|
||||
}
|
||||
>(({ currency, dataTestId, ...props }, ref) => {
|
||||
const shownCurrencyText = currency || SUPPORTED_CURRENCIES[0];
|
||||
const currencySymbol = CURRENCY_SYMBOLS[shownCurrencyText] || null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="relative flex rounded-lg shadow-sm shadow-black/5">
|
||||
<div
|
||||
className="relative flex rounded-lg shadow-sm shadow-black/5"
|
||||
data-testid={dataTestId}
|
||||
>
|
||||
{currencySymbol ? (
|
||||
<span className="pointer-events-none absolute inset-y-0 start-0 flex items-center justify-center ps-3 text-sm">
|
||||
{currencySymbol}
|
||||
|
|
@ -49,14 +55,20 @@ MoneyInput.displayName = "MoneyInput";
|
|||
const ReadOnlyMoneyInput = React.memo(
|
||||
React.forwardRef<
|
||||
HTMLInputElement,
|
||||
React.ComponentProps<"input"> & { currency: InvoiceData["currency"] }
|
||||
>(({ currency, ...props }, ref) => {
|
||||
React.ComponentProps<"input"> & {
|
||||
currency: InvoiceData["currency"];
|
||||
dataTestId?: string;
|
||||
}
|
||||
>(({ currency, dataTestId, ...props }, ref) => {
|
||||
const shownCurrencyText = currency || SUPPORTED_CURRENCIES[0];
|
||||
const currencySymbol = CURRENCY_SYMBOLS[shownCurrencyText] || null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="relative flex rounded-lg shadow-sm shadow-black/5">
|
||||
<div
|
||||
className="relative flex rounded-lg shadow-sm shadow-black/5"
|
||||
data-testid={dataTestId}
|
||||
>
|
||||
{currencySymbol ? (
|
||||
<span className="pointer-events-none absolute inset-y-0 start-0 flex items-center justify-center ps-3 text-sm">
|
||||
{currencySymbol}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { CheckIcon, XCircle, ChevronDown, XIcon } from "lucide-react";
|
||||
import { CheckIcon, XCircle, ChevronDown } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
|
@ -236,8 +236,7 @@ export const MultiSelect = React.forwardRef<
|
|||
.slice(0, maxCount)
|
||||
.map((value, index) => {
|
||||
const isLast = index === selectedLanguages.length - 1;
|
||||
const label =
|
||||
LANGUAGE_TO_LABEL[value as SupportedLanguages];
|
||||
const label = LANGUAGE_TO_LABEL[value];
|
||||
|
||||
return (
|
||||
<React.Fragment key={value}>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
import * as React from "react";
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Popover = PopoverPrimitive.Root
|
||||
const Popover = PopoverPrimitive.Root;
|
||||
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger;
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
|
|
@ -25,7 +25,7 @@ const PopoverContent = React.forwardRef<
|
|||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
))
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||
));
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent }
|
||||
export { Popover, PopoverTrigger, PopoverContent };
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
import * as React from "react";
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
|
|
@ -25,7 +25,7 @@ const Separator = React.forwardRef<
|
|||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||
);
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
||||
|
||||
export { Separator }
|
||||
export { Separator };
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ const Textarea = React.forwardRef<
|
|||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-slate-950 shadow-sm shadow-black/5 transition-shadow placeholder:text-slate-500/70 focus-visible:border-indigo-500 focus-visible:ring focus-visible:ring-indigo-200 focus-visible:ring-opacity-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-50 dark:placeholder:text-slate-400/70 dark:focus-visible:border-slate-300 dark:focus-visible:ring-slate-300/20",
|
||||
"flex min-h-[80px] w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-slate-950 shadow-sm shadow-black/5 transition-shadow placeholder:text-slate-500/70 focus-visible:border-indigo-500 focus-visible:ring focus-visible:ring-indigo-200 focus-visible:ring-opacity-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-50 dark:placeholder:text-slate-400/70 dark:focus-visible:border-slate-300 dark:focus-visible:ring-slate-300/20 [&[aria-readonly=true]]:cursor-not-allowed [&[aria-readonly=true]]:bg-slate-50 [&[aria-readonly=true]]:opacity-50 [&[aria-readonly=true]]:dark:bg-slate-900",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
|
|
|
|||
44
src/contexts/device-context.tsx
Normal file
44
src/contexts/device-context.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
"use client";
|
||||
|
||||
import { useIsDesktop } from "@/hooks/use-media-query";
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
|
||||
interface DeviceContextType {
|
||||
isDesktop: boolean;
|
||||
}
|
||||
|
||||
const DeviceContext = createContext<DeviceContextType | null>(null);
|
||||
|
||||
export function useDeviceContext() {
|
||||
const context = useContext(DeviceContext);
|
||||
if (!context) {
|
||||
throw new Error("useDeviceContext must be used within a DeviceProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function DeviceContextProvider({
|
||||
children,
|
||||
isDesktop,
|
||||
}: DeviceContextType & { children: React.ReactNode }) {
|
||||
const [isDesktopClient, setIsDesktopClient] = useState(isDesktop);
|
||||
|
||||
// Check media query on client side as an additional check
|
||||
const isMediaQueryDesktop = useIsDesktop();
|
||||
|
||||
/**
|
||||
* Update the client state if the media query is defined
|
||||
* This is to ensure that the client state is always up to date
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (isMediaQueryDesktop !== undefined) {
|
||||
setIsDesktopClient(isMediaQueryDesktop);
|
||||
}
|
||||
}, [isMediaQueryDesktop]);
|
||||
|
||||
return (
|
||||
<DeviceContext.Provider value={{ isDesktop: isDesktopClient }}>
|
||||
{children}
|
||||
</DeviceContext.Provider>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export interface UseMediaQueryOptions {
|
||||
interface UseMediaQueryOptions {
|
||||
getInitialValueInEffect: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -36,7 +36,7 @@ function getInitialValue(query: string, initialValue?: boolean) {
|
|||
return false;
|
||||
}
|
||||
|
||||
export function useMediaQuery(
|
||||
function useMediaQuery(
|
||||
query: string,
|
||||
initialValue?: boolean,
|
||||
{ getInitialValueInEffect }: UseMediaQueryOptions = {
|
||||
|
|
@ -62,3 +62,7 @@ export function useMediaQuery(
|
|||
|
||||
return matches;
|
||||
}
|
||||
|
||||
export const useIsDesktop = () => {
|
||||
return useMediaQuery("(min-width: 1024px)");
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,14 +3,14 @@ import * as Sentry from "@sentry/nextjs";
|
|||
export async function register() {
|
||||
if (
|
||||
process.env.NEXT_RUNTIME === "nodejs" &&
|
||||
process.env.NODE_ENV === "production"
|
||||
process.env.VERCEL_ENV === "production"
|
||||
) {
|
||||
await import("../sentry.server.config");
|
||||
}
|
||||
|
||||
if (
|
||||
process.env.NEXT_RUNTIME === "edge" &&
|
||||
process.env.NODE_ENV === "production"
|
||||
process.env.VERCEL_ENV === "production"
|
||||
) {
|
||||
await import("../sentry.edge.config");
|
||||
}
|
||||
|
|
|
|||
38
src/lib/check-device.server.ts
Normal file
38
src/lib/check-device.server.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
"use server";
|
||||
|
||||
import { headers } from "next/headers";
|
||||
import { UAParser } from "ua-parser-js";
|
||||
|
||||
/**
|
||||
* Check the device type based on the user agent
|
||||
* @warning **Should only be used on server side**
|
||||
*/
|
||||
export const checkDeviceUserAgent = async () => {
|
||||
if (typeof process === "undefined") {
|
||||
throw new Error(
|
||||
"[Server method] you are importing a server-only module outside of server"
|
||||
);
|
||||
}
|
||||
|
||||
const { get } = headers();
|
||||
const ua = get("user-agent");
|
||||
|
||||
const parser = new UAParser(ua || "");
|
||||
|
||||
const device = parser.getDevice();
|
||||
const os = parser.getOS();
|
||||
|
||||
// Detect tablets specifically
|
||||
const isTablet =
|
||||
device.type === "tablet" ||
|
||||
// iPad on iOS 13+ reports as desktop
|
||||
(os.name === "iOS" && !ua?.includes("iPhone") && !ua?.includes("iPod"));
|
||||
|
||||
// Detect mobile phones
|
||||
const isMobile = device.type === "mobile";
|
||||
|
||||
// Desktop is when it's neither tablet nor mobile
|
||||
const isDesktop = !isTablet && !isMobile;
|
||||
|
||||
return { isDesktop };
|
||||
};
|
||||
|
|
@ -65,7 +65,7 @@ export function getAmountInWords({
|
|||
}
|
||||
|
||||
// Get the fractional part of the total
|
||||
export function getNumberFractionalPart(total: number = 0) {
|
||||
export function getNumberFractionalPart(total = 0) {
|
||||
const schema = z.number().finite().nonnegative("Amount must be non-negative");
|
||||
|
||||
const parsedTotal = schema.safeParse(total);
|
||||
|
|
|
|||
Loading…
Reference in a new issue