diff --git a/app/components/index.ts b/app/components/index.ts index 8ad752ec8..ae06bb923 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 ========================= */ @@ -32,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"; @@ -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, @@ -134,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 5b7c32f71..60657d085 100644 --- a/app/components/invoice/actions/PdfViewer.tsx +++ b/app/components/invoice/actions/PdfViewer.tsx @@ -9,24 +9,38 @@ 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"; // 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: cleanInvoiceItemsForSettings(formValues.details.items, settings), + }, + }; + 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..dea702775 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,35 @@ const SingleItem = ({ }); useEffect(() => { - // Calculate total when rate or quantity changes - if (rate != undefined && quantity != undefined) { - const calculatedTotal = (rate * quantity).toFixed(2); + // Calculate total when rate, quantity, discount or tax changes + if (rate !== undefined && quantity !== undefined) { + const baseAmount = Number(rate) * Number(quantity); + let discountValue = 0; + let taxValue = 0; + + // Calculate discount if enabled + if (settings.discountPerItem.enabled && discount !== undefined && !isNaN(Number(discount))) { + if (discountType === "percentage") { + discountValue = baseAmount * (Number(discount) / 100); + } else { + discountValue = Number(discount); + } + } + + // 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 = amountAfterDiscount * (Number(tax) / 100); + } else { + taxValue = Number(tax); + } + } + + const calculatedTotal = (baseAmount - discountValue + taxValue).toFixed(2); setValue(`${name}[${index}].total`, calculatedTotal); } - }, [rate, quantity]); + }, [rate, quantity, discount, discountType, tax, taxType, settings.discountPerItem.enabled, settings.taxPerItem.enabled]); // DnD const { @@ -110,66 +155,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 +238,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..dd259e65e 100644 --- a/app/components/invoice/form/sections/ImportJsonButton.tsx +++ b/app/components/invoice/form/sections/ImportJsonButton.tsx @@ -5,23 +5,36 @@ import { BaseButton } from '@/app/components'; import { useInvoiceContext } from '@/contexts/InvoiceContext'; import { Import } from 'lucide-react'; -type ImportJsonButtonType = { +// Hooks +import useToasts from '@/hooks/useToasts'; + +type ImportButtonType = { setOpen: (open: boolean) => void; } -const ImportJsonButton = ({ setOpen }: ImportJsonButtonType) => { +const ImportInvoiceButton = ({ setOpen }: ImportButtonType) => { const fileInputRef = useRef(null); const { importInvoice, invoicePdfLoading } = useInvoiceContext(); + const { importInvoiceError } = useToasts(); const handleClick = () => { 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); + } else { + importInvoiceError(); + } } // Reset input value to allow selecting the same file again if (fileInputRef.current) { @@ -35,20 +48,20 @@ 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 ); }; -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 7b7dcf7bb..8c540e5d1 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: "amount", + tax: 0, + taxType: "amount", }); }; diff --git a/app/components/invoice/form/sections/PaymentInformation.tsx b/app/components/invoice/form/sections/PaymentInformation.tsx index 11abfacaf..cf50f340d 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 and is cash payment */} + {!(isCash && settings.cashPaymentMode.enabled) && ( +
+ + + +
+ )} + + {/* 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..a56c55e30 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,53 @@ 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 { 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/modals/invoice/InvoiceLoaderModal.tsx b/app/components/modals/invoice/InvoiceLoaderModal.tsx index 650f6fb2e..c045a38f1 100644 --- a/app/components/modals/invoice/InvoiceLoaderModal.tsx +++ b/app/components/modals/invoice/InvoiceLoaderModal.tsx @@ -14,7 +14,7 @@ import { // Components import { SavedInvoicesList } from "@/app/components"; -import { ImportJsonButton } from "@/app/components"; +import { ImportInvoiceButton } from "@/app/components"; // Context import { useInvoiceContext } from "@/contexts/InvoiceContext"; @@ -38,7 +38,7 @@ const InvoiceLoaderModal = ({ children }: InvoiceLoaderModalType) => {

You have {savedInvoices.length} saved invoices

- +
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 = ({ = { + name: Path; + label?: string; + placeholder?: string; + options: { label: string; value: string }[]; + vertical?: boolean; + defaultValue?: string; +}; + +const FormSelect = ({ + name, + label, + placeholder, + options, + vertical = false, + defaultValue, +}: FormSelectProps) => { + 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..0397adc36 --- /dev/null +++ b/app/components/settings/SettingsPanel.tsx @@ -0,0 +1,300 @@ +"use client"; + +import { useSettings } from "@/contexts/SettingsContext"; +import { useTranslations } from "next-intl"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { Switch } from "@/components/ui/switch"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { X, Settings } from "lucide-react"; +import { useEffect, useRef } from "react"; + +interface SettingsPanelProps { + isOpen: boolean; + onClose: () => void; +} + +export function SettingsPanel({ isOpen, onClose }: SettingsPanelProps) { + const { settings, updateSettings } = useSettings(); + const t = useTranslations("settings"); + const panelRef = useRef(null); + + // Handle Escape key + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + if (isOpen) { + window.addEventListener("keydown", handleKeyDown); + // Simple focus trap + panelRef.current?.focus(); + } + return () => window.removeEventListener("keydown", handleKeyDown); + }, [isOpen, onClose]); + + if (!isOpen) return null; + + return ( +
+
e.stopPropagation()} + > +
+
+ +

+ {t("title")} +

+
+ +
+ + + + + {t("tabs.fields")} + + + {t("tabs.items")} + + + {t("tabs.payment")} + + + + + +
+ {/* Sender Fields */} + updateSettings({ fieldRequirements: { ...settings.fieldRequirements, senderAddress: status } })} + /> + updateSettings({ fieldRequirements: { ...settings.fieldRequirements, senderZipCode: status } })} + /> + updateSettings({ fieldRequirements: { ...settings.fieldRequirements, senderCity: status } })} + /> + updateSettings({ fieldRequirements: { ...settings.fieldRequirements, senderCountry: status } })} + /> + updateSettings({ fieldRequirements: { ...settings.fieldRequirements, senderEmail: status } })} + /> + updateSettings({ fieldRequirements: { ...settings.fieldRequirements, senderPhone: status } })} + /> +
+ {/* Receiver Fields */} + updateSettings({ fieldRequirements: { ...settings.fieldRequirements, receiverAddress: status } })} + /> + updateSettings({ fieldRequirements: { ...settings.fieldRequirements, receiverZipCode: status } })} + /> + updateSettings({ fieldRequirements: { ...settings.fieldRequirements, receiverCity: status } })} + /> + updateSettings({ fieldRequirements: { ...settings.fieldRequirements, receiverCountry: status } })} + /> + updateSettings({ fieldRequirements: { ...settings.fieldRequirements, receiverEmail: status } })} + /> + updateSettings({ fieldRequirements: { ...settings.fieldRequirements, receiverPhone: status } })} + /> +
+ + + + +
+ updateSettings({ skuColumn: { ...settings.skuColumn, enabled } })} + isRequired={settings.skuColumn.required} + onToggleRequired={(required) => updateSettings({ skuColumn: { ...settings.skuColumn, required } })} + /> + updateSettings({ discountPerItem: { ...settings.discountPerItem, enabled } })} + isRequired={settings.discountPerItem.required} + onToggleRequired={(required) => updateSettings({ discountPerItem: { ...settings.discountPerItem, required } })} + /> + updateSettings({ taxPerItem: { ...settings.taxPerItem, enabled } })} + isRequired={settings.taxPerItem.required} + onToggleRequired={(required) => updateSettings({ taxPerItem: { ...settings.taxPerItem, required } })} + /> +
+
+ + + +
+
+ + {t("payment.cashPaymentMode")} + + updateSettings({ cashPaymentMode: { enabled } })} + /> +
+
+
+ +
+
+ ); +} + +function SectionHeader({ title, description }: { title: string; description: string }) { + return ( +
+

+ {title} +

+

+ {description} +

+
+ ); +} + +function FieldRequirementItem({ + label, + currentStatus, + updateStatus +}: { + label: string; + currentStatus: "required" | "optional" | "hidden"; + updateStatus: (status: "required" | "optional" | "hidden") => void; +}) { + const t = useTranslations("settings.status"); + return ( + + {label} +
+ {(["required", "optional", "hidden"] as const).map((status) => ( + + ))} +
+
+ ); +} + +function ToggleItem({ + label, + isEnabled, + onToggle, + isRequired, + onToggleRequired +}: { + label: string; + isEnabled: boolean; + onToggle: (enabled: boolean) => void; + isRequired: boolean; + onToggleRequired: (required: boolean) => void; +}) { + const t = useTranslations("settings.status"); + return ( + +
+ {label} + +
+ {isEnabled && ( +
+
+ Requirement +
+ + +
+
+
+ )} +
+ ); +} diff --git a/app/components/templates/invoice-pdf/InvoiceTemplate1.tsx b/app/components/templates/invoice-pdf/InvoiceTemplate1.tsx index 01999e3ef..fba1313de 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..5547afaec 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/ChargesContext.tsx b/contexts/ChargesContext.tsx index 269e7895d..04723fe1e 100644 --- a/contexts/ChargesContext.tsx +++ b/contexts/ChargesContext.tsx @@ -14,6 +14,9 @@ import { useFormContext, useWatch } from "react-hook-form"; // Helpers import { formatPriceToString } from "@/lib/helpers"; +// Contexts +import { useSettings } from "./SettingsContext"; + // Types import { InvoiceType, ItemType } from "@/types"; @@ -50,6 +53,7 @@ type ChargesContextProps = { export const ChargesContextProvider = ({ children }: ChargesContextProps) => { const { control, setValue, getValues } = useFormContext(); + const { settings } = useSettings(); // Form Fields const itemsArray = useWatch({ @@ -152,6 +156,18 @@ export const ChargesContextProvider = ({ children }: ChargesContextProps) => { } }, [discountSwitch, taxSwitch, shippingSwitch]); + // Reset switches and values if per-item features are enabled + useEffect(() => { + if (settings.discountPerItem.enabled) { + setDiscountSwitch(false); + setValue("details.discountDetails.amount", 0); + } + if (settings.taxPerItem.enabled) { + setTaxSwitch(false); + setValue("details.taxDetails.amount", 0); + } + }, [settings.discountPerItem.enabled, settings.taxPerItem.enabled]); + // Calculate total when values change useEffect(() => { calculateTotal(); diff --git a/contexts/InvoiceContext.tsx b/contexts/InvoiceContext.tsx index cdf5789f3..1f7eb4ac1 100644 --- a/contexts/InvoiceContext.tsx +++ b/contexts/InvoiceContext.tsx @@ -19,6 +19,8 @@ import useToasts from "@/hooks/useToasts"; // Services import { exportInvoice } from "@/services/invoice/client/exportInvoice"; +import { parseImportedFile } from "@/services/invoice/client/importInvoice"; +import { cleanInvoiceItemsForSettings } from "@/lib/helpers"; // Variables import { @@ -48,7 +50,7 @@ const defaultInvoiceContext = { deleteInvoice: (index: number) => {}, sendPdfToMail: (email: string): Promise => Promise.resolve(), exportInvoiceAs: (exportAs: ExportTypes) => {}, - importInvoice: (file: File) => {}, + importInvoice: async (file: File): Promise => Promise.resolve(), }; export const InvoiceContext = createContext(defaultInvoiceContext); @@ -57,6 +59,9 @@ export const useInvoiceContext = () => { return useContext(InvoiceContext); }; +// Contexts +import { useSettings } from "./SettingsContext"; + type InvoiceContextProviderProps = { children: React.ReactNode; }; @@ -65,6 +70,7 @@ export const InvoiceContextProvider = ({ children, }: InvoiceContextProviderProps) => { const router = useRouter(); + const { settings } = useSettings(); // Toasts const { @@ -161,28 +167,40 @@ export const InvoiceContextProvider = ({ * @returns {Promise} - A promise that resolves when the PDF is successfully generated. * @throws {Error} - If an error occurs during the PDF generation process. */ - const generatePdf = useCallback(async (data: InvoiceType) => { - setInvoicePdfLoading(true); + const generatePdf = useCallback( + async (data: InvoiceType) => { + setInvoicePdfLoading(true); - try { - const response = await fetch(GENERATE_PDF_API, { - method: "POST", - body: JSON.stringify(data), - }); + try { + // Clean data based on settings + const cleanedData = { + ...data, + details: { + ...data.details, + items: cleanInvoiceItemsForSettings(data.details.items, settings), + }, + }; + + const response = await fetch(GENERATE_PDF_API, { + method: "POST", + body: JSON.stringify(cleanedData), + }); - const result = await response.blob(); - setInvoicePdf(result); + const result = await response.blob(); + setInvoicePdf(result); - if (result.size > 0) { - // Toast - pdfGenerationSuccess(); + if (result.size > 0) { + // Toast + pdfGenerationSuccess(); + } + } catch (err) { + console.log(err); + } finally { + setInvoicePdfLoading(false); } - } catch (err) { - console.log(err); - } finally { - setInvoicePdfLoading(false); - } - }, []); + }, + [settings] + ); /** * Removes the final PDF file and switches to Live Preview @@ -351,43 +369,48 @@ export const InvoiceContextProvider = ({ const exportInvoiceAs = (exportAs: ExportTypes) => { const formValues = getValues(); + // Clean data based on settings + const cleanedData = { + ...formValues, + details: { + ...formValues.details, + items: cleanInvoiceItemsForSettings(formValues.details.items, settings), + }, + }; + // Service to export invoice with given parameters - exportInvoice(exportAs, formValues); + exportInvoice(exportAs, cleanedData); }; /** - * 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 + ) as unknown as string; + } + if (importedData.details.dueDate) { + importedData.details.dueDate = new Date( + importedData.details.dueDate + ) as unknown as string; } - - // 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..55a3d2965 --- /dev/null +++ b/contexts/SettingsContext.tsx @@ -0,0 +1,94 @@ +"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); + // Deep merge with default settings to handle missing properties + const merged: SettingsType = { + ...DEFAULT_SETTINGS, + ...parsed, + fieldRequirements: { + ...DEFAULT_SETTINGS.fieldRequirements, + ...parsed.fieldRequirements, + }, + skuColumn: { + ...DEFAULT_SETTINGS.skuColumn, + ...parsed.skuColumn, + }, + discountPerItem: { + ...DEFAULT_SETTINGS.discountPerItem, + ...parsed.discountPerItem, + }, + taxPerItem: { + ...DEFAULT_SETTINGS.taxPerItem, + ...parsed.taxPerItem, + }, + cashPaymentMode: { + ...DEFAULT_SETTINGS.cashPaymentMode, + ...parsed.cashPaymentMode, + }, + }; + setSettings(merged); + } + } 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/i18n/locales/en.json b/i18n/locales/en.json index 1a347508e..e1c98e5a1 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -89,5 +89,56 @@ }, "footer": { "developedBy": "Developed by" + }, + "settings": { + "title": "Settings", + "description": "Configure your invoice settings", + "close": "Close settings", + "tabs": { + "fields": "Fields", + "items": "Items", + "payment": "Payment" + }, + "fieldRequirements": { + "title": "Field Requirements", + "description": "Select which fields are required, optional, or hidden in the form." + }, + "itemFeatures": { + "title": "Item Features", + "description": "Enable or disable specific features for invoice items." + }, + "paymentModes": { + "title": "Payment Modes", + "description": "Configure payment methods and options." + }, + "fields": { + "senderAddress": "Sender Address", + "senderZipCode": "Sender Zip Code", + "senderCity": "Sender City", + "senderCountry": "Sender Country", + "senderEmail": "Sender Email", + "senderPhone": "Sender Phone", + "receiverAddress": "Receiver Address", + "receiverZipCode": "Receiver Zip Code", + "receiverCity": "Receiver City", + "receiverCountry": "Receiver Country", + "receiverEmail": "Receiver Email", + "receiverPhone": "Receiver Phone" + }, + "items": { + "skuColumn": "SKU Column", + "discountPerItem": "Discount Per Item", + "taxPerItem": "Tax Per Item" + }, + "payment": { + "cashPaymentMode": "Cash Payment Mode" + }, + "status": { + "required": "Required", + "optional": "Optional", + "hidden": "Hidden", + "enabled": "Enabled", + "disabled": "Disabled" + } } } diff --git a/i18n/locales/id.json b/i18n/locales/id.json index 0ae726d45..a885628c0 100644 --- a/i18n/locales/id.json +++ b/i18n/locales/id.json @@ -89,5 +89,56 @@ }, "footer": { "developedBy": "Dikembangkan oleh" + }, + "settings": { + "title": "Pengaturan", + "description": "Atur pengaturan faktur Anda", + "close": "Tutup pengaturan", + "tabs": { + "fields": "Kolom", + "items": "Barang", + "payment": "Pembayaran" + }, + "fieldRequirements": { + "title": "Kebutuhan Kolom", + "description": "Pilih kolom mana yang diperlukan, opsional, atau disembunyikan dalam formulir." + }, + "itemFeatures": { + "title": "Fitur Barang", + "description": "Aktifkan atau nonaktifkan fitur spesifik untuk barang faktur." + }, + "paymentModes": { + "title": "Metode Pembayaran", + "description": "Atur metode dan opsi pembayaran." + }, + "fields": { + "senderAddress": "Alamat Pengirim", + "senderZipCode": "Kode Pos Pengirim", + "senderCity": "Kota Pengirim", + "senderCountry": "Negara Pengirim", + "senderEmail": "Email Pengirim", + "senderPhone": "Telepon Pengirim", + "receiverAddress": "Alamat Penerima", + "receiverZipCode": "Kode Pos Penerima", + "receiverCity": "Kota Penerima", + "receiverCountry": "Negara Penerima", + "receiverEmail": "Email Penerima", + "receiverPhone": "Telepon Penerima" + }, + "items": { + "skuColumn": "Kolom SKU", + "discountPerItem": "Diskon per Barang", + "taxPerItem": "Pajak per Barang" + }, + "payment": { + "cashPaymentMode": "Metode Pembayaran Tunai" + }, + "status": { + "required": "Diperlukan", + "optional": "Opsional", + "hidden": "Sembunyikan", + "enabled": "Aktif", + "disabled": "Nonaktif" + } } } diff --git a/lib/helpers.ts b/lib/helpers.ts index e3df82d98..c5ade8dde 100644 --- a/lib/helpers.ts +++ b/lib/helpers.ts @@ -202,6 +202,50 @@ 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, + }); + + const parts = formatter.formatToParts(0); + const currencyPart = parts.find((part) => part.type === "currency"); + return currencyPart ? currencyPart.value : currencyCode; + } catch (error) { + // Fallback to currency code if symbol extraction fails + return currencyCode; + } +}; + +/** + * Clean invoice items based on settings - remove discount/tax if settings are disabled + * @param {any[]} items - Array of invoice items + * @param {any} settings - Settings object + * @returns {any[]} Cleaned items + */ +const cleanInvoiceItemsForSettings = (items: any[], settings: any) => { + return 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; + }); +}; + export { formatNumberWithCommas, formatPriceToString, @@ -210,4 +254,6 @@ export { isDataUrl, getInvoiceTemplate, fileToBuffer, + getCurrencySymbol, + cleanInvoiceItemsForSettings, }; diff --git a/lib/schemas.ts b/lib/schemas.ts index 0c1061999..e77ca7409 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,151 @@ const InvoiceSchema = z.object({ details: InvoiceDetailsSchema, }); -export { InvoiceSchema, ItemSchema }; +// Factory function to create dynamic ItemSchema based on settings +const createItemSchema = (settings: SettingsType) => { + const shape: Record = { + name: fieldValidators.stringMin1, + description: fieldValidators.stringOptional, + quantity: fieldValidators.quantity, + unitPrice: fieldValidators.unitPrice, + total: fieldValidators.stringToNumber, + }; + + // Add SKU field if enabled + if (settings.skuColumn.enabled) { + shape.sku = settings.skuColumn.required + ? fieldValidators.stringMin1 + : fieldValidators.stringOptional; + } + + // Add discount field if enabled + if (settings.discountPerItem.enabled) { + shape.discount = settings.discountPerItem.required + ? fieldValidators.nonNegativeNumber + : fieldValidators.nonNegativeNumber.optional(); + shape.discountType = fieldValidators.stringOptional; + } + + // Add tax field if enabled + if (settings.taxPerItem.enabled) { + shape.tax = settings.taxPerItem.required + ? fieldValidators.nonNegativeNumber + : fieldValidators.nonNegativeNumber.optional(); + shape.taxType = fieldValidators.stringOptional; + } + + return z.object(shape); +}; + +// 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, + isCash: z.boolean().optional(), + change: fieldValidators.nonNegativeNumber.optional(), + }); +}; + +// Helper to create dynamic sender/receiver schema based on settings +const createPartySchema = (settings: SettingsType, party: "sender" | "receiver") => { + const getValidator = (field: string, baseValidator: z.ZodTypeAny) => { + const key = `${party}${field.charAt(0).toUpperCase() + field.slice(1)}` as keyof typeof settings.fieldRequirements; + return settings.fieldRequirements[key] === "required" + ? baseValidator + : makeOptional(baseValidator); + }; + + return z.object({ + name: fieldValidators.name, + address: getValidator("address", fieldValidators.address), + zipCode: getValidator("zipCode", fieldValidators.zipCode), + city: getValidator("city", fieldValidators.city), + country: getValidator("country", fieldValidators.country), + email: getValidator("email", fieldValidators.email), + phone: getValidator("phone", fieldValidators.phone), + customInputs: z.array(CustomInputSchema).optional(), + }); +}; + +// Factory function to create dynamic sender schema based on settings +const createSenderSchema = (settings: SettingsType) => createPartySchema(settings, "sender"); + +// Factory function to create dynamic receiver schema based on settings +const createReceiverSchema = (settings: SettingsType) => createPartySchema(settings, "receiver"); + +// 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..e71f99e0f --- /dev/null +++ b/services/invoice/client/importInvoice.ts @@ -0,0 +1,246 @@ +import { InvoiceType } from "@/types"; +import * as XLSX from "xlsx"; +import { parseStringPromise } from "xml2js"; + +/** + * Unflatten an object that was flattened with underscore 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 { + // Normalize line endings to handle CRLF + const normalizedText = csvText.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + const lines = normalizedText.split("\n").filter((line) => line.trim() !== ""); + + 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 and escaped quotes + */ +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 === '"') { + // Handle escaped quotes (doubled quotes "") + if (insideQuotes && line[i + 1] === '"') { + current += '"'; + i++; // Skip the next quote + } else { + 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..e6dd69c68 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" | "hidden"; + +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"; +};