mirror of
https://github.com/soconnor0919/beenvoice.git
synced 2025-12-11 08:34:43 -05:00
Add Turso/Vercel deployment configuration
- Updated database connection to support Turso auth token - Added vercel.json with bun build configuration - Updated environment schema for production deployment - Added new features and components for production readiness
This commit is contained in:
303
DARK_MODE_GUIDE.md
Normal file
303
DARK_MODE_GUIDE.md
Normal file
@@ -0,0 +1,303 @@
|
||||
# Dark Mode Implementation Guide
|
||||
|
||||
## Overview
|
||||
|
||||
BeenVoice implements a **media query-based dark mode** system that automatically adapts to the user's system preferences. This approach provides a seamless experience without requiring manual theme switching controls.
|
||||
|
||||
## Implementation Approach
|
||||
|
||||
### Media Query-Based vs Class-Based
|
||||
|
||||
We chose **media query-based** dark mode (`@media (prefers-color-scheme: dark)`) over class-based dark mode for the following reasons:
|
||||
|
||||
- **Automatic System Integration**: Respects user's OS/browser theme preference
|
||||
- **No JavaScript Required**: Pure CSS solution with better performance
|
||||
- **Better Accessibility**: Follows system accessibility settings
|
||||
- **Seamless Experience**: No flash of incorrect theme on page load
|
||||
- **Reduced Complexity**: No need for theme toggle components or state management
|
||||
|
||||
## Configuration Changes
|
||||
|
||||
### 1. Tailwind CSS Configuration
|
||||
|
||||
**File:** `tailwind.config.ts`
|
||||
|
||||
```typescript
|
||||
export default {
|
||||
darkMode: "media", // Changed from "class" to "media"
|
||||
// ... rest of config
|
||||
} satisfies Config;
|
||||
```
|
||||
|
||||
### 2. Global CSS Updates
|
||||
|
||||
**File:** `src/styles/globals.css`
|
||||
|
||||
Key changes made:
|
||||
|
||||
- Replaced `.dark { }` class selector with `@media (prefers-color-scheme: dark) { :root { } }`
|
||||
- Maintained all existing CSS custom properties
|
||||
- Updated dark mode color definitions to use media queries
|
||||
|
||||
```css
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
/* ... all other dark mode variables */
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Component Updates
|
||||
|
||||
### Core Layout Components
|
||||
|
||||
#### 1. Root Layout (`src/app/layout.tsx`)
|
||||
- Updated background gradients with dark mode variants
|
||||
- Improved layering structure for background effects
|
||||
- Added proper z-index management
|
||||
|
||||
#### 2. Landing Page (`src/app/page.tsx`)
|
||||
- Comprehensive dark mode classes for all sections
|
||||
- Updated text colors, backgrounds, and hover states
|
||||
- Maintained brand consistency with green color scheme
|
||||
|
||||
#### 3. Authentication Pages
|
||||
- **Sign In Page** (`src/app/auth/signin/page.tsx`)
|
||||
- **Register Page** (`src/app/auth/register/page.tsx`)
|
||||
|
||||
Both pages updated with:
|
||||
- Dark background gradients
|
||||
- Card component dark backgrounds
|
||||
- Input field icon colors
|
||||
- Text color variations
|
||||
- Link hover states
|
||||
|
||||
### Navigation Components
|
||||
|
||||
#### 1. Navbar (`src/components/Navbar.tsx`)
|
||||
- Glass morphism effect with dark background support
|
||||
- Button variant adaptations
|
||||
- Text color adjustments for user information
|
||||
|
||||
#### 2. Sidebar (`src/components/Sidebar.tsx`)
|
||||
- Dark background with transparency
|
||||
- Navigation link states (active/hover)
|
||||
- Border color adaptations
|
||||
- Icon and text color updates
|
||||
|
||||
#### 3. Mobile Sidebar (`src/components/SidebarTrigger.tsx`)
|
||||
- Sheet component dark styling
|
||||
- Navigation link consistency with desktop sidebar
|
||||
- Border and background adaptations
|
||||
|
||||
### Dashboard Components
|
||||
|
||||
#### 1. Dashboard Page (`src/app/dashboard/page.tsx`)
|
||||
- Welcome text color adjustments
|
||||
- Maintained gradient text effects
|
||||
|
||||
#### 2. Universal Table (`src/components/ui/universal-table.tsx`)
|
||||
- Comprehensive table styling updates
|
||||
- Header background and text colors
|
||||
- Cell content color adaptations
|
||||
- Status badge color schemes
|
||||
- Pagination control styling
|
||||
- Grid view card backgrounds
|
||||
|
||||
## Color System
|
||||
|
||||
### Text Colors
|
||||
- **Primary Text**: `text-gray-900 dark:text-white`
|
||||
- **Secondary Text**: `text-gray-700 dark:text-gray-300`
|
||||
- **Muted Text**: `text-gray-500 dark:text-gray-400`
|
||||
- **Icon Colors**: `text-gray-400 dark:text-gray-500`
|
||||
|
||||
### Background Colors
|
||||
- **Card Backgrounds**: `dark:bg-gray-800`
|
||||
- **Hover States**: `hover:bg-gray-100 dark:hover:bg-gray-800`
|
||||
- **Border Colors**: `border-gray-200 dark:border-gray-700`
|
||||
- **Glass Effects**: `bg-white/60 dark:bg-gray-900/60`
|
||||
|
||||
### Brand Colors
|
||||
Maintained consistent green brand colors with dark mode adaptations:
|
||||
- **Primary Green**: `text-green-600 dark:text-green-400`
|
||||
- **Emerald Accents**: `bg-emerald-100 dark:bg-emerald-900/30`
|
||||
|
||||
## Testing the Implementation
|
||||
|
||||
### Manual Testing
|
||||
|
||||
1. **System Theme Toggle**:
|
||||
- Change your OS dark/light mode setting
|
||||
- Verify automatic theme switching
|
||||
- Check for smooth transitions
|
||||
|
||||
2. **Page Coverage**:
|
||||
- Landing page
|
||||
- Authentication pages (sign in/register)
|
||||
- Dashboard and navigation
|
||||
- All table views and components
|
||||
|
||||
3. **Component Testing**:
|
||||
- Form elements (inputs, buttons, selects)
|
||||
- Cards and containers
|
||||
- Navigation states (hover, active)
|
||||
- Text readability in both modes
|
||||
|
||||
### Browser Developer Tools
|
||||
|
||||
1. **Media Query Testing**:
|
||||
```css
|
||||
/* In DevTools Console */
|
||||
document.documentElement.style.colorScheme = 'dark';
|
||||
document.documentElement.style.colorScheme = 'light';
|
||||
```
|
||||
|
||||
2. **Emulation**:
|
||||
- Chrome DevTools > Rendering > Emulate CSS prefers-color-scheme
|
||||
- Firefox DevTools > Settings > Dark theme simulation
|
||||
|
||||
### Test Component
|
||||
|
||||
A comprehensive test component is available at `src/components/dark-mode-test.tsx` that displays:
|
||||
- Color variations
|
||||
- Button states
|
||||
- Form elements
|
||||
- Status indicators
|
||||
- Background patterns
|
||||
- Icon colors
|
||||
|
||||
## Best Practices for Future Development
|
||||
|
||||
### 1. Color Class Patterns
|
||||
|
||||
Always pair light mode colors with dark mode variants:
|
||||
|
||||
```tsx
|
||||
// ✅ Good
|
||||
<div className="text-gray-900 dark:text-white bg-white dark:bg-gray-800">
|
||||
|
||||
// ❌ Avoid
|
||||
<div className="text-gray-900 bg-white">
|
||||
```
|
||||
|
||||
### 2. Common Patterns
|
||||
|
||||
**Text Colors:**
|
||||
```tsx
|
||||
// Primary text
|
||||
className="text-gray-900 dark:text-white"
|
||||
|
||||
// Secondary text
|
||||
className="text-gray-700 dark:text-gray-300"
|
||||
|
||||
// Muted text
|
||||
className="text-gray-500 dark:text-gray-400"
|
||||
```
|
||||
|
||||
**Backgrounds:**
|
||||
```tsx
|
||||
// Card backgrounds
|
||||
className="bg-white dark:bg-gray-800"
|
||||
|
||||
// Hover states
|
||||
className="hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
|
||||
// Borders
|
||||
className="border-gray-200 dark:border-gray-700"
|
||||
```
|
||||
|
||||
**Interactive Elements:**
|
||||
```tsx
|
||||
// Links
|
||||
className="text-green-600 hover:text-green-700 dark:text-green-400 dark:hover:text-green-300"
|
||||
|
||||
// Active states
|
||||
className="bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400"
|
||||
```
|
||||
|
||||
### 3. Component Development Guidelines
|
||||
|
||||
1. **Always test both modes** during development
|
||||
2. **Use semantic color tokens** from the design system when possible
|
||||
3. **Maintain sufficient contrast** for accessibility
|
||||
4. **Consider glass morphism effects** with appropriate alpha values
|
||||
5. **Test with real content** to ensure readability
|
||||
|
||||
### 4. shadcn/ui Components
|
||||
|
||||
Most shadcn/ui components already include dark mode support:
|
||||
- Button variants adapt automatically
|
||||
- Input fields use CSS custom properties
|
||||
- Card components respond to theme changes
|
||||
|
||||
For custom components, follow the established patterns.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Colors Not Switching**:
|
||||
- Verify `darkMode: "media"` in `tailwind.config.ts`
|
||||
- Check CSS custom properties are properly defined
|
||||
- Ensure no conflicting class-based dark mode styles
|
||||
|
||||
2. **Flash of Incorrect Theme**:
|
||||
- Should not occur with media query approach
|
||||
- If present, check for JavaScript theme switching code
|
||||
|
||||
3. **Incomplete Styling**:
|
||||
- Search for hardcoded colors: `text-gray-XXX` without `dark:` variants
|
||||
- Use component test page to verify all elements
|
||||
|
||||
4. **Performance Issues**:
|
||||
- Media query approach should have no performance impact
|
||||
- CSS variables resolve efficiently
|
||||
|
||||
### Debugging Tools
|
||||
|
||||
1. **CSS Custom Properties Inspector**:
|
||||
```javascript
|
||||
// In DevTools Console
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--background')
|
||||
```
|
||||
|
||||
2. **Media Query Detection**:
|
||||
```javascript
|
||||
// Check current preference
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
```
|
||||
|
||||
## Browser Support
|
||||
|
||||
The media query-based approach is supported in:
|
||||
- Chrome 76+
|
||||
- Firefox 67+
|
||||
- Safari 12.1+
|
||||
- Edge 79+
|
||||
|
||||
For older browsers, the light theme serves as a fallback.
|
||||
|
||||
## Maintenance
|
||||
|
||||
### Regular Checks
|
||||
|
||||
1. **New Component Integration**: Ensure new components follow dark mode patterns
|
||||
2. **Third-party Components**: Verify external components adapt to theme
|
||||
3. **Asset Updates**: Check images/icons work in both modes
|
||||
4. **Performance Monitoring**: Ensure no CSS bloat from unused dark mode classes
|
||||
|
||||
### Updates and Migration
|
||||
|
||||
If future requirements need class-based dark mode:
|
||||
1. Update `tailwind.config.ts` to `darkMode: "class"`
|
||||
2. Add theme toggle component
|
||||
3. Implement theme persistence
|
||||
4. Update CSS to use `.dark` selector instead of media queries
|
||||
|
||||
## Conclusion
|
||||
|
||||
The media query-based dark mode implementation provides a robust, performant, and user-friendly theming solution that automatically adapts to user preferences while maintaining design consistency and accessibility standards.
|
||||
1
PDF_EXPORT_GUIDE.md
Normal file
1
PDF_EXPORT_GUIDE.md
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
49
PDF_GUIDELINES.md
Normal file
49
PDF_GUIDELINES.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# Invoice PDF Requirements
|
||||
|
||||
## Purpose
|
||||
Generate professional, print-ready invoice PDFs that represent the beenvoice brand and provide a good user experience.
|
||||
|
||||
## Core Requirements
|
||||
|
||||
### Visual Design
|
||||
- Professional appearance suitable for business use
|
||||
- Clean, modern layout that matches the app's design language
|
||||
- Consistent branding with the beenvoice logo and colors
|
||||
- Print-friendly design with good contrast and readability
|
||||
|
||||
### Content Structure
|
||||
- Header with logo, invoice title, number, and status
|
||||
- Client information and invoice details in organized sections
|
||||
- Itemized table with all invoice line items
|
||||
- Clear total amount display
|
||||
- Professional footer with branding
|
||||
|
||||
### Typography & Layout
|
||||
- Readable fonts that work across all systems
|
||||
- Proper hierarchy with clear headings and body text
|
||||
- Consistent spacing and alignment throughout
|
||||
- Responsive layout that works on different page sizes
|
||||
|
||||
### Technical Requirements
|
||||
- Generate high-quality PDFs suitable for printing
|
||||
- Support multi-page documents when content is long
|
||||
- Ensure consistent rendering across different browsers
|
||||
- Fast generation without blocking the UI
|
||||
- Proper page numbering and navigation
|
||||
|
||||
### User Experience
|
||||
- PDFs should be immediately recognizable as beenvoice invoices
|
||||
- Content should be well-organized and easy to scan
|
||||
- Professional appearance that builds trust with clients
|
||||
- Clear call-to-action and payment information
|
||||
|
||||
### Brand Consistency
|
||||
- Use the same logo and colors as the main application
|
||||
- Maintain the same visual language and design principles
|
||||
- Ensure the PDF feels like part of the beenvoice ecosystem
|
||||
|
||||
## Quality Standards
|
||||
- Professional enough for client-facing business use
|
||||
- Consistent with modern invoice design standards
|
||||
- Accessible and readable for all users
|
||||
- Print-ready with proper margins and formatting
|
||||
159
bun.lock
159
bun.lock
@@ -5,6 +5,9 @@
|
||||
"name": "beenvoice",
|
||||
"dependencies": {
|
||||
"@auth/drizzle-adapter": "^1.7.2",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@libsql/client": "^0.14.0",
|
||||
"@radix-ui/react-checkbox": "^1.3.2",
|
||||
"@radix-ui/react-dialog": "^1.1.14",
|
||||
@@ -13,21 +16,24 @@
|
||||
"@radix-ui/react-navigation-menu": "^1.2.13",
|
||||
"@radix-ui/react-popover": "^1.1.14",
|
||||
"@radix-ui/react-progress": "^1.1.7",
|
||||
"@radix-ui/react-select": "^2.2.5",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-tabs": "^1.1.12",
|
||||
"@react-pdf/renderer": "^4.3.0",
|
||||
"@t3-oss/env-nextjs": "^0.12.0",
|
||||
"@tanstack/react-query": "^5.69.0",
|
||||
"@trpc/client": "^11.0.0",
|
||||
"@trpc/react-query": "^11.0.0",
|
||||
"@trpc/server": "^11.0.0",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"drizzle-orm": "^0.41.0",
|
||||
"html2canvas": "^1.4.1",
|
||||
"jspdf": "^3.0.1",
|
||||
"file-saver": "^2.0.5",
|
||||
"lucide": "^0.525.0",
|
||||
"lucide-react": "^0.525.0",
|
||||
"next": "^15.2.3",
|
||||
@@ -65,7 +71,6 @@
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"@tailwindcss/oxide",
|
||||
"core-js",
|
||||
"unrs-resolver",
|
||||
],
|
||||
"packages": {
|
||||
@@ -81,6 +86,14 @@
|
||||
|
||||
"@date-fns/tz": ["@date-fns/tz@1.2.0", "", {}, "sha512-LBrd7MiJZ9McsOgxqWX7AaxrDjcFVjWH/tIKJd7pnR7McaslGYOP1QmmiBXdJH/H/yLCT+rcQ7FaPBUxRGUtrg=="],
|
||||
|
||||
"@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="],
|
||||
|
||||
"@dnd-kit/core": ["@dnd-kit/core@6.3.1", "", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="],
|
||||
|
||||
"@dnd-kit/sortable": ["@dnd-kit/sortable@10.0.0", "", { "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" } }, "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg=="],
|
||||
|
||||
"@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="],
|
||||
|
||||
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
|
||||
|
||||
"@emnapi/core": ["@emnapi/core@1.4.4", "", { "dependencies": { "@emnapi/wasi-threads": "1.0.3", "tslib": "^2.4.0" } }, "sha512-A9CnAbC6ARNMKcIcrQwq6HeHCjpcBZ5wSx4U01WXCqEKlrzB9F9315WDNHkrs2xbx7YjjSxbUYxuN6EQzpcY2g=="],
|
||||
@@ -285,6 +298,8 @@
|
||||
|
||||
"@petamoriken/float16": ["@petamoriken/float16@3.9.2", "", {}, "sha512-VgffxawQde93xKxT3qap3OH+meZf7VaSB5Sqd4Rqc+FP5alWbpOyan/7tRbOAvynjpG3GpdtAuGU/NdhQpmrog=="],
|
||||
|
||||
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
|
||||
|
||||
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="],
|
||||
|
||||
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
|
||||
@@ -331,6 +346,10 @@
|
||||
|
||||
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-dT9aOXUen9JSsxnMPv/0VqySQf5eDQ6LCk5Sw28kamz8wSOW2bJdlX2Bg5VUIIcV+6XlHpWTIuTPCf/UNIyq8Q=="],
|
||||
|
||||
"@radix-ui/react-select": ["@radix-ui/react-select@2.2.5", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.7", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-HnMTdXEVuuyzx63ME0ut4+sEMYW6oouHWNGUZc7ddvUWIcfCva/AMoqEW/3wnEllriMWBa0RHspCYnfCWJQYmA=="],
|
||||
|
||||
"@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA=="],
|
||||
|
||||
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.10", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-GTVAlRVrQrSw3cEARM0nAx73ixrWDPNZAruETn3oHCNP6SbZ/hNxdxp+u7VkIEv3/sFoLq1PfcHrl7Pnp0CDpw=="],
|
||||
@@ -355,6 +374,32 @@
|
||||
|
||||
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
|
||||
|
||||
"@react-pdf/fns": ["@react-pdf/fns@3.1.2", "", {}, "sha512-qTKGUf0iAMGg2+OsUcp9ffKnKi41RukM/zYIWMDJ4hRVYSr89Q7e3wSDW/Koqx3ea3Uy/z3h2y3wPX6Bdfxk6g=="],
|
||||
|
||||
"@react-pdf/font": ["@react-pdf/font@4.0.2", "", { "dependencies": { "@react-pdf/pdfkit": "^4.0.3", "@react-pdf/types": "^2.9.0", "fontkit": "^2.0.2", "is-url": "^1.2.4" } }, "sha512-/dAWu7Y2RD1RxarDZ9SkYPHgBYOhmcDnet4W/qN/m8k+A2Hr3ja54GymSR7GGxWBtxjKtNauVKrTa9LS1n8WUw=="],
|
||||
|
||||
"@react-pdf/image": ["@react-pdf/image@3.0.3", "", { "dependencies": { "@react-pdf/png-js": "^3.0.0", "jay-peg": "^1.1.1" } }, "sha512-lvP5ryzYM3wpbO9bvqLZYwEr5XBDX9jcaRICvtnoRqdJOo7PRrMnmB4MMScyb+Xw10mGeIubZAAomNAG5ONQZQ=="],
|
||||
|
||||
"@react-pdf/layout": ["@react-pdf/layout@4.4.0", "", { "dependencies": { "@react-pdf/fns": "3.1.2", "@react-pdf/image": "^3.0.3", "@react-pdf/primitives": "^4.1.1", "@react-pdf/stylesheet": "^6.1.0", "@react-pdf/textkit": "^6.0.0", "@react-pdf/types": "^2.9.0", "emoji-regex": "^10.3.0", "queue": "^6.0.1", "yoga-layout": "^3.2.1" } }, "sha512-Aq+Cc6JYausWLoks2FvHe3PwK9cTuvksB2uJ0AnkKJEUtQbvCq8eCRb1bjbbwIji9OzFRTTzZij7LzkpKHjIeA=="],
|
||||
|
||||
"@react-pdf/pdfkit": ["@react-pdf/pdfkit@4.0.3", "", { "dependencies": { "@babel/runtime": "^7.20.13", "@react-pdf/png-js": "^3.0.0", "browserify-zlib": "^0.2.0", "crypto-js": "^4.2.0", "fontkit": "^2.0.2", "jay-peg": "^1.1.1", "linebreak": "^1.1.0", "vite-compatible-readable-stream": "^3.6.1" } }, "sha512-k+Lsuq8vTwWsCqTp+CCB4+2N+sOTFrzwGA7aw3H9ix/PDWR9QksbmNg0YkzGbLAPI6CeawmiLHcf4trZ5ecLPQ=="],
|
||||
|
||||
"@react-pdf/png-js": ["@react-pdf/png-js@3.0.0", "", { "dependencies": { "browserify-zlib": "^0.2.0" } }, "sha512-eSJnEItZ37WPt6Qv5pncQDxLJRK15eaRwPT+gZoujP548CodenOVp49GST8XJvKMFt9YqIBzGBV/j9AgrOQzVA=="],
|
||||
|
||||
"@react-pdf/primitives": ["@react-pdf/primitives@4.1.1", "", {}, "sha512-IuhxYls1luJb7NUWy6q5avb1XrNaVj9bTNI40U9qGRuS6n7Hje/8H8Qi99Z9UKFV74bBP3DOf3L1wV2qZVgVrQ=="],
|
||||
|
||||
"@react-pdf/reconciler": ["@react-pdf/reconciler@1.1.4", "", { "dependencies": { "object-assign": "^4.1.1", "scheduler": "0.25.0-rc-603e6108-20241029" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-oTQDiR/t4Z/Guxac88IavpU2UgN7eR0RMI9DRKvKnvPz2DUasGjXfChAdMqDNmJJxxV26mMy9xQOUV2UU5/okg=="],
|
||||
|
||||
"@react-pdf/render": ["@react-pdf/render@4.3.0", "", { "dependencies": { "@babel/runtime": "^7.20.13", "@react-pdf/fns": "3.1.2", "@react-pdf/primitives": "^4.1.1", "@react-pdf/textkit": "^6.0.0", "@react-pdf/types": "^2.9.0", "abs-svg-path": "^0.1.1", "color-string": "^1.9.1", "normalize-svg-path": "^1.1.0", "parse-svg-path": "^0.1.2", "svg-arc-to-cubic-bezier": "^3.2.0" } }, "sha512-MdWfWaqO6d7SZD75TZ2z5L35V+cHpyA43YNRlJNG0RJ7/MeVGDQv12y/BXOJgonZKkeEGdzM3EpAt9/g4E22WA=="],
|
||||
|
||||
"@react-pdf/renderer": ["@react-pdf/renderer@4.3.0", "", { "dependencies": { "@babel/runtime": "^7.20.13", "@react-pdf/fns": "3.1.2", "@react-pdf/font": "^4.0.2", "@react-pdf/layout": "^4.4.0", "@react-pdf/pdfkit": "^4.0.3", "@react-pdf/primitives": "^4.1.1", "@react-pdf/reconciler": "^1.1.4", "@react-pdf/render": "^4.3.0", "@react-pdf/types": "^2.9.0", "events": "^3.3.0", "object-assign": "^4.1.1", "prop-types": "^15.6.2", "queue": "^6.0.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-28gpA69fU9ZQrDzmd5xMJa1bDf8t0PT3ApUKBl2PUpoE/x4JlvCB5X66nMXrfFrgF2EZrA72zWQAkvbg7TE8zw=="],
|
||||
|
||||
"@react-pdf/stylesheet": ["@react-pdf/stylesheet@6.1.0", "", { "dependencies": { "@react-pdf/fns": "3.1.2", "@react-pdf/types": "^2.9.0", "color-string": "^1.9.1", "hsl-to-hex": "^1.0.0", "media-engine": "^1.0.3", "postcss-value-parser": "^4.1.0" } }, "sha512-BGZ2sYNUp38VJUegjva/jsri3iiRGnVNjWI+G9dTwAvLNOmwFvSJzqaCsEnqQ/DW5mrTBk/577FhDY7pv6AidA=="],
|
||||
|
||||
"@react-pdf/textkit": ["@react-pdf/textkit@6.0.0", "", { "dependencies": { "@react-pdf/fns": "3.1.2", "bidi-js": "^1.0.2", "hyphen": "^1.6.4", "unicode-properties": "^1.4.1" } }, "sha512-fDt19KWaJRK/n2AaFoVm31hgGmpygmTV7LsHGJNGZkgzXcFyLsx+XUl63DTDPH3iqxj3xUX128t104GtOz8tTw=="],
|
||||
|
||||
"@react-pdf/types": ["@react-pdf/types@2.9.0", "", { "dependencies": { "@react-pdf/font": "^4.0.2", "@react-pdf/primitives": "^4.1.1", "@react-pdf/stylesheet": "^6.1.0" } }, "sha512-ckj80vZLlvl9oYrQ4tovEaqKWP3O06Eb1D48/jQWbdwz1Yh7Y9v1cEmwlP8ET+a1Whp8xfdM0xduMexkuPANCQ=="],
|
||||
|
||||
"@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="],
|
||||
|
||||
"@rushstack/eslint-patch": ["@rushstack/eslint-patch@1.12.0", "", {}, "sha512-5EwMtOqvJMMa3HbmxLlF74e+3/HhwBTMcvt3nqVJgGCozO6hzIPOBlwm8mGVNR9SN2IJpxSnlxczyDjcn7qIyw=="],
|
||||
@@ -415,20 +460,18 @@
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"@types/file-saver": ["@types/file-saver@2.0.7", "", {}, "sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A=="],
|
||||
|
||||
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
||||
|
||||
"@types/json5": ["@types/json5@0.0.29", "", {}, "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="],
|
||||
|
||||
"@types/node": ["@types/node@20.19.6", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-uYssdp9z5zH5GQ0L4zEJ2ZuavYsJwkozjiUzCRfGtaaQcyjAMJ34aP8idv61QlqTozu6kudyr6JMq9Chf09dfA=="],
|
||||
|
||||
"@types/raf": ["@types/raf@3.4.3", "", {}, "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw=="],
|
||||
|
||||
"@types/react": ["@types/react@19.1.8", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g=="],
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@19.1.6", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw=="],
|
||||
|
||||
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
|
||||
|
||||
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.36.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.36.0", "@typescript-eslint/type-utils": "8.36.0", "@typescript-eslint/utils": "8.36.0", "@typescript-eslint/visitor-keys": "8.36.0", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.36.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-lZNihHUVB6ZZiPBNgOQGSxUASI7UJWhT8nHyUGCnaQ28XFCw98IfrMCG3rUl1uwUWoAvodJQby2KTs79UTcrAg=="],
|
||||
@@ -489,6 +532,8 @@
|
||||
|
||||
"@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.11.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g=="],
|
||||
|
||||
"abs-svg-path": ["abs-svg-path@0.1.1", "", {}, "sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA=="],
|
||||
|
||||
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
|
||||
|
||||
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
|
||||
@@ -523,8 +568,6 @@
|
||||
|
||||
"async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="],
|
||||
|
||||
"atob": ["atob@2.1.2", "", { "bin": { "atob": "bin/atob.js" } }, "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg=="],
|
||||
|
||||
"attr-accept": ["attr-accept@2.2.5", "", {}, "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ=="],
|
||||
|
||||
"available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="],
|
||||
@@ -535,15 +578,19 @@
|
||||
|
||||
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"base64-arraybuffer": ["base64-arraybuffer@1.0.2", "", {}, "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ=="],
|
||||
"base64-js": ["base64-js@0.0.8", "", {}, "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw=="],
|
||||
|
||||
"bcryptjs": ["bcryptjs@3.0.2", "", { "bin": { "bcrypt": "bin/bcrypt" } }, "sha512-k38b3XOZKv60C4E2hVsXTolJWfkGRMbILBIe2IBITXciy5bOsTKot5kDrf3ZfufQtQOUN5mXceUEpU1rTl9Uog=="],
|
||||
|
||||
"bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
|
||||
|
||||
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
||||
|
||||
"btoa": ["btoa@1.2.1", "", { "bin": { "btoa": "bin/btoa.js" } }, "sha512-SB4/MIGlsiVkMcHmT+pSmIPoNDoHg+7cMzmt3Uxt628MTz2487DKSqK/fuhFBrkuqrYv5UCEnACpF4dTFNKc/g=="],
|
||||
"brotli": ["brotli@1.3.3", "", { "dependencies": { "base64-js": "^1.1.2" } }, "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg=="],
|
||||
|
||||
"browserify-zlib": ["browserify-zlib@0.2.0", "", { "dependencies": { "pako": "~1.0.5" } }, "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA=="],
|
||||
|
||||
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||
|
||||
@@ -559,8 +606,6 @@
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001727", "", {}, "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q=="],
|
||||
|
||||
"canvg": ["canvg@3.0.11", "", { "dependencies": { "@babel/runtime": "^7.12.5", "@types/raf": "^3.4.0", "core-js": "^3.8.3", "raf": "^3.4.1", "regenerator-runtime": "^0.13.7", "rgbcolor": "^1.0.1", "stackblur-canvas": "^2.0.0", "svg-pathdata": "^6.0.3" } }, "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA=="],
|
||||
|
||||
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
|
||||
"chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
|
||||
@@ -569,6 +614,8 @@
|
||||
|
||||
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
|
||||
|
||||
"clone": ["clone@2.1.2", "", {}, "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w=="],
|
||||
|
||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
|
||||
"color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
|
||||
@@ -585,11 +632,9 @@
|
||||
|
||||
"copy-anything": ["copy-anything@3.0.5", "", { "dependencies": { "is-what": "^4.1.8" } }, "sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w=="],
|
||||
|
||||
"core-js": ["core-js@3.44.0", "", {}, "sha512-aFCtd4l6GvAXwVEh3XbbVqJGHDJt0OZRa+5ePGx3LLwi12WfexqQxcsohb2wgsa/92xtl19Hd66G/L+TaAxDMw=="],
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"css-line-break": ["css-line-break@2.1.0", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w=="],
|
||||
"crypto-js": ["crypto-js@4.2.0", "", {}, "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q=="],
|
||||
|
||||
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
|
||||
|
||||
@@ -619,9 +664,9 @@
|
||||
|
||||
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
|
||||
|
||||
"doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="],
|
||||
"dfa": ["dfa@1.2.0", "", {}, "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q=="],
|
||||
|
||||
"dompurify": ["dompurify@3.2.6", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ=="],
|
||||
"doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="],
|
||||
|
||||
"drizzle-kit": ["drizzle-kit@0.30.6", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.19.7", "esbuild-register": "^3.5.0", "gel": "^2.0.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-U4wWit0fyZuGuP7iNmRleQyK2V8wCuv57vf5l3MnG4z4fzNTjY/U13M8owyQ5RavqvqxBifWORaR3wIUzlN64g=="],
|
||||
|
||||
@@ -691,6 +736,8 @@
|
||||
|
||||
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
|
||||
|
||||
"events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-glob": ["fast-glob@3.3.1", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" } }, "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg=="],
|
||||
@@ -705,10 +752,10 @@
|
||||
|
||||
"fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
|
||||
|
||||
"fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="],
|
||||
|
||||
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
|
||||
|
||||
"file-saver": ["file-saver@2.0.5", "", {}, "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA=="],
|
||||
|
||||
"file-selector": ["file-selector@2.1.2", "", { "dependencies": { "tslib": "^2.7.0" } }, "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig=="],
|
||||
|
||||
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
|
||||
@@ -719,6 +766,8 @@
|
||||
|
||||
"flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="],
|
||||
|
||||
"fontkit": ["fontkit@2.0.4", "", { "dependencies": { "@swc/helpers": "^0.5.12", "brotli": "^1.3.2", "clone": "^2.1.2", "dfa": "^1.2.0", "fast-deep-equal": "^3.1.3", "restructure": "^3.0.0", "tiny-inflate": "^1.0.3", "unicode-properties": "^1.4.0", "unicode-trie": "^2.0.0" } }, "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g=="],
|
||||
|
||||
"for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="],
|
||||
|
||||
"formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="],
|
||||
@@ -767,7 +816,11 @@
|
||||
|
||||
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||
|
||||
"html2canvas": ["html2canvas@1.4.1", "", { "dependencies": { "css-line-break": "^2.1.0", "text-segmentation": "^1.0.3" } }, "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA=="],
|
||||
"hsl-to-hex": ["hsl-to-hex@1.0.0", "", { "dependencies": { "hsl-to-rgb-for-reals": "^1.1.0" } }, "sha512-K6GVpucS5wFf44X0h2bLVRDsycgJmf9FF2elg+CrqD8GcFU8c6vYhgXn8NjUkFCwj+xDFb70qgLbTUm6sxwPmA=="],
|
||||
|
||||
"hsl-to-rgb-for-reals": ["hsl-to-rgb-for-reals@1.1.1", "", {}, "sha512-LgOWAkrN0rFaQpfdWBQlv/VhkOxb5AsBjk6NQVx4yEzWS923T07X0M1Y0VNko2H52HeSpZrZNNMJ0aFqsdVzQg=="],
|
||||
|
||||
"hyphen": ["hyphen@1.10.6", "", {}, "sha512-fXHXcGFTXOvZTSkPJuGOQf5Lv5T/R2itiiCVPg9LxAje5D00O0pP83yJShFq5V89Ly//Gt6acj7z8pbBr34stw=="],
|
||||
|
||||
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||
|
||||
@@ -775,6 +828,8 @@
|
||||
|
||||
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
|
||||
|
||||
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||
|
||||
"internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="],
|
||||
|
||||
"is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="],
|
||||
@@ -825,6 +880,8 @@
|
||||
|
||||
"is-typed-array": ["is-typed-array@1.1.15", "", { "dependencies": { "which-typed-array": "^1.1.16" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="],
|
||||
|
||||
"is-url": ["is-url@1.2.4", "", {}, "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww=="],
|
||||
|
||||
"is-weakmap": ["is-weakmap@2.0.2", "", {}, "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w=="],
|
||||
|
||||
"is-weakref": ["is-weakref@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew=="],
|
||||
@@ -839,6 +896,8 @@
|
||||
|
||||
"iterator.prototype": ["iterator.prototype@1.1.5", "", { "dependencies": { "define-data-property": "^1.1.4", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "get-proto": "^1.0.0", "has-symbols": "^1.1.0", "set-function-name": "^2.0.2" } }, "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g=="],
|
||||
|
||||
"jay-peg": ["jay-peg@1.1.1", "", { "dependencies": { "restructure": "^3.0.0" } }, "sha512-D62KEuBxz/ip2gQKOEhk/mx14o7eiFRaU+VNNSP4MOiIkwb/D6B3G1Mfas7C/Fit8EsSV2/IWjZElx/Gs6A4ww=="],
|
||||
|
||||
"jiti": ["jiti@2.4.2", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A=="],
|
||||
|
||||
"jose": ["jose@6.0.11", "", {}, "sha512-QxG7EaliDARm1O1S8BGakqncGT9s25bKL1WSf6/oa17Tkqwi8D2ZNglqCF+DsYF88/rV66Q/Q2mFAy697E1DUg=="],
|
||||
@@ -857,8 +916,6 @@
|
||||
|
||||
"json5": ["json5@1.0.2", "", { "dependencies": { "minimist": "^1.2.0" }, "bin": { "json5": "lib/cli.js" } }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="],
|
||||
|
||||
"jspdf": ["jspdf@3.0.1", "", { "dependencies": { "@babel/runtime": "^7.26.7", "atob": "^2.1.2", "btoa": "^1.2.1", "fflate": "^0.8.1" }, "optionalDependencies": { "canvg": "^3.0.11", "core-js": "^3.6.0", "dompurify": "^3.2.4", "html2canvas": "^1.0.0-rc.5" } }, "sha512-qaGIxqxetdoNnFQQXxTKUD9/Z7AloLaw94fFsOiJMxbfYdBbrBuhWmbzI8TVjrw7s3jBY1PFHofBKMV/wZPapg=="],
|
||||
|
||||
"jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="],
|
||||
|
||||
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
|
||||
@@ -893,6 +950,8 @@
|
||||
|
||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.1", "", { "os": "win32", "cpu": "x64" }, "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg=="],
|
||||
|
||||
"linebreak": ["linebreak@1.1.0", "", { "dependencies": { "base64-js": "0.0.8", "unicode-trie": "^2.0.0" } }, "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ=="],
|
||||
|
||||
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
|
||||
|
||||
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
|
||||
@@ -907,6 +966,8 @@
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
|
||||
"media-engine": ["media-engine@1.0.3", "", {}, "sha512-aa5tG6sDoK+k70B9iEX1NeyfT8ObCKhNDs6lJVpwF6r8vhUfuKMslIcirq6HIUYuuUYLefcEQOn9bSBOvawtwg=="],
|
||||
|
||||
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
|
||||
|
||||
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
|
||||
@@ -937,6 +998,8 @@
|
||||
|
||||
"node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
|
||||
|
||||
"normalize-svg-path": ["normalize-svg-path@1.1.0", "", { "dependencies": { "svg-arc-to-cubic-bezier": "^3.0.0" } }, "sha512-r9KHKG2UUeB5LoTouwDzBy2VxXlHsiM6fyLQvnJa0S5hrhzqElH/CH7TUGhT1fVvIYBIKf3OpY4YJ4CK+iaqHg=="],
|
||||
|
||||
"oauth4webapi": ["oauth4webapi@3.5.5", "", {}, "sha512-1K88D2GiAydGblHo39NBro5TebGXa+7tYoyIbxvqv3+haDDry7CBE1eSYuNbOSsYCCU6y0gdynVZAkm4YPw4hg=="],
|
||||
|
||||
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||
@@ -963,16 +1026,18 @@
|
||||
|
||||
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
|
||||
|
||||
"pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
|
||||
|
||||
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
|
||||
|
||||
"parse-svg-path": ["parse-svg-path@0.1.2", "", {}, "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ=="],
|
||||
|
||||
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
|
||||
|
||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
|
||||
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
|
||||
|
||||
"performance-now": ["performance-now@2.1.0", "", {}, "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="],
|
||||
@@ -981,6 +1046,8 @@
|
||||
|
||||
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||
|
||||
"postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
|
||||
|
||||
"preact": ["preact@10.24.3", "", {}, "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA=="],
|
||||
|
||||
"preact-render-to-string": ["preact-render-to-string@6.5.11", "", { "peerDependencies": { "preact": ">=10" } }, "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw=="],
|
||||
@@ -999,9 +1066,9 @@
|
||||
|
||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||
|
||||
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
|
||||
"queue": ["queue@6.0.2", "", { "dependencies": { "inherits": "~2.0.3" } }, "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA=="],
|
||||
|
||||
"raf": ["raf@3.4.1", "", { "dependencies": { "performance-now": "^2.1.0" } }, "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA=="],
|
||||
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
|
||||
|
||||
"react": ["react@19.1.0", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="],
|
||||
|
||||
@@ -1021,24 +1088,26 @@
|
||||
|
||||
"reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="],
|
||||
|
||||
"regenerator-runtime": ["regenerator-runtime@0.13.11", "", {}, "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="],
|
||||
|
||||
"regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="],
|
||||
|
||||
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
||||
|
||||
"resolve": ["resolve@1.22.10", "", { "dependencies": { "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="],
|
||||
|
||||
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
|
||||
|
||||
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
||||
|
||||
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
|
||||
"restructure": ["restructure@3.0.2", "", {}, "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw=="],
|
||||
|
||||
"rgbcolor": ["rgbcolor@1.0.1", "", {}, "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw=="],
|
||||
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
|
||||
|
||||
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
|
||||
|
||||
"safe-array-concat": ["safe-array-concat@1.1.3", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q=="],
|
||||
|
||||
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||
|
||||
"safe-push-apply": ["safe-push-apply@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" } }, "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA=="],
|
||||
|
||||
"safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="],
|
||||
@@ -1083,8 +1152,6 @@
|
||||
|
||||
"stable-hash": ["stable-hash@0.0.5", "", {}, "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA=="],
|
||||
|
||||
"stackblur-canvas": ["stackblur-canvas@2.7.0", "", {}, "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ=="],
|
||||
|
||||
"stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="],
|
||||
|
||||
"streamsearch": ["streamsearch@1.1.0", "", {}, "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg=="],
|
||||
@@ -1101,6 +1168,8 @@
|
||||
|
||||
"string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="],
|
||||
|
||||
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
|
||||
|
||||
"strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="],
|
||||
|
||||
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
|
||||
@@ -1113,7 +1182,7 @@
|
||||
|
||||
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
||||
|
||||
"svg-pathdata": ["svg-pathdata@6.0.3", "", {}, "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw=="],
|
||||
"svg-arc-to-cubic-bezier": ["svg-arc-to-cubic-bezier@3.2.0", "", {}, "sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g=="],
|
||||
|
||||
"tailwind-merge": ["tailwind-merge@3.3.1", "", {}, "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g=="],
|
||||
|
||||
@@ -1125,7 +1194,7 @@
|
||||
|
||||
"tar": ["tar@7.4.3", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.0.1", "mkdirp": "^3.0.1", "yallist": "^5.0.0" } }, "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw=="],
|
||||
|
||||
"text-segmentation": ["text-segmentation@1.0.3", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw=="],
|
||||
"tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.14", "", { "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" } }, "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ=="],
|
||||
|
||||
@@ -1157,6 +1226,10 @@
|
||||
|
||||
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"unicode-properties": ["unicode-properties@1.4.1", "", { "dependencies": { "base64-js": "^1.3.0", "unicode-trie": "^2.0.0" } }, "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg=="],
|
||||
|
||||
"unicode-trie": ["unicode-trie@2.0.0", "", { "dependencies": { "pako": "^0.2.5", "tiny-inflate": "^1.0.0" } }, "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ=="],
|
||||
|
||||
"unrs-resolver": ["unrs-resolver@1.11.1", "", { "dependencies": { "napi-postinstall": "^0.3.0" }, "optionalDependencies": { "@unrs/resolver-binding-android-arm-eabi": "1.11.1", "@unrs/resolver-binding-android-arm64": "1.11.1", "@unrs/resolver-binding-darwin-arm64": "1.11.1", "@unrs/resolver-binding-darwin-x64": "1.11.1", "@unrs/resolver-binding-freebsd-x64": "1.11.1", "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", "@unrs/resolver-binding-linux-x64-musl": "1.11.1", "@unrs/resolver-binding-wasm32-wasi": "1.11.1", "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" } }, "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg=="],
|
||||
|
||||
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
||||
@@ -1165,7 +1238,9 @@
|
||||
|
||||
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
|
||||
|
||||
"utrie": ["utrie@1.0.2", "", { "dependencies": { "base64-arraybuffer": "^1.0.2" } }, "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw=="],
|
||||
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||
|
||||
"vite-compatible-readable-stream": ["vite-compatible-readable-stream@3.6.1", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-t20zYkrSf868+j/p31cRIGN28Phrjm3nRSLR2fyc2tiWi4cZGVdv68yNlwnIINTkMTmPoMiSlc0OadaO7DXZaQ=="],
|
||||
|
||||
"web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
|
||||
|
||||
@@ -1187,6 +1262,8 @@
|
||||
|
||||
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||
|
||||
"yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="],
|
||||
|
||||
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
|
||||
@@ -1197,6 +1274,10 @@
|
||||
|
||||
"@humanfs/node/@humanwhocodes/retry": ["@humanwhocodes/retry@0.3.1", "", {}, "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA=="],
|
||||
|
||||
"@react-pdf/layout/emoji-regex": ["emoji-regex@10.4.0", "", {}, "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw=="],
|
||||
|
||||
"@react-pdf/reconciler/scheduler": ["scheduler@0.25.0-rc-603e6108-20241029", "", {}, "sha512-pFwF6H1XrSdYYNLfOcGlM28/j8CGLu8IvdrxqhjWULe2bPcKiKW4CV+OWqR/9fT52mywx65l7ysNkjLKBda7eA=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.4.4", "", { "dependencies": { "@emnapi/wasi-threads": "1.0.3", "tslib": "^2.4.0" }, "bundled": true }, "sha512-A9CnAbC6ARNMKcIcrQwq6HeHCjpcBZ5wSx4U01WXCqEKlrzB9F9315WDNHkrs2xbx7YjjSxbUYxuN6EQzpcY2g=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.4.4", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-hHyapA4A3gPaDCNfiqyZUStTMqIkKRshqPIuDOXv1hcBnD4U3l8cP0T1HMCfGRxQ6V64TGCcoswChANyOAwbQg=="],
|
||||
@@ -1215,6 +1296,8 @@
|
||||
|
||||
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
|
||||
|
||||
"brotli/base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
||||
|
||||
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"eslint-import-resolver-node/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
|
||||
@@ -1239,6 +1322,10 @@
|
||||
|
||||
"next-auth/@auth/core": ["@auth/core@0.37.2", "", { "dependencies": { "@panva/hkdf": "^1.2.1", "@types/cookie": "0.6.0", "cookie": "0.7.1", "jose": "^5.9.3", "oauth4webapi": "^3.0.0", "preact": "10.11.3", "preact-render-to-string": "5.2.3" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "nodemailer": "^6.8.0" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-kUvzyvkcd6h1vpeMAojK2y7+PAV5H+0Cc9+ZlKYDFhDY31AlvsB+GW5vNO4qE3Y07KeQgvNO9U0QUx/fN62kBw=="],
|
||||
|
||||
"unicode-properties/base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
||||
|
||||
"unicode-trie/pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="],
|
||||
|
||||
@@ -7,6 +7,7 @@ export default {
|
||||
dialect: "sqlite",
|
||||
dbCredentials: {
|
||||
url: env.DATABASE_URL,
|
||||
authToken: env.DATABASE_AUTH_TOKEN,
|
||||
},
|
||||
tablesFilter: ["beenvoice_*"],
|
||||
} satisfies Config;
|
||||
|
||||
2
drizzle/0000_shiny_post.sql
Normal file
2
drizzle/0000_shiny_post.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE `beenvoice_invoice_item` ADD COLUMN `position` integer DEFAULT 0 NOT NULL;
|
||||
CREATE INDEX `invoice_item_position_idx` ON `beenvoice_invoice_item` (`position`);
|
||||
125
drizzle/0000_unique_loa.sql
Normal file
125
drizzle/0000_unique_loa.sql
Normal file
@@ -0,0 +1,125 @@
|
||||
CREATE TABLE `beenvoice_account` (
|
||||
`userId` text(255) NOT NULL,
|
||||
`type` text(255) NOT NULL,
|
||||
`provider` text(255) NOT NULL,
|
||||
`providerAccountId` text(255) NOT NULL,
|
||||
`refresh_token` text,
|
||||
`access_token` text,
|
||||
`expires_at` integer,
|
||||
`token_type` text(255),
|
||||
`scope` text(255),
|
||||
`id_token` text,
|
||||
`session_state` text(255),
|
||||
PRIMARY KEY(`provider`, `providerAccountId`),
|
||||
FOREIGN KEY (`userId`) REFERENCES `beenvoice_user`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `account_user_id_idx` ON `beenvoice_account` (`userId`);--> statement-breakpoint
|
||||
CREATE TABLE `beenvoice_business` (
|
||||
`id` text(255) PRIMARY KEY NOT NULL,
|
||||
`name` text(255) NOT NULL,
|
||||
`email` text(255),
|
||||
`phone` text(50),
|
||||
`addressLine1` text(255),
|
||||
`addressLine2` text(255),
|
||||
`city` text(100),
|
||||
`state` text(50),
|
||||
`postalCode` text(20),
|
||||
`country` text(100),
|
||||
`website` text(255),
|
||||
`taxId` text(100),
|
||||
`logoUrl` text(500),
|
||||
`isDefault` integer DEFAULT false,
|
||||
`createdById` text(255) NOT NULL,
|
||||
`createdAt` integer DEFAULT (unixepoch()) NOT NULL,
|
||||
`updatedAt` integer,
|
||||
FOREIGN KEY (`createdById`) REFERENCES `beenvoice_user`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `business_created_by_idx` ON `beenvoice_business` (`createdById`);--> statement-breakpoint
|
||||
CREATE INDEX `business_name_idx` ON `beenvoice_business` (`name`);--> statement-breakpoint
|
||||
CREATE INDEX `business_email_idx` ON `beenvoice_business` (`email`);--> statement-breakpoint
|
||||
CREATE INDEX `business_is_default_idx` ON `beenvoice_business` (`isDefault`);--> statement-breakpoint
|
||||
CREATE TABLE `beenvoice_client` (
|
||||
`id` text(255) PRIMARY KEY NOT NULL,
|
||||
`name` text(255) NOT NULL,
|
||||
`email` text(255),
|
||||
`phone` text(50),
|
||||
`addressLine1` text(255),
|
||||
`addressLine2` text(255),
|
||||
`city` text(100),
|
||||
`state` text(50),
|
||||
`postalCode` text(20),
|
||||
`country` text(100),
|
||||
`createdById` text(255) NOT NULL,
|
||||
`createdAt` integer DEFAULT (unixepoch()) NOT NULL,
|
||||
`updatedAt` integer,
|
||||
FOREIGN KEY (`createdById`) REFERENCES `beenvoice_user`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `client_created_by_idx` ON `beenvoice_client` (`createdById`);--> statement-breakpoint
|
||||
CREATE INDEX `client_name_idx` ON `beenvoice_client` (`name`);--> statement-breakpoint
|
||||
CREATE INDEX `client_email_idx` ON `beenvoice_client` (`email`);--> statement-breakpoint
|
||||
CREATE TABLE `beenvoice_invoice_item` (
|
||||
`id` text(255) PRIMARY KEY NOT NULL,
|
||||
`invoiceId` text(255) NOT NULL,
|
||||
`date` integer NOT NULL,
|
||||
`description` text(500) NOT NULL,
|
||||
`hours` real NOT NULL,
|
||||
`rate` real NOT NULL,
|
||||
`amount` real NOT NULL,
|
||||
`position` integer DEFAULT 0 NOT NULL,
|
||||
`createdAt` integer DEFAULT (unixepoch()) NOT NULL,
|
||||
FOREIGN KEY (`invoiceId`) REFERENCES `beenvoice_invoice`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `invoice_item_invoice_id_idx` ON `beenvoice_invoice_item` (`invoiceId`);--> statement-breakpoint
|
||||
CREATE INDEX `invoice_item_date_idx` ON `beenvoice_invoice_item` (`date`);--> statement-breakpoint
|
||||
CREATE INDEX `invoice_item_position_idx` ON `beenvoice_invoice_item` (`position`);--> statement-breakpoint
|
||||
CREATE TABLE `beenvoice_invoice` (
|
||||
`id` text(255) PRIMARY KEY NOT NULL,
|
||||
`invoiceNumber` text(100) NOT NULL,
|
||||
`businessId` text(255),
|
||||
`clientId` text(255) NOT NULL,
|
||||
`issueDate` integer NOT NULL,
|
||||
`dueDate` integer NOT NULL,
|
||||
`status` text(50) DEFAULT 'draft' NOT NULL,
|
||||
`totalAmount` real DEFAULT 0 NOT NULL,
|
||||
`taxRate` real DEFAULT 0 NOT NULL,
|
||||
`notes` text(1000),
|
||||
`createdById` text(255) NOT NULL,
|
||||
`createdAt` integer DEFAULT (unixepoch()) NOT NULL,
|
||||
`updatedAt` integer,
|
||||
FOREIGN KEY (`businessId`) REFERENCES `beenvoice_business`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`clientId`) REFERENCES `beenvoice_client`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`createdById`) REFERENCES `beenvoice_user`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `invoice_business_id_idx` ON `beenvoice_invoice` (`businessId`);--> statement-breakpoint
|
||||
CREATE INDEX `invoice_client_id_idx` ON `beenvoice_invoice` (`clientId`);--> statement-breakpoint
|
||||
CREATE INDEX `invoice_created_by_idx` ON `beenvoice_invoice` (`createdById`);--> statement-breakpoint
|
||||
CREATE INDEX `invoice_number_idx` ON `beenvoice_invoice` (`invoiceNumber`);--> statement-breakpoint
|
||||
CREATE INDEX `invoice_status_idx` ON `beenvoice_invoice` (`status`);--> statement-breakpoint
|
||||
CREATE TABLE `beenvoice_session` (
|
||||
`sessionToken` text(255) PRIMARY KEY NOT NULL,
|
||||
`userId` text(255) NOT NULL,
|
||||
`expires` integer NOT NULL,
|
||||
FOREIGN KEY (`userId`) REFERENCES `beenvoice_user`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `session_userId_idx` ON `beenvoice_session` (`userId`);--> statement-breakpoint
|
||||
CREATE TABLE `beenvoice_user` (
|
||||
`id` text(255) PRIMARY KEY NOT NULL,
|
||||
`name` text(255),
|
||||
`email` text(255) NOT NULL,
|
||||
`password` text(255),
|
||||
`emailVerified` integer DEFAULT (unixepoch()),
|
||||
`image` text(255)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `beenvoice_verification_token` (
|
||||
`identifier` text(255) NOT NULL,
|
||||
`token` text(255) NOT NULL,
|
||||
`expires` integer NOT NULL,
|
||||
PRIMARY KEY(`identifier`, `token`)
|
||||
);
|
||||
2
drizzle/0001_yielding_dormammu.sql
Normal file
2
drizzle/0001_yielding_dormammu.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE `beenvoice_invoice` ADD COLUMN `taxRate` real NOT NULL DEFAULT 0;
|
||||
UPDATE `beenvoice_invoice` SET `taxRate` = 0 WHERE `taxRate` IS NULL;
|
||||
29
drizzle/0003_huge_kinsey_walden.sql
Normal file
29
drizzle/0003_huge_kinsey_walden.sql
Normal file
@@ -0,0 +1,29 @@
|
||||
PRAGMA foreign_keys=OFF;--> statement-breakpoint
|
||||
CREATE TABLE `__new_beenvoice_invoice` (
|
||||
`id` text(255) PRIMARY KEY NOT NULL,
|
||||
`invoiceNumber` text(100) NOT NULL,
|
||||
`businessId` text(255),
|
||||
`clientId` text(255) NOT NULL,
|
||||
`issueDate` integer NOT NULL,
|
||||
`dueDate` integer NOT NULL,
|
||||
`status` text(50) DEFAULT 'draft' NOT NULL,
|
||||
`totalAmount` real DEFAULT 0 NOT NULL,
|
||||
`taxRate` real DEFAULT 0 NOT NULL,
|
||||
`notes` text(1000),
|
||||
`createdById` text(255) NOT NULL,
|
||||
`createdAt` integer DEFAULT (unixepoch()) NOT NULL,
|
||||
`updatedAt` integer,
|
||||
FOREIGN KEY (`businessId`) REFERENCES `beenvoice_business`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`clientId`) REFERENCES `beenvoice_client`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`createdById`) REFERENCES `beenvoice_user`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `__new_beenvoice_invoice`("id", "invoiceNumber", "businessId", "clientId", "issueDate", "dueDate", "status", "totalAmount", "taxRate", "notes", "createdById", "createdAt", "updatedAt") SELECT "id", "invoiceNumber", "businessId", "clientId", "issueDate", "dueDate", "status", "totalAmount", "taxRate", "notes", "createdById", "createdAt", "updatedAt" FROM `beenvoice_invoice`;--> statement-breakpoint
|
||||
DROP TABLE `beenvoice_invoice`;--> statement-breakpoint
|
||||
ALTER TABLE `__new_beenvoice_invoice` RENAME TO `beenvoice_invoice`;--> statement-breakpoint
|
||||
PRAGMA foreign_keys=ON;--> statement-breakpoint
|
||||
CREATE INDEX `invoice_business_id_idx` ON `beenvoice_invoice` (`businessId`);--> statement-breakpoint
|
||||
CREATE INDEX `invoice_client_id_idx` ON `beenvoice_invoice` (`clientId`);--> statement-breakpoint
|
||||
CREATE INDEX `invoice_created_by_idx` ON `beenvoice_invoice` (`createdById`);--> statement-breakpoint
|
||||
CREATE INDEX `invoice_number_idx` ON `beenvoice_invoice` (`invoiceNumber`);--> statement-breakpoint
|
||||
CREATE INDEX `invoice_status_idx` ON `beenvoice_invoice` (`status`);
|
||||
884
drizzle/meta/0000_snapshot.json
Normal file
884
drizzle/meta/0000_snapshot.json
Normal file
@@ -0,0 +1,884 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "d29d5fc3-5c1a-4506-b057-2c117bcd2017",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"tables": {
|
||||
"beenvoice_account": {
|
||||
"name": "beenvoice_account",
|
||||
"columns": {
|
||||
"userId": {
|
||||
"name": "userId",
|
||||
"type": "text(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"provider": {
|
||||
"name": "provider",
|
||||
"type": "text(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"providerAccountId": {
|
||||
"name": "providerAccountId",
|
||||
"type": "text(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"refresh_token": {
|
||||
"name": "refresh_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"access_token": {
|
||||
"name": "access_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"token_type": {
|
||||
"name": "token_type",
|
||||
"type": "text(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"scope": {
|
||||
"name": "scope",
|
||||
"type": "text(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"id_token": {
|
||||
"name": "id_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"session_state": {
|
||||
"name": "session_state",
|
||||
"type": "text(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"account_user_id_idx": {
|
||||
"name": "account_user_id_idx",
|
||||
"columns": [
|
||||
"userId"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"beenvoice_account_userId_beenvoice_user_id_fk": {
|
||||
"name": "beenvoice_account_userId_beenvoice_user_id_fk",
|
||||
"tableFrom": "beenvoice_account",
|
||||
"tableTo": "beenvoice_user",
|
||||
"columnsFrom": [
|
||||
"userId"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"beenvoice_account_provider_providerAccountId_pk": {
|
||||
"columns": [
|
||||
"provider",
|
||||
"providerAccountId"
|
||||
],
|
||||
"name": "beenvoice_account_provider_providerAccountId_pk"
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"beenvoice_business": {
|
||||
"name": "beenvoice_business",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text(255)",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"phone": {
|
||||
"name": "phone",
|
||||
"type": "text(50)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"addressLine1": {
|
||||
"name": "addressLine1",
|
||||
"type": "text(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"addressLine2": {
|
||||
"name": "addressLine2",
|
||||
"type": "text(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"city": {
|
||||
"name": "city",
|
||||
"type": "text(100)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"state": {
|
||||
"name": "state",
|
||||
"type": "text(50)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"postalCode": {
|
||||
"name": "postalCode",
|
||||
"type": "text(20)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"country": {
|
||||
"name": "country",
|
||||
"type": "text(100)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"website": {
|
||||
"name": "website",
|
||||
"type": "text(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"taxId": {
|
||||
"name": "taxId",
|
||||
"type": "text(100)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"logoUrl": {
|
||||
"name": "logoUrl",
|
||||
"type": "text(500)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"isDefault": {
|
||||
"name": "isDefault",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"createdById": {
|
||||
"name": "createdById",
|
||||
"type": "text(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch())"
|
||||
},
|
||||
"updatedAt": {
|
||||
"name": "updatedAt",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"business_created_by_idx": {
|
||||
"name": "business_created_by_idx",
|
||||
"columns": [
|
||||
"createdById"
|
||||
],
|
||||
"isUnique": false
|
||||
},
|
||||
"business_name_idx": {
|
||||
"name": "business_name_idx",
|
||||
"columns": [
|
||||
"name"
|
||||
],
|
||||
"isUnique": false
|
||||
},
|
||||
"business_email_idx": {
|
||||
"name": "business_email_idx",
|
||||
"columns": [
|
||||
"email"
|
||||
],
|
||||
"isUnique": false
|
||||
},
|
||||
"business_is_default_idx": {
|
||||
"name": "business_is_default_idx",
|
||||
"columns": [
|
||||
"isDefault"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"beenvoice_business_createdById_beenvoice_user_id_fk": {
|
||||
"name": "beenvoice_business_createdById_beenvoice_user_id_fk",
|
||||
"tableFrom": "beenvoice_business",
|
||||
"tableTo": "beenvoice_user",
|
||||
"columnsFrom": [
|
||||
"createdById"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"beenvoice_client": {
|
||||
"name": "beenvoice_client",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text(255)",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"phone": {
|
||||
"name": "phone",
|
||||
"type": "text(50)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"addressLine1": {
|
||||
"name": "addressLine1",
|
||||
"type": "text(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"addressLine2": {
|
||||
"name": "addressLine2",
|
||||
"type": "text(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"city": {
|
||||
"name": "city",
|
||||
"type": "text(100)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"state": {
|
||||
"name": "state",
|
||||
"type": "text(50)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"postalCode": {
|
||||
"name": "postalCode",
|
||||
"type": "text(20)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"country": {
|
||||
"name": "country",
|
||||
"type": "text(100)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"createdById": {
|
||||
"name": "createdById",
|
||||
"type": "text(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch())"
|
||||
},
|
||||
"updatedAt": {
|
||||
"name": "updatedAt",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"client_created_by_idx": {
|
||||
"name": "client_created_by_idx",
|
||||
"columns": [
|
||||
"createdById"
|
||||
],
|
||||
"isUnique": false
|
||||
},
|
||||
"client_name_idx": {
|
||||
"name": "client_name_idx",
|
||||
"columns": [
|
||||
"name"
|
||||
],
|
||||
"isUnique": false
|
||||
},
|
||||
"client_email_idx": {
|
||||
"name": "client_email_idx",
|
||||
"columns": [
|
||||
"email"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"beenvoice_client_createdById_beenvoice_user_id_fk": {
|
||||
"name": "beenvoice_client_createdById_beenvoice_user_id_fk",
|
||||
"tableFrom": "beenvoice_client",
|
||||
"tableTo": "beenvoice_user",
|
||||
"columnsFrom": [
|
||||
"createdById"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"beenvoice_invoice_item": {
|
||||
"name": "beenvoice_invoice_item",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text(255)",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"invoiceId": {
|
||||
"name": "invoiceId",
|
||||
"type": "text(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"date": {
|
||||
"name": "date",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text(500)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"hours": {
|
||||
"name": "hours",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"rate": {
|
||||
"name": "rate",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"amount": {
|
||||
"name": "amount",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"position": {
|
||||
"name": "position",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch())"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"invoice_item_invoice_id_idx": {
|
||||
"name": "invoice_item_invoice_id_idx",
|
||||
"columns": [
|
||||
"invoiceId"
|
||||
],
|
||||
"isUnique": false
|
||||
},
|
||||
"invoice_item_date_idx": {
|
||||
"name": "invoice_item_date_idx",
|
||||
"columns": [
|
||||
"date"
|
||||
],
|
||||
"isUnique": false
|
||||
},
|
||||
"invoice_item_position_idx": {
|
||||
"name": "invoice_item_position_idx",
|
||||
"columns": [
|
||||
"position"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"beenvoice_invoice_item_invoiceId_beenvoice_invoice_id_fk": {
|
||||
"name": "beenvoice_invoice_item_invoiceId_beenvoice_invoice_id_fk",
|
||||
"tableFrom": "beenvoice_invoice_item",
|
||||
"tableTo": "beenvoice_invoice",
|
||||
"columnsFrom": [
|
||||
"invoiceId"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"beenvoice_invoice": {
|
||||
"name": "beenvoice_invoice",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text(255)",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"invoiceNumber": {
|
||||
"name": "invoiceNumber",
|
||||
"type": "text(100)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"businessId": {
|
||||
"name": "businessId",
|
||||
"type": "text(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"clientId": {
|
||||
"name": "clientId",
|
||||
"type": "text(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"issueDate": {
|
||||
"name": "issueDate",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"dueDate": {
|
||||
"name": "dueDate",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text(50)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'draft'"
|
||||
},
|
||||
"totalAmount": {
|
||||
"name": "totalAmount",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"taxRate": {
|
||||
"name": "taxRate",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"notes": {
|
||||
"name": "notes",
|
||||
"type": "text(1000)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"createdById": {
|
||||
"name": "createdById",
|
||||
"type": "text(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch())"
|
||||
},
|
||||
"updatedAt": {
|
||||
"name": "updatedAt",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"invoice_business_id_idx": {
|
||||
"name": "invoice_business_id_idx",
|
||||
"columns": [
|
||||
"businessId"
|
||||
],
|
||||
"isUnique": false
|
||||
},
|
||||
"invoice_client_id_idx": {
|
||||
"name": "invoice_client_id_idx",
|
||||
"columns": [
|
||||
"clientId"
|
||||
],
|
||||
"isUnique": false
|
||||
},
|
||||
"invoice_created_by_idx": {
|
||||
"name": "invoice_created_by_idx",
|
||||
"columns": [
|
||||
"createdById"
|
||||
],
|
||||
"isUnique": false
|
||||
},
|
||||
"invoice_number_idx": {
|
||||
"name": "invoice_number_idx",
|
||||
"columns": [
|
||||
"invoiceNumber"
|
||||
],
|
||||
"isUnique": false
|
||||
},
|
||||
"invoice_status_idx": {
|
||||
"name": "invoice_status_idx",
|
||||
"columns": [
|
||||
"status"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"beenvoice_invoice_businessId_beenvoice_business_id_fk": {
|
||||
"name": "beenvoice_invoice_businessId_beenvoice_business_id_fk",
|
||||
"tableFrom": "beenvoice_invoice",
|
||||
"tableTo": "beenvoice_business",
|
||||
"columnsFrom": [
|
||||
"businessId"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"beenvoice_invoice_clientId_beenvoice_client_id_fk": {
|
||||
"name": "beenvoice_invoice_clientId_beenvoice_client_id_fk",
|
||||
"tableFrom": "beenvoice_invoice",
|
||||
"tableTo": "beenvoice_client",
|
||||
"columnsFrom": [
|
||||
"clientId"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"beenvoice_invoice_createdById_beenvoice_user_id_fk": {
|
||||
"name": "beenvoice_invoice_createdById_beenvoice_user_id_fk",
|
||||
"tableFrom": "beenvoice_invoice",
|
||||
"tableTo": "beenvoice_user",
|
||||
"columnsFrom": [
|
||||
"createdById"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"beenvoice_session": {
|
||||
"name": "beenvoice_session",
|
||||
"columns": {
|
||||
"sessionToken": {
|
||||
"name": "sessionToken",
|
||||
"type": "text(255)",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"userId": {
|
||||
"name": "userId",
|
||||
"type": "text(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expires": {
|
||||
"name": "expires",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"session_userId_idx": {
|
||||
"name": "session_userId_idx",
|
||||
"columns": [
|
||||
"userId"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"beenvoice_session_userId_beenvoice_user_id_fk": {
|
||||
"name": "beenvoice_session_userId_beenvoice_user_id_fk",
|
||||
"tableFrom": "beenvoice_session",
|
||||
"tableTo": "beenvoice_user",
|
||||
"columnsFrom": [
|
||||
"userId"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"beenvoice_user": {
|
||||
"name": "beenvoice_user",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text(255)",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"password": {
|
||||
"name": "password",
|
||||
"type": "text(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"emailVerified": {
|
||||
"name": "emailVerified",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch())"
|
||||
},
|
||||
"image": {
|
||||
"name": "image",
|
||||
"type": "text(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"beenvoice_verification_token": {
|
||||
"name": "beenvoice_verification_token",
|
||||
"columns": {
|
||||
"identifier": {
|
||||
"name": "identifier",
|
||||
"type": "text(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"token": {
|
||||
"name": "token",
|
||||
"type": "text(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expires": {
|
||||
"name": "expires",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"beenvoice_verification_token_identifier_token_pk": {
|
||||
"columns": [
|
||||
"identifier",
|
||||
"token"
|
||||
],
|
||||
"name": "beenvoice_verification_token_identifier_token_pk"
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
683
drizzle/meta/0001_snapshot.json
Normal file
683
drizzle/meta/0001_snapshot.json
Normal file
@@ -0,0 +1,683 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "5672a328-2801-45d8-9e27-bb9fe07e6c0e",
|
||||
"prevId": "4d0fc78f-75b4-4059-b7f0-1aa656f007b7",
|
||||
"tables": {
|
||||
"beenvoice_account": {
|
||||
"name": "beenvoice_account",
|
||||
"columns": {
|
||||
"userId": {
|
||||
"name": "userId",
|
||||
"type": "text(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"provider": {
|
||||
"name": "provider",
|
||||
"type": "text(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"providerAccountId": {
|
||||
"name": "providerAccountId",
|
||||
"type": "text(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"refresh_token": {
|
||||
"name": "refresh_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"access_token": {
|
||||
"name": "access_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"token_type": {
|
||||
"name": "token_type",
|
||||
"type": "text(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"scope": {
|
||||
"name": "scope",
|
||||
"type": "text(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"id_token": {
|
||||
"name": "id_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"session_state": {
|
||||
"name": "session_state",
|
||||
"type": "text(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"account_user_id_idx": {
|
||||
"name": "account_user_id_idx",
|
||||
"columns": [
|
||||
"userId"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"beenvoice_account_userId_beenvoice_user_id_fk": {
|
||||
"name": "beenvoice_account_userId_beenvoice_user_id_fk",
|
||||
"tableFrom": "beenvoice_account",
|
||||
"tableTo": "beenvoice_user",
|
||||
"columnsFrom": [
|
||||
"userId"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"beenvoice_account_provider_providerAccountId_pk": {
|
||||
"columns": [
|
||||
"provider",
|
||||
"providerAccountId"
|
||||
],
|
||||
"name": "beenvoice_account_provider_providerAccountId_pk"
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"beenvoice_client": {
|
||||
"name": "beenvoice_client",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text(255)",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"phone": {
|
||||
"name": "phone",
|
||||
"type": "text(50)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"addressLine1": {
|
||||
"name": "addressLine1",
|
||||
"type": "text(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"addressLine2": {
|
||||
"name": "addressLine2",
|
||||
"type": "text(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"city": {
|
||||
"name": "city",
|
||||
"type": "text(100)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"state": {
|
||||
"name": "state",
|
||||
"type": "text(50)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"postalCode": {
|
||||
"name": "postalCode",
|
||||
"type": "text(20)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"country": {
|
||||
"name": "country",
|
||||
"type": "text(100)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"createdById": {
|
||||
"name": "createdById",
|
||||
"type": "text(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch())"
|
||||
},
|
||||
"updatedAt": {
|
||||
"name": "updatedAt",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"client_created_by_idx": {
|
||||
"name": "client_created_by_idx",
|
||||
"columns": [
|
||||
"createdById"
|
||||
],
|
||||
"isUnique": false
|
||||
},
|
||||
"client_name_idx": {
|
||||
"name": "client_name_idx",
|
||||
"columns": [
|
||||
"name"
|
||||
],
|
||||
"isUnique": false
|
||||
},
|
||||
"client_email_idx": {
|
||||
"name": "client_email_idx",
|
||||
"columns": [
|
||||
"email"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"beenvoice_client_createdById_beenvoice_user_id_fk": {
|
||||
"name": "beenvoice_client_createdById_beenvoice_user_id_fk",
|
||||
"tableFrom": "beenvoice_client",
|
||||
"tableTo": "beenvoice_user",
|
||||
"columnsFrom": [
|
||||
"createdById"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"beenvoice_invoice_item": {
|
||||
"name": "beenvoice_invoice_item",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text(255)",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"invoiceId": {
|
||||
"name": "invoiceId",
|
||||
"type": "text(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"date": {
|
||||
"name": "date",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text(500)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"hours": {
|
||||
"name": "hours",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"rate": {
|
||||
"name": "rate",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"amount": {
|
||||
"name": "amount",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"position": {
|
||||
"name": "position",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch())"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"invoice_item_invoice_id_idx": {
|
||||
"name": "invoice_item_invoice_id_idx",
|
||||
"columns": [
|
||||
"invoiceId"
|
||||
],
|
||||
"isUnique": false
|
||||
},
|
||||
"invoice_item_date_idx": {
|
||||
"name": "invoice_item_date_idx",
|
||||
"columns": [
|
||||
"date"
|
||||
],
|
||||
"isUnique": false
|
||||
},
|
||||
"invoice_item_position_idx": {
|
||||
"name": "invoice_item_position_idx",
|
||||
"columns": [
|
||||
"position"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"beenvoice_invoice_item_invoiceId_beenvoice_invoice_id_fk": {
|
||||
"name": "beenvoice_invoice_item_invoiceId_beenvoice_invoice_id_fk",
|
||||
"tableFrom": "beenvoice_invoice_item",
|
||||
"tableTo": "beenvoice_invoice",
|
||||
"columnsFrom": [
|
||||
"invoiceId"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"beenvoice_invoice": {
|
||||
"name": "beenvoice_invoice",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text(255)",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"invoiceNumber": {
|
||||
"name": "invoiceNumber",
|
||||
"type": "text(100)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"clientId": {
|
||||
"name": "clientId",
|
||||
"type": "text(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"issueDate": {
|
||||
"name": "issueDate",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"dueDate": {
|
||||
"name": "dueDate",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text(50)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'draft'"
|
||||
},
|
||||
"totalAmount": {
|
||||
"name": "totalAmount",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"taxRate": {
|
||||
"name": "taxRate",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"notes": {
|
||||
"name": "notes",
|
||||
"type": "text(1000)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"createdById": {
|
||||
"name": "createdById",
|
||||
"type": "text(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch())"
|
||||
},
|
||||
"updatedAt": {
|
||||
"name": "updatedAt",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"invoice_client_id_idx": {
|
||||
"name": "invoice_client_id_idx",
|
||||
"columns": [
|
||||
"clientId"
|
||||
],
|
||||
"isUnique": false
|
||||
},
|
||||
"invoice_created_by_idx": {
|
||||
"name": "invoice_created_by_idx",
|
||||
"columns": [
|
||||
"createdById"
|
||||
],
|
||||
"isUnique": false
|
||||
},
|
||||
"invoice_number_idx": {
|
||||
"name": "invoice_number_idx",
|
||||
"columns": [
|
||||
"invoiceNumber"
|
||||
],
|
||||
"isUnique": false
|
||||
},
|
||||
"invoice_status_idx": {
|
||||
"name": "invoice_status_idx",
|
||||
"columns": [
|
||||
"status"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"beenvoice_invoice_clientId_beenvoice_client_id_fk": {
|
||||
"name": "beenvoice_invoice_clientId_beenvoice_client_id_fk",
|
||||
"tableFrom": "beenvoice_invoice",
|
||||
"tableTo": "beenvoice_client",
|
||||
"columnsFrom": [
|
||||
"clientId"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"beenvoice_invoice_createdById_beenvoice_user_id_fk": {
|
||||
"name": "beenvoice_invoice_createdById_beenvoice_user_id_fk",
|
||||
"tableFrom": "beenvoice_invoice",
|
||||
"tableTo": "beenvoice_user",
|
||||
"columnsFrom": [
|
||||
"createdById"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"beenvoice_session": {
|
||||
"name": "beenvoice_session",
|
||||
"columns": {
|
||||
"sessionToken": {
|
||||
"name": "sessionToken",
|
||||
"type": "text(255)",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"userId": {
|
||||
"name": "userId",
|
||||
"type": "text(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expires": {
|
||||
"name": "expires",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"session_userId_idx": {
|
||||
"name": "session_userId_idx",
|
||||
"columns": [
|
||||
"userId"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"beenvoice_session_userId_beenvoice_user_id_fk": {
|
||||
"name": "beenvoice_session_userId_beenvoice_user_id_fk",
|
||||
"tableFrom": "beenvoice_session",
|
||||
"tableTo": "beenvoice_user",
|
||||
"columnsFrom": [
|
||||
"userId"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"beenvoice_user": {
|
||||
"name": "beenvoice_user",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text(255)",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"password": {
|
||||
"name": "password",
|
||||
"type": "text(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"emailVerified": {
|
||||
"name": "emailVerified",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch())"
|
||||
},
|
||||
"image": {
|
||||
"name": "image",
|
||||
"type": "text(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"beenvoice_verification_token": {
|
||||
"name": "beenvoice_verification_token",
|
||||
"columns": {
|
||||
"identifier": {
|
||||
"name": "identifier",
|
||||
"type": "text(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"token": {
|
||||
"name": "token",
|
||||
"type": "text(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expires": {
|
||||
"name": "expires",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"beenvoice_verification_token_identifier_token_pk": {
|
||||
"columns": [
|
||||
"identifier",
|
||||
"token"
|
||||
],
|
||||
"name": "beenvoice_verification_token_identifier_token_pk"
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
13
drizzle/meta/_journal.json
Normal file
13
drizzle/meta/_journal.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1752275489999,
|
||||
"tag": "0000_unique_loa",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
770
package-lock.json
generated
770
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
11
package.json
11
package.json
@@ -10,6 +10,7 @@
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"deploy": "drizzle-kit push && next build",
|
||||
"dev": "next dev --turbo",
|
||||
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
||||
"format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
||||
@@ -21,6 +22,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@auth/drizzle-adapter": "^1.7.2",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@libsql/client": "^0.14.0",
|
||||
"@radix-ui/react-checkbox": "^1.3.2",
|
||||
"@radix-ui/react-dialog": "^1.1.14",
|
||||
@@ -29,21 +33,24 @@
|
||||
"@radix-ui/react-navigation-menu": "^1.2.13",
|
||||
"@radix-ui/react-popover": "^1.1.14",
|
||||
"@radix-ui/react-progress": "^1.1.7",
|
||||
"@radix-ui/react-select": "^2.2.5",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-tabs": "^1.1.12",
|
||||
"@react-pdf/renderer": "^4.3.0",
|
||||
"@t3-oss/env-nextjs": "^0.12.0",
|
||||
"@tanstack/react-query": "^5.69.0",
|
||||
"@trpc/client": "^11.0.0",
|
||||
"@trpc/react-query": "^11.0.0",
|
||||
"@trpc/server": "^11.0.0",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"drizzle-orm": "^0.41.0",
|
||||
"html2canvas": "^1.4.1",
|
||||
"jspdf": "^3.0.1",
|
||||
"file-saver": "^2.0.5",
|
||||
"lucide": "^0.525.0",
|
||||
"lucide-react": "^0.525.0",
|
||||
"next": "^15.2.3",
|
||||
|
||||
BIN
public/beenvoice-logo.png
Normal file
BIN
public/beenvoice-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 169 KiB |
12
public/beenvoice-logo.svg
Normal file
12
public/beenvoice-logo.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 89 KiB |
BIN
public/fonts/azeret/AzeretMono-Regular.ttf
Normal file
BIN
public/fonts/azeret/AzeretMono-Regular.ttf
Normal file
Binary file not shown.
BIN
public/fonts/inter/Inter-Italic-Variable.ttf
Normal file
BIN
public/fonts/inter/Inter-Italic-Variable.ttf
Normal file
Binary file not shown.
BIN
public/fonts/inter/Inter-Variable.ttf
Normal file
BIN
public/fonts/inter/Inter-Variable.ttf
Normal file
Binary file not shown.
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, Suspense } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Button } from "~/components/ui/button";
|
||||
@@ -11,8 +11,10 @@ import { toast } from "sonner";
|
||||
import { Logo } from "~/components/logo";
|
||||
import { User, Mail, Lock, ArrowRight } from "lucide-react";
|
||||
|
||||
export default function RegisterPage() {
|
||||
function RegisterForm() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard";
|
||||
const [firstName, setFirstName] = useState("");
|
||||
const [lastName, setLastName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
@@ -25,17 +27,21 @@ export default function RegisterPage() {
|
||||
const res = await fetch("/api/auth/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
password
|
||||
body: JSON.stringify({
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
password,
|
||||
}),
|
||||
});
|
||||
setLoading(false);
|
||||
if (res.ok) {
|
||||
toast.success("Account created successfully! Please sign in.");
|
||||
router.push("/auth/signin");
|
||||
const signInUrl =
|
||||
callbackUrl !== "/dashboard"
|
||||
? `/auth/signin?callbackUrl=${encodeURIComponent(callbackUrl)}`
|
||||
: "/auth/signin";
|
||||
router.push(signInUrl);
|
||||
} else {
|
||||
const error = await res.text();
|
||||
toast.error(error || "Failed to create account");
|
||||
@@ -43,21 +49,25 @@ export default function RegisterPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-green-50 to-emerald-100 flex items-center justify-center p-4">
|
||||
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-green-50 to-emerald-100 p-4">
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
{/* Logo and Welcome */}
|
||||
<div className="text-center space-y-4">
|
||||
<div className="space-y-4 text-center">
|
||||
<Logo size="lg" className="mx-auto" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Join beenvoice</h1>
|
||||
<p className="text-gray-600 mt-2">Create your account to get started</p>
|
||||
<p className="mt-2 text-gray-600">
|
||||
Create your account to get started
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Registration Form */}
|
||||
<Card className="shadow-xl border-0">
|
||||
<Card className="border-0 shadow-xl">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-xl text-center">Create Account</CardTitle>
|
||||
<CardTitle className="text-center text-xl">
|
||||
Create Account
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleRegister} className="space-y-4">
|
||||
@@ -65,12 +75,12 @@ export default function RegisterPage() {
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="firstName">First Name</Label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
|
||||
<User className="absolute top-3 left-3 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
id="firstName"
|
||||
type="text"
|
||||
value={firstName}
|
||||
onChange={e => setFirstName(e.target.value)}
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
className="pl-10"
|
||||
@@ -81,12 +91,12 @@ export default function RegisterPage() {
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lastName">Last Name</Label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
|
||||
<User className="absolute top-3 left-3 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
id="lastName"
|
||||
type="text"
|
||||
value={lastName}
|
||||
onChange={e => setLastName(e.target.value)}
|
||||
onChange={(e) => setLastName(e.target.value)}
|
||||
required
|
||||
className="pl-10"
|
||||
placeholder="Last name"
|
||||
@@ -97,12 +107,12 @@ export default function RegisterPage() {
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
|
||||
<Mail className="absolute top-3 left-3 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="pl-10"
|
||||
placeholder="Enter your email"
|
||||
@@ -112,19 +122,21 @@ export default function RegisterPage() {
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
|
||||
<Lock className="absolute top-3 left-3 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={6}
|
||||
className="pl-10"
|
||||
placeholder="Create a password"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">Must be at least 6 characters</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
Must be at least 6 characters
|
||||
</p>
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? (
|
||||
@@ -139,7 +151,10 @@ export default function RegisterPage() {
|
||||
</form>
|
||||
<div className="mt-6 text-center text-sm">
|
||||
<span className="text-gray-600">Already have an account? </span>
|
||||
<Link href="/auth/signin" className="text-green-600 hover:text-green-700 font-medium">
|
||||
<Link
|
||||
href="/auth/signin"
|
||||
className="font-medium text-green-600 hover:text-green-700"
|
||||
>
|
||||
Sign in here
|
||||
</Link>
|
||||
</div>
|
||||
@@ -147,7 +162,7 @@ export default function RegisterPage() {
|
||||
</Card>
|
||||
|
||||
{/* Features */}
|
||||
<div className="text-center space-y-4">
|
||||
<div className="space-y-4 text-center">
|
||||
<p className="text-sm text-gray-500">Start invoicing like a pro</p>
|
||||
<div className="flex justify-center space-x-6 text-xs text-gray-400">
|
||||
<span>✓ Free to start</span>
|
||||
@@ -158,4 +173,28 @@ export default function RegisterPage() {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default function RegisterPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-green-50 to-emerald-100 p-4">
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
<div className="space-y-4 text-center">
|
||||
<Logo size="lg" className="mx-auto" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
Join beenvoice
|
||||
</h1>
|
||||
<p className="mt-2 text-gray-600">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<RegisterForm />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, Suspense } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Input } from "~/components/ui/input";
|
||||
@@ -12,8 +12,10 @@ import { toast } from "sonner";
|
||||
import { Logo } from "~/components/logo";
|
||||
import { Mail, Lock, ArrowRight } from "lucide-react";
|
||||
|
||||
export default function SignInPage() {
|
||||
function SignInForm() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard";
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -21,7 +23,7 @@ export default function SignInPage() {
|
||||
async function handleSignIn(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
|
||||
const result = await signIn("credentials", {
|
||||
email,
|
||||
password,
|
||||
@@ -29,44 +31,46 @@ export default function SignInPage() {
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
|
||||
|
||||
if (result?.error) {
|
||||
toast.error("Invalid email or password");
|
||||
} else {
|
||||
toast.success("Signed in successfully!");
|
||||
router.push("/dashboard");
|
||||
router.push(callbackUrl);
|
||||
router.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-green-50 to-emerald-100 flex items-center justify-center p-4">
|
||||
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-green-50 to-emerald-100 p-4">
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
{/* Logo and Welcome */}
|
||||
<div className="text-center space-y-4">
|
||||
<div className="space-y-4 text-center">
|
||||
<Logo size="lg" className="mx-auto" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Welcome back</h1>
|
||||
<p className="text-gray-600 mt-2">Sign in to your beenvoice account</p>
|
||||
<p className="mt-2 text-gray-600">
|
||||
Sign in to your beenvoice account
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sign In Form */}
|
||||
<Card className="shadow-xl border-0">
|
||||
<Card className="border-0 shadow-xl">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-xl text-center">Sign In</CardTitle>
|
||||
<CardTitle className="text-center text-xl">Sign In</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSignIn} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
|
||||
<Mail className="absolute top-3 left-3 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
className="pl-10"
|
||||
@@ -77,12 +81,12 @@ export default function SignInPage() {
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
|
||||
<Lock className="absolute top-3 left-3 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
className="pl-10"
|
||||
placeholder="Enter your password"
|
||||
@@ -101,8 +105,13 @@ export default function SignInPage() {
|
||||
</Button>
|
||||
</form>
|
||||
<div className="mt-6 text-center text-sm">
|
||||
<span className="text-gray-600">Don't have an account? </span>
|
||||
<Link href="/auth/register" className="text-green-600 hover:text-green-700 font-medium">
|
||||
<span className="text-gray-600">
|
||||
Don't have an account?{" "}
|
||||
</span>
|
||||
<Link
|
||||
href="/auth/register"
|
||||
className="font-medium text-green-600 hover:text-green-700"
|
||||
>
|
||||
Create one now
|
||||
</Link>
|
||||
</div>
|
||||
@@ -110,8 +119,10 @@ export default function SignInPage() {
|
||||
</Card>
|
||||
|
||||
{/* Features */}
|
||||
<div className="text-center space-y-4">
|
||||
<p className="text-sm text-gray-500">Simple invoicing for freelancers and small businesses</p>
|
||||
<div className="space-y-4 text-center">
|
||||
<p className="text-sm text-gray-500">
|
||||
Simple invoicing for freelancers and small businesses
|
||||
</p>
|
||||
<div className="flex justify-center space-x-6 text-xs text-gray-400">
|
||||
<span>✓ Easy client management</span>
|
||||
<span>✓ Professional invoices</span>
|
||||
@@ -121,4 +132,28 @@ export default function SignInPage() {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default function SignInPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-green-50 to-emerald-100 p-4">
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
<div className="space-y-4 text-center">
|
||||
<Logo size="lg" className="mx-auto" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
Welcome back
|
||||
</h1>
|
||||
<p className="mt-2 text-gray-600">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<SignInForm />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
20
src/app/clients/layout.tsx
Normal file
20
src/app/clients/layout.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Navbar } from "~/components/Navbar";
|
||||
import { Sidebar } from "~/components/Sidebar";
|
||||
|
||||
export default function ClientsLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<div className="flex">
|
||||
<Sidebar />
|
||||
<main className="flex-1 min-h-screen bg-background">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
42
src/app/clients/page.tsx
Normal file
42
src/app/clients/page.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import Link from "next/link";
|
||||
import { auth } from "~/server/auth";
|
||||
import { api, HydrateClient } from "~/trpc/server";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { ClientList } from "~/components/client-list";
|
||||
import { Plus } from "lucide-react";
|
||||
|
||||
export default async function ClientsPage() {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-4xl font-bold mb-4">Access Denied</h1>
|
||||
<p className="text-muted-foreground mb-8">Please sign in to view clients</p>
|
||||
<Link href="/api/auth/signin">
|
||||
<Button size="lg">Sign In</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Prefetch clients data
|
||||
void api.clients.getAll.prefetch();
|
||||
|
||||
return (
|
||||
<HydrateClient>
|
||||
<div className="p-6">
|
||||
<div className="mb-8">
|
||||
<h2 className="text-3xl font-bold mb-2">Clients</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Manage your client relationships
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ClientList />
|
||||
</div>
|
||||
</HydrateClient>
|
||||
);
|
||||
}
|
||||
243
src/app/dashboard/_components/dashboard-components.tsx
Normal file
243
src/app/dashboard/_components/dashboard-components.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
"use client";
|
||||
|
||||
import { api } from "~/trpc/react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Users,
|
||||
FileText,
|
||||
TrendingUp,
|
||||
Calendar,
|
||||
Plus,
|
||||
ArrowRight
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { DashboardStatsSkeleton, DashboardActivitySkeleton } from "~/components/ui/skeleton";
|
||||
|
||||
// Client component for dashboard stats
|
||||
export function DashboardStats() {
|
||||
const { data: clients, isLoading: clientsLoading } = api.clients.getAll.useQuery();
|
||||
const { data: invoices, isLoading: invoicesLoading } = api.invoices.getAll.useQuery();
|
||||
|
||||
if (clientsLoading || invoicesLoading) {
|
||||
return <DashboardStatsSkeleton />;
|
||||
}
|
||||
|
||||
const totalClients = clients?.length ?? 0;
|
||||
const totalInvoices = invoices?.length ?? 0;
|
||||
const totalRevenue = invoices?.reduce((sum, invoice) => sum + invoice.totalAmount, 0) ?? 0;
|
||||
const pendingInvoices = invoices?.filter(invoice => invoice.status === "sent" || invoice.status === "draft").length ?? 0;
|
||||
|
||||
// Calculate month-over-month changes (simplified)
|
||||
const lastMonthClients = 0; // This would need historical data
|
||||
const lastMonthInvoices = 0;
|
||||
const lastMonthRevenue = 0;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm hover:shadow-2xl transition-all duration-300">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-700">Total Clients</CardTitle>
|
||||
<div className="p-2 bg-emerald-100 rounded-lg">
|
||||
<Users className="h-4 w-4 text-emerald-600" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-emerald-600">{totalClients}</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
{totalClients > lastMonthClients ? "+" : ""}{totalClients - lastMonthClients} from last month
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm hover:shadow-2xl transition-all duration-300">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-700">Total Invoices</CardTitle>
|
||||
<div className="p-2 bg-blue-100 rounded-lg">
|
||||
<FileText className="h-4 w-4 text-blue-600" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-blue-600">{totalInvoices}</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
{totalInvoices > lastMonthInvoices ? "+" : ""}{totalInvoices - lastMonthInvoices} from last month
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm hover:shadow-2xl transition-all duration-300">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-700">Revenue</CardTitle>
|
||||
<div className="p-2 bg-teal-100 rounded-lg">
|
||||
<TrendingUp className="h-4 w-4 text-teal-600" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-teal-600">${totalRevenue.toFixed(2)}</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
{totalRevenue > lastMonthRevenue ? "+" : ""}{((totalRevenue - lastMonthRevenue) / (lastMonthRevenue || 1) * 100).toFixed(1)}% from last month
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm hover:shadow-2xl transition-all duration-300">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-700">Pending Invoices</CardTitle>
|
||||
<div className="p-2 bg-orange-100 rounded-lg">
|
||||
<Calendar className="h-4 w-4 text-orange-600" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-orange-600">{pendingInvoices}</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
Due this month
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Client component for dashboard cards
|
||||
export function DashboardCards() {
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
|
||||
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm hover:shadow-2xl transition-all duration-300">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-emerald-700">
|
||||
<div className="p-2 bg-emerald-100 rounded-lg">
|
||||
<Users className="h-5 w-5" />
|
||||
</div>
|
||||
Manage Clients
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-gray-600">
|
||||
Add new clients and manage your existing client relationships.
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
asChild
|
||||
className="bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700 text-white font-medium shadow-lg hover:shadow-xl transition-all duration-200"
|
||||
>
|
||||
<Link href="/dashboard/clients/new">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Client
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
asChild
|
||||
className="border-gray-300 text-gray-700 hover:bg-gray-50 font-medium"
|
||||
>
|
||||
<Link href="/dashboard/clients">
|
||||
View All Clients
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm hover:shadow-2xl transition-all duration-300">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-emerald-700">
|
||||
<div className="p-2 bg-emerald-100 rounded-lg">
|
||||
<FileText className="h-5 w-5" />
|
||||
</div>
|
||||
Create Invoices
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-gray-600">
|
||||
Generate professional invoices and track payments.
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
asChild
|
||||
className="bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700 text-white font-medium shadow-lg hover:shadow-xl transition-all duration-200"
|
||||
>
|
||||
<Link href="/dashboard/invoices/new">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
New Invoice
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
asChild
|
||||
className="border-gray-300 text-gray-700 hover:bg-gray-50 font-medium"
|
||||
>
|
||||
<Link href="/dashboard/invoices">
|
||||
View All Invoices
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Client component for recent activity
|
||||
export function DashboardActivity() {
|
||||
const { data: invoices, isLoading } = api.invoices.getAll.useQuery();
|
||||
|
||||
if (isLoading) {
|
||||
return <DashboardActivitySkeleton />;
|
||||
}
|
||||
|
||||
const recentInvoices = invoices?.slice(0, 5) ?? [];
|
||||
|
||||
return (
|
||||
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-emerald-700">Recent Activity</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{recentInvoices.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<div className="p-4 bg-gray-100 rounded-full w-20 h-20 mx-auto mb-4 flex items-center justify-center">
|
||||
<FileText className="h-8 w-8 text-gray-400" />
|
||||
</div>
|
||||
<p className="text-lg font-medium mb-2">No recent activity</p>
|
||||
<p className="text-sm">Start by adding your first client or creating an invoice</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{recentInvoices.map((invoice) => (
|
||||
<div key={invoice.id} className="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-emerald-100 rounded-lg">
|
||||
<FileText className="h-4 w-4 text-emerald-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Invoice #{invoice.invoiceNumber}</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{invoice.client?.name ?? "Unknown Client"} • ${invoice.totalAmount.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded-full ${
|
||||
invoice.status === "paid" ? "bg-green-100 text-green-800" :
|
||||
invoice.status === "sent" ? "bg-blue-100 text-blue-800" :
|
||||
invoice.status === "overdue" ? "bg-red-100 text-red-800" :
|
||||
"bg-gray-100 text-gray-800"
|
||||
}`}>
|
||||
{invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1)}
|
||||
</span>
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={`/dashboard/invoices/${invoice.id}`}>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
11
src/app/dashboard/businesses/[id]/edit/page.tsx
Normal file
11
src/app/dashboard/businesses/[id]/edit/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { BusinessForm } from "~/components/business-form";
|
||||
import { useParams } from "next/navigation";
|
||||
|
||||
export default function EditBusinessPage() {
|
||||
const params = useParams();
|
||||
const businessId = Array.isArray(params?.id) ? params.id[0] : params?.id;
|
||||
if (!businessId) return null;
|
||||
return <BusinessForm businessId={businessId} mode="edit" />;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { api } from "~/trpc/react";
|
||||
import { UniversalTable } from "~/components/ui/universal-table";
|
||||
import { TableSkeleton } from "~/components/ui/skeleton";
|
||||
|
||||
export function BusinessesTable() {
|
||||
const { isLoading } = api.businesses.getAll.useQuery();
|
||||
|
||||
if (isLoading) {
|
||||
return <TableSkeleton rows={8} />;
|
||||
}
|
||||
|
||||
return <UniversalTable resource="businesses" />;
|
||||
}
|
||||
5
src/app/dashboard/businesses/new/page.tsx
Normal file
5
src/app/dashboard/businesses/new/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { BusinessForm } from "~/components/business-form";
|
||||
|
||||
export default function NewBusinessPage() {
|
||||
return <BusinessForm mode="create" />;
|
||||
}
|
||||
35
src/app/dashboard/businesses/page.tsx
Normal file
35
src/app/dashboard/businesses/page.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { api, HydrateClient } from "~/trpc/server";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Plus } from "lucide-react";
|
||||
import { BusinessesTable } from "./_components/businesses-table";
|
||||
|
||||
export default async function BusinessesPage() {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8 flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h1 className="bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-3xl font-bold text-transparent">
|
||||
Businesses
|
||||
</h1>
|
||||
<p className="mt-1 text-lg text-gray-600">
|
||||
Manage your businesses and their information.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="bg-gradient-to-r from-emerald-600 to-teal-600 font-medium text-white shadow-lg hover:from-emerald-700 hover:to-teal-700 hover:shadow-xl"
|
||||
>
|
||||
<Link href="/dashboard/businesses/new">
|
||||
<Plus className="mr-2 h-5 w-5" /> Add Business
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<HydrateClient>
|
||||
<BusinessesTable />
|
||||
</HydrateClient>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,45 +1,26 @@
|
||||
import { auth } from "~/server/auth";
|
||||
import { HydrateClient } from "~/trpc/server";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { ClientForm } from "~/components/client-form";
|
||||
import Link from "next/link";
|
||||
|
||||
interface EditClientPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function EditClientPage({ params }: EditClientPageProps) {
|
||||
const session = await auth();
|
||||
const { id } = await params;
|
||||
|
||||
if (!session?.user) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-emerald-50 via-white to-teal-50">
|
||||
<div className="text-center">
|
||||
<h1 className="text-4xl font-bold mb-4 bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent">Access Denied</h1>
|
||||
<p className="text-muted-foreground mb-8">Please sign in to edit clients</p>
|
||||
<Link href="/api/auth/signin">
|
||||
<Button
|
||||
size="lg"
|
||||
className="bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700 text-white font-medium shadow-lg hover:shadow-xl transition-all duration-200"
|
||||
>
|
||||
Sign In
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent">Edit Client</h1>
|
||||
<p className="text-gray-600 mt-1 text-lg">Update client information below.</p>
|
||||
<h1 className="bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-3xl font-bold text-transparent">
|
||||
Edit Client
|
||||
</h1>
|
||||
<p className="mt-1 text-lg text-gray-600">
|
||||
Update client information below.
|
||||
</p>
|
||||
</div>
|
||||
<HydrateClient>
|
||||
<ClientForm mode="edit" clientId={id} />
|
||||
</HydrateClient>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
15
src/app/dashboard/clients/_components/clients-table.tsx
Normal file
15
src/app/dashboard/clients/_components/clients-table.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { api } from "~/trpc/react";
|
||||
import { UniversalTable } from "~/components/ui/universal-table";
|
||||
import { TableSkeleton } from "~/components/ui/skeleton";
|
||||
|
||||
export function ClientsTable() {
|
||||
const { isLoading } = api.clients.getAll.useQuery();
|
||||
|
||||
if (isLoading) {
|
||||
return <TableSkeleton rows={8} />;
|
||||
}
|
||||
|
||||
return <UniversalTable resource="clients" />;
|
||||
}
|
||||
@@ -1,40 +1,20 @@
|
||||
import { auth } from "~/server/auth";
|
||||
import { HydrateClient } from "~/trpc/server";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { ClientForm } from "~/components/client-form";
|
||||
import Link from "next/link";
|
||||
|
||||
export default async function NewClientPage() {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-emerald-50 via-white to-teal-50">
|
||||
<div className="text-center">
|
||||
<h1 className="text-4xl font-bold mb-4 bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent">Access Denied</h1>
|
||||
<p className="text-muted-foreground mb-8">Please sign in to create clients</p>
|
||||
<Link href="/api/auth/signin">
|
||||
<Button
|
||||
size="lg"
|
||||
className="bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700 text-white font-medium shadow-lg hover:shadow-xl transition-all duration-200"
|
||||
>
|
||||
Sign In
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent">Add Client</h1>
|
||||
<p className="text-gray-600 mt-1 text-lg">Enter client details below to add a new client.</p>
|
||||
<h1 className="bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-3xl font-bold text-transparent">
|
||||
Add Client
|
||||
</h1>
|
||||
<p className="mt-1 text-lg text-gray-600">
|
||||
Enter client details below to add a new client.
|
||||
</p>
|
||||
</div>
|
||||
<HydrateClient>
|
||||
<ClientForm mode="create" />
|
||||
</HydrateClient>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,51 +1,35 @@
|
||||
import Link from "next/link";
|
||||
import { auth } from "~/server/auth";
|
||||
|
||||
import { api, HydrateClient } from "~/trpc/server";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { UniversalTable } from "~/components/ui/universal-table";
|
||||
import { Plus } from "lucide-react";
|
||||
import { ClientsTable } from "./_components/clients-table";
|
||||
|
||||
export default async function ClientsPage() {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-emerald-50 via-white to-teal-50">
|
||||
<div className="text-center">
|
||||
<h1 className="text-4xl font-bold mb-4 bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent">Access Denied</h1>
|
||||
<p className="text-muted-foreground mb-8">Please sign in to view clients</p>
|
||||
<Link href="/api/auth/signin">
|
||||
<Button
|
||||
size="lg"
|
||||
className="bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700 text-white font-medium shadow-lg hover:shadow-xl transition-all duration-200"
|
||||
>
|
||||
Sign In
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Prefetch clients data
|
||||
void api.clients.getAll.prefetch();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between mb-8 gap-4">
|
||||
<div className="mb-8 flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent">Clients</h1>
|
||||
<p className="text-gray-600 mt-1 text-lg">Manage your clients and their information.</p>
|
||||
<h1 className="bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-3xl font-bold text-transparent">
|
||||
Clients
|
||||
</h1>
|
||||
<p className="mt-1 text-lg text-gray-600">
|
||||
Manage your clients and their information.
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild size="lg" className="bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700 text-white font-medium shadow-lg hover:shadow-xl">
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="bg-gradient-to-r from-emerald-600 to-teal-600 font-medium text-white shadow-lg hover:from-emerald-700 hover:to-teal-700 hover:shadow-xl"
|
||||
>
|
||||
<Link href="/dashboard/clients/new">
|
||||
<Plus className="mr-2 h-5 w-5" /> Add Client
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<HydrateClient>
|
||||
<UniversalTable resource="clients" />
|
||||
<ClientsTable />
|
||||
</HydrateClient>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { InvoiceView } from "~/components/invoice-view";
|
||||
import { InvoiceForm } from "~/components/invoice-form";
|
||||
|
||||
interface UnifiedInvoicePageProps {
|
||||
invoiceId: string;
|
||||
mode: string;
|
||||
}
|
||||
|
||||
export function UnifiedInvoicePage({
|
||||
invoiceId,
|
||||
mode,
|
||||
}: UnifiedInvoicePageProps) {
|
||||
return (
|
||||
<div>
|
||||
{/* Always render InvoiceForm to preserve state, but hide when in view mode */}
|
||||
<div className={mode === "edit" ? "block" : "hidden"}>
|
||||
<InvoiceForm invoiceId={invoiceId} />
|
||||
</div>
|
||||
|
||||
{/* Show InvoiceView only when in view mode */}
|
||||
{mode === "view" && <InvoiceView invoiceId={invoiceId} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { auth } from "~/server/auth";
|
||||
import { api, HydrateClient } from "~/trpc/server";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { InvoiceForm } from "~/components/invoice-form";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
interface EditInvoicePageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function EditInvoicePage({ params }: EditInvoicePageProps) {
|
||||
const session = await auth();
|
||||
const { id } = await params;
|
||||
|
||||
if (!session?.user) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-emerald-50 via-white to-teal-50">
|
||||
<div className="text-center">
|
||||
<h1 className="text-4xl font-bold mb-4 bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent">Access Denied</h1>
|
||||
<p className="text-muted-foreground mb-8">Please sign in to edit invoices</p>
|
||||
<Link href="/api/auth/signin">
|
||||
<Button
|
||||
size="lg"
|
||||
className="bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700 text-white font-medium shadow-lg hover:shadow-xl transition-all duration-200"
|
||||
>
|
||||
Sign In
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Prefetch invoice data
|
||||
try {
|
||||
await api.invoices.getById.prefetch({ id: id });
|
||||
} catch (error) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent">Edit Invoice</h1>
|
||||
<p className="text-gray-600 mt-1 text-lg">Update the invoice details below.</p>
|
||||
</div>
|
||||
<HydrateClient>
|
||||
<InvoiceForm invoiceId={id} />
|
||||
</HydrateClient>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,63 +1,72 @@
|
||||
import { auth } from "~/server/auth";
|
||||
import { api, HydrateClient } from "~/trpc/server";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { InvoiceView } from "~/components/invoice-view";
|
||||
import { InvoiceForm } from "~/components/invoice-form";
|
||||
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Edit } from "lucide-react";
|
||||
import { Edit, Eye, ArrowLeft } from "lucide-react";
|
||||
import { UnifiedInvoicePage } from "./_components/unified-invoice-page";
|
||||
|
||||
interface InvoicePageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
searchParams: Promise<{ mode?: string }>;
|
||||
}
|
||||
|
||||
export default async function InvoicePage({ params }: InvoicePageProps) {
|
||||
const session = await auth();
|
||||
export default async function InvoicePage({
|
||||
params,
|
||||
searchParams,
|
||||
}: InvoicePageProps) {
|
||||
const { id } = await params;
|
||||
|
||||
if (!session?.user) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-emerald-50 via-white to-teal-50">
|
||||
<div className="text-center">
|
||||
<h1 className="text-4xl font-bold mb-4 bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent">Access Denied</h1>
|
||||
<p className="text-muted-foreground mb-8">Please sign in to view invoices</p>
|
||||
<Link href="/api/auth/signin">
|
||||
<Button
|
||||
size="lg"
|
||||
className="bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700 text-white font-medium shadow-lg hover:shadow-xl transition-all duration-200"
|
||||
>
|
||||
Sign In
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Prefetch invoice data
|
||||
try {
|
||||
await api.invoices.getById.prefetch({ id: id });
|
||||
} catch (error) {
|
||||
notFound();
|
||||
}
|
||||
const { mode = "view" } = await searchParams;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-8 gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent">Invoice Details</h1>
|
||||
<p className="text-gray-600 mt-1 text-lg">View and manage invoice information.</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Button asChild variant="outline" size="lg" className="bg-white/80 border-gray-200 hover:bg-gray-50 text-gray-700 font-medium shadow-lg hover:shadow-xl">
|
||||
<Link href={`/dashboard/invoices/${id}/edit`}>
|
||||
<Edit className="mr-2 h-5 w-5" /> Edit Invoice
|
||||
<div className="mb-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-3xl font-bold text-transparent">
|
||||
Invoice Details
|
||||
</h1>
|
||||
<p className="mt-1 text-lg text-gray-600">
|
||||
View and manage invoice information.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="relative flex rounded-lg border border-gray-200 bg-gray-100 p-1">
|
||||
<div
|
||||
className={`absolute top-1 bottom-1 rounded-md bg-white shadow-sm transition-all duration-300 ease-in-out ${
|
||||
mode === "view" ? "left-1 w-10" : "left-11 w-10"
|
||||
}`}
|
||||
/>
|
||||
<Link
|
||||
href={`/dashboard/invoices/${id}?mode=view`}
|
||||
className={`relative z-10 rounded-md px-3 py-2 transition-all duration-200 ${
|
||||
mode === "view"
|
||||
? "text-emerald-600"
|
||||
: "text-gray-600 hover:bg-gray-50 hover:text-gray-800"
|
||||
}`}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Link
|
||||
href={`/dashboard/invoices/${id}?mode=edit`}
|
||||
className={`relative z-10 rounded-md px-3 py-2 transition-all duration-200 ${
|
||||
mode === "edit"
|
||||
? "text-emerald-600"
|
||||
: "text-gray-600 hover:bg-gray-50 hover:text-gray-800"
|
||||
}`}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<HydrateClient>
|
||||
<UnifiedInvoicePage invoiceId={id} mode={mode} />
|
||||
</HydrateClient>
|
||||
</div>
|
||||
</div>
|
||||
<HydrateClient>
|
||||
<InvoiceView invoiceId={id} />
|
||||
</HydrateClient>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
15
src/app/dashboard/invoices/_components/invoices-table.tsx
Normal file
15
src/app/dashboard/invoices/_components/invoices-table.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { api } from "~/trpc/react";
|
||||
import { UniversalTable } from "~/components/ui/universal-table";
|
||||
import { TableSkeleton } from "~/components/ui/skeleton";
|
||||
|
||||
export function InvoicesTable() {
|
||||
const { isLoading } = api.invoices.getAll.useQuery();
|
||||
|
||||
if (isLoading) {
|
||||
return <TableSkeleton rows={8} />;
|
||||
}
|
||||
|
||||
return <UniversalTable resource="invoices" />;
|
||||
}
|
||||
@@ -1,36 +1,16 @@
|
||||
import Link from "next/link";
|
||||
import { auth } from "~/server/auth";
|
||||
import { api, HydrateClient } from "~/trpc/server";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { HydrateClient } from "~/trpc/server";
|
||||
import { CSVImportPage } from "~/components/csv-import-page";
|
||||
|
||||
export default async function ImportPage() {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-emerald-50 via-white to-teal-50">
|
||||
<div className="text-center">
|
||||
<h1 className="text-4xl font-bold mb-4 bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent">Access Denied</h1>
|
||||
<p className="text-muted-foreground mb-8">Please sign in to import invoices</p>
|
||||
<Link href="/api/auth/signin">
|
||||
<Button
|
||||
size="lg"
|
||||
className="bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700 text-white font-medium shadow-lg hover:shadow-xl transition-all duration-200"
|
||||
>
|
||||
Sign In
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent">Import Invoices</h1>
|
||||
<p className="text-gray-600 mt-1 text-lg">Upload CSV files to create invoices in batch.</p>
|
||||
<h1 className="bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-3xl font-bold text-transparent">
|
||||
Import Invoices
|
||||
</h1>
|
||||
<p className="mt-1 text-lg text-gray-600">
|
||||
Upload CSV files to create invoices in batch.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<HydrateClient>
|
||||
@@ -38,4 +18,4 @@ export default async function ImportPage() {
|
||||
</HydrateClient>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,40 +1,20 @@
|
||||
import { auth } from "~/server/auth";
|
||||
import { HydrateClient } from "~/trpc/server";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import Link from "next/link";
|
||||
import { InvoiceForm } from "~/components/invoice-form";
|
||||
|
||||
export default async function NewInvoicePage() {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-emerald-50 via-white to-teal-50">
|
||||
<div className="text-center">
|
||||
<h1 className="text-4xl font-bold mb-4 bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent">Access Denied</h1>
|
||||
<p className="text-muted-foreground mb-8">Please sign in to create invoices</p>
|
||||
<Link href="/api/auth/signin">
|
||||
<Button
|
||||
size="lg"
|
||||
className="bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700 text-white font-medium shadow-lg hover:shadow-xl transition-all duration-200"
|
||||
>
|
||||
Sign In
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent">Create Invoice</h1>
|
||||
<p className="text-gray-600 mt-1 text-lg">Fill out the details below to create a new invoice.</p>
|
||||
<h1 className="bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-3xl font-bold text-transparent">
|
||||
Create Invoice
|
||||
</h1>
|
||||
<p className="mt-1 text-lg text-gray-600">
|
||||
Fill out the details below to create a new invoice.
|
||||
</p>
|
||||
</div>
|
||||
<HydrateClient>
|
||||
<InvoiceForm />
|
||||
</HydrateClient>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,49 +1,38 @@
|
||||
import Link from "next/link";
|
||||
import { auth } from "~/server/auth";
|
||||
|
||||
import { api, HydrateClient } from "~/trpc/server";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { UniversalTable } from "~/components/ui/universal-table";
|
||||
import { Plus, Upload } from "lucide-react";
|
||||
import { InvoicesTable } from "./_components/invoices-table";
|
||||
|
||||
export default async function InvoicesPage() {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-emerald-50 via-white to-teal-50">
|
||||
<div className="text-center">
|
||||
<h1 className="text-4xl font-bold mb-4 bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent">Access Denied</h1>
|
||||
<p className="text-muted-foreground mb-8">Please sign in to view invoices</p>
|
||||
<Link href="/api/auth/signin">
|
||||
<Button
|
||||
size="lg"
|
||||
className="bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700 text-white font-medium shadow-lg hover:shadow-xl transition-all duration-200"
|
||||
>
|
||||
Sign In
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Prefetch invoices data
|
||||
void api.invoices.getAll.prefetch();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between mb-8 gap-4">
|
||||
<div className="mb-8 flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent">Invoices</h1>
|
||||
<p className="text-gray-600 mt-1 text-lg">Manage your invoices and payments.</p>
|
||||
<h1 className="bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-3xl font-bold text-transparent">
|
||||
Invoices
|
||||
</h1>
|
||||
<p className="mt-1 text-lg text-gray-600">
|
||||
Manage your invoices and payments.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Button asChild variant="outline" size="lg" className="bg-white/80 border-gray-200 hover:bg-gray-50 text-gray-700 font-medium shadow-lg hover:shadow-xl">
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="border-gray-200 bg-white/80 font-medium text-gray-700 shadow-lg hover:bg-gray-50 hover:shadow-xl"
|
||||
>
|
||||
<Link href="/dashboard/invoices/import">
|
||||
<Upload className="mr-2 h-5 w-5" /> Import CSV
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild size="lg" className="bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700 text-white font-medium shadow-lg hover:shadow-xl">
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="bg-gradient-to-r from-emerald-600 to-teal-600 font-medium text-white shadow-lg hover:from-emerald-700 hover:to-teal-700 hover:shadow-xl"
|
||||
>
|
||||
<Link href="/dashboard/invoices/new">
|
||||
<Plus className="mr-2 h-5 w-5" /> Add Invoice
|
||||
</Link>
|
||||
@@ -51,8 +40,8 @@ export default async function InvoicesPage() {
|
||||
</div>
|
||||
</div>
|
||||
<HydrateClient>
|
||||
<UniversalTable resource="invoices" />
|
||||
<InvoicesTable />
|
||||
</HydrateClient>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,15 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
||||
<>
|
||||
<Navbar />
|
||||
<Sidebar />
|
||||
<main className="min-h-screen pt-24 ml-70">
|
||||
{/* Mobile layout - no left margin */}
|
||||
<main className="min-h-screen pt-24 md:hidden">
|
||||
<div className="px-4 sm:px-6 pt-4 pb-6">
|
||||
<DashboardBreadcrumbs />
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
{/* Desktop layout - with sidebar margin */}
|
||||
<main className="min-h-screen pt-24 hidden md:block ml-70">
|
||||
<div className="px-8 pt-6 pb-6">
|
||||
<DashboardBreadcrumbs />
|
||||
{children}
|
||||
|
||||
@@ -1,191 +1,31 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "~/server/auth";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Users,
|
||||
FileText,
|
||||
TrendingUp,
|
||||
Calendar,
|
||||
Plus,
|
||||
ArrowRight
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { api, HydrateClient } from "~/trpc/server";
|
||||
import {
|
||||
DashboardStats,
|
||||
DashboardCards,
|
||||
DashboardActivity,
|
||||
} from "./_components/dashboard-components";
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
redirect("/auth/signin");
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-4xl font-bold bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent">
|
||||
Welcome back, {session.user.name?.split(" ")[0] ?? "User"}!
|
||||
<h1 className="bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-4xl font-bold text-transparent">
|
||||
Welcome back, {session?.user?.name?.split(" ")[0] ?? "User"}!
|
||||
</h1>
|
||||
<p className="text-gray-600 mt-2 text-lg">
|
||||
<p className="mt-2 text-lg text-gray-600">
|
||||
Here's what's happening with your invoicing business
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm hover:shadow-2xl transition-all duration-300">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-700">Total Clients</CardTitle>
|
||||
<div className="p-2 bg-emerald-100 rounded-lg">
|
||||
<Users className="h-4 w-4 text-emerald-600" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-emerald-600">0</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
+0 from last month
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm hover:shadow-2xl transition-all duration-300">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-700">Total Invoices</CardTitle>
|
||||
<div className="p-2 bg-blue-100 rounded-lg">
|
||||
<FileText className="h-4 w-4 text-blue-600" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-blue-600">0</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
+0 from last month
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm hover:shadow-2xl transition-all duration-300">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-700">Revenue</CardTitle>
|
||||
<div className="p-2 bg-teal-100 rounded-lg">
|
||||
<TrendingUp className="h-4 w-4 text-teal-600" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-teal-600">$0</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
+0% from last month
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm hover:shadow-2xl transition-all duration-300">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-700">Pending Invoices</CardTitle>
|
||||
<div className="p-2 bg-orange-100 rounded-lg">
|
||||
<Calendar className="h-4 w-4 text-orange-600" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-orange-600">0</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
Due this month
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
|
||||
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm hover:shadow-2xl transition-all duration-300">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-emerald-700">
|
||||
<div className="p-2 bg-emerald-100 rounded-lg">
|
||||
<Users className="h-5 w-5" />
|
||||
</div>
|
||||
Manage Clients
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-gray-600">
|
||||
Add new clients and manage your existing client relationships.
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
asChild
|
||||
className="bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700 text-white font-medium shadow-lg hover:shadow-xl transition-all duration-200"
|
||||
>
|
||||
<Link href="/dashboard/clients/new">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Client
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
asChild
|
||||
className="border-gray-300 text-gray-700 hover:bg-gray-50 font-medium"
|
||||
>
|
||||
<Link href="/dashboard/clients">
|
||||
View All Clients
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm hover:shadow-2xl transition-all duration-300">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-emerald-700">
|
||||
<div className="p-2 bg-emerald-100 rounded-lg">
|
||||
<FileText className="h-5 w-5" />
|
||||
</div>
|
||||
Create Invoices
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-gray-600">
|
||||
Generate professional invoices and track payments.
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
asChild
|
||||
className="bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700 text-white font-medium shadow-lg hover:shadow-xl transition-all duration-200"
|
||||
>
|
||||
<Link href="/dashboard/invoices/new">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
New Invoice
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
asChild
|
||||
className="border-gray-300 text-gray-700 hover:bg-gray-50 font-medium"
|
||||
>
|
||||
<Link href="/dashboard/invoices">
|
||||
View All Invoices
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Recent Activity */}
|
||||
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-emerald-700">Recent Activity</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<div className="p-4 bg-gray-100 rounded-full w-20 h-20 mx-auto mb-4 flex items-center justify-center">
|
||||
<FileText className="h-8 w-8 text-gray-400" />
|
||||
</div>
|
||||
<p className="text-lg font-medium mb-2">No recent activity</p>
|
||||
<p className="text-sm">Start by adding your first client or creating an invoice</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<HydrateClient>
|
||||
<DashboardStats />
|
||||
<DashboardCards />
|
||||
<DashboardActivity />
|
||||
</HydrateClient>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
20
src/app/invoices/layout.tsx
Normal file
20
src/app/invoices/layout.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Navbar } from "~/components/Navbar";
|
||||
import { Sidebar } from "~/components/Sidebar";
|
||||
|
||||
export default function InvoicesLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<div className="flex">
|
||||
<Sidebar />
|
||||
<main className="flex-1 min-h-screen bg-background">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
42
src/app/invoices/page.tsx
Normal file
42
src/app/invoices/page.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import Link from "next/link";
|
||||
import { auth } from "~/server/auth";
|
||||
import { api, HydrateClient } from "~/trpc/server";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { InvoiceList } from "~/components/invoice-list";
|
||||
import { Plus } from "lucide-react";
|
||||
|
||||
export default async function InvoicesPage() {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-4xl font-bold mb-4">Access Denied</h1>
|
||||
<p className="text-muted-foreground mb-8">Please sign in to view invoices</p>
|
||||
<Link href="/api/auth/signin">
|
||||
<Button size="lg">Sign In</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Prefetch invoices data
|
||||
void api.invoices.getAll.prefetch();
|
||||
|
||||
return (
|
||||
<HydrateClient>
|
||||
<div className="p-6">
|
||||
<div className="mb-8">
|
||||
<h2 className="text-3xl font-bold mb-2">Invoices</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Manage your invoices and payments
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<InvoiceList />
|
||||
</div>
|
||||
</HydrateClient>
|
||||
);
|
||||
}
|
||||
@@ -3,29 +3,31 @@ import Link from "next/link";
|
||||
import { useSession, signOut } from "next-auth/react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Logo } from "./logo";
|
||||
import { SidebarTrigger } from "./SidebarTrigger";
|
||||
|
||||
export function Navbar() {
|
||||
const { data: session } = useSession();
|
||||
return (
|
||||
<header className="fixed top-6 left-6 right-6 z-30">
|
||||
<header className="fixed top-4 left-4 right-4 md:top-6 md:left-6 md:right-6 z-30">
|
||||
<div className="bg-white/60 backdrop-blur-md shadow-2xl rounded-xl border-0">
|
||||
<div className="flex h-16 items-center justify-between px-8">
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex h-14 md:h-16 items-center justify-between px-4 md:px-8">
|
||||
<div className="flex items-center gap-4 md:gap-6">
|
||||
<SidebarTrigger />
|
||||
<Link href="/dashboard" className="flex items-center gap-2">
|
||||
<Logo size="md" />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2 md:gap-4">
|
||||
{session?.user ? (
|
||||
<>
|
||||
<span className="text-sm text-gray-700 hidden sm:inline font-medium">
|
||||
<span className="text-xs md:text-sm text-gray-700 hidden sm:inline font-medium">
|
||||
{session.user.name ?? session.user.email}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => signOut({ callbackUrl: "/" })}
|
||||
className="border-gray-300 text-gray-700 hover:bg-gray-50"
|
||||
className="border-gray-300 text-gray-700 hover:bg-gray-50 text-xs md:text-sm"
|
||||
>
|
||||
Sign Out
|
||||
</Button>
|
||||
@@ -36,7 +38,7 @@ export function Navbar() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-gray-700 hover:bg-gray-100"
|
||||
className="text-gray-700 hover:bg-gray-100 text-xs md:text-sm"
|
||||
>
|
||||
Sign In
|
||||
</Button>
|
||||
@@ -44,7 +46,7 @@ export function Navbar() {
|
||||
<Link href="/auth/register">
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700 text-white font-medium"
|
||||
className="bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700 text-white font-medium text-xs md:text-sm"
|
||||
>
|
||||
Register
|
||||
</Button>
|
||||
|
||||
@@ -2,72 +2,19 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Sheet, SheetContent, SheetTrigger } from "~/components/ui/sheet";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { MenuIcon, Settings, LayoutDashboard, Users, FileText } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Settings, LayoutDashboard, Users, FileText, Building } from "lucide-react";
|
||||
|
||||
const navLinks = [
|
||||
{ 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 },
|
||||
];
|
||||
|
||||
export function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile trigger */}
|
||||
<div className="md:hidden p-2">
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="outline" size="icon" aria-label="Open sidebar">
|
||||
<MenuIcon className="h-5 w-5" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" className="p-0 w-64 bg-white/95 border-0 rounded-r-xl backdrop-blur-sm">
|
||||
<nav className="flex flex-col gap-1 p-4">
|
||||
<div className="mb-2 text-xs font-semibold text-gray-400 tracking-wider uppercase">Main</div>
|
||||
{navLinks.map((link) => {
|
||||
const Icon = link.icon;
|
||||
return (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
aria-current={pathname === link.href ? "page" : undefined}
|
||||
className={`flex items-center gap-3 rounded-lg px-3 py-2 text-base font-medium transition-all duration-200 ${
|
||||
pathname === link.href
|
||||
? "bg-emerald-100 text-emerald-700 shadow-lg"
|
||||
: "text-gray-700 hover:bg-gray-100"
|
||||
}`}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
{link.name}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
<div className="border-t border-gray-200 my-4" />
|
||||
<div className="mb-2 text-xs font-semibold text-gray-400 tracking-wider uppercase">Account</div>
|
||||
<Link
|
||||
href="/dashboard/settings"
|
||||
className={`flex items-center gap-3 rounded-lg px-3 py-2 text-base font-medium transition-all duration-200 ${
|
||||
pathname === "/dashboard/settings"
|
||||
? "bg-emerald-100 text-emerald-700 shadow-lg"
|
||||
: "text-gray-700 hover:bg-gray-100"
|
||||
}`}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<Settings className="h-5 w-5" />
|
||||
Settings
|
||||
</Link>
|
||||
</nav>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
{/* Desktop sidebar */}
|
||||
<aside className="hidden md:flex flex-col justify-between fixed left-6 top-28 bottom-6 w-64 z-20 bg-white/60 backdrop-blur-md shadow-2xl rounded-xl border-0 p-8">
|
||||
<nav className="flex flex-col gap-1">
|
||||
<div className="mb-2 text-xs font-semibold text-gray-400 tracking-wider uppercase">Main</div>
|
||||
@@ -106,6 +53,5 @@ export function Sidebar() {
|
||||
</Link>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
81
src/components/SidebarTrigger.tsx
Normal file
81
src/components/SidebarTrigger.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import { Sheet, SheetContent, SheetTrigger, SheetHeader, SheetTitle } from "~/components/ui/sheet";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { MenuIcon, Settings, LayoutDashboard, Users, FileText } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
const navLinks = [
|
||||
{ name: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
|
||||
{ name: "Clients", href: "/dashboard/clients", icon: Users },
|
||||
{ name: "Invoices", href: "/dashboard/invoices", icon: FileText },
|
||||
];
|
||||
|
||||
export function SidebarTrigger() {
|
||||
const pathname = usePathname();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label="Open sidebar"
|
||||
className="md:hidden bg-white/80 backdrop-blur-sm border-gray-200 shadow-lg hover:bg-white h-8 w-8"
|
||||
>
|
||||
<MenuIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent
|
||||
side="left"
|
||||
className="p-0 w-80 max-w-[85vw] bg-white/95 border-0 backdrop-blur-sm"
|
||||
>
|
||||
<SheetHeader className="p-4 border-b border-gray-200">
|
||||
<SheetTitle>Navigation</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 flex flex-col gap-1 p-4">
|
||||
<div className="mb-2 text-xs font-semibold text-gray-400 tracking-wider uppercase">Main</div>
|
||||
{navLinks.map((link) => {
|
||||
const Icon = link.icon;
|
||||
return (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
aria-current={pathname === link.href ? "page" : undefined}
|
||||
className={`flex items-center gap-3 rounded-lg px-3 py-3 text-base font-medium transition-all duration-200 ${
|
||||
pathname === link.href
|
||||
? "bg-emerald-100 text-emerald-700 shadow-lg"
|
||||
: "text-gray-700 hover:bg-gray-100"
|
||||
}`}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
{link.name}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="border-t border-gray-200 my-4" />
|
||||
<div className="mb-2 text-xs font-semibold text-gray-400 tracking-wider uppercase">Account</div>
|
||||
<Link
|
||||
href="/dashboard/settings"
|
||||
className={`flex items-center gap-3 rounded-lg px-3 py-3 text-base font-medium transition-all duration-200 ${
|
||||
pathname === "/dashboard/settings"
|
||||
? "bg-emerald-100 text-emerald-700 shadow-lg"
|
||||
: "text-gray-700 hover:bg-gray-100"
|
||||
}`}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<Settings className="h-5 w-5" />
|
||||
Settings
|
||||
</Link>
|
||||
</nav>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
454
src/components/business-form.tsx
Normal file
454
src/components/business-form.tsx
Normal file
@@ -0,0 +1,454 @@
|
||||
"use client";
|
||||
|
||||
import { Building, Mail, MapPin, Phone, Save, Globe, BadgeDollarSign, Image, Star } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent } from "~/components/ui/card";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { SearchableSelect } from "~/components/ui/select";
|
||||
import { FormSkeleton } from "~/components/ui/skeleton";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
interface BusinessFormProps {
|
||||
businessId?: string;
|
||||
mode: "create" | "edit";
|
||||
}
|
||||
|
||||
export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
||||
const router = useRouter();
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
addressLine1: "",
|
||||
addressLine2: "",
|
||||
city: "",
|
||||
state: "",
|
||||
postalCode: "",
|
||||
country: "",
|
||||
website: "",
|
||||
taxId: "",
|
||||
logoUrl: "",
|
||||
isDefault: false,
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Fetch business data if editing
|
||||
const { data: business, isLoading: isLoadingBusiness } = api.businesses.getById.useQuery(
|
||||
{ id: businessId! },
|
||||
{ enabled: mode === "edit" && !!businessId }
|
||||
);
|
||||
|
||||
const createBusiness = api.businesses.create.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Business created successfully");
|
||||
router.push("/dashboard/businesses");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || "Failed to create business");
|
||||
},
|
||||
});
|
||||
|
||||
const updateBusiness = api.businesses.update.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Business updated successfully");
|
||||
router.push("/dashboard/businesses");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || "Failed to update business");
|
||||
},
|
||||
});
|
||||
|
||||
// Load business data when editing
|
||||
useEffect(() => {
|
||||
if (business && mode === "edit") {
|
||||
setFormData({
|
||||
name: business.name,
|
||||
email: business.email ?? "",
|
||||
phone: business.phone ?? "",
|
||||
addressLine1: business.addressLine1 ?? "",
|
||||
addressLine2: business.addressLine2 ?? "",
|
||||
city: business.city ?? "",
|
||||
state: business.state ?? "",
|
||||
postalCode: business.postalCode ?? "",
|
||||
country: business.country ?? "",
|
||||
website: business.website ?? "",
|
||||
taxId: business.taxId ?? "",
|
||||
logoUrl: business.logoUrl ?? "",
|
||||
isDefault: business.isDefault ?? false,
|
||||
});
|
||||
}
|
||||
}, [business, mode]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
try {
|
||||
if (mode === "create") {
|
||||
await createBusiness.mutateAsync(formData);
|
||||
} else {
|
||||
await updateBusiness.mutateAsync({
|
||||
id: businessId!,
|
||||
...formData,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (field: string, value: string | boolean) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
// Phone number formatting (reuse from client-form)
|
||||
const formatPhoneNumber = (value: string) => {
|
||||
const phoneNumber = value.replace(/\D/g, '');
|
||||
if (phoneNumber.length <= 3) {
|
||||
return phoneNumber;
|
||||
} else if (phoneNumber.length <= 6) {
|
||||
return `(${phoneNumber.slice(0, 3)}) ${phoneNumber.slice(3)}`;
|
||||
} else {
|
||||
return `(${phoneNumber.slice(0, 3)}) ${phoneNumber.slice(3, 6)}-${phoneNumber.slice(6, 10)}`;
|
||||
}
|
||||
};
|
||||
|
||||
const handlePhoneChange = (value: string) => {
|
||||
const formatted = formatPhoneNumber(value);
|
||||
handleInputChange("phone", formatted);
|
||||
};
|
||||
|
||||
// US States and Countries (reuse from client-form)
|
||||
const US_STATES = [
|
||||
{ value: "__placeholder__", label: "Select State" },
|
||||
{ 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" }
|
||||
];
|
||||
|
||||
const MOST_USED_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" }
|
||||
];
|
||||
|
||||
const ALL_COUNTRIES = [
|
||||
"Afghanistan", "Albania", "Algeria", "Andorra", "Angola", "Antigua and Barbuda", "Argentina", "Armenia", "Australia", "Austria", "Azerbaijan", "Bahamas", "Bahrain", "Bangladesh", "Barbados", "Belarus", "Belgium", "Belize", "Benin", "Bhutan", "Bolivia", "Bosnia and Herzegovina", "Botswana", "Brazil", "Brunei", "Bulgaria", "Burkina Faso", "Burundi", "Cabo Verde", "Cambodia", "Cameroon", "Canada", "Central African Republic", "Chad", "Chile", "China", "Colombia", "Comoros", "Congo", "Costa Rica", "Croatia", "Cuba", "Cyprus", "Czech Republic", "Democratic Republic of the Congo", "Denmark", "Djibouti", "Dominica", "Dominican Republic", "East Timor", "Ecuador", "Egypt", "El Salvador", "Equatorial Guinea", "Eritrea", "Estonia", "Eswatini", "Ethiopia", "Fiji", "Finland", "France", "Gabon", "Gambia", "Georgia", "Germany", "Ghana", "Greece", "Grenada", "Guatemala", "Guinea", "Guinea-Bissau", "Guyana", "Haiti", "Honduras", "Hungary", "Iceland", "India", "Indonesia", "Iran", "Iraq", "Ireland", "Israel", "Italy", "Ivory Coast", "Jamaica", "Japan", "Jordan", "Kazakhstan", "Kenya", "Kiribati", "Kuwait", "Kyrgyzstan", "Laos", "Latvia", "Lebanon", "Lesotho", "Liberia", "Libya", "Liechtenstein", "Lithuania", "Luxembourg", "Madagascar", "Malawi", "Malaysia", "Maldives", "Mali", "Malta", "Marshall Islands", "Mauritania", "Mauritius", "Mexico", "Micronesia", "Moldova", "Monaco", "Mongolia", "Montenegro", "Morocco", "Mozambique", "Myanmar", "Namibia", "Nauru", "Nepal", "Netherlands", "New Zealand", "Nicaragua", "Niger", "Nigeria", "North Korea", "North Macedonia", "Norway", "Oman", "Pakistan", "Palau", "Palestine", "Panama", "Papua New Guinea", "Paraguay", "Peru", "Philippines", "Poland", "Portugal", "Qatar", "Romania", "Russia", "Rwanda", "Saint Kitts and Nevis", "Saint Lucia", "Saint Vincent and the Grenadines", "Samoa", "San Marino", "Sao Tome and Principe", "Saudi Arabia", "Senegal", "Serbia", "Seychelles", "Sierra Leone", "Singapore", "Slovakia", "Slovenia", "Solomon Islands", "Somalia", "South Africa", "South Korea", "South Sudan", "Spain", "Sri Lanka", "Sudan", "Suriname", "Sweden", "Switzerland", "Syria", "Taiwan", "Tajikistan", "Tanzania", "Thailand", "Togo", "Tonga", "Trinidad and Tobago", "Tunisia", "Turkey", "Turkmenistan", "Tuvalu", "Uganda", "Ukraine", "United Arab Emirates", "United Kingdom", "United States", "Uruguay", "Uzbekistan", "Vanuatu", "Vatican City", "Venezuela", "Vietnam", "Yemen", "Zambia", "Zimbabwe"
|
||||
];
|
||||
|
||||
const OTHER_COUNTRIES = ALL_COUNTRIES
|
||||
.filter(c => !MOST_USED_COUNTRIES.some(mc => mc.value === c))
|
||||
.map(country => ({ value: country, label: country }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label));
|
||||
|
||||
const ALL_COUNTRIES_OPTIONS = [
|
||||
{ value: "__placeholder__", label: "Select country" },
|
||||
...MOST_USED_COUNTRIES,
|
||||
...OTHER_COUNTRIES
|
||||
];
|
||||
|
||||
if (mode === "edit" && isLoadingBusiness) {
|
||||
return (
|
||||
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm w-full my-8 px-0">
|
||||
<CardContent className="p-8">
|
||||
<FormSkeleton />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm w-full my-8 px-0">
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-8">
|
||||
{/* Basic Information Section */}
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center space-x-2 text-emerald-700">
|
||||
<Building className="h-5 w-5" />
|
||||
<h3 className="text-lg font-semibold">Business Information</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name" className="text-sm font-medium text-gray-700">
|
||||
Business Name *
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => handleInputChange("name", e.target.value)}
|
||||
required
|
||||
placeholder="Enter business name"
|
||||
className="h-12 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email" className="text-sm font-medium text-gray-700">
|
||||
Email Address
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => handleInputChange("email", e.target.value)}
|
||||
placeholder="business@example.com"
|
||||
className="h-12 pl-10 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contact Information Section */}
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center space-x-2 text-emerald-700">
|
||||
<Phone className="h-5 w-5" />
|
||||
<h3 className="text-lg font-semibold">Contact Information</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone" className="text-sm font-medium text-gray-700">
|
||||
Phone Number
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Phone className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
id="phone"
|
||||
type="tel"
|
||||
value={formData.phone}
|
||||
onChange={(e) => handlePhoneChange(e.target.value)}
|
||||
placeholder="(555) 123-4567"
|
||||
className="h-12 pl-10 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="website" className="text-sm font-medium text-gray-700">
|
||||
Website
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Globe className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
id="website"
|
||||
type="url"
|
||||
value={formData.website}
|
||||
onChange={(e) => handleInputChange("website", e.target.value)}
|
||||
placeholder="https://yourbusiness.com"
|
||||
className="h-12 pl-10 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Address Section */}
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center space-x-2 text-emerald-700">
|
||||
<MapPin className="h-5 w-5" />
|
||||
<h3 className="text-lg font-semibold">Address</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="addressLine1" className="text-sm font-medium text-gray-700">
|
||||
Address Line 1
|
||||
</Label>
|
||||
<Input
|
||||
id="addressLine1"
|
||||
value={formData.addressLine1}
|
||||
onChange={(e) => handleInputChange("addressLine1", e.target.value)}
|
||||
placeholder="123 Main St"
|
||||
className="h-12 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="addressLine2" className="text-sm font-medium text-gray-700">
|
||||
Address Line 2
|
||||
</Label>
|
||||
<Input
|
||||
id="addressLine2"
|
||||
value={formData.addressLine2}
|
||||
onChange={(e) => handleInputChange("addressLine2", e.target.value)}
|
||||
placeholder="Suite 100"
|
||||
className="h-12 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="city" className="text-sm font-medium text-gray-700">
|
||||
City
|
||||
</Label>
|
||||
<Input
|
||||
id="city"
|
||||
value={formData.city}
|
||||
onChange={(e) => handleInputChange("city", e.target.value)}
|
||||
placeholder="City"
|
||||
className="h-12 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="state" className="text-sm font-medium text-gray-700">
|
||||
State/Province
|
||||
</Label>
|
||||
<SearchableSelect
|
||||
value={formData.state}
|
||||
onValueChange={(value) => handleInputChange("state", value)}
|
||||
options={US_STATES}
|
||||
placeholder="Select State"
|
||||
searchPlaceholder="Search states..."
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="postalCode" className="text-sm font-medium text-gray-700">
|
||||
Postal Code
|
||||
</Label>
|
||||
<Input
|
||||
id="postalCode"
|
||||
value={formData.postalCode}
|
||||
onChange={(e) => handleInputChange("postalCode", e.target.value)}
|
||||
placeholder="ZIP or postal code"
|
||||
className="h-12 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="country" className="text-sm font-medium text-gray-700">
|
||||
Country
|
||||
</Label>
|
||||
<SearchableSelect
|
||||
value={formData.country}
|
||||
onValueChange={(value) => handleInputChange("country", value)}
|
||||
options={ALL_COUNTRIES_OPTIONS}
|
||||
placeholder="Select country"
|
||||
searchPlaceholder="Search countries..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tax, Logo, Default Section */}
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center space-x-2 text-emerald-700">
|
||||
<BadgeDollarSign className="h-5 w-5" />
|
||||
<h3 className="text-lg font-semibold">Other Details</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="taxId" className="text-sm font-medium text-gray-700">
|
||||
Tax ID / VAT Number
|
||||
</Label>
|
||||
<Input
|
||||
id="taxId"
|
||||
value={formData.taxId}
|
||||
onChange={(e) => handleInputChange("taxId", e.target.value)}
|
||||
placeholder="Tax ID or VAT number"
|
||||
className="h-12 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="logoUrl" className="text-sm font-medium text-gray-700">
|
||||
Logo URL
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Image className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
id="logoUrl"
|
||||
value={formData.logoUrl}
|
||||
onChange={(e) => handleInputChange("logoUrl", e.target.value)}
|
||||
placeholder="https://yourbusiness.com/logo.png"
|
||||
className="h-12 pl-10 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 mt-4">
|
||||
<input
|
||||
id="isDefault"
|
||||
type="checkbox"
|
||||
checked={formData.isDefault}
|
||||
onChange={(e) => handleInputChange("isDefault", e.target.checked)}
|
||||
className="h-5 w-5 text-emerald-600 border-gray-300 rounded focus:ring-emerald-500"
|
||||
/>
|
||||
<Label htmlFor="isDefault" className="text-sm font-medium text-gray-700 flex items-center">
|
||||
<Star className="h-4 w-4 mr-1 text-yellow-400" /> Set as default business
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-4 mt-8">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
asChild
|
||||
className="border-gray-300"
|
||||
disabled={loading}
|
||||
>
|
||||
<Link href="/dashboard/businesses">Cancel</Link>
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
className="bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700 text-white font-medium shadow-lg hover:shadow-xl"
|
||||
disabled={loading}
|
||||
>
|
||||
<Save className="mr-2 h-5 w-5" />
|
||||
{mode === "create" ? "Create Business" : "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { FormSkeleton } from "~/components/ui/skeleton";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
interface ClientFormProps {
|
||||
@@ -129,17 +130,9 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
|
||||
|
||||
if (mode === "edit" && isLoadingClient) {
|
||||
return (
|
||||
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm">
|
||||
<CardHeader>
|
||||
<CardTitle>Loading client...</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="h-10 bg-muted rounded animate-pulse" />
|
||||
<div className="h-10 bg-muted rounded animate-pulse" />
|
||||
<div className="h-10 bg-muted rounded animate-pulse" />
|
||||
<div className="h-20 bg-muted rounded animate-pulse" />
|
||||
</div>
|
||||
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm w-full my-8 px-0">
|
||||
<CardContent className="p-8">
|
||||
<FormSkeleton />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
@@ -147,25 +140,6 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
|
||||
|
||||
return (
|
||||
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm w-full my-8 px-0">
|
||||
{/* <CardHeader className="text-center pb-8"> */}
|
||||
{/* <div className="flex items-center justify-center space-x-4 mb-4"> */}
|
||||
{/* <Link href="/dashboard/clients">
|
||||
<Button variant="ghost" size="sm" className="hover:bg-white/50">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Clients
|
||||
</Button>
|
||||
</Link> */}
|
||||
{/* </div> */}
|
||||
{/* <CardTitle className="text-3xl font-bold bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent">
|
||||
{mode === "create" ? "Add New Client" : "Edit Client"}
|
||||
</CardTitle> */}
|
||||
{/* <p className="text-muted-foreground mt-2">
|
||||
{mode === "create"
|
||||
? "Create a new client profile with complete contact information"
|
||||
: "Update your client's information"
|
||||
}
|
||||
</p> */}
|
||||
{/* </CardHeader> */}
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-8">
|
||||
{/* Basic Information Section */}
|
||||
@@ -250,7 +224,7 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
|
||||
id="addressLine1"
|
||||
value={formData.addressLine1}
|
||||
onChange={(e) => handleInputChange("addressLine1", e.target.value)}
|
||||
placeholder="Street address, P.O. box, company name"
|
||||
placeholder="123 Main Street"
|
||||
className="h-12 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
@@ -262,10 +236,12 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
|
||||
id="addressLine2"
|
||||
value={formData.addressLine2}
|
||||
onChange={(e) => handleInputChange("addressLine2", e.target.value)}
|
||||
placeholder="Apartment, suite, unit, building, floor, etc."
|
||||
placeholder="Suite 100"
|
||||
className="h-12 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="city" className="text-sm font-medium text-gray-700">
|
||||
City
|
||||
@@ -274,23 +250,24 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
|
||||
id="city"
|
||||
value={formData.city}
|
||||
onChange={(e) => handleInputChange("city", e.target.value)}
|
||||
placeholder="City or town"
|
||||
placeholder="New York"
|
||||
className="h-12 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="state" className="text-sm font-medium text-gray-700">
|
||||
State
|
||||
State / Province
|
||||
</Label>
|
||||
<select
|
||||
id="state"
|
||||
value={formData.state}
|
||||
onChange={e => handleInputChange("state", e.target.value)}
|
||||
className="h-12 w-full rounded-md border border-gray-200 bg-white px-3 py-2 text-gray-700 focus:border-emerald-500 focus:ring-emerald-500"
|
||||
onChange={(e) => handleInputChange("state", e.target.value)}
|
||||
className="h-12 w-full rounded-md border border-gray-200 bg-white px-3 py-2 text-sm focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
|
||||
>
|
||||
<option value="">Select a state</option>
|
||||
{US_STATES.filter(s => s).map(state => (
|
||||
<option key={state} value={state}>{state}</option>
|
||||
{US_STATES.map((state) => (
|
||||
<option key={state} value={state}>
|
||||
{state || "Select State"}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
@@ -302,10 +279,11 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
|
||||
id="postalCode"
|
||||
value={formData.postalCode}
|
||||
onChange={(e) => handleInputChange("postalCode", e.target.value)}
|
||||
placeholder="ZIP or postal code"
|
||||
placeholder="12345"
|
||||
className="h-12 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="country" className="text-sm font-medium text-gray-700">
|
||||
Country
|
||||
@@ -313,41 +291,55 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
|
||||
<select
|
||||
id="country"
|
||||
value={formData.country}
|
||||
onChange={e => handleInputChange("country", e.target.value)}
|
||||
className="h-12 w-full rounded-md border border-gray-200 bg-white px-3 py-2 text-gray-700 focus:border-emerald-500 focus:ring-emerald-500"
|
||||
onChange={(e) => handleInputChange("country", e.target.value)}
|
||||
className="h-12 w-full rounded-md border border-gray-200 bg-white px-3 py-2 text-sm focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
|
||||
>
|
||||
<option value="">Select a country</option>
|
||||
{MOST_USED_COUNTRIES.map(country => (
|
||||
<option key={country} value={country}>{country}</option>
|
||||
<option value="">Select Country</option>
|
||||
<optgroup label="Most Used">
|
||||
{MOST_USED_COUNTRIES.map((country) => (
|
||||
<option key={country} value={country}>
|
||||
{country}
|
||||
</option>
|
||||
))}
|
||||
<option disabled>──────────</option>
|
||||
{OTHER_COUNTRIES.map(country => (
|
||||
<option key={country} value={country}>{country}</option>
|
||||
</optgroup>
|
||||
<optgroup label="All Countries">
|
||||
{OTHER_COUNTRIES.map((country) => (
|
||||
<option key={country} value={country}>
|
||||
{country}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-col sm:flex-row space-y-3 sm:space-y-0 sm:space-x-4 pt-6 border-t border-gray-200">
|
||||
{/* Submit Button */}
|
||||
<div className="flex gap-3 pt-6">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex-1 h-12 bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700 text-white font-medium shadow-lg hover:shadow-xl transition-all duration-200"
|
||||
className="bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700 text-white font-medium shadow-lg hover:shadow-xl transition-all duration-200"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||||
{mode === "create" ? "Creating..." : "Updating..."}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{loading ? "Saving..." : mode === "create" ? "Create Client" : "Update Client"}
|
||||
{mode === "create" ? "Create Client" : "Update Client"}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Link href="/dashboard/clients" className="flex-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full h-12 border-gray-300 text-gray-700 hover:bg-gray-50 font-medium"
|
||||
onClick={() => router.push("/dashboard/clients")}
|
||||
className="border-gray-300 text-gray-700 hover:bg-gray-50 font-medium"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
|
||||
232
src/components/dark-mode-test.tsx
Normal file
232
src/components/dark-mode-test.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import {
|
||||
Sun,
|
||||
Moon,
|
||||
Palette,
|
||||
Check,
|
||||
X,
|
||||
Info,
|
||||
AlertCircle,
|
||||
Settings,
|
||||
User,
|
||||
Mail
|
||||
} from "lucide-react";
|
||||
|
||||
export function DarkModeTest() {
|
||||
return (
|
||||
<div className="min-h-screen p-8 space-y-8">
|
||||
{/* Header */}
|
||||
<div className="text-center space-y-4">
|
||||
<h1 className="text-4xl font-bold text-gray-900 dark:text-white">
|
||||
Dark Mode Test Suite
|
||||
</h1>
|
||||
<p className="text-lg text-gray-600 dark:text-gray-300">
|
||||
Testing media query-based dark mode implementation
|
||||
</p>
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
<Sun className="h-4 w-4" />
|
||||
<span>Light Mode</span>
|
||||
</div>
|
||||
<div className="h-4 w-px bg-gray-300 dark:bg-gray-600" />
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
<Moon className="h-4 w-4" />
|
||||
<span>Dark Mode (Auto)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-8 md:grid-cols-2 lg:grid-cols-3">
|
||||
{/* Color Test Card */}
|
||||
<Card className="dark:bg-gray-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Palette className="h-5 w-5" />
|
||||
Color Tests
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Text Colors:</p>
|
||||
<div className="text-gray-900 dark:text-white">Primary Text</div>
|
||||
<div className="text-gray-700 dark:text-gray-300">Secondary Text</div>
|
||||
<div className="text-gray-500 dark:text-gray-400">Muted Text</div>
|
||||
<div className="text-green-600 dark:text-green-400">Success Text</div>
|
||||
<div className="text-red-600 dark:text-red-400">Error Text</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Button Test Card */}
|
||||
<Card className="dark:bg-gray-800">
|
||||
<CardHeader>
|
||||
<CardTitle>Button Variants</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm">Default</Button>
|
||||
<Button variant="secondary" size="sm">Secondary</Button>
|
||||
<Button variant="outline" size="sm">Outline</Button>
|
||||
<Button variant="ghost" size="sm">Ghost</Button>
|
||||
<Button variant="destructive" size="sm">Destructive</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Form Elements Card */}
|
||||
<Card className="dark:bg-gray-800">
|
||||
<CardHeader>
|
||||
<CardTitle>Form Elements</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="test-input">Test Input</Label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute top-3 left-3 h-4 w-4 text-gray-400 dark:text-gray-500" />
|
||||
<Input
|
||||
id="test-input"
|
||||
placeholder="Enter text here..."
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="test-select">Test Select</Label>
|
||||
<select
|
||||
id="test-select"
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 dark:bg-input/30"
|
||||
>
|
||||
<option value="">Select an option</option>
|
||||
<option value="1">Option 1</option>
|
||||
<option value="2">Option 2</option>
|
||||
</select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Status Badges Card */}
|
||||
<Card className="dark:bg-gray-800">
|
||||
<CardHeader>
|
||||
<CardTitle>Status Indicators</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="default">Default</Badge>
|
||||
<Badge variant="secondary">Secondary</Badge>
|
||||
<Badge variant="destructive">Error</Badge>
|
||||
<Badge variant="outline">Outline</Badge>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
<span className="text-gray-700 dark:text-gray-300">Success Status</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<X className="h-4 w-4 text-red-500" />
|
||||
<span className="text-gray-700 dark:text-gray-300">Error Status</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Info className="h-4 w-4 text-blue-500" />
|
||||
<span className="text-gray-700 dark:text-gray-300">Info Status</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<AlertCircle className="h-4 w-4 text-yellow-500" />
|
||||
<span className="text-gray-700 dark:text-gray-300">Warning Status</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Background Test Card */}
|
||||
<Card className="dark:bg-gray-800">
|
||||
<CardHeader>
|
||||
<CardTitle>Background Tests</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<div className="p-3 bg-gray-50 dark:bg-gray-700 rounded-md">
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300">Light Background</p>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-100 dark:bg-gray-600 rounded-md">
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300">Medium Background</p>
|
||||
</div>
|
||||
<div className="p-3 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-md">
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300">Card Background</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Icon Test Card */}
|
||||
<Card className="dark:bg-gray-800">
|
||||
<CardHeader>
|
||||
<CardTitle>Icon Colors</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<User className="h-6 w-6 text-gray-700 dark:text-gray-300" />
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">Default</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<Settings className="h-6 w-6 text-green-600 dark:text-green-400" />
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">Success</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<AlertCircle className="h-6 w-6 text-red-600 dark:text-red-400" />
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">Error</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<Info className="h-6 w-6 text-blue-600 dark:text-blue-400" />
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">Info</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* System Information */}
|
||||
<Card className="dark:bg-gray-800">
|
||||
<CardHeader>
|
||||
<CardTitle>System Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">Dark Mode Method:</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Media Query (@media (prefers-color-scheme: dark))</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">Tailwind Config:</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">darkMode: "media"</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">CSS Variables:</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">oklch() color space</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Instructions */}
|
||||
<Card className="border-blue-200 dark:border-blue-800 dark:bg-gray-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-blue-700 dark:text-blue-300">Testing Instructions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm text-blue-600 dark:text-blue-400">
|
||||
<p>• Change your system theme between light and dark to test automatic switching</p>
|
||||
<p>• All UI elements should adapt colors automatically</p>
|
||||
<p>• Text should remain readable in both modes</p>
|
||||
<p>• Icons and buttons should have appropriate contrast</p>
|
||||
<p>• Form elements should be clearly visible and functional</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
143
src/components/dark-mode-toggle.tsx
Normal file
143
src/components/dark-mode-toggle.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Sun, Moon, Monitor } from "lucide-react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
|
||||
type Theme = "light" | "dark" | "system";
|
||||
|
||||
export function DarkModeToggle() {
|
||||
const [theme, setTheme] = useState<Theme>("system");
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
|
||||
// Get stored theme preference or default to system
|
||||
const storedTheme = localStorage.getItem("theme") as Theme | null;
|
||||
setTheme(storedTheme || "system");
|
||||
|
||||
// Listen for system preference changes when using system theme
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const handleSystemChange = () => {
|
||||
const currentTheme = localStorage.getItem("theme");
|
||||
if (!currentTheme || currentTheme === "system") {
|
||||
applyTheme("system");
|
||||
}
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener("change", handleSystemChange);
|
||||
|
||||
return () => {
|
||||
mediaQuery.removeEventListener("change", handleSystemChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const applyTheme = (newTheme: Theme) => {
|
||||
const root = document.documentElement;
|
||||
|
||||
if (newTheme === "light") {
|
||||
root.classList.remove("dark");
|
||||
root.classList.add("light");
|
||||
} else if (newTheme === "dark") {
|
||||
root.classList.remove("light");
|
||||
root.classList.add("dark");
|
||||
} else {
|
||||
// System theme - remove manual classes and let CSS media query handle it
|
||||
root.classList.remove("light", "dark");
|
||||
const systemDark = window.matchMedia(
|
||||
"(prefers-color-scheme: dark)",
|
||||
).matches;
|
||||
if (systemDark) {
|
||||
root.classList.add("dark");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleThemeChange = (newTheme: Theme) => {
|
||||
setTheme(newTheme);
|
||||
|
||||
if (newTheme === "system") {
|
||||
localStorage.removeItem("theme");
|
||||
} else {
|
||||
localStorage.setItem("theme", newTheme);
|
||||
}
|
||||
|
||||
applyTheme(newTheme);
|
||||
};
|
||||
|
||||
// Don't render until mounted to avoid hydration mismatch
|
||||
if (!mounted) {
|
||||
return (
|
||||
<Button variant="ghost" size="icon" className="h-9 w-9">
|
||||
<Monitor className="h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
const getIcon = () => {
|
||||
switch (theme) {
|
||||
case "light":
|
||||
return <Sun className="h-4 w-4" />;
|
||||
case "dark":
|
||||
return <Moon className="h-4 w-4" />;
|
||||
case "system":
|
||||
return <Monitor className="h-4 w-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getLabel = () => {
|
||||
switch (theme) {
|
||||
case "light":
|
||||
return "Light mode";
|
||||
case "dark":
|
||||
return "Dark mode";
|
||||
case "system":
|
||||
return "System theme";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9"
|
||||
aria-label={getLabel()}
|
||||
>
|
||||
{getIcon()}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleThemeChange("light")}
|
||||
className={theme === "light" ? "bg-accent" : ""}
|
||||
>
|
||||
<Sun className="mr-2 h-4 w-4" />
|
||||
Light
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleThemeChange("dark")}
|
||||
className={theme === "dark" ? "bg-accent" : ""}
|
||||
>
|
||||
<Moon className="mr-2 h-4 w-4" />
|
||||
Dark
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleThemeChange("system")}
|
||||
className={theme === "system" ? "bg-accent" : ""}
|
||||
>
|
||||
<Monitor className="mr-2 h-4 w-4" />
|
||||
System
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import Link from "next/link";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import React from "react";
|
||||
import { api } from "~/trpc/react";
|
||||
import { format } from "date-fns";
|
||||
import { Skeleton } from "~/components/ui/skeleton";
|
||||
|
||||
function isUUID(str: string) {
|
||||
return /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(str);
|
||||
@@ -20,11 +22,21 @@ export function DashboardBreadcrumbs() {
|
||||
if (segments[1] === "clients" && segments[2] && isUUID(segments[2])) {
|
||||
clientId = segments[2];
|
||||
}
|
||||
const { data: client } = api.clients.getById.useQuery(
|
||||
const { data: client, isLoading: clientLoading } = api.clients.getById.useQuery(
|
||||
{ id: clientId ?? "" },
|
||||
{ enabled: !!clientId }
|
||||
);
|
||||
|
||||
// Find invoiceId if present
|
||||
let invoiceId: string | undefined = undefined;
|
||||
if (segments[1] === "invoices" && segments[2] && isUUID(segments[2])) {
|
||||
invoiceId = segments[2];
|
||||
}
|
||||
const { data: invoice, isLoading: invoiceLoading } = api.invoices.getById.useQuery(
|
||||
{ id: invoiceId ?? "" },
|
||||
{ enabled: !!invoiceId }
|
||||
);
|
||||
|
||||
// Generate breadcrumb items based on pathname
|
||||
const breadcrumbs = React.useMemo(() => {
|
||||
const items = [];
|
||||
@@ -32,9 +44,16 @@ export function DashboardBreadcrumbs() {
|
||||
const segment = segments[i];
|
||||
const path = `/${segments.slice(0, i + 1).join('/')}`;
|
||||
if (segment === 'dashboard') continue;
|
||||
let label = segment;
|
||||
|
||||
let label: string | React.ReactElement = segment ?? "";
|
||||
if (segment === 'clients') label = 'Clients';
|
||||
if (isUUID(segment ?? "") && client) label = client.name ?? "";
|
||||
if (isUUID(segment ?? "") && clientLoading) label = <Skeleton className="h-5 w-24 inline-block align-middle" />;
|
||||
else if (isUUID(segment ?? "") && client) label = client.name ?? "";
|
||||
if (isUUID(segment ?? "") && invoiceLoading) label = <Skeleton className="h-5 w-24 inline-block align-middle" />;
|
||||
else if (isUUID(segment ?? "") && invoice) {
|
||||
const issueDate = new Date(invoice.issueDate);
|
||||
label = format(issueDate, "MMM dd, yyyy");
|
||||
}
|
||||
if (segment === 'invoices') label = 'Invoices';
|
||||
if (segment === 'new') label = 'New';
|
||||
// Only show 'Edit' if not the last segment
|
||||
@@ -49,29 +68,29 @@ export function DashboardBreadcrumbs() {
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}, [segments, client]);
|
||||
}, [segments, client, invoice, clientLoading, invoiceLoading]);
|
||||
|
||||
if (breadcrumbs.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Breadcrumb className="mb-6">
|
||||
<BreadcrumbList>
|
||||
<Breadcrumb className="mb-4 sm:mb-6">
|
||||
<BreadcrumbList className="flex-wrap">
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href="/dashboard">Dashboard</Link>
|
||||
<Link href="/dashboard" className="text-sm sm:text-base">Dashboard</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
{breadcrumbs.map((crumb) => (
|
||||
<React.Fragment key={crumb.href}>
|
||||
<BreadcrumbSeparator>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
<ChevronRight className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
</BreadcrumbSeparator>
|
||||
<BreadcrumbItem>
|
||||
{crumb.isLast ? (
|
||||
<BreadcrumbPage>{crumb.label}</BreadcrumbPage>
|
||||
<BreadcrumbPage className="text-sm sm:text-base">{crumb.label}</BreadcrumbPage>
|
||||
) : (
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href={crumb.href}>{crumb.label}</Link>
|
||||
<Link href={crumb.href} className="text-sm sm:text-base">{crumb.label}</Link>
|
||||
</BreadcrumbLink>
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
|
||||
282
src/components/editable-invoice-items.tsx
Normal file
282
src/components/editable-invoice-items.tsx
Normal file
@@ -0,0 +1,282 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import {
|
||||
useSortable,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Trash2, GripVertical, CalendarIcon } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { Calendar } from "~/components/ui/calendar";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "~/components/ui/popover";
|
||||
|
||||
interface InvoiceItem {
|
||||
id: string;
|
||||
date: Date;
|
||||
description: string;
|
||||
hours: number;
|
||||
rate: number;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
interface EditableInvoiceItemsProps {
|
||||
items: InvoiceItem[];
|
||||
onItemsChange: (items: InvoiceItem[]) => void;
|
||||
onRemoveItem: (index: number) => void;
|
||||
}
|
||||
|
||||
function SortableItem({
|
||||
item,
|
||||
index,
|
||||
onItemChange,
|
||||
onRemove
|
||||
}: {
|
||||
item: InvoiceItem;
|
||||
index: number;
|
||||
onItemChange: (index: number, field: string, value: any) => void;
|
||||
onRemove: (index: number) => void;
|
||||
}) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: item.id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
const handleItemChange = (field: string, value: any) => {
|
||||
onItemChange(index, field, value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={`grid grid-cols-12 gap-2 items-center p-4 border border-gray-200 rounded-lg hover:border-emerald-300 transition-colors ${
|
||||
isDragging ? "opacity-50 shadow-lg" : ""
|
||||
}`}
|
||||
>
|
||||
{/* Drag Handle */}
|
||||
<div className="col-span-1 flex items-center justify-center h-10">
|
||||
<button
|
||||
type="button"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 cursor-grab active:cursor-grabbing rounded hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Date */}
|
||||
<div className="col-span-2">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-between font-normal h-10 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500 text-sm"
|
||||
>
|
||||
{item.date ? format(item.date, "MMM dd") : "Date"}
|
||||
<CalendarIcon className="h-4 w-4 text-gray-400" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto overflow-hidden p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={item.date}
|
||||
captionLayout="dropdown"
|
||||
onSelect={(selectedDate: Date | undefined) => {
|
||||
handleItemChange("date", selectedDate || new Date())
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="col-span-4">
|
||||
<Input
|
||||
value={item.description}
|
||||
onChange={e => handleItemChange("description", e.target.value)}
|
||||
placeholder="Work description"
|
||||
className="h-10 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Hours */}
|
||||
<div className="col-span-1">
|
||||
<Input
|
||||
type="number"
|
||||
step="0.25"
|
||||
min="0"
|
||||
value={item.hours}
|
||||
onChange={e => handleItemChange("hours", e.target.value)}
|
||||
placeholder="0"
|
||||
className="h-10 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Rate */}
|
||||
<div className="col-span-2">
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={item.rate}
|
||||
onChange={e => handleItemChange("rate", e.target.value)}
|
||||
placeholder="0.00"
|
||||
className="h-10 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Amount */}
|
||||
<div className="col-span-1">
|
||||
<div className="h-10 flex items-center px-3 border border-gray-200 rounded-md bg-gray-50 text-gray-700 font-medium">
|
||||
${item.amount.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Remove Button */}
|
||||
<div className="col-span-1">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => onRemove(index)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-10 w-10 p-0 border-red-200 text-red-700 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EditableInvoiceItems({ items, onItemsChange, onRemoveItem }: EditableInvoiceItemsProps) {
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsClient(true);
|
||||
}, []);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
);
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (active.id !== over?.id) {
|
||||
const oldIndex = items.findIndex(item => item.id === active.id);
|
||||
const newIndex = items.findIndex(item => item.id === over?.id);
|
||||
|
||||
const newItems = arrayMove(items, oldIndex, newIndex);
|
||||
onItemsChange(newItems);
|
||||
}
|
||||
};
|
||||
|
||||
const handleItemChange = (index: number, field: string, value: any) => {
|
||||
const newItems = [...items];
|
||||
if (field === "hours" || field === "rate") {
|
||||
if (newItems[index]) {
|
||||
newItems[index][field as "hours" | "rate"] = parseFloat(value) || 0;
|
||||
newItems[index].amount = newItems[index].hours * newItems[index].rate;
|
||||
}
|
||||
} else if (field === "date") {
|
||||
if (newItems[index]) {
|
||||
newItems[index][field as "date"] = value;
|
||||
}
|
||||
} else {
|
||||
if (newItems[index]) {
|
||||
newItems[index][field as "description"] = value;
|
||||
}
|
||||
}
|
||||
onItemsChange(newItems);
|
||||
};
|
||||
|
||||
// Show skeleton loading on server-side
|
||||
if (!isClient) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{items.map((item, index) => (
|
||||
<div key={item.id} className="grid grid-cols-12 gap-2 items-center p-4 border border-gray-200 rounded-lg animate-pulse">
|
||||
<div className="col-span-1 flex items-center justify-center h-10">
|
||||
<div className="w-4 h-4 bg-gray-300 rounded"></div>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<div className="h-10 bg-gray-300 rounded"></div>
|
||||
</div>
|
||||
<div className="col-span-4">
|
||||
<div className="h-10 bg-gray-300 rounded"></div>
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<div className="h-10 bg-gray-300 rounded"></div>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<div className="h-10 bg-gray-300 rounded"></div>
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<div className="h-10 bg-gray-300 rounded"></div>
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<div className="h-10 w-10 bg-gray-300 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext items={items.map(item => item.id)} strategy={verticalListSortingStrategy}>
|
||||
<div className="space-y-3">
|
||||
{items.map((item, index) => (
|
||||
<SortableItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
index={index}
|
||||
onItemChange={handleItemChange}
|
||||
onRemove={onRemoveItem}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
@@ -3,17 +3,41 @@
|
||||
import * as React from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { api } from "~/trpc/react";
|
||||
import { Card, CardContent } from "~/components/ui/card";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { DatePicker } from "~/components/ui/date-picker";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { SearchableSelect } from "~/components/ui/select";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select";
|
||||
import { toast } from "sonner";
|
||||
import { Calendar, FileText, User, Plus, Trash2 } from "lucide-react";
|
||||
import {
|
||||
Calendar,
|
||||
FileText,
|
||||
User,
|
||||
Plus,
|
||||
Trash2,
|
||||
DollarSign,
|
||||
Clock,
|
||||
Edit3,
|
||||
Save,
|
||||
X,
|
||||
AlertCircle,
|
||||
Building
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { format } from "date-fns";
|
||||
import { FormSkeleton } from "~/components/ui/skeleton";
|
||||
import { EditableInvoiceItems } from "~/components/editable-invoice-items";
|
||||
|
||||
const STATUS_OPTIONS = ["draft", "sent", "paid", "overdue"];
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: "draft", label: "Draft", color: "bg-gray-100 text-gray-800" },
|
||||
{ value: "sent", label: "Sent", color: "bg-blue-100 text-blue-800" },
|
||||
{ value: "paid", label: "Paid", color: "bg-green-100 text-green-800" },
|
||||
{ value: "overdue", label: "Overdue", color: "bg-red-100 text-red-800" },
|
||||
] as const;
|
||||
|
||||
interface InvoiceFormProps {
|
||||
invoiceId?: string;
|
||||
@@ -23,19 +47,23 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
const router = useRouter();
|
||||
const [formData, setFormData] = useState({
|
||||
invoiceNumber: `INV-${new Date().toISOString().slice(0, 10).replace(/-/g, '')}-${Date.now().toString().slice(-6)}`,
|
||||
businessId: "",
|
||||
clientId: "",
|
||||
issueDate: new Date(),
|
||||
dueDate: new Date(),
|
||||
status: "draft" as "draft" | "sent" | "paid" | "overdue",
|
||||
notes: "",
|
||||
taxRate: 0,
|
||||
items: [
|
||||
{ date: new Date(), description: "", hours: 0, rate: 0, amount: 0 },
|
||||
{ id: crypto.randomUUID(), date: new Date(), description: "", hours: 0, rate: 0, amount: 0 },
|
||||
],
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [defaultRate, setDefaultRate] = useState(0);
|
||||
|
||||
// Fetch clients for dropdown
|
||||
// Fetch clients and businesses for dropdowns
|
||||
const { data: clients, isLoading: loadingClients } = api.clients.getAll.useQuery();
|
||||
const { data: businesses, isLoading: loadingBusinesses } = api.businesses.getAll.useQuery();
|
||||
|
||||
// Fetch existing invoice data if editing
|
||||
const { data: existingInvoice, isLoading: loadingInvoice } = api.invoices.getById.useQuery(
|
||||
@@ -48,49 +76,43 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
if (existingInvoice && invoiceId) {
|
||||
setFormData({
|
||||
invoiceNumber: existingInvoice.invoiceNumber,
|
||||
businessId: existingInvoice.businessId ?? "",
|
||||
clientId: existingInvoice.clientId,
|
||||
issueDate: new Date(existingInvoice.issueDate),
|
||||
dueDate: new Date(existingInvoice.dueDate),
|
||||
status: existingInvoice.status as "draft" | "sent" | "paid" | "overdue",
|
||||
notes: existingInvoice.notes || "",
|
||||
notes: existingInvoice.notes ?? "",
|
||||
taxRate: existingInvoice.taxRate,
|
||||
items: existingInvoice.items?.map(item => ({
|
||||
id: crypto.randomUUID(),
|
||||
date: new Date(item.date),
|
||||
description: item.description,
|
||||
hours: item.hours,
|
||||
rate: item.rate,
|
||||
amount: item.amount,
|
||||
})) || [{ date: new Date(), description: "", hours: 0, rate: 0, amount: 0 }],
|
||||
})) || [{ id: crypto.randomUUID(), date: new Date(), description: "", hours: 0, rate: 0, amount: 0 }],
|
||||
});
|
||||
|
||||
// Set default rate from first item
|
||||
if (existingInvoice.items?.[0]) {
|
||||
setDefaultRate(existingInvoice.items[0].rate);
|
||||
}
|
||||
}
|
||||
}, [existingInvoice, invoiceId]);
|
||||
|
||||
// Calculate total amount
|
||||
const totalAmount = formData.items.reduce(
|
||||
(sum, item) => sum + (item.hours * item.rate),
|
||||
0
|
||||
);
|
||||
// Calculate totals
|
||||
const totals = React.useMemo(() => {
|
||||
const subtotal = formData.items.reduce((sum, item) => sum + (item.hours * item.rate), 0);
|
||||
const taxAmount = (subtotal * formData.taxRate) / 100;
|
||||
const total = subtotal + taxAmount;
|
||||
return {
|
||||
subtotal,
|
||||
taxAmount,
|
||||
total,
|
||||
};
|
||||
}, [formData.items, formData.taxRate]);
|
||||
|
||||
|
||||
// Update item amount on change
|
||||
const handleItemChange = (idx: number, field: string, value: any) => {
|
||||
setFormData((prev) => {
|
||||
const items = [...prev.items];
|
||||
if (field === "hours" || field === "rate") {
|
||||
if (items[idx]) {
|
||||
items[idx][field as "hours" | "rate"] = parseFloat(value) || 0;
|
||||
items[idx].amount = items[idx].hours * items[idx].rate;
|
||||
}
|
||||
} else if (field === "date") {
|
||||
if (items[idx]) {
|
||||
items[idx][field as "date"] = value;
|
||||
}
|
||||
} else {
|
||||
if (items[idx]) {
|
||||
items[idx][field as "description"] = value;
|
||||
}
|
||||
}
|
||||
return { ...prev, items };
|
||||
});
|
||||
};
|
||||
|
||||
// Add new item
|
||||
const addItem = () => {
|
||||
@@ -98,16 +120,30 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
...prev,
|
||||
items: [
|
||||
...prev.items,
|
||||
{ date: new Date(), description: "", hours: 0, rate: 0, amount: 0 },
|
||||
{ id: crypto.randomUUID(), date: new Date(), description: "", hours: 0, rate: defaultRate, amount: 0 },
|
||||
],
|
||||
}));
|
||||
};
|
||||
|
||||
// Remove item
|
||||
const removeItem = (idx: number) => {
|
||||
if (formData.items.length > 1) {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
items: prev.items.filter((_, i) => i !== idx),
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// Apply default rate to all items
|
||||
const applyDefaultRate = () => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
items: prev.items.filter((_, i) => i !== idx),
|
||||
items: prev.items.map(item => ({
|
||||
...item,
|
||||
rate: defaultRate,
|
||||
amount: item.hours * defaultRate,
|
||||
})),
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -135,13 +171,45 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
// Handle form submit
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Validate form
|
||||
if (!formData.businessId) {
|
||||
toast.error("Please select a business");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.clientId) {
|
||||
toast.error("Please select a client");
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.items.some(item => !item.description.trim())) {
|
||||
toast.error("Please fill in all item descriptions");
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.items.some(item => item.hours <= 0)) {
|
||||
toast.error("Please enter valid hours for all items");
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.items.some(item => item.rate <= 0)) {
|
||||
toast.error("Please enter valid rates for all items");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
// In the handleSubmit, ensure items are sent in the current array order with no sorting
|
||||
const submitData = {
|
||||
...formData,
|
||||
items: formData.items.map(item => ({
|
||||
...item,
|
||||
items: formData.items.map((item) => ({
|
||||
date: new Date(item.date),
|
||||
description: item.description,
|
||||
hours: item.hours,
|
||||
rate: item.rate,
|
||||
amount: item.amount,
|
||||
// position will be set by backend based on array order
|
||||
})),
|
||||
};
|
||||
|
||||
@@ -161,40 +229,150 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
// Show loading state while fetching existing invoice data
|
||||
if (invoiceId && loadingInvoice) {
|
||||
return (
|
||||
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm w-full my-8 px-0">
|
||||
<CardContent>
|
||||
<div className="space-y-8">
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center space-x-2 text-emerald-700">
|
||||
<FileText className="h-5 w-5" />
|
||||
<h3 className="text-lg font-semibold">Invoice Details</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="h-12 bg-gray-200 rounded animate-pulse"></div>
|
||||
<div className="h-12 bg-gray-200 rounded animate-pulse"></div>
|
||||
<div className="h-12 bg-gray-200 rounded animate-pulse"></div>
|
||||
<div className="h-12 bg-gray-200 rounded animate-pulse"></div>
|
||||
<div className="h-12 bg-gray-200 rounded animate-pulse"></div>
|
||||
<div className="h-12 bg-gray-200 rounded animate-pulse md:col-span-2"></div>
|
||||
<div className="space-y-6 pb-20">
|
||||
{/* Invoice Details Card Skeleton */}
|
||||
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm">
|
||||
<CardHeader>
|
||||
<div className="h-6 bg-gray-300 rounded w-48 animate-pulse"></div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 lg:gap-6">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<div className="h-4 bg-gray-300 rounded w-24 animate-pulse"></div>
|
||||
<div className="h-10 bg-gray-300 rounded animate-pulse"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Invoice Items Card Skeleton */}
|
||||
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="h-6 bg-gray-300 rounded w-32 animate-pulse"></div>
|
||||
<div className="h-10 bg-gray-300 rounded w-24 animate-pulse"></div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Items Table Header Skeleton */}
|
||||
<div className="grid grid-cols-12 gap-2 px-4 py-3 bg-gray-50 rounded-lg">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="h-4 bg-gray-300 rounded animate-pulse"></div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Items Skeleton */}
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="grid grid-cols-12 gap-2 items-center p-4 border border-gray-200 rounded-lg animate-pulse">
|
||||
{Array.from({ length: 8 }).map((_, j) => (
|
||||
<div key={j} className="h-10 bg-gray-300 rounded"></div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Form Controls Bar Skeleton */}
|
||||
<div className="mt-6">
|
||||
<div className="bg-white/90 rounded-2xl border border-gray-200 shadow-sm p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="h-4 bg-gray-300 rounded w-32 animate-pulse"></div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 bg-gray-300 rounded w-20 animate-pulse"></div>
|
||||
<div className="h-10 bg-gray-300 rounded w-32 animate-pulse"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const selectedClient = clients?.find(c => c.id === formData.clientId);
|
||||
const selectedBusiness = businesses?.find(b => b.id === formData.businessId);
|
||||
|
||||
// Show loading state while fetching clients
|
||||
if (loadingClients) {
|
||||
return (
|
||||
<div className="space-y-6 pb-20">
|
||||
{/* Invoice Details Card Skeleton */}
|
||||
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm">
|
||||
<CardHeader>
|
||||
<div className="h-6 bg-gray-300 rounded w-48 animate-pulse"></div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 lg:gap-6">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<div className="h-4 bg-gray-300 rounded w-24 animate-pulse"></div>
|
||||
<div className="h-10 bg-gray-300 rounded animate-pulse"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Invoice Items Card Skeleton */}
|
||||
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="h-6 bg-gray-300 rounded w-32 animate-pulse"></div>
|
||||
<div className="h-10 bg-gray-300 rounded w-24 animate-pulse"></div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Items Table Header Skeleton */}
|
||||
<div className="grid grid-cols-12 gap-2 px-4 py-3 bg-gray-50 rounded-lg">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="h-4 bg-gray-300 rounded animate-pulse"></div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Items Skeleton */}
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="grid grid-cols-12 gap-2 items-center p-4 border border-gray-200 rounded-lg animate-pulse">
|
||||
{Array.from({ length: 8 }).map((_, j) => (
|
||||
<div key={j} className="h-10 bg-gray-300 rounded"></div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Form Controls Bar Skeleton */}
|
||||
<div className="mt-6">
|
||||
<div className="bg-white/90 rounded-2xl border border-gray-200 shadow-sm p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="h-4 bg-gray-300 rounded w-32 animate-pulse"></div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 bg-gray-300 rounded w-20 animate-pulse"></div>
|
||||
<div className="h-10 bg-gray-300 rounded w-32 animate-pulse"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm w-full my-8 px-0">
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-8">
|
||||
{/* Invoice Details */}
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center space-x-2 text-emerald-700">
|
||||
<form id="invoice-form" onSubmit={handleSubmit} className="space-y-6 pb-20">
|
||||
{/* Invoice Details Card */}
|
||||
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-emerald-700">
|
||||
<FileText className="h-5 w-5" />
|
||||
<h3 className="text-lg font-semibold">Invoice Details</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
Invoice Details
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 lg:gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invoiceNumber" className="text-sm font-medium text-gray-700">
|
||||
Invoice Number
|
||||
@@ -202,183 +380,311 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
<Input
|
||||
id="invoiceNumber"
|
||||
value={formData.invoiceNumber}
|
||||
className="h-12 border-gray-200 bg-gray-50"
|
||||
className="h-10 border-gray-200 bg-gray-50"
|
||||
placeholder="Auto-generated"
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="businessId" className="text-sm font-medium text-gray-700">
|
||||
Business *
|
||||
</Label>
|
||||
<SearchableSelect
|
||||
value={formData.businessId}
|
||||
onValueChange={(value) => setFormData(f => ({ ...f, businessId: value }))}
|
||||
options={businesses?.map(business => ({ value: business.id, label: business.name })) ?? []}
|
||||
placeholder="Select a business"
|
||||
searchPlaceholder="Search businesses..."
|
||||
disabled={loadingBusinesses}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="clientId" className="text-sm font-medium text-gray-700">
|
||||
Client
|
||||
Client *
|
||||
</Label>
|
||||
<select
|
||||
id="clientId"
|
||||
<SearchableSelect
|
||||
value={formData.clientId}
|
||||
onChange={e => setFormData(f => ({ ...f, clientId: e.target.value }))}
|
||||
className="h-12 w-full rounded-md border border-gray-200 bg-white px-3 py-2 text-gray-700 focus:border-emerald-500 focus:ring-emerald-500"
|
||||
required
|
||||
onValueChange={(value) => setFormData(f => ({ ...f, clientId: value }))}
|
||||
options={clients?.map(client => ({ value: client.id, label: client.name })) ?? []}
|
||||
placeholder="Select a client"
|
||||
searchPlaceholder="Search clients..."
|
||||
disabled={loadingClients}
|
||||
>
|
||||
<option value="">Select a client</option>
|
||||
{clients?.map(client => (
|
||||
<option key={client.id} value={client.id}>{client.name}</option>
|
||||
))}
|
||||
</select>
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="status" className="text-sm font-medium text-gray-700">
|
||||
Status
|
||||
</Label>
|
||||
<Select
|
||||
value={formData.status}
|
||||
onValueChange={(value) => setFormData(f => ({ ...f, status: value as "draft" | "sent" | "paid" | "overdue" }))}
|
||||
>
|
||||
<SelectTrigger className="h-10 border-gray-200 bg-gray-50">
|
||||
<SelectValue placeholder="Select status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="draft">Draft</SelectItem>
|
||||
<SelectItem value="sent">Sent</SelectItem>
|
||||
<SelectItem value="paid">Paid</SelectItem>
|
||||
<SelectItem value="overdue">Overdue</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="issueDate" className="text-sm font-medium text-gray-700">
|
||||
Issue Date
|
||||
Issue Date *
|
||||
</Label>
|
||||
<DatePicker
|
||||
date={formData.issueDate}
|
||||
onDateChange={date => setFormData(f => ({ ...f, issueDate: date || new Date() }))}
|
||||
onDateChange={date => setFormData(f => ({ ...f, issueDate: date ?? new Date() }))}
|
||||
placeholder="Select issue date"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dueDate" className="text-sm font-medium text-gray-700">
|
||||
Due Date
|
||||
Due Date *
|
||||
</Label>
|
||||
<DatePicker
|
||||
date={formData.dueDate}
|
||||
onDateChange={date => setFormData(f => ({ ...f, dueDate: date || new Date() }))}
|
||||
onDateChange={date => setFormData(f => ({ ...f, dueDate: date ?? new Date() }))}
|
||||
placeholder="Select due date"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="status" className="text-sm font-medium text-gray-700">
|
||||
Status
|
||||
<Label htmlFor="defaultRate" className="text-sm font-medium text-gray-700">
|
||||
Default Rate ($/hr)
|
||||
</Label>
|
||||
<select
|
||||
id="status"
|
||||
value={formData.status}
|
||||
onChange={e => setFormData(f => ({ ...f, status: e.target.value as "draft" | "sent" | "paid" | "overdue" }))}
|
||||
className="h-12 w-full rounded-md border border-gray-200 bg-white px-3 py-2 text-gray-700 focus:border-emerald-500 focus:ring-emerald-500"
|
||||
required
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="defaultRate"
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={defaultRate}
|
||||
onChange={e => setDefaultRate(parseFloat(e.target.value) || 0)}
|
||||
placeholder="0.00"
|
||||
className="h-10 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={applyDefaultRate}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-10 border-emerald-200 text-emerald-700 hover:bg-emerald-50"
|
||||
>
|
||||
{STATUS_OPTIONS.map(status => (
|
||||
<option key={status} value={status}>{status.charAt(0).toUpperCase() + status.slice(1)}</option>
|
||||
))}
|
||||
</select>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label htmlFor="notes" className="text-sm font-medium text-gray-700">
|
||||
Notes
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="taxRate" className="text-sm font-medium text-gray-700">
|
||||
Tax Rate (%)
|
||||
</Label>
|
||||
<Input
|
||||
id="notes"
|
||||
value={formData.notes}
|
||||
onChange={e => setFormData(f => ({ ...f, notes: e.target.value }))}
|
||||
placeholder="Additional notes (optional)"
|
||||
className="h-12 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500"
|
||||
id="taxRate"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="100"
|
||||
value={formData.taxRate}
|
||||
onChange={e => setFormData(f => ({ ...f, taxRate: parseFloat(e.target.value) || 0 }))}
|
||||
placeholder="0.00"
|
||||
className="h-10 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Invoice Items */}
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center space-x-2 text-emerald-700">
|
||||
<User className="h-5 w-5" />
|
||||
<h3 className="text-lg font-semibold">Invoice Items</h3>
|
||||
{selectedBusiness && (
|
||||
<div className="p-4 bg-emerald-50 rounded-lg border border-emerald-200">
|
||||
<div className="flex items-center gap-2 text-emerald-700 mb-2">
|
||||
<Building className="h-4 w-4" />
|
||||
<span className="font-medium">Business Information</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-700">
|
||||
<p className="font-medium">{selectedBusiness.name}</p>
|
||||
{selectedBusiness.email && <p>{selectedBusiness.email}</p>}
|
||||
{selectedBusiness.phone && <p>{selectedBusiness.phone}</p>}
|
||||
{selectedBusiness.addressLine1 && (
|
||||
<p>{selectedBusiness.addressLine1}</p>
|
||||
)}
|
||||
{(selectedBusiness.city ?? selectedBusiness.state ?? selectedBusiness.postalCode) && (
|
||||
<p>
|
||||
{[selectedBusiness.city, selectedBusiness.state, selectedBusiness.postalCode]
|
||||
.filter(Boolean)
|
||||
.join(", ")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{formData.items.map((item, idx) => (
|
||||
<div key={idx} className="grid grid-cols-1 md:grid-cols-5 gap-4 items-end bg-emerald-50/30 rounded-lg p-4">
|
||||
<div className="space-y-1">
|
||||
<Label>Date</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={format(item.date, "yyyy-MM-dd")}
|
||||
onChange={e => handleItemChange(idx, "date", new Date(e.target.value))}
|
||||
className="h-10 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1 md:col-span-2">
|
||||
<Label>Description</Label>
|
||||
<Input
|
||||
value={item.description}
|
||||
onChange={e => handleItemChange(idx, "description", e.target.value)}
|
||||
placeholder="Description"
|
||||
className="h-10 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Hours</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.1}
|
||||
value={item.hours}
|
||||
onChange={e => handleItemChange(idx, "hours", e.target.value)}
|
||||
className="h-10 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Rate</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.01}
|
||||
value={item.rate}
|
||||
onChange={e => handleItemChange(idx, "rate", e.target.value)}
|
||||
className="h-10 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Amount</Label>
|
||||
<Input
|
||||
value={item.amount.toFixed(2)}
|
||||
readOnly
|
||||
className="h-10 border-gray-200 bg-gray-100 text-gray-700"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-center md:col-span-5">
|
||||
{formData.items.length > 1 && (
|
||||
<Button type="button" variant="destructive" size="sm" onClick={() => removeItem(idx)}>
|
||||
<Trash2 className="h-4 w-4 mr-1" /> Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<Button type="button" variant="outline" onClick={addItem} className="w-full md:w-auto">
|
||||
<Plus className="mr-2 h-4 w-4" /> Add Item
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{selectedClient && (
|
||||
<div className="p-4 bg-emerald-50 rounded-lg border border-emerald-200">
|
||||
<div className="flex items-center gap-2 text-emerald-700 mb-2">
|
||||
<User className="h-4 w-4" />
|
||||
<span className="font-medium">Client Information</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-700">
|
||||
<p className="font-medium">{selectedClient.name}</p>
|
||||
{selectedClient.email && <p>{selectedClient.email}</p>}
|
||||
{selectedClient.phone && <p>{selectedClient.phone}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Total Amount */}
|
||||
<div className="flex justify-end items-center text-lg font-semibold text-emerald-700">
|
||||
Total: ${totalAmount.toFixed(2)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="notes" className="text-sm font-medium text-gray-700">
|
||||
Notes
|
||||
</Label>
|
||||
<textarea
|
||||
id="notes"
|
||||
value={formData.notes}
|
||||
onChange={e => setFormData(f => ({ ...f, notes: e.target.value }))}
|
||||
className="w-full rounded-md border border-gray-200 bg-white px-3 py-2 text-gray-700 focus:border-emerald-500 focus:ring-emerald-500 min-h-[80px] resize-none"
|
||||
placeholder="Additional notes, terms, or special instructions..."
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-col sm:flex-row space-y-3 sm:space-y-0 sm:space-x-4 pt-6 border-t border-gray-200">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading || (!!invoiceId && loadingInvoice)}
|
||||
className="flex-1 h-12 bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700 text-white font-medium shadow-lg hover:shadow-xl transition-all duration-200"
|
||||
{/* Invoice Items Card */}
|
||||
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2 text-emerald-700">
|
||||
<Clock className="h-5 w-5" />
|
||||
Invoice Items
|
||||
</CardTitle>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={addItem}
|
||||
variant="outline"
|
||||
className="border-emerald-200 text-emerald-700 hover:bg-emerald-50"
|
||||
>
|
||||
{loading ? "Saving..." : invoiceId ? "Update Invoice" : "Create Invoice"}
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Item
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Items Table Header */}
|
||||
<div className="grid grid-cols-12 gap-2 px-4 py-3 bg-gray-50 rounded-lg font-medium text-sm text-gray-700 items-center">
|
||||
<div className="col-span-1 text-center">⋮⋮</div>
|
||||
<div className="col-span-2">Date</div>
|
||||
<div className="col-span-4">Description</div>
|
||||
<div className="col-span-1">Hours</div>
|
||||
<div className="col-span-2">Rate ($)</div>
|
||||
<div className="col-span-1">Amount</div>
|
||||
<div className="col-span-1"></div>
|
||||
</div>
|
||||
|
||||
{/* Items */}
|
||||
<EditableInvoiceItems
|
||||
items={formData.items}
|
||||
onItemsChange={(newItems) => setFormData(prev => ({ ...prev, items: newItems }))}
|
||||
onRemoveItem={removeItem}
|
||||
/>
|
||||
|
||||
{/* Validation Messages */}
|
||||
{formData.items.some(item => !item.description.trim()) && (
|
||||
<div className="flex items-center gap-2 text-amber-600 text-sm">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
Please fill in all item descriptions
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formData.items.some(item => item.hours <= 0) && (
|
||||
<div className="flex items-center gap-2 text-amber-600 text-sm">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
Please enter valid hours for all items
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formData.items.some(item => item.rate <= 0) && (
|
||||
<div className="flex items-center gap-2 text-amber-600 text-sm">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
Please enter valid rates for all items
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Totals */}
|
||||
<div className="flex justify-end">
|
||||
<div className="text-right space-y-2">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm text-gray-600">Subtotal: ${totals.subtotal.toFixed(2)}</div>
|
||||
{formData.taxRate > 0 && (
|
||||
<div className="text-sm text-gray-600">
|
||||
Tax ({formData.taxRate}%): ${totals.taxAmount.toFixed(2)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-lg font-medium text-gray-700">Total Amount</div>
|
||||
<div className="text-3xl font-bold text-emerald-600">${totals.total.toFixed(2)}</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{formData.items.length} item{formData.items.length !== 1 ? 's' : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Form Controls Bar */}
|
||||
<div className="mt-6">
|
||||
<div className="bg-white/90 rounded-2xl border border-gray-200 shadow-sm p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 bg-emerald-500 rounded-full"></div>
|
||||
<span>Ready to save</span>
|
||||
</div>
|
||||
{formData.items.length > 0 && (
|
||||
<span className="text-gray-400">•</span>
|
||||
)}
|
||||
{formData.items.length > 0 && (
|
||||
<span>{formData.items.length} item{formData.items.length !== 1 ? 's' : ''}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="flex-1 w-full h-12 border-gray-300 text-gray-700 hover:bg-gray-50 font-medium"
|
||||
onClick={() => router.push(invoiceId ? `/dashboard/invoices/${invoiceId}` : "/dashboard/invoices")}
|
||||
onClick={() => router.push("/dashboard/invoices")}
|
||||
className="border-gray-300 text-gray-700 hover:bg-gray-50 font-medium"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700 text-white font-medium shadow-lg hover:shadow-xl transition-all duration-200"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||||
{invoiceId ? "Updating..." : "Creating..."}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{invoiceId ? "Update Invoice" : "Create Invoice"}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -4,30 +4,56 @@ import { useState } from "react";
|
||||
import { api } from "~/trpc/react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "~/components/ui/dialog";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog";
|
||||
import { toast } from "sonner";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { format } from "date-fns";
|
||||
import { Calendar, FileText, User, DollarSign, Trash2, Edit, Download, Send } from "lucide-react";
|
||||
import {
|
||||
Calendar,
|
||||
FileText,
|
||||
User,
|
||||
DollarSign,
|
||||
Trash2,
|
||||
Edit,
|
||||
Download,
|
||||
Send,
|
||||
ArrowLeft,
|
||||
Clock,
|
||||
MapPin,
|
||||
Mail,
|
||||
Phone,
|
||||
AlertCircle,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { generateInvoicePDF } from "~/lib/pdf-export";
|
||||
import { InvoiceViewSkeleton } from "~/components/ui/skeleton";
|
||||
|
||||
interface InvoiceViewProps {
|
||||
invoiceId: string;
|
||||
}
|
||||
|
||||
const statusColors = {
|
||||
draft: "bg-gray-100 text-gray-800",
|
||||
sent: "bg-blue-100 text-blue-800",
|
||||
paid: "bg-green-100 text-green-800",
|
||||
overdue: "bg-red-100 text-red-800",
|
||||
} as const;
|
||||
|
||||
const statusLabels = {
|
||||
draft: "Draft",
|
||||
sent: "Sent",
|
||||
paid: "Paid",
|
||||
overdue: "Overdue",
|
||||
const statusConfig = {
|
||||
draft: { label: "Draft", color: "bg-gray-100 text-gray-800", icon: FileText },
|
||||
sent: { label: "Sent", color: "bg-blue-100 text-blue-800", icon: Send },
|
||||
paid: {
|
||||
label: "Paid",
|
||||
color: "bg-green-100 text-green-800",
|
||||
icon: DollarSign,
|
||||
},
|
||||
overdue: {
|
||||
label: "Overdue",
|
||||
color: "bg-red-100 text-red-800",
|
||||
icon: AlertCircle,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export function InvoiceView({ invoiceId }: InvoiceViewProps) {
|
||||
@@ -36,7 +62,11 @@ export function InvoiceView({ invoiceId }: InvoiceViewProps) {
|
||||
const [isExportingPDF, setIsExportingPDF] = useState(false);
|
||||
|
||||
// Fetch invoice data
|
||||
const { data: invoice, isLoading, refetch } = api.invoices.getById.useQuery({ id: invoiceId });
|
||||
const {
|
||||
data: invoice,
|
||||
isLoading,
|
||||
refetch,
|
||||
} = api.invoices.getById.useQuery({ id: invoiceId });
|
||||
|
||||
// Delete mutation
|
||||
const deleteInvoice = api.invoices.delete.useMutation({
|
||||
@@ -69,13 +99,15 @@ export function InvoiceView({ invoiceId }: InvoiceViewProps) {
|
||||
deleteInvoice.mutate({ id: invoiceId });
|
||||
};
|
||||
|
||||
const handleStatusUpdate = (newStatus: "draft" | "sent" | "paid" | "overdue") => {
|
||||
const handleStatusUpdate = (
|
||||
newStatus: "draft" | "sent" | "paid" | "overdue",
|
||||
) => {
|
||||
updateStatus.mutate({ id: invoiceId, status: newStatus });
|
||||
};
|
||||
|
||||
const handlePDFExport = async () => {
|
||||
if (!invoice) return;
|
||||
|
||||
|
||||
setIsExportingPDF(true);
|
||||
try {
|
||||
await generateInvoicePDF(invoice);
|
||||
@@ -99,31 +131,26 @@ export function InvoiceView({ invoiceId }: InvoiceViewProps) {
|
||||
return format(new Date(date), "MMM dd, yyyy");
|
||||
};
|
||||
|
||||
const isOverdue =
|
||||
invoice &&
|
||||
new Date(invoice.dueDate) < new Date() &&
|
||||
invoice.status !== "paid";
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm">
|
||||
<CardHeader>
|
||||
<div className="h-8 bg-gray-200 rounded animate-pulse"></div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="h-4 bg-gray-200 rounded animate-pulse"></div>
|
||||
<div className="h-4 bg-gray-200 rounded animate-pulse"></div>
|
||||
<div className="h-4 bg-gray-200 rounded animate-pulse"></div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
return <InvoiceViewSkeleton />;
|
||||
}
|
||||
|
||||
if (!invoice) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<FileText className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">Invoice not found</h3>
|
||||
<p className="text-gray-500 mb-4">The invoice you're looking for doesn't exist or has been deleted.</p>
|
||||
<div className="py-12 text-center">
|
||||
<FileText className="mx-auto mb-4 h-12 w-12 text-gray-400" />
|
||||
<h3 className="mb-2 text-lg font-medium text-gray-900">
|
||||
Invoice not found
|
||||
</h3>
|
||||
<p className="mb-4 text-gray-500">
|
||||
The invoice you're looking for doesn't exist or has been
|
||||
deleted.
|
||||
</p>
|
||||
<Button asChild>
|
||||
<Link href="/dashboard/invoices">Back to Invoices</Link>
|
||||
</Button>
|
||||
@@ -131,99 +158,151 @@ export function InvoiceView({ invoiceId }: InvoiceViewProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const StatusIcon =
|
||||
statusConfig[invoice.status as keyof typeof statusConfig].icon;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Invoice Header */}
|
||||
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm">
|
||||
<CardHeader>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle className="text-2xl font-bold text-gray-900 flex items-center gap-2">
|
||||
<FileText className="h-6 w-6 text-emerald-600" />
|
||||
{invoice.invoiceNumber}
|
||||
</CardTitle>
|
||||
<p className="text-gray-600 mt-1">Created on {formatDate(invoice.createdAt)}</p>
|
||||
{/* Status Alert */}
|
||||
{isOverdue && (
|
||||
<Card className="border-red-200 bg-red-50">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 text-red-700">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
<span className="font-medium">This invoice is overdue</span>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<span className={`px-3 py-1 rounded-full text-sm font-medium ${statusColors[invoice.status as keyof typeof statusColors]}`}>
|
||||
{statusLabels[invoice.status as keyof typeof statusLabels]}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleStatusUpdate("sent")}
|
||||
disabled={invoice.status === "sent" || updateStatus.isLoading}
|
||||
>
|
||||
<Send className="h-4 w-4 mr-1" />
|
||||
Mark Sent
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleStatusUpdate("paid")}
|
||||
disabled={invoice.status === "paid" || updateStatus.isLoading}
|
||||
>
|
||||
<DollarSign className="h-4 w-4 mr-1" />
|
||||
Mark Paid
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handlePDFExport}
|
||||
disabled={isExportingPDF}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-1" />
|
||||
{isExportingPDF ? "Generating..." : "Export PDF"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{/* Main Content */}
|
||||
<div className="space-y-6 lg:col-span-2">
|
||||
{/* Invoice Header Card */}
|
||||
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm">
|
||||
<CardContent>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-lg bg-emerald-100 p-2">
|
||||
<FileText className="h-6 w-6 text-emerald-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900">
|
||||
{invoice.invoiceNumber}
|
||||
</h2>
|
||||
<p className="text-gray-600">Professional Invoice</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500">Issue Date</span>
|
||||
<p className="font-medium text-gray-900">
|
||||
{formatDate(invoice.issueDate)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Due Date</span>
|
||||
<p className="font-medium text-gray-900">
|
||||
{formatDate(invoice.dueDate)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 text-right">
|
||||
<Badge
|
||||
className={`${statusConfig[invoice.status as keyof typeof statusConfig].color} px-3 py-1 text-sm font-medium`}
|
||||
>
|
||||
<StatusIcon className="mr-1 h-3 w-3" />
|
||||
{
|
||||
statusConfig[invoice.status as keyof typeof statusConfig]
|
||||
.label
|
||||
}
|
||||
</Badge>
|
||||
<div className="text-3xl font-bold text-emerald-600">
|
||||
{formatCurrency(invoice.totalAmount)}
|
||||
</div>
|
||||
<Button
|
||||
onClick={handlePDFExport}
|
||||
disabled={isExportingPDF}
|
||||
className="transform-none bg-gradient-to-r from-emerald-600 to-teal-600 font-medium text-white shadow-lg transition-shadow duration-200 hover:from-emerald-700 hover:to-teal-700 hover:shadow-xl"
|
||||
>
|
||||
{isExportingPDF ? (
|
||||
<>
|
||||
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||||
Generating PDF...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Download PDF
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Invoice Details */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Main Details */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Client Information */}
|
||||
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm">
|
||||
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
||||
<User className="h-5 w-5 text-emerald-600" />
|
||||
Client Information
|
||||
<CardTitle className="flex items-center gap-2 text-emerald-700">
|
||||
<User className="h-5 w-5" />
|
||||
Bill To
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700">Client Name</label>
|
||||
<p className="text-gray-900 font-medium">{invoice.client?.name}</p>
|
||||
</div>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
{invoice.client?.name}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 text-sm md:grid-cols-2">
|
||||
{invoice.client?.email && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700">Email</label>
|
||||
<p className="text-gray-900">{invoice.client.email}</p>
|
||||
<div className="flex items-center gap-2 text-gray-600">
|
||||
<Mail className="h-4 w-4 text-gray-400" />
|
||||
{invoice.client.email}
|
||||
</div>
|
||||
)}
|
||||
{invoice.client?.phone && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700">Phone</label>
|
||||
<p className="text-gray-900">{invoice.client.phone}</p>
|
||||
<div className="flex items-center gap-2 text-gray-600">
|
||||
<Phone className="h-4 w-4 text-gray-400" />
|
||||
{invoice.client.phone}
|
||||
</div>
|
||||
)}
|
||||
{(invoice.client?.addressLine1 || invoice.client?.city || invoice.client?.state) && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700">Address</label>
|
||||
<p className="text-gray-900">
|
||||
{[
|
||||
invoice.client?.addressLine1,
|
||||
invoice.client?.addressLine2,
|
||||
invoice.client?.city,
|
||||
invoice.client?.state,
|
||||
invoice.client?.postalCode,
|
||||
].filter(Boolean).join(", ")}
|
||||
</p>
|
||||
{(invoice.client?.addressLine1 ??
|
||||
invoice.client?.city ??
|
||||
invoice.client?.state) && (
|
||||
<div className="flex items-start gap-2 text-gray-600 md:col-span-2">
|
||||
<MapPin className="mt-0.5 h-4 w-4 flex-shrink-0 text-gray-400" />
|
||||
<div>
|
||||
{invoice.client?.addressLine1 && (
|
||||
<div>{invoice.client.addressLine1}</div>
|
||||
)}
|
||||
{invoice.client?.addressLine2 && (
|
||||
<div>{invoice.client.addressLine2}</div>
|
||||
)}
|
||||
{(invoice.client?.city ??
|
||||
invoice.client?.state ??
|
||||
invoice.client?.postalCode) && (
|
||||
<div>
|
||||
{[
|
||||
invoice.client?.city,
|
||||
invoice.client?.state,
|
||||
invoice.client?.postalCode,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", ")}
|
||||
</div>
|
||||
)}
|
||||
{invoice.client?.country && (
|
||||
<div>{invoice.client.country}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -231,122 +310,178 @@ export function InvoiceView({ invoiceId }: InvoiceViewProps) {
|
||||
</Card>
|
||||
|
||||
{/* Invoice Items */}
|
||||
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm">
|
||||
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-semibold text-gray-900">Invoice Items</CardTitle>
|
||||
<CardTitle className="flex items-center gap-2 text-emerald-700">
|
||||
<Clock className="h-5 w-5" />
|
||||
Invoice Items
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200">
|
||||
<th className="text-left py-3 px-4 font-semibold text-gray-700">Date</th>
|
||||
<th className="text-left py-3 px-4 font-semibold text-gray-700">Description</th>
|
||||
<th className="text-right py-3 px-4 font-semibold text-gray-700">Hours</th>
|
||||
<th className="text-right py-3 px-4 font-semibold text-gray-700">Rate</th>
|
||||
<th className="text-right py-3 px-4 font-semibold text-gray-700">Amount</th>
|
||||
<div className="overflow-hidden rounded-lg border border-gray-200">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-gray-700">
|
||||
Date
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-gray-700">
|
||||
Description
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-sm font-semibold text-gray-700">
|
||||
Hours
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-sm font-semibold text-gray-700">
|
||||
Rate
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-sm font-semibold text-gray-700">
|
||||
Amount
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{invoice.items?.map((item, index) => (
|
||||
<tr key={index} className="border-b border-gray-100 hover:bg-emerald-50/30 transition-colors">
|
||||
<td className="py-3 px-4 text-gray-900">{formatDate(item.date)}</td>
|
||||
<td className="py-3 px-4 text-gray-900">{item.description}</td>
|
||||
<td className="py-3 px-4 text-gray-900 text-right">{item.hours}</td>
|
||||
<td className="py-3 px-4 text-gray-900 text-right">{formatCurrency(item.rate)}</td>
|
||||
<td className="py-3 px-4 text-gray-900 font-semibold text-right">{formatCurrency(item.amount)}</td>
|
||||
<tr
|
||||
key={item.id || index}
|
||||
className="border-t border-gray-100 hover:bg-gray-50"
|
||||
>
|
||||
<td className="px-4 py-3 text-sm text-gray-900">
|
||||
{formatDate(item.date)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-900">
|
||||
{item.description}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-sm text-gray-900">
|
||||
{item.hours}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-sm text-gray-900">
|
||||
{formatCurrency(item.rate)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-sm font-medium text-gray-900">
|
||||
{formatCurrency(item.amount)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t-2 border-emerald-200 bg-emerald-50/50">
|
||||
<td colSpan={4} className="py-4 px-4 text-right font-semibold text-gray-900">Total:</td>
|
||||
<td className="py-4 px-4 text-right font-bold text-emerald-600 text-lg">{formatCurrency(invoice.totalAmount)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* Invoice Summary */}
|
||||
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
||||
<Calendar className="h-5 w-5 text-emerald-600" />
|
||||
Invoice Summary
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700">Issue Date</label>
|
||||
<p className="text-gray-900">{formatDate(invoice.issueDate)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700">Due Date</label>
|
||||
<p className="text-gray-900">{formatDate(invoice.dueDate)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700">Status</label>
|
||||
<span className={`inline-block px-2 py-1 rounded-full text-xs font-medium ${statusColors[invoice.status as keyof typeof statusColors]}`}>
|
||||
{statusLabels[invoice.status as keyof typeof statusLabels]}
|
||||
</span>
|
||||
</div>
|
||||
<div className="pt-4 border-t border-gray-200">
|
||||
<label className="text-lg font-semibold text-gray-900">Total Amount</label>
|
||||
<p className="text-2xl font-bold text-emerald-600">{formatCurrency(invoice.totalAmount)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Notes */}
|
||||
{invoice.notes && (
|
||||
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm">
|
||||
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-semibold text-gray-900">Notes</CardTitle>
|
||||
<CardTitle className="text-emerald-700">Notes</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-gray-700 whitespace-pre-wrap">{invoice.notes}</p>
|
||||
<p className="whitespace-pre-wrap text-gray-700">
|
||||
{invoice.notes}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm">
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* Status Actions */}
|
||||
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-semibold text-gray-900">Actions</CardTitle>
|
||||
<CardTitle className="text-emerald-700">Status Actions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{invoice.status === "draft" && (
|
||||
<Button
|
||||
onClick={() => handleStatusUpdate("sent")}
|
||||
disabled={updateStatus.isPending}
|
||||
className="w-full bg-blue-600 text-white hover:bg-blue-700"
|
||||
>
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
Mark as Sent
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{invoice.status === "sent" && (
|
||||
<Button
|
||||
onClick={() => handleStatusUpdate("paid")}
|
||||
disabled={updateStatus.isPending}
|
||||
className="w-full bg-green-600 text-white hover:bg-green-700"
|
||||
>
|
||||
<DollarSign className="mr-2 h-4 w-4" />
|
||||
Mark as Paid
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{invoice.status === "overdue" && (
|
||||
<Button
|
||||
onClick={() => handleStatusUpdate("paid")}
|
||||
disabled={updateStatus.isPending}
|
||||
className="w-full bg-green-600 text-white hover:bg-green-700"
|
||||
>
|
||||
<DollarSign className="mr-2 h-4 w-4" />
|
||||
Mark as Paid
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{invoice.status === "paid" && (
|
||||
<div className="py-4 text-center">
|
||||
<DollarSign className="mx-auto mb-2 h-8 w-8 text-green-600" />
|
||||
<p className="font-medium text-green-600">Invoice Paid</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Invoice Summary */}
|
||||
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-emerald-700">Summary</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600">Subtotal</span>
|
||||
<span className="font-medium">
|
||||
{formatCurrency(invoice.totalAmount)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600">Tax</span>
|
||||
<span className="font-medium">$0.00</span>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex justify-between text-lg font-bold">
|
||||
<span>Total</span>
|
||||
<span className="text-emerald-600">
|
||||
{formatCurrency(invoice.totalAmount)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 pt-4 text-center">
|
||||
<p className="text-sm text-gray-500">
|
||||
{invoice.items?.length ?? 0} item
|
||||
{invoice.items?.length !== 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Danger Zone */}
|
||||
<Card className="border-0 border-red-200 bg-white/80 shadow-xl backdrop-blur-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-red-700">Danger Zone</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<Button asChild className="w-full bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700 text-white font-medium">
|
||||
<Link href={`/dashboard/invoices/${invoiceId}/edit`}>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
Edit Invoice
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full border-gray-300 text-gray-700 hover:bg-gray-50"
|
||||
onClick={handlePDFExport}
|
||||
disabled={isExportingPDF}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
{isExportingPDF ? "Generating PDF..." : "Download PDF"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="w-full bg-red-600 hover:bg-red-700"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete Invoice
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleDelete}
|
||||
variant="outline"
|
||||
className="w-full border-red-200 text-red-700 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete Invoice
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -354,32 +489,36 @@ export function InvoiceView({ invoiceId }: InvoiceViewProps) {
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent className="bg-white/95 backdrop-blur-sm border-0 shadow-2xl">
|
||||
<DialogContent className="border-0 bg-white/95 shadow-2xl backdrop-blur-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl font-bold text-gray-800">Delete Invoice</DialogTitle>
|
||||
<DialogTitle className="text-xl font-bold text-gray-800">
|
||||
Delete Invoice
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-gray-600">
|
||||
Are you sure you want to delete this invoice? This action cannot be undone.
|
||||
Are you sure you want to delete this invoice? This action cannot
|
||||
be undone and will permanently remove the invoice and all its
|
||||
data.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeleteDialogOpen(false)}
|
||||
className="border-gray-300 text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={confirmDelete}
|
||||
disabled={deleteInvoice.isPending}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
disabled={deleteInvoice.isLoading}
|
||||
>
|
||||
{deleteInvoice.isLoading ? "Deleting..." : "Delete"}
|
||||
{deleteInvoice.isPending ? "Deleting..." : "Delete Invoice"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,40 +1,27 @@
|
||||
import Image from "next/image";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { CircleDollarSign } from "lucide-react";
|
||||
|
||||
interface LogoProps {
|
||||
className?: string;
|
||||
size?: "sm" | "md" | "lg";
|
||||
showIcon?: boolean;
|
||||
}
|
||||
|
||||
export function Logo({ className, size = "md" }: LogoProps) {
|
||||
const sizeClasses = {
|
||||
sm: "text-lg",
|
||||
md: "text-2xl",
|
||||
lg: "text-4xl",
|
||||
sm: { width: 120, height: 32 },
|
||||
md: { width: 160, height: 42 },
|
||||
lg: { width: 240, height: 64 },
|
||||
};
|
||||
const { width, height } = sizeClasses[size];
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center space-x-2", className)}>
|
||||
<CircleDollarSign className="w-6 h-6 text-green-500"/>
|
||||
<div className="flex items-center">
|
||||
<span
|
||||
className={cn(
|
||||
"bg-gradient-to-r from-green-600 via-green-700 to-emerald-700 bg-clip-text font-bold tracking-tight text-transparent",
|
||||
sizeClasses[size],
|
||||
)}
|
||||
>
|
||||
been
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"font-semibold tracking-wide text-gray-800",
|
||||
sizeClasses[size],
|
||||
)}
|
||||
>
|
||||
voice
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Image
|
||||
src="/beenvoice-logo.svg"
|
||||
alt="beenvoice logo"
|
||||
width={width}
|
||||
height={height}
|
||||
className={className}
|
||||
priority
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ export function DatePicker({
|
||||
id={id}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"w-full justify-between font-normal h-9 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500 text-sm",
|
||||
"w-full justify-between font-normal h-10 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500 text-sm",
|
||||
!date && "text-gray-500"
|
||||
)}
|
||||
>
|
||||
|
||||
356
src/components/ui/select.tsx
Normal file
356
src/components/ui/select.tsx
Normal file
@@ -0,0 +1,356 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon, Search } from "lucide-react"
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-2 rounded-md border border-gray-200 bg-gray-50 px-3 h-10 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-emerald-500 focus-visible:ring-emerald-500 focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "popper",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
}
|
||||
|
||||
// Enhanced SelectContent with search functionality
|
||||
function SelectContentWithSearch({
|
||||
className,
|
||||
children,
|
||||
position = "popper",
|
||||
searchPlaceholder = "Search...",
|
||||
onSearchChange,
|
||||
searchValue,
|
||||
isOpen,
|
||||
filteredOptions,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content> & {
|
||||
searchPlaceholder?: string;
|
||||
onSearchChange?: (value: string) => void;
|
||||
searchValue?: string;
|
||||
isOpen?: boolean;
|
||||
filteredOptions?: { value: string; label: string }[];
|
||||
}) {
|
||||
const searchInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const wasOpen = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
// Only focus when dropdown transitions from closed to open
|
||||
if (isOpen && !wasOpen.current && searchInputRef.current) {
|
||||
searchInputRef.current.focus();
|
||||
}
|
||||
wasOpen.current = !!isOpen;
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-96 min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-hidden rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
onEscapeKeyDown={(e) => {
|
||||
// Prevent escape from closing the dropdown when typing
|
||||
if (searchValue) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
onPointerDownOutside={(e) => {
|
||||
// Prevent closing when clicking inside the search input
|
||||
if (searchInputRef.current?.contains(e.target as Node)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{onSearchChange && (
|
||||
<div className="flex items-center px-3 py-2 border-b">
|
||||
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
className="flex h-8 w-full rounded-md bg-transparent py-2 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 border-0 focus:ring-0 focus:outline-none"
|
||||
placeholder={searchPlaceholder}
|
||||
value={searchValue}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
// Prevent the dropdown from closing when typing
|
||||
if (e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
}
|
||||
// Prevent arrow keys from moving focus away from search
|
||||
if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.key)) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
// Ensure the search input stays focused
|
||||
e.target.select();
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport className="p-1">
|
||||
{filteredOptions && filteredOptions.length === 0 ? (
|
||||
<div className="px-3 py-2 text-sm text-muted-foreground select-none">No results found</div>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
// Searchable Select component
|
||||
interface SearchableSelectProps {
|
||||
value?: string;
|
||||
onValueChange?: (value: string) => void;
|
||||
placeholder?: string;
|
||||
options: { value: string; label: string }[];
|
||||
searchPlaceholder?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function SearchableSelect({
|
||||
value,
|
||||
onValueChange,
|
||||
placeholder,
|
||||
options,
|
||||
searchPlaceholder = "Search...",
|
||||
className,
|
||||
disabled
|
||||
}: SearchableSelectProps) {
|
||||
const [searchValue, setSearchValue] = React.useState("");
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
|
||||
const filteredOptions = React.useMemo(() => {
|
||||
if (!searchValue) return options;
|
||||
return options.filter(option =>
|
||||
option.label.toLowerCase().includes(searchValue.toLowerCase())
|
||||
);
|
||||
}, [options, searchValue]);
|
||||
|
||||
// Convert empty string to placeholder value for display
|
||||
const displayValue = value === "" ? "__placeholder__" : value;
|
||||
|
||||
// Convert placeholder value back to empty string when selected
|
||||
const handleValueChange = (newValue: string) => {
|
||||
const actualValue = newValue === "__placeholder__" ? "" : newValue;
|
||||
onValueChange?.(actualValue);
|
||||
// Clear search when an option is selected
|
||||
setSearchValue("");
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={displayValue}
|
||||
onValueChange={handleValueChange}
|
||||
disabled={disabled}
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
>
|
||||
<SelectTrigger className={cn("w-full", className)}>
|
||||
<SelectValue
|
||||
placeholder={placeholder}
|
||||
// Always show placeholder if nothing is selected
|
||||
data-placeholder={displayValue === "__placeholder__"}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContentWithSearch
|
||||
searchPlaceholder={searchPlaceholder}
|
||||
searchValue={searchValue}
|
||||
onSearchChange={setSearchValue}
|
||||
isOpen={isOpen}
|
||||
filteredOptions={filteredOptions}
|
||||
>
|
||||
{filteredOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContentWithSearch>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectContentWithSearch,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SearchableSelect,
|
||||
}
|
||||
31
src/components/ui/separator.tsx
Normal file
31
src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(
|
||||
(
|
||||
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||
ref
|
||||
) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||
|
||||
export { Separator }
|
||||
@@ -4,10 +4,212 @@ function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("bg-accent animate-pulse rounded-md", className)}
|
||||
className={cn("bg-muted animate-pulse rounded-md", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Dashboard skeleton components
|
||||
export function DashboardStatsSkeleton() {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="shadow-xl border-0 bg-white/80 backdrop-blur-sm rounded-xl p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-8 w-8 rounded-lg" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-16 mb-2" />
|
||||
<Skeleton className="h-3 w-32" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function DashboardCardsSkeleton() {
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
|
||||
{Array.from({ length: 2 }).map((_, i) => (
|
||||
<div key={i} className="shadow-xl border-0 bg-white/80 backdrop-blur-sm rounded-xl p-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Skeleton className="h-8 w-8 rounded-lg" />
|
||||
<Skeleton className="h-6 w-32" />
|
||||
</div>
|
||||
<Skeleton className="h-4 w-full mb-4" />
|
||||
<div className="flex gap-3">
|
||||
<Skeleton className="h-10 w-24" />
|
||||
<Skeleton className="h-10 w-32" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function DashboardActivitySkeleton() {
|
||||
return (
|
||||
<div className="shadow-xl border-0 bg-white/80 backdrop-blur-sm rounded-xl p-6">
|
||||
<Skeleton className="h-6 w-32 mb-6" />
|
||||
<div className="text-center py-12">
|
||||
<Skeleton className="h-20 w-20 rounded-full mx-auto mb-4" />
|
||||
<Skeleton className="h-6 w-48 mx-auto mb-2" />
|
||||
<Skeleton className="h-4 w-64 mx-auto" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Table skeleton components
|
||||
export function TableSkeleton({ rows = 5 }: { rows?: number }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Search and filters */}
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<Skeleton className="h-10 w-64" />
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-10 w-24" />
|
||||
<Skeleton className="h-10 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="border rounded-lg">
|
||||
<div className="p-4 border-b">
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-8 w-8" />
|
||||
<Skeleton className="h-8 w-8" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: rows }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-4">
|
||||
<Skeleton className="h-4 w-4" />
|
||||
<Skeleton className="h-4 flex-1" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-8 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-8 w-8" />
|
||||
<Skeleton className="h-8 w-8" />
|
||||
<Skeleton className="h-8 w-8" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Form skeleton components
|
||||
export function FormSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Skeleton className="h-4 w-20 mb-2" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
<div>
|
||||
<Skeleton className="h-4 w-24 mb-2" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
<div>
|
||||
<Skeleton className="h-4 w-16 mb-2" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Skeleton className="h-4 w-20 mb-2" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
<div>
|
||||
<Skeleton className="h-4 w-16 mb-2" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Skeleton className="h-10 w-24" />
|
||||
<Skeleton className="h-10 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Invoice view skeleton
|
||||
export function InvoiceViewSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-4 w-64" />
|
||||
</div>
|
||||
<Skeleton className="h-10 w-32" />
|
||||
</div>
|
||||
|
||||
{/* Client info */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-5 w-24" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-5 w-24" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Items table */}
|
||||
<div className="border rounded-lg">
|
||||
<div className="p-4 border-b">
|
||||
<Skeleton className="h-5 w-32" />
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-4">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-4 flex-1" />
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Total */}
|
||||
<div className="flex justify-end">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-6 w-32" />
|
||||
<Skeleton className="h-8 w-40" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,10 @@ export const env = createEnv({
|
||||
? z.string()
|
||||
: z.string().optional(),
|
||||
DATABASE_URL: z.string().url(),
|
||||
DATABASE_AUTH_TOKEN:
|
||||
process.env.NODE_ENV === "production"
|
||||
? z.string()
|
||||
: z.string().optional(),
|
||||
NODE_ENV: z
|
||||
.enum(["development", "test", "production"])
|
||||
.default("development"),
|
||||
@@ -33,6 +37,7 @@ export const env = createEnv({
|
||||
runtimeEnv: {
|
||||
AUTH_SECRET: process.env.AUTH_SECRET,
|
||||
DATABASE_URL: process.env.DATABASE_URL,
|
||||
DATABASE_AUTH_TOKEN: process.env.DATABASE_AUTH_TOKEN,
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
},
|
||||
/**
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
import jsPDF from 'jspdf';
|
||||
import html2canvas from 'html2canvas';
|
||||
|
||||
interface InvoiceData {
|
||||
invoiceNumber: string;
|
||||
issueDate: Date;
|
||||
dueDate: Date;
|
||||
status: string;
|
||||
totalAmount: number;
|
||||
notes?: string | null;
|
||||
client?: {
|
||||
name: string;
|
||||
email?: string | null;
|
||||
phone?: string | null;
|
||||
addressLine1?: string | null;
|
||||
addressLine2?: string | null;
|
||||
city?: string | null;
|
||||
state?: string | null;
|
||||
postalCode?: string | null;
|
||||
country?: string | null;
|
||||
} | null;
|
||||
items?: Array<{
|
||||
date: Date;
|
||||
description: string;
|
||||
hours: number;
|
||||
rate: number;
|
||||
amount: number;
|
||||
}> | null;
|
||||
}
|
||||
|
||||
export async function generateInvoicePDF(invoice: InvoiceData): Promise<void> {
|
||||
// Create a temporary div to render the invoice
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.style.position = 'absolute';
|
||||
tempDiv.style.left = '-9999px';
|
||||
tempDiv.style.top = '0';
|
||||
tempDiv.style.width = '800px';
|
||||
tempDiv.style.backgroundColor = 'white';
|
||||
tempDiv.style.padding = '40px';
|
||||
tempDiv.style.fontFamily = 'Arial, sans-serif';
|
||||
tempDiv.style.fontSize = '12px';
|
||||
tempDiv.style.lineHeight = '1.4';
|
||||
tempDiv.style.color = '#333';
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return new Date(date).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
const statusColors = {
|
||||
draft: '#6B7280',
|
||||
sent: '#3B82F6',
|
||||
paid: '#10B981',
|
||||
overdue: '#EF4444',
|
||||
};
|
||||
|
||||
const statusLabels = {
|
||||
draft: 'Draft',
|
||||
sent: 'Sent',
|
||||
paid: 'Paid',
|
||||
overdue: 'Overdue',
|
||||
};
|
||||
|
||||
tempDiv.innerHTML = `
|
||||
<div style="max-width: 720px; margin: 0 auto;">
|
||||
<!-- Header -->
|
||||
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 40px; border-bottom: 2px solid #10B981; padding-bottom: 20px;">
|
||||
<div>
|
||||
<h1 style="margin: 0; font-size: 32px; font-weight: bold; color: #10B981;">beenvoice</h1>
|
||||
<p style="margin: 5px 0 0 0; color: #6B7280; font-size: 14px;">Professional Invoicing</p>
|
||||
</div>
|
||||
<div style="text-align: right;">
|
||||
<h2 style="margin: 0; font-size: 24px; color: #1F2937;">INVOICE</h2>
|
||||
<p style="margin: 5px 0 0 0; font-size: 18px; font-weight: bold; color: #10B981;">${invoice.invoiceNumber}</p>
|
||||
<div style="margin-top: 10px; display: inline-block; padding: 4px 12px; border-radius: 20px; font-size: 12px; font-weight: bold; background-color: ${statusColors[invoice.status as keyof typeof statusColors] || '#6B7280'}; color: white;">
|
||||
${statusLabels[invoice.status as keyof typeof statusLabels] || invoice.status}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Invoice Details -->
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 40px;">
|
||||
<div style="flex: 1;">
|
||||
<h3 style="margin: 0 0 15px 0; font-size: 16px; color: #1F2937; border-bottom: 1px solid #E5E7EB; padding-bottom: 5px;">Bill To:</h3>
|
||||
<div style="font-size: 14px; line-height: 1.6;">
|
||||
<p style="margin: 0 0 5px 0; font-weight: bold; font-size: 16px;">${invoice.client?.name || 'N/A'}</p>
|
||||
${invoice.client?.email ? `<p style="margin: 0 0 5px 0;">${invoice.client.email}</p>` : ''}
|
||||
${invoice.client?.phone ? `<p style="margin: 0 0 5px 0;">${invoice.client.phone}</p>` : ''}
|
||||
${invoice.client?.addressLine1 || invoice.client?.city || invoice.client?.state ? `
|
||||
<p style="margin: 0 0 5px 0;">
|
||||
${[
|
||||
invoice.client?.addressLine1,
|
||||
invoice.client?.addressLine2,
|
||||
invoice.client?.city,
|
||||
invoice.client?.state,
|
||||
invoice.client?.postalCode,
|
||||
].filter(Boolean).join(', ')}
|
||||
</p>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div style="flex: 1; text-align: right;">
|
||||
<h3 style="margin: 0 0 15px 0; font-size: 16px; color: #1F2937; border-bottom: 1px solid #E5E7EB; padding-bottom: 5px;">Invoice Details:</h3>
|
||||
<div style="font-size: 14px; line-height: 1.6;">
|
||||
<p style="margin: 0 0 5px 0;"><strong>Issue Date:</strong> ${formatDate(invoice.issueDate)}</p>
|
||||
<p style="margin: 0 0 5px 0;"><strong>Due Date:</strong> ${formatDate(invoice.dueDate)}</p>
|
||||
<p style="margin: 0 0 5px 0;"><strong>Invoice #:</strong> ${invoice.invoiceNumber}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Invoice Items Table -->
|
||||
<div style="margin-bottom: 30px;">
|
||||
<table style="width: 100%; border-collapse: collapse; border: 1px solid #E5E7EB;">
|
||||
<thead>
|
||||
<tr style="background-color: #F9FAFB;">
|
||||
<th style="border: 1px solid #E5E7EB; padding: 12px; text-align: left; font-weight: bold; color: #1F2937;">Date</th>
|
||||
<th style="border: 1px solid #E5E7EB; padding: 12px; text-align: left; font-weight: bold; color: #1F2937;">Description</th>
|
||||
<th style="border: 1px solid #E5E7EB; padding: 12px; text-align: right; font-weight: bold; color: #1F2937;">Hours</th>
|
||||
<th style="border: 1px solid #E5E7EB; padding: 12px; text-align: right; font-weight: bold; color: #1F2937;">Rate</th>
|
||||
<th style="border: 1px solid #E5E7EB; padding: 12px; text-align: right; font-weight: bold; color: #1F2937;">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${invoice.items?.map(item => `
|
||||
<tr>
|
||||
<td style="border: 1px solid #E5E7EB; padding: 12px; color: #374151;">${formatDate(item.date)}</td>
|
||||
<td style="border: 1px solid #E5E7EB; padding: 12px; color: #374151;">${item.description}</td>
|
||||
<td style="border: 1px solid #E5E7EB; padding: 12px; text-align: right; color: #374151;">${item.hours}</td>
|
||||
<td style="border: 1px solid #E5E7EB; padding: 12px; text-align: right; color: #374151;">${formatCurrency(item.rate)}</td>
|
||||
<td style="border: 1px solid #E5E7EB; padding: 12px; text-align: right; font-weight: bold; color: #374151;">${formatCurrency(item.amount)}</td>
|
||||
</tr>
|
||||
`).join('') || ''}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Total -->
|
||||
<div style="display: flex; justify-content: flex-end; margin-bottom: 30px;">
|
||||
<div style="width: 300px; border: 2px solid #10B981; border-radius: 8px; padding: 20px; background-color: #F0FDF4;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<span style="font-size: 18px; font-weight: bold; color: #1F2937;">Total Amount:</span>
|
||||
<span style="font-size: 24px; font-weight: bold; color: #10B981;">${formatCurrency(invoice.totalAmount)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notes -->
|
||||
${invoice.notes ? `
|
||||
<div style="margin-bottom: 30px;">
|
||||
<h3 style="margin: 0 0 15px 0; font-size: 16px; color: #1F2937; border-bottom: 1px solid #E5E7EB; padding-bottom: 5px;">Notes:</h3>
|
||||
<div style="background-color: #F9FAFB; border: 1px solid #E5E7EB; border-radius: 6px; padding: 15px; font-size: 14px; line-height: 1.6; color: #374151; white-space: pre-wrap;">
|
||||
${invoice.notes}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<!-- Footer -->
|
||||
<div style="margin-top: 40px; padding-top: 20px; border-top: 1px solid #E5E7EB; text-align: center; color: #6B7280; font-size: 12px;">
|
||||
<p style="margin: 0;">Thank you for your business!</p>
|
||||
<p style="margin: 5px 0 0 0;">Generated by beenvoice - Professional Invoicing Solution</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(tempDiv);
|
||||
|
||||
try {
|
||||
const canvas = await html2canvas(tempDiv, {
|
||||
scale: 2,
|
||||
useCORS: true,
|
||||
allowTaint: true,
|
||||
backgroundColor: '#ffffff',
|
||||
width: 800,
|
||||
height: tempDiv.scrollHeight,
|
||||
});
|
||||
|
||||
const imgData = canvas.toDataURL('image/png');
|
||||
const pdf = new jsPDF('p', 'mm', 'a4');
|
||||
|
||||
const imgWidth = 210; // A4 width in mm
|
||||
const pageHeight = 295; // A4 height in mm
|
||||
const imgHeight = (canvas.height * imgWidth) / canvas.width;
|
||||
let heightLeft = imgHeight;
|
||||
|
||||
let position = 0;
|
||||
|
||||
pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight);
|
||||
heightLeft -= pageHeight;
|
||||
|
||||
while (heightLeft >= 0) {
|
||||
position = heightLeft - imgHeight;
|
||||
pdf.addPage();
|
||||
pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight);
|
||||
heightLeft -= pageHeight;
|
||||
}
|
||||
|
||||
pdf.save(`${invoice.invoiceNumber}.pdf`);
|
||||
} finally {
|
||||
document.body.removeChild(tempDiv);
|
||||
}
|
||||
}
|
||||
976
src/lib/pdf-export.tsx
Normal file
976
src/lib/pdf-export.tsx
Normal file
@@ -0,0 +1,976 @@
|
||||
import {
|
||||
Document,
|
||||
Page,
|
||||
Text,
|
||||
View,
|
||||
StyleSheet,
|
||||
Font,
|
||||
Image,
|
||||
pdf,
|
||||
} from "@react-pdf/renderer";
|
||||
import { saveAs } from "file-saver";
|
||||
import React from "react";
|
||||
|
||||
// Register Inter font
|
||||
Font.register({
|
||||
family: "Inter",
|
||||
src: "/fonts/inter/Inter-Variable.ttf",
|
||||
fontWeight: "normal",
|
||||
});
|
||||
|
||||
Font.register({
|
||||
family: "Inter",
|
||||
src: "/fonts/inter/Inter-Italic-Variable.ttf",
|
||||
fontStyle: "italic",
|
||||
});
|
||||
|
||||
// Register Azeret Mono fonts for numbers and tables - multiple weights
|
||||
Font.register({
|
||||
family: "AzeretMono",
|
||||
src: "/fonts/azeret/AzeretMono-Regular.ttf",
|
||||
fontWeight: "normal",
|
||||
});
|
||||
|
||||
Font.register({
|
||||
family: "AzeretMono",
|
||||
src: "/fonts/azeret/AzeretMono-Regular.ttf",
|
||||
fontWeight: "semibold",
|
||||
});
|
||||
|
||||
Font.register({
|
||||
family: "AzeretMono",
|
||||
src: "/fonts/azeret/AzeretMono-Regular.ttf",
|
||||
fontWeight: "bold",
|
||||
});
|
||||
|
||||
Font.register({
|
||||
family: "AzeretMono",
|
||||
src: "/fonts/azeret/AzeretMono-Italic-Variable.ttf",
|
||||
fontStyle: "italic",
|
||||
});
|
||||
|
||||
interface InvoiceData {
|
||||
invoiceNumber: string;
|
||||
issueDate: Date;
|
||||
dueDate: Date;
|
||||
status: string;
|
||||
totalAmount: number;
|
||||
taxRate: number;
|
||||
notes?: string | null;
|
||||
business?: {
|
||||
name: string;
|
||||
email?: string | null;
|
||||
phone?: string | null;
|
||||
addressLine1?: string | null;
|
||||
addressLine2?: string | null;
|
||||
city?: string | null;
|
||||
state?: string | null;
|
||||
postalCode?: string | null;
|
||||
country?: string | null;
|
||||
website?: string | null;
|
||||
taxId?: string | null;
|
||||
} | null;
|
||||
client?: {
|
||||
name: string;
|
||||
email?: string | null;
|
||||
phone?: string | null;
|
||||
addressLine1?: string | null;
|
||||
addressLine2?: string | null;
|
||||
city?: string | null;
|
||||
state?: string | null;
|
||||
postalCode?: string | null;
|
||||
country?: string | null;
|
||||
} | null;
|
||||
items?: Array<{
|
||||
date: Date;
|
||||
description: string;
|
||||
hours: number;
|
||||
rate: number;
|
||||
amount: number;
|
||||
} | null> | null;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
page: {
|
||||
flexDirection: "column",
|
||||
backgroundColor: "#ffffff",
|
||||
fontFamily: "Inter",
|
||||
fontSize: 10,
|
||||
paddingTop: 40,
|
||||
paddingBottom: 80,
|
||||
paddingHorizontal: 40,
|
||||
},
|
||||
|
||||
// Dense header (first page)
|
||||
denseHeader: {
|
||||
marginBottom: 30,
|
||||
borderBottom: "2px solid #16a34a",
|
||||
paddingBottom: 20,
|
||||
},
|
||||
|
||||
headerTop: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
marginBottom: 20,
|
||||
},
|
||||
|
||||
businessSection: {
|
||||
flexDirection: "column",
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
businessName: {
|
||||
fontSize: 24,
|
||||
fontWeight: "bold",
|
||||
color: "#1f2937",
|
||||
marginBottom: 4,
|
||||
},
|
||||
|
||||
businessInfo: {
|
||||
fontSize: 11,
|
||||
color: "#6b7280",
|
||||
marginBottom: 2,
|
||||
},
|
||||
|
||||
businessAddress: {
|
||||
fontSize: 11,
|
||||
color: "#6b7280",
|
||||
lineHeight: 1.4,
|
||||
marginTop: 4,
|
||||
},
|
||||
|
||||
invoiceSection: {
|
||||
flexDirection: "column",
|
||||
alignItems: "flex-end",
|
||||
},
|
||||
|
||||
invoiceTitle: {
|
||||
fontSize: 32,
|
||||
fontWeight: "bold",
|
||||
color: "#16a34a",
|
||||
marginBottom: 8,
|
||||
},
|
||||
|
||||
invoiceNumber: {
|
||||
fontSize: 15,
|
||||
fontWeight: "semibold",
|
||||
fontFamily: "AzeretMono",
|
||||
color: "#1f2937",
|
||||
marginBottom: 4,
|
||||
},
|
||||
|
||||
statusBadge: {
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 4,
|
||||
fontSize: 12,
|
||||
fontWeight: "bold",
|
||||
textAlign: "center",
|
||||
},
|
||||
|
||||
statusPaid: {
|
||||
backgroundColor: "#dcfce7",
|
||||
color: "#166534",
|
||||
},
|
||||
|
||||
statusUnpaid: {
|
||||
backgroundColor: "#fef3c7",
|
||||
color: "#92400e",
|
||||
},
|
||||
|
||||
// Details section (first page only)
|
||||
detailsSection: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: 20,
|
||||
},
|
||||
|
||||
detailsColumn: {
|
||||
flex: 1,
|
||||
marginRight: 20,
|
||||
},
|
||||
|
||||
sectionTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: "bold",
|
||||
color: "#1f2937",
|
||||
marginBottom: 12,
|
||||
},
|
||||
|
||||
clientName: {
|
||||
fontSize: 13,
|
||||
fontWeight: "bold",
|
||||
color: "#1f2937",
|
||||
marginBottom: 4,
|
||||
},
|
||||
|
||||
clientInfo: {
|
||||
fontSize: 11,
|
||||
color: "#6b7280",
|
||||
marginBottom: 2,
|
||||
},
|
||||
|
||||
clientAddress: {
|
||||
fontSize: 11,
|
||||
color: "#6b7280",
|
||||
lineHeight: 1.4,
|
||||
marginTop: 4,
|
||||
},
|
||||
|
||||
detailRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: 4,
|
||||
},
|
||||
|
||||
detailLabel: {
|
||||
fontSize: 11,
|
||||
color: "#6b7280",
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
detailValue: {
|
||||
fontSize: 10,
|
||||
fontFamily: "AzeretMono",
|
||||
color: "#1f2937",
|
||||
fontWeight: "semibold",
|
||||
flex: 1,
|
||||
textAlign: "right",
|
||||
},
|
||||
|
||||
// Notes section (first page only)
|
||||
notesSection: {
|
||||
marginTop: 20,
|
||||
marginBottom: 20,
|
||||
padding: 15,
|
||||
backgroundColor: "#f9fafb",
|
||||
borderRadius: 4,
|
||||
border: "1px solid #e5e7eb",
|
||||
},
|
||||
|
||||
notesTitle: {
|
||||
fontSize: 12,
|
||||
fontWeight: "bold",
|
||||
color: "#1f2937",
|
||||
marginBottom: 6,
|
||||
},
|
||||
|
||||
notesContent: {
|
||||
fontSize: 10,
|
||||
color: "#6b7280",
|
||||
lineHeight: 1.4,
|
||||
},
|
||||
|
||||
// Separator styles
|
||||
headerSeparator: {
|
||||
height: 1,
|
||||
backgroundColor: "#e5e7eb",
|
||||
marginVertical: 8,
|
||||
},
|
||||
|
||||
// Abridged header (other pages)
|
||||
abridgedHeader: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: 20,
|
||||
paddingBottom: 15,
|
||||
borderBottom: "1px solid #e5e7eb",
|
||||
},
|
||||
|
||||
abridgedBusinessName: {
|
||||
fontSize: 18,
|
||||
fontWeight: "bold",
|
||||
color: "#1f2937",
|
||||
},
|
||||
|
||||
abridgedInvoiceInfo: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 15,
|
||||
},
|
||||
|
||||
abridgedInvoiceTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: "bold",
|
||||
color: "#16a34a",
|
||||
},
|
||||
|
||||
abridgedInvoiceNumber: {
|
||||
fontSize: 13,
|
||||
fontWeight: "semibold",
|
||||
fontFamily: "AzeretMono",
|
||||
color: "#1f2937",
|
||||
},
|
||||
|
||||
// Table styles
|
||||
tableContainer: {
|
||||
flex: 1,
|
||||
marginBottom: 20,
|
||||
},
|
||||
|
||||
tableHeader: {
|
||||
flexDirection: "row",
|
||||
backgroundColor: "#f3f4f6",
|
||||
borderBottom: "2px solid #16a34a",
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
|
||||
tableHeaderCell: {
|
||||
fontSize: 11,
|
||||
fontWeight: "bold",
|
||||
color: "#1f2937",
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
|
||||
tableHeaderDate: {
|
||||
width: "15%",
|
||||
},
|
||||
|
||||
tableHeaderDescription: {
|
||||
width: "40%",
|
||||
},
|
||||
|
||||
tableHeaderHours: {
|
||||
width: "12%",
|
||||
textAlign: "right",
|
||||
},
|
||||
|
||||
tableHeaderRate: {
|
||||
width: "15%",
|
||||
textAlign: "right",
|
||||
},
|
||||
|
||||
tableHeaderAmount: {
|
||||
width: "18%",
|
||||
textAlign: "right",
|
||||
},
|
||||
|
||||
tableRow: {
|
||||
flexDirection: "row",
|
||||
borderBottom: "1px solid #e5e7eb",
|
||||
paddingVertical: 2,
|
||||
paddingHorizontal: 4,
|
||||
alignItems: "flex-start",
|
||||
},
|
||||
|
||||
tableRowAlt: {
|
||||
backgroundColor: "#f9fafb",
|
||||
},
|
||||
|
||||
tableCell: {
|
||||
fontSize: 10,
|
||||
color: "#1f2937",
|
||||
paddingHorizontal: 4,
|
||||
paddingVertical: 2,
|
||||
},
|
||||
|
||||
tableCellDate: {
|
||||
width: "15%",
|
||||
fontFamily: "AzeretMono",
|
||||
fontWeight: "semibold",
|
||||
alignSelf: "flex-start",
|
||||
},
|
||||
|
||||
tableCellDescription: {
|
||||
width: "40%",
|
||||
lineHeight: 1.3,
|
||||
alignSelf: "flex-start",
|
||||
},
|
||||
|
||||
tableCellHours: {
|
||||
width: "12%",
|
||||
textAlign: "right",
|
||||
fontFamily: "AzeretMono",
|
||||
fontWeight: "semibold",
|
||||
alignSelf: "flex-start",
|
||||
},
|
||||
|
||||
tableCellRate: {
|
||||
width: "15%",
|
||||
textAlign: "right",
|
||||
fontFamily: "AzeretMono",
|
||||
fontWeight: "semibold",
|
||||
alignSelf: "flex-start",
|
||||
},
|
||||
|
||||
tableCellAmount: {
|
||||
width: "18%",
|
||||
textAlign: "right",
|
||||
fontFamily: "AzeretMono",
|
||||
fontWeight: "bold",
|
||||
alignSelf: "flex-start",
|
||||
},
|
||||
|
||||
// Totals section
|
||||
totalsSection: {
|
||||
marginTop: 20,
|
||||
flexDirection: "row",
|
||||
justifyContent: "flex-end",
|
||||
},
|
||||
|
||||
totalsBox: {
|
||||
width: 250,
|
||||
padding: 15,
|
||||
backgroundColor: "#f9fafb",
|
||||
border: "1px solid #e5e7eb",
|
||||
borderRadius: 4,
|
||||
},
|
||||
|
||||
totalRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: 6,
|
||||
},
|
||||
|
||||
totalLabel: {
|
||||
fontSize: 11,
|
||||
color: "#6b7280",
|
||||
},
|
||||
|
||||
totalAmount: {
|
||||
fontSize: 10,
|
||||
fontFamily: "AzeretMono",
|
||||
color: "#1f2937",
|
||||
fontWeight: "semibold",
|
||||
},
|
||||
|
||||
finalTotalRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
marginTop: 8,
|
||||
paddingTop: 8,
|
||||
borderTop: "2px solid #16a34a",
|
||||
},
|
||||
|
||||
finalTotalLabel: {
|
||||
fontSize: 14,
|
||||
fontWeight: "bold",
|
||||
color: "#1f2937",
|
||||
},
|
||||
|
||||
finalTotalAmount: {
|
||||
fontSize: 15,
|
||||
fontFamily: "AzeretMono",
|
||||
fontWeight: "bold",
|
||||
color: "#16a34a",
|
||||
},
|
||||
|
||||
itemCount: {
|
||||
fontSize: 9,
|
||||
color: "#6b7280",
|
||||
textAlign: "center",
|
||||
marginTop: 6,
|
||||
fontStyle: "italic",
|
||||
},
|
||||
|
||||
// Footer
|
||||
footer: {
|
||||
position: "absolute",
|
||||
bottom: 30,
|
||||
left: 40,
|
||||
right: 40,
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
paddingTop: 15,
|
||||
borderTop: "1px solid #e5e7eb",
|
||||
},
|
||||
|
||||
footerLogo: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
},
|
||||
|
||||
pageNumber: {
|
||||
fontSize: 10,
|
||||
color: "#6b7280",
|
||||
},
|
||||
});
|
||||
|
||||
// Helper functions
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return new Date(date).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
switch (status) {
|
||||
case "paid":
|
||||
return "PAID";
|
||||
case "draft":
|
||||
case "sent":
|
||||
case "overdue":
|
||||
default:
|
||||
return "UNPAID";
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusStyle = (status: string) => {
|
||||
switch (status) {
|
||||
case "paid":
|
||||
return [styles.statusBadge, styles.statusPaid];
|
||||
default:
|
||||
return [styles.statusBadge, styles.statusUnpaid];
|
||||
}
|
||||
};
|
||||
|
||||
// Dynamic pagination calculation based on page height
|
||||
function calculateItemsPerPage(
|
||||
isFirstPage: boolean,
|
||||
hasNotes: boolean,
|
||||
): number {
|
||||
// Estimate available space in points (1 point = 1/72 inch)
|
||||
const pageHeight = 792; // Letter size height in points
|
||||
const margins = 80; // Top + bottom margins
|
||||
const footerSpace = 60; // Footer space
|
||||
|
||||
let availableHeight = pageHeight - margins - footerSpace;
|
||||
|
||||
if (isFirstPage) {
|
||||
// Dense header takes significant space
|
||||
availableHeight -= 200; // Dense header space
|
||||
} else {
|
||||
// Abridged header is smaller
|
||||
availableHeight -= 60; // Abridged header space
|
||||
}
|
||||
|
||||
if (hasNotes) {
|
||||
// Last page needs space for totals and notes
|
||||
availableHeight -= 120; // Totals + notes space
|
||||
} else {
|
||||
// Regular page just needs totals space
|
||||
availableHeight -= 80; // Totals space only
|
||||
}
|
||||
|
||||
// Table header takes space
|
||||
availableHeight -= 30; // Table header
|
||||
|
||||
// Each row is approximately 18 points (includes padding and text)
|
||||
const rowHeight = 18;
|
||||
|
||||
return Math.max(1, Math.floor(availableHeight / rowHeight));
|
||||
}
|
||||
|
||||
// Dynamic pagination function
|
||||
function paginateItems(
|
||||
items: NonNullable<InvoiceData["items"]>,
|
||||
hasNotes = false,
|
||||
) {
|
||||
const validItems = items.filter(Boolean);
|
||||
const pages: Array<typeof validItems> = [];
|
||||
|
||||
if (validItems.length === 0) {
|
||||
return [[]];
|
||||
}
|
||||
|
||||
let currentIndex = 0;
|
||||
let pageIndex = 0;
|
||||
|
||||
while (currentIndex < validItems.length) {
|
||||
const isFirstPage = pageIndex === 0;
|
||||
const remainingItems = validItems.length - currentIndex;
|
||||
|
||||
// Calculate items per page for this page
|
||||
let itemsPerPage = calculateItemsPerPage(isFirstPage, false);
|
||||
|
||||
// Check if this would create orphans (< 4 items on next page)
|
||||
if (remainingItems > itemsPerPage && remainingItems - itemsPerPage < 4) {
|
||||
// Distribute items more evenly to avoid orphans
|
||||
itemsPerPage = Math.floor(remainingItems / 2);
|
||||
}
|
||||
|
||||
// Check if this is the last page and needs space for totals/notes
|
||||
const isLastPage = currentIndex + itemsPerPage >= validItems.length;
|
||||
if (isLastPage && hasNotes) {
|
||||
// Recalculate with space for totals and notes
|
||||
const maxItemsWithNotes = calculateItemsPerPage(false, true);
|
||||
itemsPerPage = Math.min(itemsPerPage, maxItemsWithNotes);
|
||||
}
|
||||
|
||||
const pageItems = validItems.slice(
|
||||
currentIndex,
|
||||
currentIndex + itemsPerPage,
|
||||
);
|
||||
|
||||
pages.push(pageItems);
|
||||
currentIndex += itemsPerPage;
|
||||
pageIndex++;
|
||||
}
|
||||
|
||||
return pages;
|
||||
}
|
||||
|
||||
// Dense header component (first page)
|
||||
const DenseHeader: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => (
|
||||
<View style={styles.denseHeader}>
|
||||
<View style={styles.headerTop}>
|
||||
<View style={styles.businessSection}>
|
||||
<Text style={styles.businessName}>
|
||||
{invoice.business?.name ?? "Your Business Name"}
|
||||
</Text>
|
||||
{invoice.business?.email && (
|
||||
<Text style={styles.businessInfo}>{invoice.business.email}</Text>
|
||||
)}
|
||||
{invoice.business?.phone && (
|
||||
<Text style={styles.businessInfo}>{invoice.business.phone}</Text>
|
||||
)}
|
||||
{(invoice.business?.addressLine1 ??
|
||||
invoice.business?.city ??
|
||||
invoice.business?.state) && (
|
||||
<Text style={styles.businessAddress}>
|
||||
{[
|
||||
invoice.business?.addressLine1,
|
||||
invoice.business?.addressLine2,
|
||||
invoice.business?.city &&
|
||||
invoice.business?.state &&
|
||||
invoice.business?.postalCode
|
||||
? `${invoice.business.city}, ${invoice.business.state} ${invoice.business.postalCode}`
|
||||
: [
|
||||
invoice.business?.city,
|
||||
invoice.business?.state,
|
||||
invoice.business?.postalCode,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", "),
|
||||
invoice.business?.country,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n")}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.invoiceSection}>
|
||||
<Text style={styles.invoiceTitle}>INVOICE</Text>
|
||||
<Text style={styles.invoiceNumber}>#{invoice.invoiceNumber}</Text>
|
||||
<View style={getStatusStyle(invoice.status)}>
|
||||
<Text>{getStatusLabel(invoice.status)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.headerSeparator} />
|
||||
|
||||
<View style={styles.detailsSection}>
|
||||
<View style={styles.detailsColumn}>
|
||||
<Text style={styles.sectionTitle}>BILL TO:</Text>
|
||||
<Text style={styles.clientName}>{invoice.client?.name ?? "N/A"}</Text>
|
||||
{invoice.client?.email && (
|
||||
<Text style={styles.clientInfo}>{invoice.client.email}</Text>
|
||||
)}
|
||||
{invoice.client?.phone && (
|
||||
<Text style={styles.clientInfo}>{invoice.client.phone}</Text>
|
||||
)}
|
||||
{(invoice.client?.addressLine1 ??
|
||||
invoice.client?.city ??
|
||||
invoice.client?.state) && (
|
||||
<Text style={styles.clientAddress}>
|
||||
{[
|
||||
invoice.client?.addressLine1,
|
||||
invoice.client?.addressLine2,
|
||||
invoice.client?.city,
|
||||
invoice.client?.state,
|
||||
invoice.client?.postalCode,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", ")}
|
||||
{invoice.client?.country ? "\n" + invoice.client.country : ""}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.detailsColumn}>
|
||||
<Text style={styles.sectionTitle}>INVOICE DETAILS:</Text>
|
||||
<View style={styles.detailRow}>
|
||||
<Text style={styles.detailLabel}>Issue Date:</Text>
|
||||
<Text style={styles.detailValue}>
|
||||
{formatDate(invoice.issueDate)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.detailRow}>
|
||||
<Text style={styles.detailLabel}>Due Date:</Text>
|
||||
<Text style={styles.detailValue}>{formatDate(invoice.dueDate)}</Text>
|
||||
</View>
|
||||
<View style={styles.detailRow}>
|
||||
<Text style={styles.detailLabel}>Invoice #:</Text>
|
||||
<Text style={styles.detailValue}>{invoice.invoiceNumber}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
// Abridged header component (other pages)
|
||||
const AbridgedHeader: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => (
|
||||
<View style={styles.abridgedHeader}>
|
||||
<Text style={styles.abridgedBusinessName}>
|
||||
{invoice.business?.name ?? "Your Business Name"}
|
||||
</Text>
|
||||
<View style={styles.abridgedInvoiceInfo}>
|
||||
<Text style={styles.abridgedInvoiceTitle}>INVOICE</Text>
|
||||
<Text style={styles.abridgedInvoiceNumber}>#{invoice.invoiceNumber}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
// Table header component
|
||||
const TableHeader: React.FC = () => (
|
||||
<View style={styles.tableHeader}>
|
||||
<Text style={[styles.tableHeaderCell, styles.tableHeaderDate]}>Date</Text>
|
||||
<Text style={[styles.tableHeaderCell, styles.tableHeaderDescription]}>
|
||||
Description
|
||||
</Text>
|
||||
<Text style={[styles.tableHeaderCell, styles.tableHeaderHours]}>Hours</Text>
|
||||
<Text style={[styles.tableHeaderCell, styles.tableHeaderRate]}>Rate</Text>
|
||||
<Text style={[styles.tableHeaderCell, styles.tableHeaderAmount]}>
|
||||
Amount
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
// Footer component
|
||||
const NotesSection: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => {
|
||||
if (!invoice.notes) return null;
|
||||
|
||||
return (
|
||||
<View style={styles.notesSection}>
|
||||
<Text style={styles.notesTitle}>Notes:</Text>
|
||||
<Text style={styles.notesContent}>{invoice.notes}</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const Footer: React.FC = () => (
|
||||
<View style={styles.footer} fixed>
|
||||
<View style={styles.footerLogo}>
|
||||
<Image
|
||||
src="/beenvoice-logo.png"
|
||||
style={{
|
||||
height: 24,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
<Text
|
||||
style={styles.pageNumber}
|
||||
render={({ pageNumber, totalPages }) =>
|
||||
`Page ${pageNumber} of ${totalPages}`
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
// Totals section component
|
||||
const TotalsSection: React.FC<{
|
||||
invoice: InvoiceData;
|
||||
items: Array<NonNullable<InvoiceData["items"]>[0]>;
|
||||
}> = ({ invoice, items }) => {
|
||||
const subtotal = items.reduce((sum, item) => sum + (item?.amount ?? 0), 0);
|
||||
const taxAmount = (subtotal * invoice.taxRate) / 100;
|
||||
|
||||
return (
|
||||
<View style={styles.totalsSection}>
|
||||
<View style={styles.totalsBox}>
|
||||
<View style={styles.totalRow}>
|
||||
<Text style={styles.totalLabel}>Subtotal:</Text>
|
||||
<Text style={styles.totalAmount}>{formatCurrency(subtotal)}</Text>
|
||||
</View>
|
||||
|
||||
{invoice.taxRate > 0 && (
|
||||
<View style={styles.totalRow}>
|
||||
<Text style={styles.totalLabel}>Tax ({invoice.taxRate}%):</Text>
|
||||
<Text style={styles.totalAmount}>{formatCurrency(taxAmount)}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.finalTotalRow}>
|
||||
<Text style={styles.finalTotalLabel}>Total:</Text>
|
||||
<Text style={styles.finalTotalAmount}>
|
||||
{formatCurrency(invoice.totalAmount)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text style={styles.itemCount}>
|
||||
{items.length} item{items.length !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// Main PDF component
|
||||
const InvoicePDF: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => {
|
||||
const items = invoice.items?.filter(Boolean) ?? [];
|
||||
const paginatedItems = paginateItems(items, Boolean(invoice.notes));
|
||||
|
||||
return (
|
||||
<Document>
|
||||
{paginatedItems.map((pageItems, pageIndex) => {
|
||||
const isFirstPage = pageIndex === 0;
|
||||
const isLastPage = pageIndex === paginatedItems.length - 1;
|
||||
const hasItems = pageItems.length > 0;
|
||||
|
||||
return (
|
||||
<Page key={`page-${pageIndex}`} size="LETTER" style={styles.page}>
|
||||
{/* Header */}
|
||||
{isFirstPage ? (
|
||||
<DenseHeader invoice={invoice} />
|
||||
) : (
|
||||
<AbridgedHeader invoice={invoice} />
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
{hasItems && (
|
||||
<View style={styles.tableContainer}>
|
||||
<TableHeader />
|
||||
{pageItems.map(
|
||||
(item, index) =>
|
||||
item && (
|
||||
<View
|
||||
key={`${pageIndex}-${index}`}
|
||||
style={[
|
||||
styles.tableRow,
|
||||
index % 2 === 0 ? styles.tableRowAlt : {},
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.tableCell, styles.tableCellDate]}>
|
||||
{formatDate(item.date)}
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
styles.tableCell,
|
||||
styles.tableCellDescription,
|
||||
]}
|
||||
>
|
||||
{item.description}
|
||||
</Text>
|
||||
<Text style={[styles.tableCell, styles.tableCellHours]}>
|
||||
{item.hours}
|
||||
</Text>
|
||||
<Text style={[styles.tableCell, styles.tableCellRate]}>
|
||||
{formatCurrency(item.rate)}
|
||||
</Text>
|
||||
<Text
|
||||
style={[styles.tableCell, styles.tableCellAmount]}
|
||||
>
|
||||
{formatCurrency(item.amount)}
|
||||
</Text>
|
||||
</View>
|
||||
),
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Totals (only on last page) */}
|
||||
{isLastPage && <TotalsSection invoice={invoice} items={items} />}
|
||||
|
||||
{/* Notes (only on last page) */}
|
||||
{isLastPage && <NotesSection invoice={invoice} />}
|
||||
|
||||
{/* Footer */}
|
||||
<Footer />
|
||||
</Page>
|
||||
);
|
||||
})}
|
||||
</Document>
|
||||
);
|
||||
};
|
||||
|
||||
// Export functions
|
||||
export async function generateInvoicePDF(invoice: InvoiceData): Promise<void> {
|
||||
try {
|
||||
// Validate invoice data
|
||||
if (!invoice) {
|
||||
throw new Error("Invoice data is required");
|
||||
}
|
||||
|
||||
if (!invoice.invoiceNumber) {
|
||||
throw new Error("Invoice number is required");
|
||||
}
|
||||
|
||||
if (!invoice.client?.name) {
|
||||
throw new Error("Client information is required");
|
||||
}
|
||||
|
||||
// Generate PDF blob with timeout
|
||||
const pdfPromise = pdf(<InvoicePDF invoice={invoice} />).toBlob();
|
||||
const timeoutPromise = new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error("PDF generation timed out")), 30000),
|
||||
);
|
||||
|
||||
const blob = await Promise.race([pdfPromise, timeoutPromise]);
|
||||
|
||||
// Validate blob
|
||||
if (!blob || blob.size === 0) {
|
||||
throw new Error("Generated PDF is empty");
|
||||
}
|
||||
|
||||
// Create filename with timestamp for uniqueness
|
||||
const timestamp = new Date().toISOString().slice(0, 10);
|
||||
const filename = `Invoice_${invoice.invoiceNumber}_${timestamp}.pdf`;
|
||||
|
||||
// Download the PDF
|
||||
saveAs(blob, filename);
|
||||
} catch (error) {
|
||||
console.error("PDF generation error:", error);
|
||||
|
||||
// Provide more specific error messages
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes("timeout")) {
|
||||
throw new Error("PDF generation took too long. Please try again.");
|
||||
} else if (error.message.includes("empty")) {
|
||||
throw new Error("Generated PDF is invalid. Please try again.");
|
||||
} else if (error.message.includes("required")) {
|
||||
throw new Error(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Failed to generate PDF. Please try again.");
|
||||
}
|
||||
}
|
||||
|
||||
// Additional utility function for generating PDF without downloading
|
||||
export async function generateInvoicePDFBlob(
|
||||
invoice: InvoiceData,
|
||||
): Promise<Blob> {
|
||||
try {
|
||||
// Validate invoice data
|
||||
if (!invoice) {
|
||||
throw new Error("Invoice data is required");
|
||||
}
|
||||
|
||||
if (!invoice.invoiceNumber) {
|
||||
throw new Error("Invoice number is required");
|
||||
}
|
||||
|
||||
if (!invoice.client?.name) {
|
||||
throw new Error("Client information is required");
|
||||
}
|
||||
|
||||
// Generate PDF blob
|
||||
const blob = await pdf(<InvoicePDF invoice={invoice} />).toBlob();
|
||||
|
||||
// Validate blob
|
||||
if (!blob || blob.size === 0) {
|
||||
throw new Error("Generated PDF is empty");
|
||||
}
|
||||
|
||||
return blob;
|
||||
} catch (error) {
|
||||
console.error("PDF generation error:", error);
|
||||
throw new Error("Failed to generate PDF");
|
||||
}
|
||||
}
|
||||
54
src/middleware.ts
Normal file
54
src/middleware.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
// Define public routes that don't require authentication
|
||||
const publicRoutes = ["/", "/auth/signin", "/auth/register"];
|
||||
|
||||
// Define API routes that should be handled separately
|
||||
const apiRoutes = ["/api/auth", "/api/trpc"];
|
||||
|
||||
// Allow API routes to pass through
|
||||
if (apiRoutes.some((route) => pathname.startsWith(route))) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// Allow public routes for everyone
|
||||
if (publicRoutes.includes(pathname)) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// Check for session token in cookies (Auth.js v5 cookie names)
|
||||
const sessionToken =
|
||||
request.cookies.get("authjs.session-token")?.value ||
|
||||
request.cookies.get("__Secure-authjs.session-token")?.value ||
|
||||
request.cookies.get("next-auth.session-token")?.value ||
|
||||
request.cookies.get("__Secure-next-auth.session-token")?.value;
|
||||
|
||||
// If no session token, redirect to sign-in
|
||||
if (!sessionToken) {
|
||||
const signInUrl = new URL("/auth/signin", request.url);
|
||||
signInUrl.searchParams.set("callbackUrl", request.url);
|
||||
return NextResponse.redirect(signInUrl);
|
||||
}
|
||||
|
||||
// Session token exists, allow the request to proceed
|
||||
// The actual pages will validate the token properly
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
/*
|
||||
* Match all request paths except for the ones starting with:
|
||||
* - api/auth (Auth.js API routes)
|
||||
* - _next/static (static files)
|
||||
* - _next/image (image optimization files)
|
||||
* - favicon.ico (favicon file)
|
||||
* - public folder files
|
||||
*/
|
||||
"/((?!api/auth|_next/static|_next/image|favicon.ico|.*\\.png$|.*\\.jpg$|.*\\.jpeg$|.*\\.gif$|.*\\.svg$).*)",
|
||||
],
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import { clientsRouter } from "~/server/api/routers/clients";
|
||||
import { businessesRouter } from "~/server/api/routers/businesses";
|
||||
import { invoicesRouter } from "~/server/api/routers/invoices";
|
||||
import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc";
|
||||
|
||||
@@ -9,6 +10,7 @@ import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc";
|
||||
*/
|
||||
export const appRouter = createTRPCRouter({
|
||||
clients: clientsRouter,
|
||||
businesses: businessesRouter,
|
||||
invoices: invoicesRouter,
|
||||
});
|
||||
|
||||
|
||||
208
src/server/api/routers/businesses.ts
Normal file
208
src/server/api/routers/businesses.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { z } from "zod";
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
import { businesses } from "~/server/db/schema";
|
||||
import { eq, and, desc } from "drizzle-orm";
|
||||
import { invoices } from "~/server/db/schema";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
const businessSchema = z.object({
|
||||
name: z.string().min(1, "Business name is required"),
|
||||
email: z.string().email().optional().or(z.literal("")),
|
||||
phone: z.string().optional().or(z.literal("")),
|
||||
addressLine1: z.string().optional().or(z.literal("")),
|
||||
addressLine2: z.string().optional().or(z.literal("")),
|
||||
city: z.string().optional().or(z.literal("")),
|
||||
state: z.string().optional().or(z.literal("")),
|
||||
postalCode: z.string().optional().or(z.literal("")),
|
||||
country: z.string().optional().or(z.literal("")),
|
||||
website: z.string().url().optional().or(z.literal("")),
|
||||
taxId: z.string().optional().or(z.literal("")),
|
||||
logoUrl: z.string().optional().or(z.literal("")),
|
||||
isDefault: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export const businessesRouter = createTRPCRouter({
|
||||
// Get all businesses for the current user
|
||||
getAll: protectedProcedure.query(async ({ ctx }) => {
|
||||
const userBusinesses = await ctx.db
|
||||
.select()
|
||||
.from(businesses)
|
||||
.where(eq(businesses.createdById, ctx.session.user.id))
|
||||
.orderBy(desc(businesses.isDefault), desc(businesses.createdAt));
|
||||
|
||||
return userBusinesses;
|
||||
}),
|
||||
|
||||
// Get a single business by ID
|
||||
getById: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const business = await ctx.db
|
||||
.select()
|
||||
.from(businesses)
|
||||
.where(
|
||||
and(
|
||||
eq(businesses.id, input.id),
|
||||
eq(businesses.createdById, ctx.session.user.id)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return business[0];
|
||||
}),
|
||||
|
||||
// Get default business for the current user
|
||||
getDefault: protectedProcedure.query(async ({ ctx }) => {
|
||||
const defaultBusiness = await ctx.db
|
||||
.select()
|
||||
.from(businesses)
|
||||
.where(
|
||||
and(
|
||||
eq(businesses.createdById, ctx.session.user.id),
|
||||
eq(businesses.isDefault, true)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return defaultBusiness[0];
|
||||
}),
|
||||
|
||||
// Create a new business
|
||||
create: protectedProcedure
|
||||
.input(businessSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// If this is the first business or isDefault is true, unset other defaults
|
||||
if (input.isDefault) {
|
||||
await ctx.db
|
||||
.update(businesses)
|
||||
.set({ isDefault: false })
|
||||
.where(eq(businesses.createdById, ctx.session.user.id));
|
||||
}
|
||||
|
||||
const [newBusiness] = await ctx.db
|
||||
.insert(businesses)
|
||||
.values({
|
||||
...input,
|
||||
createdById: ctx.session.user.id,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return newBusiness;
|
||||
}),
|
||||
|
||||
// Update an existing business
|
||||
update: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
...businessSchema.shape,
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { id, ...updateData } = input;
|
||||
|
||||
// If setting this business as default, unset other defaults
|
||||
if (updateData.isDefault) {
|
||||
await ctx.db
|
||||
.update(businesses)
|
||||
.set({ isDefault: false })
|
||||
.where(
|
||||
and(
|
||||
eq(businesses.createdById, ctx.session.user.id),
|
||||
eq(businesses.id, id)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const [updatedBusiness] = await ctx.db
|
||||
.update(businesses)
|
||||
.set({
|
||||
...updateData,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(businesses.id, id),
|
||||
eq(businesses.createdById, ctx.session.user.id)
|
||||
)
|
||||
)
|
||||
.returning();
|
||||
|
||||
if (!updatedBusiness) {
|
||||
throw new Error("Business not found or you don't have permission to update it");
|
||||
}
|
||||
|
||||
return updatedBusiness;
|
||||
}),
|
||||
|
||||
// Delete a business
|
||||
delete: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Check if business exists and belongs to user
|
||||
const business = await ctx.db
|
||||
.select()
|
||||
.from(businesses)
|
||||
.where(
|
||||
and(
|
||||
eq(businesses.id, input.id),
|
||||
eq(businesses.createdById, ctx.session.user.id)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!business[0]) {
|
||||
throw new Error("Business not found or you don't have permission to delete it");
|
||||
}
|
||||
|
||||
// Check if this business has any invoices
|
||||
const invoiceCount = await ctx.db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(invoices)
|
||||
.where(eq(invoices.businessId, input.id));
|
||||
|
||||
if (invoiceCount[0] && invoiceCount[0].count > 0) {
|
||||
throw new Error("Cannot delete business that has invoices. Please delete all invoices first.");
|
||||
}
|
||||
|
||||
await ctx.db
|
||||
.delete(businesses)
|
||||
.where(
|
||||
and(
|
||||
eq(businesses.id, input.id),
|
||||
eq(businesses.createdById, ctx.session.user.id)
|
||||
)
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
// Set a business as default
|
||||
setDefault: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// First, unset all other defaults for this user
|
||||
await ctx.db
|
||||
.update(businesses)
|
||||
.set({ isDefault: false })
|
||||
.where(eq(businesses.createdById, ctx.session.user.id));
|
||||
|
||||
// Then set the specified business as default
|
||||
const [updatedBusiness] = await ctx.db
|
||||
.update(businesses)
|
||||
.set({ isDefault: true })
|
||||
.where(
|
||||
and(
|
||||
eq(businesses.id, input.id),
|
||||
eq(businesses.createdById, ctx.session.user.id)
|
||||
)
|
||||
)
|
||||
.returning();
|
||||
|
||||
if (!updatedBusiness) {
|
||||
throw new Error("Business not found or you don't have permission to update it");
|
||||
}
|
||||
|
||||
return updatedBusiness;
|
||||
}),
|
||||
});
|
||||
@@ -1,18 +1,19 @@
|
||||
import { z } from "zod";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
import { clients } from "~/server/db/schema";
|
||||
import { clients, invoices } from "~/server/db/schema";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
const createClientSchema = z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
email: z.string().email("Invalid email").optional(),
|
||||
phone: z.string().optional(),
|
||||
addressLine1: z.string().optional(),
|
||||
addressLine2: z.string().optional(),
|
||||
city: z.string().optional(),
|
||||
state: z.string().optional(),
|
||||
postalCode: z.string().optional(),
|
||||
country: z.string().optional(),
|
||||
name: z.string().min(1, "Name is required").max(255, "Name is too long"),
|
||||
email: z.string().email("Invalid email").optional().or(z.literal("")),
|
||||
phone: z.string().max(50, "Phone number is too long").optional().or(z.literal("")),
|
||||
addressLine1: z.string().max(255, "Address is too long").optional().or(z.literal("")),
|
||||
addressLine2: z.string().max(255, "Address is too long").optional().or(z.literal("")),
|
||||
city: z.string().max(100, "City name is too long").optional().or(z.literal("")),
|
||||
state: z.string().max(50, "State name is too long").optional().or(z.literal("")),
|
||||
postalCode: z.string().max(20, "Postal code is too long").optional().or(z.literal("")),
|
||||
country: z.string().max(100, "Country name is too long").optional().or(z.literal("")),
|
||||
});
|
||||
|
||||
const updateClientSchema = createClientSchema.partial().extend({
|
||||
@@ -21,16 +22,25 @@ const updateClientSchema = createClientSchema.partial().extend({
|
||||
|
||||
export const clientsRouter = createTRPCRouter({
|
||||
getAll: protectedProcedure.query(async ({ ctx }) => {
|
||||
try {
|
||||
return await ctx.db.query.clients.findMany({
|
||||
where: eq(clients.createdById, ctx.session.user.id),
|
||||
orderBy: (clients, { desc }) => [desc(clients.createdAt)],
|
||||
});
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to fetch clients",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
getById: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return await ctx.db.query.clients.findFirst({
|
||||
try {
|
||||
const client = await ctx.db.query.clients.findFirst({
|
||||
where: eq(clients.id, input.id),
|
||||
with: {
|
||||
invoices: {
|
||||
@@ -38,33 +48,174 @@ export const clientsRouter = createTRPCRouter({
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!client) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Client not found",
|
||||
});
|
||||
}
|
||||
|
||||
// Check if user owns this client
|
||||
if (client.createdById !== ctx.session.user.id) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You don't have permission to view this client",
|
||||
});
|
||||
}
|
||||
|
||||
return client;
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) throw error;
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to fetch client",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
create: protectedProcedure
|
||||
.input(createClientSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return await ctx.db.insert(clients).values({
|
||||
...input,
|
||||
try {
|
||||
// Clean up empty strings to null, but preserve required fields
|
||||
const cleanInput = Object.fromEntries(
|
||||
Object.entries(input).map(([key, value]) => [
|
||||
key,
|
||||
value === "" ? null : value,
|
||||
])
|
||||
);
|
||||
|
||||
const [client] = await ctx.db.insert(clients).values({
|
||||
name: input.name, // Ensure name is included
|
||||
...cleanInput,
|
||||
createdById: ctx.session.user.id,
|
||||
}).returning();
|
||||
|
||||
if (!client) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to create client",
|
||||
});
|
||||
}
|
||||
|
||||
return client;
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) throw error;
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to create client",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
update: protectedProcedure
|
||||
.input(updateClientSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
try {
|
||||
const { id, ...data } = input;
|
||||
return await ctx.db
|
||||
|
||||
// Verify client exists and belongs to user
|
||||
const existingClient = await ctx.db.query.clients.findFirst({
|
||||
where: eq(clients.id, id),
|
||||
});
|
||||
|
||||
if (!existingClient) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Client not found",
|
||||
});
|
||||
}
|
||||
|
||||
if (existingClient.createdById !== ctx.session.user.id) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You don't have permission to update this client",
|
||||
});
|
||||
}
|
||||
|
||||
// Clean up empty strings to null
|
||||
const cleanData = Object.fromEntries(
|
||||
Object.entries(data).map(([key, value]) => [
|
||||
key,
|
||||
value === "" ? null : value,
|
||||
])
|
||||
);
|
||||
|
||||
const [updatedClient] = await ctx.db
|
||||
.update(clients)
|
||||
.set({
|
||||
...data,
|
||||
...cleanData,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(clients.id, id));
|
||||
.where(eq(clients.id, id))
|
||||
.returning();
|
||||
|
||||
if (!updatedClient) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to update client",
|
||||
});
|
||||
}
|
||||
|
||||
return updatedClient;
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) throw error;
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to update client",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
delete: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return await ctx.db.delete(clients).where(eq(clients.id, input.id));
|
||||
try {
|
||||
// Verify client exists and belongs to user
|
||||
const client = await ctx.db.query.clients.findFirst({
|
||||
where: eq(clients.id, input.id),
|
||||
});
|
||||
|
||||
if (!client) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Client not found",
|
||||
});
|
||||
}
|
||||
|
||||
if (client.createdById !== ctx.session.user.id) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You don't have permission to delete this client",
|
||||
});
|
||||
}
|
||||
|
||||
// Check if client has invoices
|
||||
const clientInvoices = await ctx.db.query.invoices.findMany({
|
||||
where: eq(invoices.clientId, input.id),
|
||||
});
|
||||
|
||||
if (clientInvoices.length > 0) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Cannot delete client with existing invoices. Please delete or reassign the invoices first.",
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.db.delete(clients).where(eq(clients.id, input.id));
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) throw error;
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to delete client",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
import { z } from "zod";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
import { invoices, invoiceItems, clients } from "~/server/db/schema";
|
||||
import { invoices, invoiceItems, clients, businesses } from "~/server/db/schema";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
const invoiceItemSchema = z.object({
|
||||
date: z.date(),
|
||||
@@ -12,11 +13,13 @@ const invoiceItemSchema = z.object({
|
||||
|
||||
const createInvoiceSchema = z.object({
|
||||
invoiceNumber: z.string().min(1, "Invoice number is required"),
|
||||
businessId: z.string().min(1, "Business is required").optional(),
|
||||
clientId: z.string().min(1, "Client is required"),
|
||||
issueDate: z.date(),
|
||||
dueDate: z.date(),
|
||||
status: z.enum(["draft", "sent", "paid", "overdue"]).default("draft"),
|
||||
notes: z.string().optional(),
|
||||
taxRate: z.number().min(0).max(100).default(0),
|
||||
items: z.array(invoiceItemSchema).min(1, "At least one item is required"),
|
||||
});
|
||||
|
||||
@@ -24,39 +27,123 @@ const updateInvoiceSchema = createInvoiceSchema.partial().extend({
|
||||
id: z.string(),
|
||||
});
|
||||
|
||||
const updateStatusSchema = z.object({
|
||||
id: z.string(),
|
||||
status: z.enum(["draft", "sent", "paid", "overdue"]),
|
||||
});
|
||||
|
||||
export const invoicesRouter = createTRPCRouter({
|
||||
getAll: protectedProcedure.query(async ({ ctx }) => {
|
||||
try {
|
||||
return await ctx.db.query.invoices.findMany({
|
||||
where: eq(invoices.createdById, ctx.session.user.id),
|
||||
with: {
|
||||
business: true,
|
||||
client: true,
|
||||
items: true,
|
||||
},
|
||||
orderBy: (invoices, { desc }) => [desc(invoices.createdAt)],
|
||||
});
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to fetch invoices",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
getById: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return await ctx.db.query.invoices.findFirst({
|
||||
try {
|
||||
const invoice = await ctx.db.query.invoices.findFirst({
|
||||
where: eq(invoices.id, input.id),
|
||||
with: {
|
||||
business: true,
|
||||
client: true,
|
||||
items: {
|
||||
orderBy: (items, { asc }) => [asc(items.date)],
|
||||
orderBy: (items, { asc }) => [asc(items.position)],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!invoice) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Invoice not found",
|
||||
});
|
||||
}
|
||||
|
||||
// Check if user owns this invoice
|
||||
if (invoice.createdById !== ctx.session.user.id) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You don't have permission to view this invoice",
|
||||
});
|
||||
}
|
||||
|
||||
return invoice;
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) throw error;
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to fetch invoice",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
create: protectedProcedure
|
||||
.input(createInvoiceSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
try {
|
||||
const { items, ...invoiceData } = input;
|
||||
|
||||
// Verify business exists and belongs to user (if provided)
|
||||
if (invoiceData.businessId) {
|
||||
const business = await ctx.db.query.businesses.findFirst({
|
||||
where: eq(businesses.id, invoiceData.businessId),
|
||||
});
|
||||
|
||||
if (!business) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Business not found",
|
||||
});
|
||||
}
|
||||
|
||||
if (business.createdById !== ctx.session.user.id) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You don't have permission to create invoices for this business",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Verify client exists and belongs to user
|
||||
const client = await ctx.db.query.clients.findFirst({
|
||||
where: eq(clients.id, invoiceData.clientId),
|
||||
});
|
||||
|
||||
if (!client) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Client not found",
|
||||
});
|
||||
}
|
||||
|
||||
if (client.createdById !== ctx.session.user.id) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You don't have permission to create invoices for this client",
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate total amount
|
||||
const totalAmount = items.reduce((sum, item) => sum + (item.hours * item.rate), 0);
|
||||
// Calculate subtotal and tax
|
||||
const subtotal = items.reduce((sum, item) => sum + (item.hours * item.rate), 0);
|
||||
const taxAmount = (subtotal * invoiceData.taxRate) / 100;
|
||||
const totalAmount = subtotal + taxAmount;
|
||||
|
||||
// Create invoice
|
||||
const [invoice] = await ctx.db.insert(invoices).values({
|
||||
@@ -66,29 +153,91 @@ export const invoicesRouter = createTRPCRouter({
|
||||
}).returning();
|
||||
|
||||
if (!invoice) {
|
||||
throw new Error("Failed to create invoice");
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to create invoice",
|
||||
});
|
||||
}
|
||||
|
||||
// Create invoice items
|
||||
const itemsToInsert = items.map(item => ({
|
||||
const itemsToInsert = items.map((item, idx) => ({
|
||||
...item,
|
||||
invoiceId: invoice.id,
|
||||
amount: item.hours * item.rate,
|
||||
position: idx,
|
||||
}));
|
||||
|
||||
await ctx.db.insert(invoiceItems).values(itemsToInsert);
|
||||
|
||||
return invoice;
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) throw error;
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to create invoice",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
update: protectedProcedure
|
||||
.input(updateInvoiceSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
try {
|
||||
const { id, items, ...invoiceData } = input;
|
||||
|
||||
// Verify invoice exists and belongs to user
|
||||
const existingInvoice = await ctx.db.query.invoices.findFirst({
|
||||
where: eq(invoices.id, id),
|
||||
});
|
||||
|
||||
if (!existingInvoice) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Invoice not found",
|
||||
});
|
||||
}
|
||||
|
||||
if (existingInvoice.createdById !== ctx.session.user.id) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You don't have permission to update this invoice",
|
||||
});
|
||||
}
|
||||
|
||||
// If business is being updated, verify it belongs to user
|
||||
if (invoiceData.businessId) {
|
||||
const business = await ctx.db.query.businesses.findFirst({
|
||||
where: eq(businesses.id, invoiceData.businessId),
|
||||
});
|
||||
|
||||
if (!business || business.createdById !== ctx.session.user.id) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You don't have permission to use this business",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// If client is being updated, verify it belongs to user
|
||||
if (invoiceData.clientId) {
|
||||
const client = await ctx.db.query.clients.findFirst({
|
||||
where: eq(clients.id, invoiceData.clientId),
|
||||
});
|
||||
|
||||
if (!client || client.createdById !== ctx.session.user.id) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You don't have permission to use this client",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (items) {
|
||||
// Calculate total amount
|
||||
const totalAmount = items.reduce((sum, item) => sum + (item.hours * item.rate), 0);
|
||||
// Calculate subtotal and tax
|
||||
const subtotal = items.reduce((sum, item) => sum + (item.hours * item.rate), 0);
|
||||
const taxAmount = (subtotal * (invoiceData.taxRate ?? existingInvoice.taxRate)) / 100;
|
||||
const totalAmount = subtotal + taxAmount;
|
||||
|
||||
// Update invoice
|
||||
await ctx.db
|
||||
@@ -103,10 +252,11 @@ export const invoicesRouter = createTRPCRouter({
|
||||
// Delete existing items and create new ones
|
||||
await ctx.db.delete(invoiceItems).where(eq(invoiceItems.invoiceId, id));
|
||||
|
||||
const itemsToInsert = items.map(item => ({
|
||||
const itemsToInsert = items.map((item, idx) => ({
|
||||
...item,
|
||||
invoiceId: id,
|
||||
amount: item.hours * item.rate,
|
||||
position: idx,
|
||||
}));
|
||||
|
||||
await ctx.db.insert(invoiceItems).values(itemsToInsert);
|
||||
@@ -122,27 +272,92 @@ export const invoicesRouter = createTRPCRouter({
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) throw error;
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to update invoice",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
delete: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
try {
|
||||
// Verify invoice exists and belongs to user
|
||||
const invoice = await ctx.db.query.invoices.findFirst({
|
||||
where: eq(invoices.id, input.id),
|
||||
});
|
||||
|
||||
if (!invoice) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Invoice not found",
|
||||
});
|
||||
}
|
||||
|
||||
if (invoice.createdById !== ctx.session.user.id) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You don't have permission to delete this invoice",
|
||||
});
|
||||
}
|
||||
|
||||
// Items will be deleted automatically due to cascade
|
||||
return await ctx.db.delete(invoices).where(eq(invoices.id, input.id));
|
||||
await ctx.db.delete(invoices).where(eq(invoices.id, input.id));
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) throw error;
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to delete invoice",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
updateStatus: protectedProcedure
|
||||
.input(z.object({
|
||||
id: z.string(),
|
||||
status: z.enum(["draft", "sent", "paid", "overdue"]),
|
||||
}))
|
||||
.input(updateStatusSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return await ctx.db
|
||||
try {
|
||||
// Verify invoice exists and belongs to user
|
||||
const invoice = await ctx.db.query.invoices.findFirst({
|
||||
where: eq(invoices.id, input.id),
|
||||
});
|
||||
|
||||
if (!invoice) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Invoice not found",
|
||||
});
|
||||
}
|
||||
|
||||
if (invoice.createdById !== ctx.session.user.id) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You don't have permission to update this invoice",
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.db
|
||||
.update(invoices)
|
||||
.set({
|
||||
status: input.status,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(invoices.id, input.id));
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) throw error;
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to update invoice status",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
@@ -1,39 +0,0 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
createTRPCRouter,
|
||||
protectedProcedure,
|
||||
publicProcedure,
|
||||
} from "~/server/api/trpc";
|
||||
import { posts } from "~/server/db/schema";
|
||||
|
||||
export const postRouter = createTRPCRouter({
|
||||
hello: publicProcedure
|
||||
.input(z.object({ text: z.string() }))
|
||||
.query(({ input }) => {
|
||||
return {
|
||||
greeting: `Hello ${input.text}`,
|
||||
};
|
||||
}),
|
||||
|
||||
create: protectedProcedure
|
||||
.input(z.object({ name: z.string().min(1) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ctx.db.insert(posts).values({
|
||||
name: input.name,
|
||||
createdById: ctx.session.user.id,
|
||||
});
|
||||
}),
|
||||
|
||||
getLatest: protectedProcedure.query(async ({ ctx }) => {
|
||||
const post = await ctx.db.query.posts.findFirst({
|
||||
orderBy: (posts, { desc }) => [desc(posts.createdAt)],
|
||||
});
|
||||
|
||||
return post ?? null;
|
||||
}),
|
||||
|
||||
getSecretMessage: protectedProcedure.query(() => {
|
||||
return "you can now see this secret message!";
|
||||
}),
|
||||
});
|
||||
@@ -53,6 +53,9 @@ export const authConfig = {
|
||||
if (!credentials?.email || !credentials?.password) {
|
||||
return null;
|
||||
}
|
||||
if (typeof credentials.email !== 'string' || typeof credentials.password !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.email, credentials.email),
|
||||
|
||||
@@ -13,7 +13,11 @@ const globalForDb = globalThis as unknown as {
|
||||
};
|
||||
|
||||
export const client =
|
||||
globalForDb.client ?? createClient({ url: env.DATABASE_URL });
|
||||
globalForDb.client ??
|
||||
createClient({
|
||||
url: env.DATABASE_URL,
|
||||
authToken: env.DATABASE_AUTH_TOKEN,
|
||||
});
|
||||
if (env.NODE_ENV !== "production") globalForDb.client = client;
|
||||
|
||||
export const db = drizzle(client, { schema });
|
||||
|
||||
@@ -27,6 +27,7 @@ export const users = createTable("user", (d) => ({
|
||||
export const usersRelations = relations(users, ({ many }) => ({
|
||||
accounts: many(accounts),
|
||||
clients: many(clients),
|
||||
businesses: many(businesses),
|
||||
invoices: many(invoices),
|
||||
}));
|
||||
|
||||
@@ -127,6 +128,50 @@ export const clientsRelations = relations(clients, ({ one, many }) => ({
|
||||
invoices: many(invoices),
|
||||
}));
|
||||
|
||||
export const businesses = createTable(
|
||||
"business",
|
||||
(d) => ({
|
||||
id: d
|
||||
.text({ length: 255 })
|
||||
.notNull()
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
name: d.text({ length: 255 }).notNull(),
|
||||
email: d.text({ length: 255 }),
|
||||
phone: d.text({ length: 50 }),
|
||||
addressLine1: d.text({ length: 255 }),
|
||||
addressLine2: d.text({ length: 255 }),
|
||||
city: d.text({ length: 100 }),
|
||||
state: d.text({ length: 50 }),
|
||||
postalCode: d.text({ length: 20 }),
|
||||
country: d.text({ length: 100 }),
|
||||
website: d.text({ length: 255 }),
|
||||
taxId: d.text({ length: 100 }),
|
||||
logoUrl: d.text({ length: 500 }),
|
||||
isDefault: d.integer({ mode: "boolean" }).default(false),
|
||||
createdById: d
|
||||
.text({ length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
createdAt: d
|
||||
.integer({ mode: "timestamp" })
|
||||
.default(sql`(unixepoch())`)
|
||||
.notNull(),
|
||||
updatedAt: d.integer({ mode: "timestamp" }).$onUpdate(() => new Date()),
|
||||
}),
|
||||
(t) => [
|
||||
index("business_created_by_idx").on(t.createdById),
|
||||
index("business_name_idx").on(t.name),
|
||||
index("business_email_idx").on(t.email),
|
||||
index("business_is_default_idx").on(t.isDefault),
|
||||
],
|
||||
);
|
||||
|
||||
export const businessesRelations = relations(businesses, ({ one, many }) => ({
|
||||
createdBy: one(users, { fields: [businesses.createdById], references: [users.id] }),
|
||||
invoices: many(invoices),
|
||||
}));
|
||||
|
||||
export const invoices = createTable(
|
||||
"invoice",
|
||||
(d) => ({
|
||||
@@ -136,6 +181,9 @@ export const invoices = createTable(
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
invoiceNumber: d.text({ length: 100 }).notNull(),
|
||||
businessId: d
|
||||
.text({ length: 255 })
|
||||
.references(() => businesses.id),
|
||||
clientId: d
|
||||
.text({ length: 255 })
|
||||
.notNull()
|
||||
@@ -144,6 +192,7 @@ export const invoices = createTable(
|
||||
dueDate: d.integer({ mode: "timestamp" }).notNull(),
|
||||
status: d.text({ length: 50 }).notNull().default("draft"), // draft, sent, paid, overdue
|
||||
totalAmount: d.real().notNull().default(0),
|
||||
taxRate: d.real().notNull().default(0.00),
|
||||
notes: d.text({ length: 1000 }),
|
||||
createdById: d
|
||||
.text({ length: 255 })
|
||||
@@ -156,6 +205,7 @@ export const invoices = createTable(
|
||||
updatedAt: d.integer({ mode: "timestamp" }).$onUpdate(() => new Date()),
|
||||
}),
|
||||
(t) => [
|
||||
index("invoice_business_id_idx").on(t.businessId),
|
||||
index("invoice_client_id_idx").on(t.clientId),
|
||||
index("invoice_created_by_idx").on(t.createdById),
|
||||
index("invoice_number_idx").on(t.invoiceNumber),
|
||||
@@ -164,6 +214,7 @@ export const invoices = createTable(
|
||||
);
|
||||
|
||||
export const invoicesRelations = relations(invoices, ({ one, many }) => ({
|
||||
business: one(businesses, { fields: [invoices.businessId], references: [businesses.id] }),
|
||||
client: one(clients, { fields: [invoices.clientId], references: [clients.id] }),
|
||||
createdBy: one(users, { fields: [invoices.createdById], references: [users.id] }),
|
||||
items: many(invoiceItems),
|
||||
@@ -186,6 +237,7 @@ export const invoiceItems = createTable(
|
||||
hours: d.real().notNull(),
|
||||
rate: d.real().notNull(),
|
||||
amount: d.real().notNull(),
|
||||
position: d.integer().notNull().default(0), // NEW: position for ordering
|
||||
createdAt: d
|
||||
.integer({ mode: "timestamp" })
|
||||
.default(sql`(unixepoch())`)
|
||||
@@ -194,6 +246,7 @@ export const invoiceItems = createTable(
|
||||
(t) => [
|
||||
index("invoice_item_invoice_id_idx").on(t.invoiceId),
|
||||
index("invoice_item_date_idx").on(t.date),
|
||||
index("invoice_item_position_idx").on(t.position), // NEW: index for position
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,11 +1,47 @@
|
||||
import {
|
||||
defaultShouldDehydrateQuery,
|
||||
QueryClient,
|
||||
QueryCache,
|
||||
MutationCache,
|
||||
} from "@tanstack/react-query";
|
||||
import { TRPCClientError } from "@trpc/client";
|
||||
import { toast } from "sonner";
|
||||
import SuperJSON from "superjson";
|
||||
|
||||
export const createQueryClient = () =>
|
||||
new QueryClient({
|
||||
queryCache: new QueryCache({
|
||||
onError: (error) => {
|
||||
if (
|
||||
error instanceof TRPCClientError &&
|
||||
error.data &&
|
||||
typeof error.data === "object" &&
|
||||
"code" in error.data &&
|
||||
(error.data as { code: string }).code === "UNAUTHORIZED"
|
||||
) {
|
||||
toast.error("Please sign in to continue");
|
||||
if (typeof window !== "undefined") {
|
||||
window.location.href = "/auth/signin";
|
||||
}
|
||||
}
|
||||
},
|
||||
}),
|
||||
mutationCache: new MutationCache({
|
||||
onError: (error) => {
|
||||
if (
|
||||
error instanceof TRPCClientError &&
|
||||
error.data &&
|
||||
typeof error.data === "object" &&
|
||||
"code" in error.data &&
|
||||
(error.data as { code: string }).code === "UNAUTHORIZED"
|
||||
) {
|
||||
toast.error("Please sign in to continue");
|
||||
if (typeof window !== "undefined") {
|
||||
window.location.href = "/auth/signin";
|
||||
}
|
||||
}
|
||||
},
|
||||
}),
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
// With SSR, we usually want to set some default staleTime
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
import animate from "tailwindcss-animate";
|
||||
|
||||
export default {
|
||||
darkMode: ["class"],
|
||||
darkMode: "class",
|
||||
content: [
|
||||
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
@@ -68,5 +69,8 @@ export default {
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require("tailwindcss-animate")],
|
||||
plugins: [animate],
|
||||
future: {
|
||||
hoverOnlyWhenSupported: true,
|
||||
},
|
||||
} satisfies Config;
|
||||
14
vercel.json
Normal file
14
vercel.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"buildCommand": "bun run db:push && bun run build",
|
||||
"installCommand": "bun install --frozen-lockfile",
|
||||
"framework": "nextjs",
|
||||
"functions": {
|
||||
"app/api/**": {
|
||||
"maxDuration": 30
|
||||
},
|
||||
"app/**": {
|
||||
"maxDuration": 30
|
||||
}
|
||||
},
|
||||
"crons": []
|
||||
}
|
||||
Reference in New Issue
Block a user