feat: polish invoice editor and viewer UI with custom NumberInput

component

- Create custom NumberInput component with increment/decrement buttons
- Add 0.25 step increments for hours and rates in invoice forms
- Implement emerald-themed styling with hover states and accessibility
- Add keyboard navigation (arrow keys) and proper ARIA support
- Condense invoice editor tax/totals section into efficient grid layout
- Update client dropdown to single-line format (name + email)
- Add fixed footer with floating action bar pattern matching business
  forms
- Redesign invoice viewer with better space utilization and visual
  hierarchy
- Maintain professional appearance and consistent design system
- Fix Next.js 15 params Promise handling across all invoice pages
- Resolve TypeScript compilation errors and type-only imports
This commit is contained in:
2025-07-15 00:29:02 -04:00
parent 89de059501
commit f331136090
79 changed files with 9944 additions and 4223 deletions

388
src/lib/form-constants.ts Normal file
View File

@@ -0,0 +1,388 @@
/**
* Shared form constants and utilities
*/
// US States
export const US_STATES = [
{ value: "AL", label: "Alabama" },
{ value: "AK", label: "Alaska" },
{ value: "AZ", label: "Arizona" },
{ value: "AR", label: "Arkansas" },
{ value: "CA", label: "California" },
{ value: "CO", label: "Colorado" },
{ value: "CT", label: "Connecticut" },
{ value: "DE", label: "Delaware" },
{ value: "FL", label: "Florida" },
{ value: "GA", label: "Georgia" },
{ value: "HI", label: "Hawaii" },
{ value: "ID", label: "Idaho" },
{ value: "IL", label: "Illinois" },
{ value: "IN", label: "Indiana" },
{ value: "IA", label: "Iowa" },
{ value: "KS", label: "Kansas" },
{ value: "KY", label: "Kentucky" },
{ value: "LA", label: "Louisiana" },
{ value: "ME", label: "Maine" },
{ value: "MD", label: "Maryland" },
{ value: "MA", label: "Massachusetts" },
{ value: "MI", label: "Michigan" },
{ value: "MN", label: "Minnesota" },
{ value: "MS", label: "Mississippi" },
{ value: "MO", label: "Missouri" },
{ value: "MT", label: "Montana" },
{ value: "NE", label: "Nebraska" },
{ value: "NV", label: "Nevada" },
{ value: "NH", label: "New Hampshire" },
{ value: "NJ", label: "New Jersey" },
{ value: "NM", label: "New Mexico" },
{ value: "NY", label: "New York" },
{ value: "NC", label: "North Carolina" },
{ value: "ND", label: "North Dakota" },
{ value: "OH", label: "Ohio" },
{ value: "OK", label: "Oklahoma" },
{ value: "OR", label: "Oregon" },
{ value: "PA", label: "Pennsylvania" },
{ value: "RI", label: "Rhode Island" },
{ value: "SC", label: "South Carolina" },
{ value: "SD", label: "South Dakota" },
{ value: "TN", label: "Tennessee" },
{ value: "TX", label: "Texas" },
{ value: "UT", label: "Utah" },
{ value: "VT", label: "Vermont" },
{ value: "VA", label: "Virginia" },
{ value: "WA", label: "Washington" },
{ value: "WV", label: "West Virginia" },
{ value: "WI", label: "Wisconsin" },
{ value: "WY", label: "Wyoming" },
];
// Most commonly used countries
export const POPULAR_COUNTRIES = [
{ value: "United States", label: "United States" },
{ value: "United Kingdom", label: "United Kingdom" },
{ value: "Canada", label: "Canada" },
{ value: "Australia", label: "Australia" },
{ value: "Germany", label: "Germany" },
{ value: "France", label: "France" },
{ value: "India", label: "India" },
{ value: "Japan", label: "Japan" },
{ value: "Mexico", label: "Mexico" },
{ value: "Brazil", label: "Brazil" },
];
// All countries with ISO codes
export const ALL_COUNTRIES = [
{ value: "Afghanistan", label: "Afghanistan" },
{ value: "Albania", label: "Albania" },
{ value: "Algeria", label: "Algeria" },
{ value: "Andorra", label: "Andorra" },
{ value: "Angola", label: "Angola" },
{ value: "Antigua and Barbuda", label: "Antigua and Barbuda" },
{ value: "Argentina", label: "Argentina" },
{ value: "Armenia", label: "Armenia" },
{ value: "Australia", label: "Australia" },
{ value: "Austria", label: "Austria" },
{ value: "Azerbaijan", label: "Azerbaijan" },
{ value: "Bahamas", label: "Bahamas" },
{ value: "Bahrain", label: "Bahrain" },
{ value: "Bangladesh", label: "Bangladesh" },
{ value: "Barbados", label: "Barbados" },
{ value: "Belarus", label: "Belarus" },
{ value: "Belgium", label: "Belgium" },
{ value: "Belize", label: "Belize" },
{ value: "Benin", label: "Benin" },
{ value: "Bhutan", label: "Bhutan" },
{ value: "Bolivia", label: "Bolivia" },
{ value: "Bosnia and Herzegovina", label: "Bosnia and Herzegovina" },
{ value: "Botswana", label: "Botswana" },
{ value: "Brazil", label: "Brazil" },
{ value: "Brunei", label: "Brunei" },
{ value: "Bulgaria", label: "Bulgaria" },
{ value: "Burkina Faso", label: "Burkina Faso" },
{ value: "Burundi", label: "Burundi" },
{ value: "Cabo Verde", label: "Cabo Verde" },
{ value: "Cambodia", label: "Cambodia" },
{ value: "Cameroon", label: "Cameroon" },
{ value: "Canada", label: "Canada" },
{ value: "Central African Republic", label: "Central African Republic" },
{ value: "Chad", label: "Chad" },
{ value: "Chile", label: "Chile" },
{ value: "China", label: "China" },
{ value: "Colombia", label: "Colombia" },
{ value: "Comoros", label: "Comoros" },
{ value: "Congo", label: "Congo" },
{ value: "Costa Rica", label: "Costa Rica" },
{ value: "Croatia", label: "Croatia" },
{ value: "Cuba", label: "Cuba" },
{ value: "Cyprus", label: "Cyprus" },
{ value: "Czech Republic", label: "Czech Republic" },
{
value: "Democratic Republic of the Congo",
label: "Democratic Republic of the Congo",
},
{ value: "Denmark", label: "Denmark" },
{ value: "Djibouti", label: "Djibouti" },
{ value: "Dominica", label: "Dominica" },
{ value: "Dominican Republic", label: "Dominican Republic" },
{ value: "East Timor", label: "East Timor" },
{ value: "Ecuador", label: "Ecuador" },
{ value: "Egypt", label: "Egypt" },
{ value: "El Salvador", label: "El Salvador" },
{ value: "Equatorial Guinea", label: "Equatorial Guinea" },
{ value: "Eritrea", label: "Eritrea" },
{ value: "Estonia", label: "Estonia" },
{ value: "Eswatini", label: "Eswatini" },
{ value: "Ethiopia", label: "Ethiopia" },
{ value: "Fiji", label: "Fiji" },
{ value: "Finland", label: "Finland" },
{ value: "France", label: "France" },
{ value: "Gabon", label: "Gabon" },
{ value: "Gambia", label: "Gambia" },
{ value: "Georgia", label: "Georgia" },
{ value: "Germany", label: "Germany" },
{ value: "Ghana", label: "Ghana" },
{ value: "Greece", label: "Greece" },
{ value: "Grenada", label: "Grenada" },
{ value: "Guatemala", label: "Guatemala" },
{ value: "Guinea", label: "Guinea" },
{ value: "Guinea-Bissau", label: "Guinea-Bissau" },
{ value: "Guyana", label: "Guyana" },
{ value: "Haiti", label: "Haiti" },
{ value: "Honduras", label: "Honduras" },
{ value: "Hungary", label: "Hungary" },
{ value: "Iceland", label: "Iceland" },
{ value: "India", label: "India" },
{ value: "Indonesia", label: "Indonesia" },
{ value: "Iran", label: "Iran" },
{ value: "Iraq", label: "Iraq" },
{ value: "Ireland", label: "Ireland" },
{ value: "Israel", label: "Israel" },
{ value: "Italy", label: "Italy" },
{ value: "Ivory Coast", label: "Ivory Coast" },
{ value: "Jamaica", label: "Jamaica" },
{ value: "Japan", label: "Japan" },
{ value: "Jordan", label: "Jordan" },
{ value: "Kazakhstan", label: "Kazakhstan" },
{ value: "Kenya", label: "Kenya" },
{ value: "Kiribati", label: "Kiribati" },
{ value: "Kuwait", label: "Kuwait" },
{ value: "Kyrgyzstan", label: "Kyrgyzstan" },
{ value: "Laos", label: "Laos" },
{ value: "Latvia", label: "Latvia" },
{ value: "Lebanon", label: "Lebanon" },
{ value: "Lesotho", label: "Lesotho" },
{ value: "Liberia", label: "Liberia" },
{ value: "Libya", label: "Libya" },
{ value: "Liechtenstein", label: "Liechtenstein" },
{ value: "Lithuania", label: "Lithuania" },
{ value: "Luxembourg", label: "Luxembourg" },
{ value: "Madagascar", label: "Madagascar" },
{ value: "Malawi", label: "Malawi" },
{ value: "Malaysia", label: "Malaysia" },
{ value: "Maldives", label: "Maldives" },
{ value: "Mali", label: "Mali" },
{ value: "Malta", label: "Malta" },
{ value: "Marshall Islands", label: "Marshall Islands" },
{ value: "Mauritania", label: "Mauritania" },
{ value: "Mauritius", label: "Mauritius" },
{ value: "Mexico", label: "Mexico" },
{ value: "Micronesia", label: "Micronesia" },
{ value: "Moldova", label: "Moldova" },
{ value: "Monaco", label: "Monaco" },
{ value: "Mongolia", label: "Mongolia" },
{ value: "Montenegro", label: "Montenegro" },
{ value: "Morocco", label: "Morocco" },
{ value: "Mozambique", label: "Mozambique" },
{ value: "Myanmar", label: "Myanmar" },
{ value: "Namibia", label: "Namibia" },
{ value: "Nauru", label: "Nauru" },
{ value: "Nepal", label: "Nepal" },
{ value: "Netherlands", label: "Netherlands" },
{ value: "New Zealand", label: "New Zealand" },
{ value: "Nicaragua", label: "Nicaragua" },
{ value: "Niger", label: "Niger" },
{ value: "Nigeria", label: "Nigeria" },
{ value: "North Korea", label: "North Korea" },
{ value: "North Macedonia", label: "North Macedonia" },
{ value: "Norway", label: "Norway" },
{ value: "Oman", label: "Oman" },
{ value: "Pakistan", label: "Pakistan" },
{ value: "Palau", label: "Palau" },
{ value: "Palestine", label: "Palestine" },
{ value: "Panama", label: "Panama" },
{ value: "Papua New Guinea", label: "Papua New Guinea" },
{ value: "Paraguay", label: "Paraguay" },
{ value: "Peru", label: "Peru" },
{ value: "Philippines", label: "Philippines" },
{ value: "Poland", label: "Poland" },
{ value: "Portugal", label: "Portugal" },
{ value: "Qatar", label: "Qatar" },
{ value: "Romania", label: "Romania" },
{ value: "Russia", label: "Russia" },
{ value: "Rwanda", label: "Rwanda" },
{ value: "Saint Kitts and Nevis", label: "Saint Kitts and Nevis" },
{ value: "Saint Lucia", label: "Saint Lucia" },
{
value: "Saint Vincent and the Grenadines",
label: "Saint Vincent and the Grenadines",
},
{ value: "Samoa", label: "Samoa" },
{ value: "San Marino", label: "San Marino" },
{ value: "Sao Tome and Principe", label: "Sao Tome and Principe" },
{ value: "Saudi Arabia", label: "Saudi Arabia" },
{ value: "Senegal", label: "Senegal" },
{ value: "Serbia", label: "Serbia" },
{ value: "Seychelles", label: "Seychelles" },
{ value: "Sierra Leone", label: "Sierra Leone" },
{ value: "Singapore", label: "Singapore" },
{ value: "Slovakia", label: "Slovakia" },
{ value: "Slovenia", label: "Slovenia" },
{ value: "Solomon Islands", label: "Solomon Islands" },
{ value: "Somalia", label: "Somalia" },
{ value: "South Africa", label: "South Africa" },
{ value: "South Korea", label: "South Korea" },
{ value: "South Sudan", label: "South Sudan" },
{ value: "Spain", label: "Spain" },
{ value: "Sri Lanka", label: "Sri Lanka" },
{ value: "Sudan", label: "Sudan" },
{ value: "Suriname", label: "Suriname" },
{ value: "Sweden", label: "Sweden" },
{ value: "Switzerland", label: "Switzerland" },
{ value: "Syria", label: "Syria" },
{ value: "Taiwan", label: "Taiwan" },
{ value: "Tajikistan", label: "Tajikistan" },
{ value: "Tanzania", label: "Tanzania" },
{ value: "Thailand", label: "Thailand" },
{ value: "Togo", label: "Togo" },
{ value: "Tonga", label: "Tonga" },
{ value: "Trinidad and Tobago", label: "Trinidad and Tobago" },
{ value: "Tunisia", label: "Tunisia" },
{ value: "Turkey", label: "Turkey" },
{ value: "Turkmenistan", label: "Turkmenistan" },
{ value: "Tuvalu", label: "Tuvalu" },
{ value: "Uganda", label: "Uganda" },
{ value: "Ukraine", label: "Ukraine" },
{ value: "United Arab Emirates", label: "United Arab Emirates" },
{ value: "United Kingdom", label: "United Kingdom" },
{ value: "United States", label: "United States" },
{ value: "Uruguay", label: "Uruguay" },
{ value: "Uzbekistan", label: "Uzbekistan" },
{ value: "Vanuatu", label: "Vanuatu" },
{ value: "Vatican City", label: "Vatican City" },
{ value: "Venezuela", label: "Venezuela" },
{ value: "Vietnam", label: "Vietnam" },
{ value: "Yemen", label: "Yemen" },
{ value: "Zambia", label: "Zambia" },
{ value: "Zimbabwe", label: "Zimbabwe" },
];
// Phone number formatting
export function formatPhoneNumber(value: string): string {
// Remove all non-numeric characters
const phoneNumber = value.replace(/\D/g, "");
// Format as US phone number
if (phoneNumber.length <= 3) {
return phoneNumber;
} else if (phoneNumber.length <= 6) {
return `(${phoneNumber.slice(0, 3)}) ${phoneNumber.slice(3)}`;
} else if (phoneNumber.length <= 10) {
return `(${phoneNumber.slice(0, 3)}) ${phoneNumber.slice(3, 6)}-${phoneNumber.slice(6, 10)}`;
} else {
// Handle international numbers
return `+${phoneNumber.slice(0, phoneNumber.length - 10)} (${phoneNumber.slice(-10, -7)}) ${phoneNumber.slice(-7, -4)}-${phoneNumber.slice(-4)}`;
}
}
// Email validation
export function isValidEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
// URL formatting
export function formatWebsiteUrl(url: string): string {
if (!url) return "";
// If URL doesn't start with http:// or https://, add https://
if (!url.match(/^https?:\/\//i)) {
return `https://${url}`;
}
return url;
}
// Postal code formatting
export function formatPostalCode(
value: string,
country: string = "United States",
): string {
if (country === "United States") {
// Format as US ZIP code (12345 or 12345-6789)
const digits = value.replace(/\D/g, "");
if (digits.length <= 5) {
return digits;
} else {
return `${digits.slice(0, 5)}-${digits.slice(5, 9)}`;
}
} else if (country === "Canada") {
// Format as Canadian postal code (A1A 1A1)
const cleaned = value.toUpperCase().replace(/[^A-Z0-9]/g, "");
if (cleaned.length <= 3) {
return cleaned;
} else {
return `${cleaned.slice(0, 3)} ${cleaned.slice(3, 6)}`;
}
}
// Return as-is for other countries
return value;
}
// Tax ID formatting
export function formatTaxId(value: string, type: string = "EIN"): string {
const digits = value.replace(/\D/g, "");
if (type === "EIN") {
// Format as XX-XXXXXXX
if (digits.length <= 2) {
return digits;
} else {
return `${digits.slice(0, 2)}-${digits.slice(2, 9)}`;
}
} else if (type === "SSN") {
// Format as XXX-XX-XXXX
if (digits.length <= 3) {
return digits;
} else if (digits.length <= 5) {
return `${digits.slice(0, 3)}-${digits.slice(3)}`;
} else {
return `${digits.slice(0, 3)}-${digits.slice(3, 5)}-${digits.slice(5, 9)}`;
}
}
return value;
}
// Form validation messages
export const VALIDATION_MESSAGES = {
required: "This field is required",
email: "Please enter a valid email address",
phone: "Please enter a valid phone number",
url: "Please enter a valid URL",
postalCode: "Please enter a valid postal code",
taxId: "Please enter a valid tax ID",
};
// Form field placeholders
export const PLACEHOLDERS = {
name: "Enter name",
email: "email@example.com",
phone: "(555) 123-4567",
addressLine1: "123 Main Street",
addressLine2: "Suite 100",
city: "San Francisco",
postalCode: "12345",
website: "www.example.com",
taxId: "12-3456789",
};

36
src/lib/navigation.ts Normal file
View File

@@ -0,0 +1,36 @@
import {
Settings,
LayoutDashboard,
Users,
FileText,
Building,
} from "lucide-react";
export interface NavLink {
name: string;
href: string;
icon: React.ComponentType<{ className?: string }>;
}
export interface NavSection {
title: string;
links: NavLink[];
}
export const navigationConfig: NavSection[] = [
{
title: "Main",
links: [
{ name: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
{ name: "Clients", href: "/dashboard/clients", icon: Users },
{ name: "Businesses", href: "/dashboard/businesses", icon: Building },
{ name: "Invoices", href: "/dashboard/invoices", icon: FileText },
],
},
{
title: "Account",
links: [
{ name: "Settings", href: "/dashboard/settings", icon: Settings },
],
},
];

121
src/lib/pluralize.ts Normal file
View File

@@ -0,0 +1,121 @@
/**
* Pluralization rules for common entities in the app
*/
const PLURALIZATION_RULES: Record<string, { singular: string; plural: string }> = {
business: { singular: "Business", plural: "Businesses" },
client: { singular: "Client", plural: "Clients" },
invoice: { singular: "Invoice", plural: "Invoices" },
setting: { singular: "Setting", plural: "Settings" },
user: { singular: "User", plural: "Users" },
payment: { singular: "Payment", plural: "Payments" },
item: { singular: "Item", plural: "Items" },
tax: { singular: "Tax", plural: "Taxes" },
category: { singular: "Category", plural: "Categories" },
company: { singular: "Company", plural: "Companies" },
};
/**
* Get the plural form of a word
*/
export function pluralize(word: string, count?: number): string {
// If count is provided and is 1, return singular
if (count === 1) {
return word;
}
const lowerWord = word.toLowerCase();
// Check if we have a specific rule for this word
if (PLURALIZATION_RULES[lowerWord]) {
return PLURALIZATION_RULES[lowerWord].plural;
}
// Apply general pluralization rules
// Words ending in s, ss, sh, ch, x, z
if (/(?:s|ss|sh|ch|x|z)$/i.test(word)) {
return word + "es";
}
// Words ending in consonant + y
if (/[^aeiou]y$/i.test(word)) {
return word.slice(0, -1) + "ies";
}
// Words ending in f or fe
if (/(?:f|fe)$/i.test(word)) {
return word.replace(/(?:f|fe)$/i, "ves");
}
// Default: just add 's'
return word + "s";
}
/**
* Get the singular form of a word
*/
export function singularize(word: string): string {
const lowerWord = word.toLowerCase();
// Check if we have a specific rule for this word (search by plural)
const rule = Object.values(PLURALIZATION_RULES).find(
(r) => r.plural.toLowerCase() === lowerWord
);
if (rule) {
return rule.singular;
}
// Apply general singularization rules
// Words ending in ies
if (/ies$/i.test(word)) {
return word.slice(0, -3) + "y";
}
// Words ending in es
if (/(?:s|ss|sh|ch|x|z)es$/i.test(word)) {
return word.slice(0, -2);
}
// Words ending in ves
if (/ves$/i.test(word)) {
return word.slice(0, -3) + "f";
}
// Words ending in s
if (/s$/i.test(word) && word.length > 1) {
return word.slice(0, -1);
}
// Default: return as is
return word;
}
/**
* Capitalize the first letter of a word
*/
export function capitalize(word: string): string {
if (!word) return word;
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
}
/**
* Get a properly formatted label for a route segment
*/
export function getRouteLabel(segment: string, isPlural: boolean = true): string {
// First, check if it's already in our rules
const rule = PLURALIZATION_RULES[segment.toLowerCase()];
if (rule) {
return isPlural ? rule.plural : rule.singular;
}
// If not, try to find it by plural form
const singularForm = singularize(segment);
const singularRule = PLURALIZATION_RULES[singularForm.toLowerCase()];
if (singularRule) {
return isPlural ? singularRule.plural : singularRule.singular;
}
// Otherwise, just capitalize and optionally pluralize
const capitalized = capitalize(segment);
return isPlural ? pluralize(capitalized) : capitalized;
}