From 9709f2d3be911be9e9160c0131e85af3b604c582 Mon Sep 17 00:00:00 2001 From: NickDabizaz <94162428+NickDabizaz@users.noreply.github.com> Date: Tue, 24 Mar 2026 19:30:56 +0700 Subject: [PATCH 1/2] feat: add settings infrastructure, enhanced line items, i18n, and bug fixes - feat(settings): add SettingsContext with localStorage persistence and dynamic Zod schema factory - feat(settings): add Settings panel (Sheet/Drawer) with gear icon in navbar - feat(line-items): add per-item discount, tax, and SKU/item code columns (settings-gated) - feat(payment): add cash payment mode with change field and conditional bank details - feat(currency): add auto currency symbol display in Invoice Details and line items - feat(import): add multi-format import support for CSV, XML, and XLS/XLSX - feat(i18n): register Bahasa Indonesia locale in LOCALES array - fix(navbar): comment out DevDebug panel to remove DEV debug overlay in navbar - chore(migration): add migrateInvoice() to handle legacy localStorage invoices with new fields --- app/components/index.ts | 8 + app/components/invoice/actions/PdfViewer.tsx | 24 +- app/components/invoice/form/Charges.tsx | 46 +- app/components/invoice/form/SingleItem.tsx | 235 +++++++-- .../form/sections/ImportJsonButton.tsx | 25 +- .../invoice/form/sections/Items.tsx | 5 + .../form/sections/PaymentInformation.tsx | 91 +++- app/components/layout/BaseNavbar.tsx | 71 ++- .../reusables/form-fields/FormInput.tsx | 3 + .../reusables/form-fields/FormSelect.tsx | 74 +++ app/components/settings/SettingsPanel.tsx | 469 ++++++++++++++++++ .../invoice-pdf/InvoiceTemplate1.tsx | 143 ++++-- .../invoice-pdf/InvoiceTemplate2.tsx | 168 +++++-- contexts/InvoiceContext.tsx | 53 +- contexts/Providers.tsx | 28 +- contexts/SettingsContext.tsx | 69 +++ lib/helpers.ts | 26 + lib/schemas.ts | 227 ++++++++- lib/variables.ts | 60 ++- services/invoice/client/importInvoice.ts | 237 +++++++++ types.ts | 36 ++ 21 files changed, 1833 insertions(+), 265 deletions(-) create mode 100644 app/components/reusables/form-fields/FormSelect.tsx create mode 100644 app/components/settings/SettingsPanel.tsx create mode 100644 contexts/SettingsContext.tsx create mode 100644 services/invoice/client/importInvoice.ts diff --git a/app/components/index.ts b/app/components/index.ts index 8ad752ec8..1b40edd45 100644 --- a/app/components/index.ts +++ b/app/components/index.ts @@ -4,6 +4,11 @@ import BaseNavbar from "./layout/BaseNavbar"; import BaseFooter from "./layout/BaseFooter"; +/* ========================= + * Settings + ========================= */ +import { SettingsPanel } from "./settings/SettingsPanel"; + /* ========================= * Invoice ========================= */ @@ -44,6 +49,7 @@ import FinalPdf from "./invoice/actions/FinalPdf"; import CurrencySelector from "./reusables/form-fields/CurrencySelector"; import FormInput from "./reusables/form-fields/FormInput"; import FormTextarea from "./reusables/form-fields/FormTextarea"; +import FormSelect from "./reusables/form-fields/FormSelect"; import DatePickerFormField from "./reusables/form-fields/DatePickerFormField"; import FormFile from "./reusables/form-fields/FormFile"; import ChargeInput from "./reusables/form-fields/ChargeInput"; @@ -101,6 +107,7 @@ import DevDebug from "./dev/DevDebug"; export { BaseNavbar, BaseFooter, + SettingsPanel, InvoiceMain, InvoiceForm, InvoiceActions, @@ -123,6 +130,7 @@ export { FinalPdf, FormInput, FormTextarea, + FormSelect, DatePickerFormField, FormFile, ChargeInput, diff --git a/app/components/invoice/actions/PdfViewer.tsx b/app/components/invoice/actions/PdfViewer.tsx index 5b7c32f71..2b323784b 100644 --- a/app/components/invoice/actions/PdfViewer.tsx +++ b/app/components/invoice/actions/PdfViewer.tsx @@ -11,22 +11,44 @@ import { FinalPdf, LivePreview } from "@/app/components"; // Contexts import { useInvoiceContext } from "@/contexts/InvoiceContext"; +import { useSettings } from "@/contexts/SettingsContext"; // Types import { InvoiceType } from "@/types"; const PdfViewer = () => { const { invoicePdf } = useInvoiceContext(); + const { settings } = useSettings(); const { watch } = useFormContext(); const [debouncedWatch] = useDebounce(watch, 1000); const formValues = debouncedWatch(); + // Clean data based on settings - remove discount/tax if settings are disabled + const cleanedData = { + ...formValues, + details: { + ...formValues.details, + items: formValues.details.items.map(item => { + const cleanedItem = { ...item }; + if (!settings.discountPerItem.enabled) { + cleanedItem.discount = undefined; + cleanedItem.discountType = undefined; + } + if (!settings.taxPerItem.enabled) { + cleanedItem.tax = undefined; + cleanedItem.taxType = undefined; + } + return cleanedItem; + }) + } + }; + return (
{invoicePdf.size == 0 ? ( - + ) : ( )} diff --git a/app/components/invoice/form/Charges.tsx b/app/components/invoice/form/Charges.tsx index d2bed5927..b33dd5677 100644 --- a/app/components/invoice/form/Charges.tsx +++ b/app/components/invoice/form/Charges.tsx @@ -13,6 +13,7 @@ import { ChargeInput } from "@/app/components"; // Contexts import { useChargesContext } from "@/contexts/ChargesContext"; import { useTranslationContext } from "@/contexts/TranslationContext"; +import { useSettings } from "@/contexts/SettingsContext"; // Helpers import { formatNumberWithCommas } from "@/lib/helpers"; @@ -26,6 +27,7 @@ const Charges = () => { } = useFormContext(); const { _t } = useTranslationContext(); + const { settings } = useSettings(); const { discountSwitch, @@ -63,35 +65,39 @@ const Charges = () => {
{/* Switches */}
-
- - + {!settings.discountPerItem.enabled && (
+ +
- { - setDiscountSwitch(value); - }} - /> +
+ { + setDiscountSwitch(value); + }} + /> +
-
- -
- + )} + {!settings.taxPerItem.enabled && (
+ +
- { - setTaxSwitch(value); - }} - /> +
+ { + setTaxSwitch(value); + }} + /> +
-
+ )}
diff --git a/app/components/invoice/form/SingleItem.tsx b/app/components/invoice/form/SingleItem.tsx index cec969bae..dd73158d4 100644 --- a/app/components/invoice/form/SingleItem.tsx +++ b/app/components/invoice/form/SingleItem.tsx @@ -14,10 +14,11 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; // Components -import { BaseButton, FormInput, FormTextarea } from "@/app/components"; +import { BaseButton, FormInput, FormSelect, FormTextarea } from "@/app/components"; // Contexts import { useTranslationContext } from "@/contexts/TranslationContext"; +import { useSettings } from "@/contexts/SettingsContext"; // Icons import { ChevronDown, ChevronUp, GripVertical, Trash2 } from "lucide-react"; @@ -45,6 +46,7 @@ const SingleItem = ({ removeField, }: SingleItemProps) => { const { control, setValue } = useFormContext(); + const { settings } = useSettings(); const { _t } = useTranslationContext(); @@ -64,6 +66,26 @@ const SingleItem = ({ control, }); + const discount = useWatch({ + name: `${name}[${index}].discount`, + control, + }); + + const discountType = useWatch({ + name: `${name}[${index}].discountType`, + control, + }); + + const tax = useWatch({ + name: `${name}[${index}].tax`, + control, + }); + + const taxType = useWatch({ + name: `${name}[${index}].taxType`, + control, + }); + const total = useWatch({ name: `${name}[${index}].total`, control, @@ -76,12 +98,32 @@ const SingleItem = ({ }); useEffect(() => { - // Calculate total when rate or quantity changes + // Calculate total when rate, quantity, discount or tax changes if (rate != undefined && quantity != undefined) { - const calculatedTotal = (rate * quantity).toFixed(2); + const baseAmount = rate * quantity; + let discountValue = 0; + let taxValue = 0; + + if (discount != undefined && !isNaN(discount)) { + if (discountType === "percentage") { + discountValue = baseAmount * (discount / 100); + } else { + discountValue = discount; + } + } + + if (tax != undefined && !isNaN(tax)) { + if (taxType === "percentage") { + taxValue = baseAmount * (tax / 100); + } else { + taxValue = tax; + } + } + + const calculatedTotal = (baseAmount - discountValue - taxValue).toFixed(2); setValue(`${name}[${index}].total`, calculatedTotal); } - }, [rate, quantity]); + }, [rate, quantity, discount, discountType, tax, taxType]); // DnD const { @@ -110,66 +152,81 @@ const SingleItem = ({
- {/* {isDragging &&
} */} -
- {itemName != "" ? ( -

- #{index + 1} - {itemName} -

- ) : ( -

#{index + 1} - Empty name

- )} - -
- {/* Drag and Drop Button */} + {/* Header with Title & Controls */} +
+
+ {/* Drag Handle */}
- +
- {/* Up Button */} + {/* Item Title */} +
+

+ Item #{index + 1} +

+

+ {itemName || "Untitled item"} +

+
+
+ + {/* Action Buttons */} +
moveFieldUp(index)} disabled={index === 0} + className="h-8 w-8" > - + - {/* Down Button */} moveFieldDown(index)} disabled={index === fields.length - 1} + className="h-8 w-8" > - +
+ + {/* Main Fields Grid */}
- +
+ +
@@ -178,41 +235,111 @@ const SingleItem = ({ type="number" label={_t("form.steps.lineItems.rate")} labelHelper={`(${currency})`} - placeholder={_t("form.steps.lineItems.rate")} - className="w-[8rem]" + placeholder="0" vertical /> -
-
- +
+ +
+

+ {total} {currency} +

-
+ + {/* Description */} -
- {/* Not allowing deletion for first item when there is only 1 item */} - {fields.length > 1 && ( + + {/* Optional Fields Section */} + {(settings.skuColumn.enabled || settings.discountPerItem.enabled || settings.taxPerItem.enabled) && ( +
+ {/* SKU Column - Conditional */} + {settings.skuColumn.enabled && ( +
+ +
+ )} + + {/* Discount Per Item - Conditional */} + {settings.discountPerItem.enabled && ( +
+ + +
+ )} + + {/* Tax Per Item - Conditional */} + {settings.taxPerItem.enabled && ( +
+ + +
+ )} +
+ )} + + {/* Delete Button */} + {fields.length > 1 && ( +
removeField(index)} + className="w-full" > - + {_t("form.steps.lineItems.removeItem")} - )} -
+
+ )}
); }; diff --git a/app/components/invoice/form/sections/ImportJsonButton.tsx b/app/components/invoice/form/sections/ImportJsonButton.tsx index a7e40451f..4c01fada3 100644 --- a/app/components/invoice/form/sections/ImportJsonButton.tsx +++ b/app/components/invoice/form/sections/ImportJsonButton.tsx @@ -5,11 +5,11 @@ import { BaseButton } from '@/app/components'; import { useInvoiceContext } from '@/contexts/InvoiceContext'; import { Import } from 'lucide-react'; -type ImportJsonButtonType = { +type ImportButtonType = { setOpen: (open: boolean) => void; } -const ImportJsonButton = ({ setOpen }: ImportJsonButtonType) => { +const ImportJsonButton = ({ setOpen }: ImportButtonType) => { const fileInputRef = useRef(null); const { importInvoice, invoicePdfLoading } = useInvoiceContext(); @@ -17,11 +17,18 @@ const ImportJsonButton = ({ setOpen }: ImportJsonButtonType) => { fileInputRef.current?.click(); }; - const handleFileChange = (event: React.ChangeEvent) => { + const handleFileChange = async (event: React.ChangeEvent) => { const file = event.target.files?.[0]; - if (file && file.type === 'application/json') { - importInvoice(file); - setOpen(false); + if (file) { + const fileName = file.name.toLowerCase(); + const ext = fileName.split('.').pop() || ''; + + // Check if file extension is supported + const supportedFormats = ['json', 'csv', 'xml', 'xls', 'xlsx']; + if (supportedFormats.includes(ext)) { + await importInvoice(file); + setOpen(false); + } } // Reset input value to allow selecting the same file again if (fileInputRef.current) { @@ -35,17 +42,17 @@ const ImportJsonButton = ({ setOpen }: ImportJsonButtonType) => { type="file" ref={fileInputRef} onChange={handleFileChange} - accept=".json" + accept=".json,.csv,.xml,.xls,.xlsx" style={{ display: 'none' }} /> - Import JSON + Import Invoice ); diff --git a/app/components/invoice/form/sections/Items.tsx b/app/components/invoice/form/sections/Items.tsx index 7b7dcf7bb..07ac51bef 100644 --- a/app/components/invoice/form/sections/Items.tsx +++ b/app/components/invoice/form/sections/Items.tsx @@ -52,6 +52,11 @@ const Items = () => { quantity: 0, unitPrice: 0, total: 0, + sku: "", + discount: 0, + discountType: "", + tax: 0, + taxType: "", }); }; diff --git a/app/components/invoice/form/sections/PaymentInformation.tsx b/app/components/invoice/form/sections/PaymentInformation.tsx index 11abfacaf..d078768a5 100644 --- a/app/components/invoice/form/sections/PaymentInformation.tsx +++ b/app/components/invoice/form/sections/PaymentInformation.tsx @@ -1,36 +1,89 @@ "use client"; +import { useFormContext, useWatch, Controller } from "react-hook-form"; + // Components import { FormInput, Subheading } from "@/app/components"; // Contexts import { useTranslationContext } from "@/contexts/TranslationContext"; +import { useSettings } from "@/contexts/SettingsContext"; + +// UI Components +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; const PaymentInformation = () => { const { _t } = useTranslationContext(); + const { settings } = useSettings(); + const { control } = useFormContext(); + + const isCash = useWatch({ + name: "details.paymentInformation.isCash", + control, + defaultValue: false, + }); + return (
{_t("form.steps.paymentInfo.heading")}: -
- - - ( +
+ + +
+ )} /> -
+ )} + + {/* Bank Details - Hidden when cash mode is enabled */} + {!isCash && ( +
+ + + +
+ )} + + {/* Change Field - Shown only when cash mode is enabled */} + {isCash && settings.cashPaymentMode.enabled && ( +
+ +
+ )}
); }; diff --git a/app/components/layout/BaseNavbar.tsx b/app/components/layout/BaseNavbar.tsx index a777d6972..c43e8076f 100644 --- a/app/components/layout/BaseNavbar.tsx +++ b/app/components/layout/BaseNavbar.tsx @@ -1,4 +1,6 @@ -import { useMemo } from "react"; +"use client"; + +import { useState } from "react"; // Next import Link from "next/link"; @@ -9,36 +11,55 @@ import Logo from "@/public/assets/img/invoify-logo.svg"; // ShadCn import { Card } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; // Components -import { DevDebug, LanguageSelector, ThemeSwitcher } from "@/app/components"; +// import { DevDebug, LanguageSelector, ThemeSwitcher } from "@/app/components"; +import { LanguageSelector, ThemeSwitcher } from "@/app/components"; +import { SettingsPanel } from "@/app/components/settings/SettingsPanel"; + +// Icons +import { Settings } from "lucide-react"; const BaseNavbar = () => { - const devEnv = useMemo(() => { - return process.env.NODE_ENV === "development"; - }, []); + const [isSettingsOpen, setIsSettingsOpen] = useState(false); return ( -
- -
+ <> +
+ +
+ setIsSettingsOpen(false)} + /> + ); }; diff --git a/app/components/reusables/form-fields/FormInput.tsx b/app/components/reusables/form-fields/FormInput.tsx index e3dca38b9..0e21a976c 100644 --- a/app/components/reusables/form-fields/FormInput.tsx +++ b/app/components/reusables/form-fields/FormInput.tsx @@ -27,6 +27,7 @@ const FormInput = ({ labelHelper, placeholder, vertical = false, + defaultValue, ...props }: FormInputProps) => { const { control } = useFormContext(); @@ -46,6 +47,7 @@ const FormInput = ({ { + const { control } = useFormContext(); + + return ( + ( + + {label && {label}} + + + + )} + /> + ); +}; + +export default FormSelect; diff --git a/app/components/settings/SettingsPanel.tsx b/app/components/settings/SettingsPanel.tsx new file mode 100644 index 000000000..ca8ab147f --- /dev/null +++ b/app/components/settings/SettingsPanel.tsx @@ -0,0 +1,469 @@ +"use client"; + +import { useSettings } from "@/contexts/SettingsContext"; +import { useTranslations } from "next-intl"; +import { useFormContext } from "react-hook-form"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { Switch } from "@/components/ui/switch"; +import { Label } from "@/components/ui/label"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { X } from "lucide-react"; +import { InvoiceType } from "@/types"; + +interface SettingsPanelProps { + isOpen: boolean; + onClose: () => void; +} + +export const SettingsPanel = ({ isOpen, onClose }: SettingsPanelProps) => { + const { settings, updateSettings, resetSettings } = useSettings(); + const { getValues, setValue } = useFormContext(); + const t = useTranslations(); + + if (!isOpen) return null; + + // Clear discount values from all items when discount per item is disabled + const handleDiscountToggle = (enabled: boolean) => { + updateSettings({ + discountPerItem: { + ...settings.discountPerItem, + enabled, + }, + }); + + if (!enabled) { + const formValues = getValues(); + const updatedItems = formValues.details.items.map(item => ({ + ...item, + discount: undefined, + discountType: undefined, + })); + setValue("details.items", updatedItems); + } + }; + + // Clear tax values from all items when tax per item is disabled + const handleTaxToggle = (enabled: boolean) => { + updateSettings({ + taxPerItem: { + ...settings.taxPerItem, + enabled, + }, + }); + + if (!enabled) { + const formValues = getValues(); + const updatedItems = formValues.details.items.map(item => ({ + ...item, + tax: undefined, + taxType: undefined, + })); + setValue("details.items", updatedItems); + } + }; + + return ( + <> + {/* Backdrop */} +
+ + {/* Settings Panel */} +
+
+

Settings

+ +
+ +
+ + + Fields + Items + Payment + + + {/* Fields Tab - Two Column Layout */} + +
+ {/* SENDER FIELDS SECTION */} +
+
+

+ Sender Fields +

+
+ + updateSettings({ + fieldRequirements: { + ...settings.fieldRequirements, + senderAddress: value, + }, + }) + } + /> + + updateSettings({ + fieldRequirements: { + ...settings.fieldRequirements, + senderZipCode: value, + }, + }) + } + /> + + updateSettings({ + fieldRequirements: { + ...settings.fieldRequirements, + senderCity: value, + }, + }) + } + /> + + updateSettings({ + fieldRequirements: { + ...settings.fieldRequirements, + senderCountry: value, + }, + }) + } + /> + + updateSettings({ + fieldRequirements: { + ...settings.fieldRequirements, + senderEmail: value, + }, + }) + } + /> + + updateSettings({ + fieldRequirements: { + ...settings.fieldRequirements, + senderPhone: value, + }, + }) + } + /> +
+
+
+ + {/* RECEIVER FIELDS SECTION */} +
+
+

+ Receiver Fields +

+
+ + updateSettings({ + fieldRequirements: { + ...settings.fieldRequirements, + receiverAddress: value, + }, + }) + } + /> + + updateSettings({ + fieldRequirements: { + ...settings.fieldRequirements, + receiverZipCode: value, + }, + }) + } + /> + + updateSettings({ + fieldRequirements: { + ...settings.fieldRequirements, + receiverCity: value, + }, + }) + } + /> + + updateSettings({ + fieldRequirements: { + ...settings.fieldRequirements, + receiverCountry: value, + }, + }) + } + /> + + updateSettings({ + fieldRequirements: { + ...settings.fieldRequirements, + receiverEmail: value, + }, + }) + } + /> + + updateSettings({ + fieldRequirements: { + ...settings.fieldRequirements, + receiverPhone: value, + }, + }) + } + /> +
+
+
+
+ + {/* Currency Display - Full Width */} +
+

+ Currency Display +

+ + updateSettings({ + currencyDisplay: + value === "required" + ? "symbolAndCode" + : "symbolOnly", + }) + } + optionLabels={{ required: "Symbol + Code", optional: "Symbol Only" }} + /> +
+
+ + {/* Items Tab */} + + + updateSettings({ + skuColumn: { + ...settings.skuColumn, + enabled, + }, + }) + } + onRequiredChange={(required) => + updateSettings({ + skuColumn: { + ...settings.skuColumn, + required, + }, + }) + } + /> + + + updateSettings({ + discountPerItem: { + ...settings.discountPerItem, + required, + }, + }) + } + /> + + + updateSettings({ + taxPerItem: { + ...settings.taxPerItem, + required, + }, + }) + } + /> + + + {/* Payment Tab */} + +
+
+ + + updateSettings({ + cashPaymentMode: { enabled }, + }) + } + /> +
+ {settings.cashPaymentMode.enabled && ( +

+ When enabled, bank details become optional and a "Change" field appears +

+ )} +
+
+
+ + {/* Reset Button - Always at bottom */} +
+ +
+
+
+ + ); +}; + +interface FieldRequirementItemProps { + label: string; + value: "required" | "optional"; + onChange: (value: "required" | "optional") => void; + optionLabels?: { required: string; optional: string }; +} + +function FieldRequirementItem({ + label, + value, + onChange, + optionLabels, +}: FieldRequirementItemProps) { + return ( +
+ +
+ + +
+
+ ); +} + +interface ToggleSettingWithRequiredProps { + label: string; + enabled: boolean; + required: boolean; + onEnabledChange: (enabled: boolean) => void; + onRequiredChange: (required: boolean) => void; +} + +function ToggleSettingWithRequired({ + label, + enabled, + required, + onEnabledChange, + onRequiredChange, +}: ToggleSettingWithRequiredProps) { + return ( + +
+
+ + +
+ {enabled && ( +
+ +
+ + +
+
+ )} +
+
+ ); +} diff --git a/app/components/templates/invoice-pdf/InvoiceTemplate1.tsx b/app/components/templates/invoice-pdf/InvoiceTemplate1.tsx index 01999e3ef..e98f180d2 100644 --- a/app/components/templates/invoice-pdf/InvoiceTemplate1.tsx +++ b/app/components/templates/invoice-pdf/InvoiceTemplate1.tsx @@ -15,6 +15,12 @@ import { InvoiceType } from "@/types"; const InvoiceTemplate = (data: InvoiceType) => { const { sender, receiver, details } = data; + // Check if discount and tax are used + const hasDiscount = details.items.some(item => item.discount && item.discount > 0); + const hasTax = details.items.some(item => item.tax && item.tax > 0); + const colCount = 5 + (hasDiscount ? 1 : 0) + (hasTax ? 1 : 0); + const gridClass = colCount === 5 ? 'sm:grid-cols-5' : colCount === 6 ? 'sm:grid-cols-6' : 'sm:grid-cols-7'; + return (
@@ -32,13 +38,15 @@ const InvoiceTemplate = (data: InvoiceType) => {

Invoice #

{details.invoiceNumber} -
- {sender.address} -
- {sender.zipCode}, {sender.city} -
- {sender.country} -
+
+ {[sender.address, sender.zipCode, sender.city, sender.country] + .filter(Boolean) + .map((part, index, arr) => ( + + {part} + {index < arr.length - 1 &&
} +
+ ))}
@@ -47,13 +55,15 @@ const InvoiceTemplate = (data: InvoiceType) => {

Bill to:

{receiver.name}

- {}
- {receiver.address && receiver.address.length > 0 ? receiver.address : null} - {receiver.zipCode && receiver.zipCode.length > 0 ? `, ${receiver.zipCode}` : null} -
- {receiver.city}, {receiver.country} -
+ {[receiver.address, receiver.zipCode, receiver.city, receiver.country] + .filter(Boolean) + .map((part, index, arr) => ( + + {part} + {index < arr.length - 1 &&
} +
+ ))}
@@ -76,35 +86,70 @@ const InvoiceTemplate = (data: InvoiceType) => {
-
+
Item
Qty
Rate
+ {hasDiscount &&
Disc
} + {hasTax &&
Tax
}
Amount
-
- {details.items.map((item, index) => ( - -
-

{item.name}

-

{item.description}

-
-
-

{item.quantity}

-
-
-

- {item.unitPrice} {details.currency} -

-
-
-

- {item.total} {details.currency} -

-
-
- ))} +
+ {details.items.map((item, index) => { + const discountAmount = item.discount && item.discountType === 'percentage' + ? (item.unitPrice * item.quantity * item.discount / 100) + : (item.discount || 0); + const taxAmount = item.tax && item.taxType === 'percentage' + ? (item.unitPrice * item.quantity * item.tax / 100) + : (item.tax || 0); + return ( + +
+

+ {item.name} + {item.sku && ({item.sku})} +

+

{item.description}

+
+
+

{item.quantity}

+
+
+

+ {item.unitPrice} {details.currency} +

+
+ {hasDiscount && ( +
+

+ {item.discount && item.discount > 0 ? ( + item.discountType === 'percentage' + ? `${item.discount}% (${formatNumberWithCommas(discountAmount)})` + : `${formatNumberWithCommas(discountAmount)}` + ) : '0'} +

+
+ )} + {hasTax && ( +
+

+ {item.tax && item.tax > 0 ? ( + item.taxType === 'percentage' + ? `${item.tax}% (${formatNumberWithCommas(taxAmount)})` + : `${formatNumberWithCommas(taxAmount)}` + ) : '0'} +

+
+ )} +
+

+ {item.total} {details.currency} +

+
+
+ ); + })}
@@ -166,6 +211,14 @@ const InvoiceTemplate = (data: InvoiceType) => { )} + {details.paymentInformation?.isCash && details.paymentInformation?.change && details.paymentInformation.change > 0 && ( +
+
Change:
+
+ {formatNumberWithCommas(details.paymentInformation.change)} {details.currency} +
+
+ )}
@@ -181,12 +234,18 @@ const InvoiceTemplate = (data: InvoiceType) => {

{details.paymentTerms}

- - Please send the payment to this address -

Bank: {details.paymentInformation?.bankName}

-

Account name: {details.paymentInformation?.accountName}

-

Account no: {details.paymentInformation?.accountNumber}

-
+ {details.paymentInformation?.isCash ? ( + + Payment Method: Cash + + ) : ( + + Please send the payment to this address +

Bank: {details.paymentInformation?.bankName}

+

Account name: {details.paymentInformation?.accountName}

+

Account no: {details.paymentInformation?.accountNumber}

+
+ )}

diff --git a/app/components/templates/invoice-pdf/InvoiceTemplate2.tsx b/app/components/templates/invoice-pdf/InvoiceTemplate2.tsx index 86406424f..ba7d636bb 100644 --- a/app/components/templates/invoice-pdf/InvoiceTemplate2.tsx +++ b/app/components/templates/invoice-pdf/InvoiceTemplate2.tsx @@ -14,6 +14,13 @@ import { InvoiceType } from "@/types"; const InvoiceTemplate2 = (data: InvoiceType) => { const { sender, receiver, details } = data; + + // Check if discount and tax are used + const hasDiscount = details.items.some(item => item.discount && item.discount > 0); + const hasTax = details.items.some(item => item.tax && item.tax > 0); + const colCount = 5 + (hasDiscount ? 1 : 0) + (hasTax ? 1 : 0); + const gridClass = colCount === 5 ? 'sm:grid-cols-5' : colCount === 6 ? 'sm:grid-cols-6' : 'sm:grid-cols-7'; + return (

@@ -38,13 +45,15 @@ const InvoiceTemplate2 = (data: InvoiceType) => {
-
- {sender.address} -
- {sender.zipCode}, {sender.city} -
- {sender.country} -
+
+ {[sender.address, sender.zipCode, sender.city, sender.country] + .filter(Boolean) + .map((part, index, arr) => ( + + {part} + {index < arr.length - 1 &&
} +
+ ))}
@@ -58,10 +67,14 @@ const InvoiceTemplate2 = (data: InvoiceType) => { {receiver.name}
- {receiver.address}, {receiver.zipCode} -
- {receiver.city}, {receiver.country} -
+ {[receiver.address, receiver.zipCode, receiver.city, receiver.country] + .filter(Boolean) + .map((part, index, arr) => ( + + {part} + {index < arr.length - 1 &&
} +
+ ))}
@@ -93,7 +106,7 @@ const InvoiceTemplate2 = (data: InvoiceType) => {
-
+
Item
@@ -103,39 +116,72 @@ const InvoiceTemplate2 = (data: InvoiceType) => {
Rate
+ {hasDiscount &&
Disc
} + {hasTax &&
Tax
}
Amount
-
- {details.items.map((item, index) => ( - -
-

- {item.name} -

-

- {item.description} -

-
-
-

- {item.quantity} -

-
-
-

- {item.unitPrice} {details.currency} -

-
-
-

- {item.total} {details.currency} -

-
-
- ))} +
+ {details.items.map((item, index) => { + const discountAmount = item.discount && item.discountType === 'percentage' + ? (item.unitPrice * item.quantity * item.discount / 100) + : (item.discount || 0); + const taxAmount = item.tax && item.taxType === 'percentage' + ? (item.unitPrice * item.quantity * item.tax / 100) + : (item.tax || 0); + return ( + +
+

+ {item.name} + {item.sku && ({item.sku})} +

+

+ {item.description} +

+
+
+

+ {item.quantity} +

+
+
+

+ {item.unitPrice} {details.currency} +

+
+ {hasDiscount && ( +
+

+ {item.discount && item.discount > 0 ? ( + item.discountType === 'percentage' + ? `${item.discount}% (${formatNumberWithCommas(discountAmount)})` + : `${formatNumberWithCommas(discountAmount)}` + ) : '0'} +

+
+ )} + {hasTax && ( +
+

+ {item.tax && item.tax > 0 ? ( + item.taxType === 'percentage' + ? `${item.tax}% (${formatNumberWithCommas(taxAmount)})` + : `${formatNumberWithCommas(taxAmount)}` + ) : '0'} +

+
+ )} +
+

+ {item.total} {details.currency} +

+
+
+ ); + })}
@@ -221,6 +267,14 @@ const InvoiceTemplate2 = (data: InvoiceType) => { )} + {details.paymentInformation?.isCash && details.paymentInformation?.change && details.paymentInformation.change > 0 && ( +
+
Change:
+
+ {formatNumberWithCommas(details.paymentInformation.change)} {details.currency} +
+
+ )}
@@ -244,20 +298,26 @@ const InvoiceTemplate2 = (data: InvoiceType) => {

- - Please send the payment to this address -

- Bank: {details.paymentInformation?.bankName} -

-

- Account name:{" "} - {details.paymentInformation?.accountName} -

-

- Account no:{" "} - {details.paymentInformation?.accountNumber} -

-
+ {details.paymentInformation?.isCash ? ( + + Payment Method: Cash + + ) : ( + + Please send the payment to this address +

+ Bank: {details.paymentInformation?.bankName} +

+

+ Account name:{" "} + {details.paymentInformation?.accountName} +

+

+ Account no:{" "} + {details.paymentInformation?.accountNumber} +

+
+ )}

diff --git a/contexts/InvoiceContext.tsx b/contexts/InvoiceContext.tsx index cdf5789f3..f89ecc032 100644 --- a/contexts/InvoiceContext.tsx +++ b/contexts/InvoiceContext.tsx @@ -19,6 +19,7 @@ import useToasts from "@/hooks/useToasts"; // Services import { exportInvoice } from "@/services/invoice/client/exportInvoice"; +import { parseImportedFile } from "@/services/invoice/client/importInvoice"; // Variables import { @@ -356,38 +357,34 @@ export const InvoiceContextProvider = ({ }; /** - * Import an invoice from a JSON file. + * Import an invoice from a file (JSON, CSV, XML, XLS, XLSX). * - * @param {File} file - The JSON file to import. + * @param {File} file - The file to import. */ - const importInvoice = (file: File) => { - const reader = new FileReader(); - reader.onload = (event) => { - try { - const importedData = JSON.parse(event.target?.result as string); - - // Parse the dates - if (importedData.details) { - if (importedData.details.invoiceDate) { - importedData.details.invoiceDate = new Date( - importedData.details.invoiceDate - ); - } - if (importedData.details.dueDate) { - importedData.details.dueDate = new Date( - importedData.details.dueDate - ); - } + const importInvoice = async (file: File) => { + try { + const importedData = await parseImportedFile(file); + + // Parse the dates + if (importedData.details) { + if (importedData.details.invoiceDate) { + importedData.details.invoiceDate = new Date( + importedData.details.invoiceDate + ); + } + if (importedData.details.dueDate) { + importedData.details.dueDate = new Date( + importedData.details.dueDate + ); } - - // Reset form with imported data - reset(importedData); - } catch (error) { - console.error("Error parsing JSON file:", error); - importInvoiceError(); } - }; - reader.readAsText(file); + + // Reset form with imported data + reset(importedData); + } catch (error) { + console.error("Error importing file:", error); + importInvoiceError(); + } }; return ( diff --git a/contexts/Providers.tsx b/contexts/Providers.tsx index 935bdafe4..3a5f9fa63 100644 --- a/contexts/Providers.tsx +++ b/contexts/Providers.tsx @@ -9,11 +9,12 @@ import { FormProvider, useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; // Schema -import { InvoiceSchema } from "@/lib/schemas"; +import { createInvoiceSchema } from "@/lib/schemas"; // Context import { ThemeProvider } from "@/contexts/ThemeProvider"; import { TranslationProvider } from "@/contexts/TranslationContext"; +import { SettingsProvider, useSettings } from "@/contexts/SettingsContext"; import { InvoiceContextProvider } from "@/contexts/InvoiceContext"; import { ChargesContextProvider } from "@/contexts/ChargesContext"; @@ -50,9 +51,12 @@ type ProvidersProps = { children: React.ReactNode; }; -const Providers = ({ children }: ProvidersProps) => { +// Inner component that uses settings +const FormWithSettings = ({ children }: { children: React.ReactNode }) => { + const { settings } = useSettings(); + const form = useForm({ - resolver: zodResolver(InvoiceSchema), + resolver: zodResolver(createInvoiceSchema(settings)), defaultValues: FORM_DEFAULT_VALUES, }); @@ -65,6 +69,16 @@ const Providers = ({ children }: ProvidersProps) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + return ( + + + {children} + + + ); +}; + +const Providers = ({ children }: ProvidersProps) => { return ( { disableTransitionOnChange > - - - {children} - - + + {children} + ); diff --git a/contexts/SettingsContext.tsx b/contexts/SettingsContext.tsx new file mode 100644 index 000000000..b5140e0ed --- /dev/null +++ b/contexts/SettingsContext.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { createContext, useContext, useEffect, useState, ReactNode } from "react"; +import { SettingsType } from "@/types"; +import { DEFAULT_SETTINGS, LOCAL_STORAGE_SETTINGS_KEY } from "@/lib/variables"; + +interface SettingsContextType { + settings: SettingsType; + updateSettings: (partial: Partial) => void; + resetSettings: () => void; +} + +const SettingsContext = createContext(undefined); + +export const SettingsProvider = ({ children }: { children: ReactNode }) => { + const [settings, setSettings] = useState(DEFAULT_SETTINGS); + const [isLoaded, setIsLoaded] = useState(false); + + // Load settings from localStorage on mount + useEffect(() => { + try { + const stored = localStorage.getItem(LOCAL_STORAGE_SETTINGS_KEY); + if (stored) { + const parsed = JSON.parse(stored) as SettingsType; + setSettings(parsed); + } + } catch (error) { + console.error("Failed to load settings from localStorage:", error); + setSettings(DEFAULT_SETTINGS); + } + setIsLoaded(true); + }, []); + + // Auto-persist settings to localStorage whenever they change + useEffect(() => { + if (isLoaded) { + try { + localStorage.setItem(LOCAL_STORAGE_SETTINGS_KEY, JSON.stringify(settings)); + } catch (error) { + console.error("Failed to save settings to localStorage:", error); + } + } + }, [settings, isLoaded]); + + const updateSettings = (partial: Partial) => { + setSettings((prev) => ({ + ...prev, + ...partial, + })); + }; + + const resetSettings = () => { + setSettings(DEFAULT_SETTINGS); + }; + + return ( + + {children} + + ); +}; + +export const useSettings = () => { + const context = useContext(SettingsContext); + if (!context) { + throw new Error("useSettings must be used within SettingsProvider"); + } + return context; +}; diff --git a/lib/helpers.ts b/lib/helpers.ts index e3df82d98..1eec22565 100644 --- a/lib/helpers.ts +++ b/lib/helpers.ts @@ -202,6 +202,31 @@ const fileToBuffer = async (file: File) => { return pdfBuffer; }; +/** + * Get currency symbol for a given currency code + * @param {string} currencyCode - Currency code (e.g., "USD", "EUR", "IDR") + * @returns {string} Currency symbol (e.g., "$", "€", "Rp.") + */ +const getCurrencySymbol = (currencyCode: string): string => { + try { + const formatter = new Intl.NumberFormat("en-US", { + style: "currency", + currency: currencyCode, + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }); + + // Format 0 to get the symbol + const formatted = formatter.format(0); + // Extract symbol from formatted string (e.g., "$0" -> "$") + const symbol = formatted.replace(/[\d.,\s]/g, ""); + return symbol || currencyCode; + } catch (error) { + // Fallback to currency code if symbol extraction fails + return currencyCode; + } +}; + export { formatNumberWithCommas, formatPriceToString, @@ -210,4 +235,5 @@ export { isDataUrl, getInvoiceTemplate, fileToBuffer, + getCurrencySymbol, }; diff --git a/lib/schemas.ts b/lib/schemas.ts index 0c1061999..3eb91c6eb 100644 --- a/lib/schemas.ts +++ b/lib/schemas.ts @@ -6,6 +6,9 @@ import { formatNumberWithCommas } from "@/lib/helpers"; // Variables import { DATE_OPTIONS } from "@/lib/variables"; +// Types +import { SettingsType } from "@/types"; + // TODO: Refactor some of the validators. Ex: name and zipCode or address and country have same rules // Field Validators const fieldValidators = { @@ -81,6 +84,10 @@ const fieldValidators = { }), }; +// Helper: converts empty string to undefined so optional validators pass +const makeOptional = (validator: z.ZodTypeAny) => + z.preprocess((v) => (v === "" ? undefined : v), validator.optional()); + const CustomInputSchema = z.object({ key: z.string(), value: z.string(), @@ -114,12 +121,21 @@ const ItemSchema = z.object({ quantity: fieldValidators.quantity, unitPrice: fieldValidators.unitPrice, total: fieldValidators.stringToNumber, + // Optional fields that may be enabled via settings + sku: fieldValidators.stringOptional, + discount: fieldValidators.nonNegativeNumber.optional(), + discountType: fieldValidators.stringOptional, + tax: fieldValidators.nonNegativeNumber.optional(), + taxType: fieldValidators.stringOptional, }); const PaymentInformationSchema = z.object({ - bankName: fieldValidators.stringMin1, - accountName: fieldValidators.stringMin1, - accountNumber: fieldValidators.stringMin1, + bankName: fieldValidators.stringMin1.optional(), + accountName: fieldValidators.stringMin1.optional(), + accountNumber: fieldValidators.stringMin1.optional(), + // Optional fields for cash payment mode + isCash: z.boolean().optional(), + change: fieldValidators.nonNegativeNumber.optional(), }); const DiscountDetailsSchema = z.object({ @@ -172,4 +188,207 @@ const InvoiceSchema = z.object({ details: InvoiceDetailsSchema, }); -export { InvoiceSchema, ItemSchema }; +// Factory function to create dynamic ItemSchema based on settings +const createItemSchema = (settings: SettingsType) => { + let schema = z.object({ + name: fieldValidators.stringMin1, + description: fieldValidators.stringOptional, + quantity: fieldValidators.quantity, + unitPrice: fieldValidators.unitPrice, + total: fieldValidators.stringToNumber, + }); + + // Add SKU field if enabled + if (settings.skuColumn.enabled) { + const skuValidator = settings.skuColumn.required + ? fieldValidators.stringMin1 + : fieldValidators.stringOptional; + schema = schema.extend({ + sku: skuValidator, + }); + } + + // Add discount field if enabled + if (settings.discountPerItem.enabled) { + const discountValidator = settings.discountPerItem.required + ? fieldValidators.nonNegativeNumber + : fieldValidators.nonNegativeNumber.optional(); + schema = schema.extend({ + discount: discountValidator, + discountType: fieldValidators.stringOptional, + }); + } + + // Add tax field if enabled + if (settings.taxPerItem.enabled) { + const taxValidator = settings.taxPerItem.required + ? fieldValidators.nonNegativeNumber + : fieldValidators.nonNegativeNumber.optional(); + schema = schema.extend({ + tax: taxValidator, + taxType: fieldValidators.stringOptional, + }); + } + + return schema; +}; + +// Factory function to create dynamic PaymentInformationSchema based on settings +const createPaymentInformationSchema = (settings: SettingsType) => { + if (settings.cashPaymentMode.enabled) { + return z + .object({ + // Use makeOptional so empty strings "" become undefined and pass when cash is ON + bankName: makeOptional(fieldValidators.stringMin1), + accountName: makeOptional(fieldValidators.stringMin1), + accountNumber: makeOptional(fieldValidators.stringMin1), + isCash: z.boolean().optional(), + change: fieldValidators.nonNegativeNumber.optional(), + }) + .superRefine((data, ctx) => { + // Only validate bank fields when NOT using cash payment + if (!data.isCash) { + if (!data.bankName) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Bank name required when not using cash payment", + path: ["bankName"], + }); + } + if (!data.accountName) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Account name required when not using cash payment", + path: ["accountName"], + }); + } + if (!data.accountNumber) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Account number required when not using cash payment", + path: ["accountNumber"], + }); + } + } + }); + } + + return z.object({ + bankName: fieldValidators.stringMin1, + accountName: fieldValidators.stringMin1, + accountNumber: fieldValidators.stringMin1, + }); +}; + +// Factory function to create dynamic sender schema based on settings +const createSenderSchema = (settings: SettingsType) => { + const emailValidator = settings.fieldRequirements.senderEmail === "required" + ? fieldValidators.email + : makeOptional(fieldValidators.email); + + const phoneValidator = settings.fieldRequirements.senderPhone === "required" + ? fieldValidators.phone + : makeOptional(fieldValidators.phone); + + const addressValidator = settings.fieldRequirements.senderAddress === "required" + ? fieldValidators.address + : makeOptional(fieldValidators.address); + + const zipCodeValidator = settings.fieldRequirements.senderZipCode === "required" + ? fieldValidators.zipCode + : makeOptional(fieldValidators.zipCode); + + const cityValidator = settings.fieldRequirements.senderCity === "required" + ? fieldValidators.city + : makeOptional(fieldValidators.city); + + const countryValidator = settings.fieldRequirements.senderCountry === "required" + ? fieldValidators.country + : makeOptional(fieldValidators.country); + + return z.object({ + name: fieldValidators.name, + address: addressValidator, + zipCode: zipCodeValidator, + city: cityValidator, + country: countryValidator, + email: emailValidator, + phone: phoneValidator, + customInputs: z.array(CustomInputSchema).optional(), + }); +}; + +// Factory function to create dynamic receiver schema based on settings +const createReceiverSchema = (settings: SettingsType) => { + const emailValidator = settings.fieldRequirements.receiverEmail === "required" + ? fieldValidators.email + : makeOptional(fieldValidators.email); + + const phoneValidator = settings.fieldRequirements.receiverPhone === "required" + ? fieldValidators.phone + : makeOptional(fieldValidators.phone); + + const addressValidator = settings.fieldRequirements.receiverAddress === "required" + ? fieldValidators.address + : makeOptional(fieldValidators.address); + + const zipCodeValidator = settings.fieldRequirements.receiverZipCode === "required" + ? fieldValidators.zipCode + : makeOptional(fieldValidators.zipCode); + + const cityValidator = settings.fieldRequirements.receiverCity === "required" + ? fieldValidators.city + : makeOptional(fieldValidators.city); + + const countryValidator = settings.fieldRequirements.receiverCountry === "required" + ? fieldValidators.country + : makeOptional(fieldValidators.country); + + return z.object({ + name: fieldValidators.name, + address: addressValidator, + zipCode: zipCodeValidator, + city: cityValidator, + country: countryValidator, + email: emailValidator, + phone: phoneValidator, + customInputs: z.array(CustomInputSchema).optional(), + }); +}; + +// Factory function to create dynamic InvoiceDetailsSchema based on settings +const createInvoiceDetailsSchema = (settings: SettingsType) => { + return z.object({ + invoiceLogo: fieldValidators.stringOptional, + invoiceNumber: fieldValidators.stringMin1, + invoiceDate: fieldValidators.date, + dueDate: fieldValidators.date, + purchaseOrderNumber: fieldValidators.stringOptional, + currency: fieldValidators.string, + language: fieldValidators.string, + items: z.array(createItemSchema(settings)), + paymentInformation: createPaymentInformationSchema(settings).optional(), + taxDetails: TaxDetailsSchema.optional(), + discountDetails: DiscountDetailsSchema.optional(), + shippingDetails: ShippingDetailsSchema.optional(), + subTotal: fieldValidators.nonNegativeNumber, + totalAmount: fieldValidators.nonNegativeNumber, + totalAmountInWords: fieldValidators.string, + additionalNotes: fieldValidators.stringOptional, + paymentTerms: fieldValidators.stringMin1, + signature: SignatureSchema.optional(), + updatedAt: fieldValidators.stringOptional, + pdfTemplate: z.number(), + }); +}; + +// Factory function to create dynamic InvoiceSchema based on settings +const createInvoiceSchema = (settings: SettingsType) => { + return z.object({ + sender: createSenderSchema(settings), + receiver: createReceiverSchema(settings), + details: createInvoiceDetailsSchema(settings), + }); +}; + +export { InvoiceSchema, ItemSchema, createInvoiceSchema, createItemSchema, createPaymentInformationSchema, createSenderSchema, createReceiverSchema, createInvoiceDetailsSchema }; diff --git a/lib/variables.ts b/lib/variables.ts index 97a1774b3..ae91874c5 100644 --- a/lib/variables.ts +++ b/lib/variables.ts @@ -1,5 +1,5 @@ // Types -import { SignatureColor, SignatureFont } from "@/types"; +import { SignatureColor, SignatureFont, SettingsType } from "@/types"; /** * Environment @@ -30,6 +30,7 @@ export const CURRENCIES_API = * Local storage */ export const LOCAL_STORAGE_INVOICE_DRAFT_KEY = "invoify:invoiceDraft"; +export const LOCAL_STORAGE_SETTINGS_KEY = "invoify:settings"; /** * Tailwind @@ -66,9 +67,46 @@ export const LOCALES = [ { code: "ja", name: "日本語" }, { code: "nb-NO", name: "Norwegian (bokmål)" }, { code: "nn-NO", name: "Norwegian (nynorsk)" }, + { code: "id", name: "Bahasa Indonesia" }, ]; export const DEFAULT_LOCALE = LOCALES[0].code; +/** + * Settings + */ +export const DEFAULT_SETTINGS: SettingsType = { + fieldRequirements: { + senderEmail: "required", + senderPhone: "required", + senderAddress: "required", + senderZipCode: "required", + senderCity: "required", + senderCountry: "required", + receiverEmail: "required", + receiverPhone: "required", + receiverAddress: "required", + receiverZipCode: "required", + receiverCity: "required", + receiverCountry: "required", + }, + discountPerItem: { + enabled: false, + required: false, + }, + taxPerItem: { + enabled: false, + required: false, + }, + skuColumn: { + enabled: false, + required: false, + }, + cashPaymentMode: { + enabled: false, + }, + currencyDisplay: "symbolOnly", +}; + /** * Signature variables */ @@ -149,6 +187,11 @@ export const FORM_DEFAULT_VALUES = { quantity: 0, unitPrice: 0, total: 0, + sku: "", + discount: 0, + discountType: "amount", + tax: 0, + taxType: "amount", }, ], currency: "USD", @@ -213,6 +256,11 @@ export const FORM_FILL_VALUES = { quantity: 4, unitPrice: 50, total: 200, + sku: "", + discount: 0, + discountType: "amount", + tax: 0, + taxType: "amount", }, { name: "Product 2", @@ -220,6 +268,11 @@ export const FORM_FILL_VALUES = { quantity: 5, unitPrice: 50, total: 250, + sku: "", + discount: 0, + discountType: "amount", + tax: 0, + taxType: "amount", }, { name: "Product 3", @@ -227,6 +280,11 @@ export const FORM_FILL_VALUES = { quantity: 5, unitPrice: 80, total: 400, + sku: "", + discount: 0, + discountType: "amount", + tax: 0, + taxType: "amount", }, ], currency: "USD", diff --git a/services/invoice/client/importInvoice.ts b/services/invoice/client/importInvoice.ts new file mode 100644 index 000000000..bb418eae0 --- /dev/null +++ b/services/invoice/client/importInvoice.ts @@ -0,0 +1,237 @@ +import { InvoiceType } from "@/types"; +import * as XLSX from "xlsx"; +import { parseStringPromise } from "xml2js"; + +/** + * Unflatten an object that was flattened with dot notation + * Example: { "sender_name": "John", "sender_email": "john@example.com" } + * becomes: { sender: { name: "John", email: "john@example.com" } } + */ +function unflattenObject(obj: Record): Record { + const result: Record = {}; + + for (const key in obj) { + const keys = key.split("_"); + let current = result; + + for (let i = 0; i < keys.length - 1; i++) { + const k = keys[i]; + if (!current[k]) { + current[k] = {}; + } + current = current[k]; + } + + current[keys[keys.length - 1]] = obj[key]; + } + + return result; +} + +/** + * Parse a JSON file and return the invoice data + */ +async function parseJsonFile(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = (event) => { + try { + const data = JSON.parse(event.target?.result as string); + resolve(data as InvoiceType); + } catch (error) { + reject(new Error("Failed to parse JSON file")); + } + }; + reader.onerror = () => reject(new Error("Failed to read file")); + reader.readAsText(file); + }); +} + +/** + * Simple CSV parser that converts CSV to an object + * Handles quoted values and commas within quotes + */ +function parseCSV(csvText: string): Record { + const lines = csvText.split("\n"); + if (lines.length < 2) { + throw new Error("CSV file must have at least headers and one data row"); + } + + const headers = parseCSVLine(lines[0]); + const dataLine = parseCSVLine(lines[1]); + + const result: Record = {}; + headers.forEach((header, index) => { + result[header] = dataLine[index] || ""; + }); + + return result; +} + +/** + * Parse a single CSV line, handling quoted values + */ +function parseCSVLine(line: string): string[] { + const result: string[] = []; + let current = ""; + let insideQuotes = false; + + for (let i = 0; i < line.length; i++) { + const char = line[i]; + + if (char === '"') { + insideQuotes = !insideQuotes; + } else if (char === "," && !insideQuotes) { + result.push(current.trim()); + current = ""; + } else { + current += char; + } + } + + result.push(current.trim()); + return result; +} + +/** + * Parse a CSV file and return the invoice data + */ +async function parseCSVFile(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = (event) => { + try { + const csvText = event.target?.result as string; + const firstRow = parseCSV(csvText); + const unflattened = unflattenObject(firstRow); + resolve(unflattened as InvoiceType); + } catch (error) { + reject( + error instanceof Error + ? error + : new Error("Failed to parse CSV file") + ); + } + }; + reader.onerror = () => reject(new Error("Failed to read file")); + reader.readAsText(file); + }); +} + +/** + * Parse an XML file and return the invoice data + */ +async function parseXMLFile(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = async (event) => { + try { + const xmlText = event.target?.result as string; + const parsedXml = await parseStringPromise(xmlText); + + // The xml2js parser wraps the root element, we need to extract it + // Assuming the root element is the invoice data + const invoiceKey = Object.keys(parsedXml)[0]; + const invoiceData = parsedXml[invoiceKey]; + + // Convert all array values to single values (xml2js default behavior) + const cleanData = cleanXMLData(invoiceData); + resolve(cleanData as InvoiceType); + } catch (error) { + reject(new Error("Failed to parse XML file")); + } + }; + reader.onerror = () => reject(new Error("Failed to read file")); + reader.readAsText(file); + }); +} + +/** + * Clean XML parsed data by converting arrays to values + */ +function cleanXMLData(obj: any): any { + if (Array.isArray(obj)) { + if (obj.length === 1) { + return cleanXMLData(obj[0]); + } + return obj.map((item) => cleanXMLData(item)); + } else if (typeof obj === "object" && obj !== null) { + const result: Record = {}; + for (const key in obj) { + result[key] = cleanXMLData(obj[key]); + } + return result; + } + return obj; +} + +/** + * Parse an XLSX file and return the invoice data + */ +async function parseXLSXFile(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = (event) => { + try { + const data = new Uint8Array(event.target?.result as ArrayBuffer); + const workbook = XLSX.read(data, { type: "array" }); + + // Get the first sheet + const sheetName = workbook.SheetNames[0]; + if (!sheetName) { + reject(new Error("XLSX file has no sheets")); + return; + } + + const worksheet = workbook.Sheets[sheetName]; + // Parse the first row (header and data combined) + const json = XLSX.utils.sheet_to_json(worksheet); + + if (json.length === 0) { + reject(new Error("XLSX sheet is empty")); + return; + } + + // Take the first row and unflatten it + const firstRow = json[0] as Record; + const unflattened = unflattenObject(firstRow); + resolve(unflattened as InvoiceType); + } catch (error) { + reject(new Error("Failed to parse XLSX file")); + } + }; + reader.onerror = () => reject(new Error("Failed to read file")); + reader.readAsArrayBuffer(file); + }); +} + +/** + * Parse an imported file based on its extension and return the invoice data + * Supports: JSON, CSV, XML, XLS, XLSX + */ +export async function parseImportedFile(file: File): Promise { + const fileName = file.name.toLowerCase(); + const extension = fileName.split(".").pop() || ""; + + try { + switch (extension) { + case "json": + return await parseJsonFile(file); + case "csv": + return await parseCSVFile(file); + case "xml": + return await parseXMLFile(file); + case "xlsx": + case "xls": + return await parseXLSXFile(file); + default: + throw new Error( + `Unsupported file format: .${extension}. Supported formats: JSON, CSV, XML, XLS, XLSX` + ); + } + } catch (error) { + throw error instanceof Error + ? error + : new Error("Unknown error while parsing file"); + } +} diff --git a/types.ts b/types.ts index e571a1444..9baf81a73 100644 --- a/types.ts +++ b/types.ts @@ -56,3 +56,39 @@ export enum ExportTypes { XLSX = "XLSX", DOCX = "DOCX", } + +// Settings types +export type FieldRequirementSetting = "required" | "optional"; + +export type SettingsType = { + fieldRequirements: { + senderEmail: FieldRequirementSetting; + senderPhone: FieldRequirementSetting; + senderAddress: FieldRequirementSetting; + senderZipCode: FieldRequirementSetting; + senderCity: FieldRequirementSetting; + senderCountry: FieldRequirementSetting; + receiverEmail: FieldRequirementSetting; + receiverPhone: FieldRequirementSetting; + receiverAddress: FieldRequirementSetting; + receiverZipCode: FieldRequirementSetting; + receiverCity: FieldRequirementSetting; + receiverCountry: FieldRequirementSetting; + }; + discountPerItem: { + enabled: boolean; + required: boolean; + }; + taxPerItem: { + enabled: boolean; + required: boolean; + }; + skuColumn: { + enabled: boolean; + required: boolean; + }; + cashPaymentMode: { + enabled: boolean; + }; + currencyDisplay: "symbolOnly" | "symbolAndCode"; +}; From a764f883d68665235180598396df5d79d48f421e Mon Sep 17 00:00:00 2001 From: NickDabizaz <94162428+NickDabizaz@users.noreply.github.com> Date: Tue, 24 Mar 2026 20:58:23 +0700 Subject: [PATCH 2/2] Fix code review issues: resolve type errors, handle hidden field requirement status, and add missing settings translation for id locale --- app/components/index.ts | 4 +- app/components/invoice/actions/PdfViewer.tsx | 18 +- app/components/invoice/form/SingleItem.tsx | 27 +- .../form/sections/ImportJsonButton.tsx | 10 +- .../invoice/form/sections/Items.tsx | 4 +- .../form/sections/PaymentInformation.tsx | 4 +- app/components/layout/BaseNavbar.tsx | 2 - .../modals/invoice/InvoiceLoaderModal.tsx | 4 +- .../reusables/form-fields/FormSelect.tsx | 15 +- app/components/settings/SettingsPanel.tsx | 649 +++++++----------- .../invoice-pdf/InvoiceTemplate1.tsx | 8 +- .../invoice-pdf/InvoiceTemplate2.tsx | 8 +- contexts/ChargesContext.tsx | 16 + contexts/InvoiceContext.tsx | 70 +- contexts/SettingsContext.tsx | 29 +- i18n/locales/en.json | 51 ++ i18n/locales/id.json | 51 ++ lib/helpers.ts | 30 +- lib/schemas.ts | 112 +-- services/invoice/client/importInvoice.ts | 17 +- types.ts | 2 +- 21 files changed, 551 insertions(+), 580 deletions(-) diff --git a/app/components/index.ts b/app/components/index.ts index 1b40edd45..ae06bb923 100644 --- a/app/components/index.ts +++ b/app/components/index.ts @@ -37,7 +37,7 @@ import InvoiceDetails from "./invoice/form/sections/InvoiceDetails"; import Items from "./invoice/form/sections/Items"; import PaymentInformation from "./invoice/form/sections/PaymentInformation"; import InvoiceSummary from "./invoice/form/sections/InvoiceSummary"; -import ImportJsonButton from "./invoice/form/sections/ImportJsonButton"; +import ImportInvoiceButton from "./invoice/form/sections/ImportJsonButton"; // * Actions import PdfViewer from "./invoice/actions/PdfViewer"; @@ -142,7 +142,7 @@ export { SendPdfToEmailModal, InvoiceLoaderModal, InvoiceExportModal, - ImportJsonButton, + ImportInvoiceButton, SignatureModal, DrawSignature, TypeSignature, diff --git a/app/components/invoice/actions/PdfViewer.tsx b/app/components/invoice/actions/PdfViewer.tsx index 2b323784b..60657d085 100644 --- a/app/components/invoice/actions/PdfViewer.tsx +++ b/app/components/invoice/actions/PdfViewer.tsx @@ -9,6 +9,9 @@ import { useFormContext } from "react-hook-form"; // Components import { FinalPdf, LivePreview } from "@/app/components"; +// Helpers +import { cleanInvoiceItemsForSettings } from "@/lib/helpers"; + // Contexts import { useInvoiceContext } from "@/contexts/InvoiceContext"; import { useSettings } from "@/contexts/SettingsContext"; @@ -30,19 +33,8 @@ const PdfViewer = () => { ...formValues, details: { ...formValues.details, - items: formValues.details.items.map(item => { - const cleanedItem = { ...item }; - if (!settings.discountPerItem.enabled) { - cleanedItem.discount = undefined; - cleanedItem.discountType = undefined; - } - if (!settings.taxPerItem.enabled) { - cleanedItem.tax = undefined; - cleanedItem.taxType = undefined; - } - return cleanedItem; - }) - } + items: cleanInvoiceItemsForSettings(formValues.details.items, settings), + }, }; return ( diff --git a/app/components/invoice/form/SingleItem.tsx b/app/components/invoice/form/SingleItem.tsx index dd73158d4..dea702775 100644 --- a/app/components/invoice/form/SingleItem.tsx +++ b/app/components/invoice/form/SingleItem.tsx @@ -99,31 +99,34 @@ const SingleItem = ({ useEffect(() => { // Calculate total when rate, quantity, discount or tax changes - if (rate != undefined && quantity != undefined) { - const baseAmount = rate * quantity; + if (rate !== undefined && quantity !== undefined) { + const baseAmount = Number(rate) * Number(quantity); let discountValue = 0; let taxValue = 0; - if (discount != undefined && !isNaN(discount)) { + // Calculate discount if enabled + if (settings.discountPerItem.enabled && discount !== undefined && !isNaN(Number(discount))) { if (discountType === "percentage") { - discountValue = baseAmount * (discount / 100); + discountValue = baseAmount * (Number(discount) / 100); } else { - discountValue = discount; + discountValue = Number(discount); } } - if (tax != undefined && !isNaN(tax)) { + // Calculate tax if enabled - applied on post-discount amount if percentage + if (settings.taxPerItem.enabled && tax !== undefined && !isNaN(Number(tax))) { + const amountAfterDiscount = baseAmount - discountValue; if (taxType === "percentage") { - taxValue = baseAmount * (tax / 100); + taxValue = amountAfterDiscount * (Number(tax) / 100); } else { - taxValue = tax; + taxValue = Number(tax); } } - const calculatedTotal = (baseAmount - discountValue - taxValue).toFixed(2); + const calculatedTotal = (baseAmount - discountValue + taxValue).toFixed(2); setValue(`${name}[${index}].total`, calculatedTotal); } - }, [rate, quantity, discount, discountType, tax, taxType]); + }, [rate, quantity, discount, discountType, tax, taxType, settings.discountPerItem.enabled, settings.taxPerItem.enabled]); // DnD const { @@ -285,7 +288,7 @@ const SingleItem = ({ disabled={!rate} /> void; } -const ImportJsonButton = ({ setOpen }: ImportButtonType) => { +const ImportInvoiceButton = ({ setOpen }: ImportButtonType) => { const fileInputRef = useRef(null); const { importInvoice, invoicePdfLoading } = useInvoiceContext(); + const { importInvoiceError } = useToasts(); const handleClick = () => { fileInputRef.current?.click(); @@ -28,6 +32,8 @@ const ImportJsonButton = ({ setOpen }: ImportButtonType) => { if (supportedFormats.includes(ext)) { await importInvoice(file); setOpen(false); + } else { + importInvoiceError(); } } // Reset input value to allow selecting the same file again @@ -58,4 +64,4 @@ const ImportJsonButton = ({ setOpen }: ImportButtonType) => { ); }; -export default ImportJsonButton; \ No newline at end of file +export default ImportInvoiceButton; \ No newline at end of file diff --git a/app/components/invoice/form/sections/Items.tsx b/app/components/invoice/form/sections/Items.tsx index 07ac51bef..8c540e5d1 100644 --- a/app/components/invoice/form/sections/Items.tsx +++ b/app/components/invoice/form/sections/Items.tsx @@ -54,9 +54,9 @@ const Items = () => { total: 0, sku: "", discount: 0, - discountType: "", + discountType: "amount", tax: 0, - taxType: "", + taxType: "amount", }); }; diff --git a/app/components/invoice/form/sections/PaymentInformation.tsx b/app/components/invoice/form/sections/PaymentInformation.tsx index d078768a5..cf50f340d 100644 --- a/app/components/invoice/form/sections/PaymentInformation.tsx +++ b/app/components/invoice/form/sections/PaymentInformation.tsx @@ -48,8 +48,8 @@ const PaymentInformation = () => { /> )} - {/* Bank Details - Hidden when cash mode is enabled */} - {!isCash && ( + {/* Bank Details - Hidden when cash mode is enabled and is cash payment */} + {!(isCash && settings.cashPaymentMode.enabled) && (

{ style={{ height: "auto" }} /> - {/* {devEnv && } */}