chore: import codes

This commit is contained in:
2026-04-12 12:50:14 -04:00
parent 4bcb4898e8
commit 65ae3c6788
241 changed files with 48834 additions and 1 deletions
+19
View File
@@ -0,0 +1,19 @@
#################################
# MODULE INVOICE
ZEN_MODULE_INVOICE=false
ZEN_MODULE_INVOICE_INTEREST_ENABLED=true
ZEN_MODULE_INVOICE_INTEREST_RATE=1
ZEN_MODULE_INVOICE_INTEREST_GRACE_DAYS=3
ZEN_MODULE_INVOICE_OVERDUE_EMAIL=support@hyko.cx
ZEN_MODULE_INVOICE_INTERAC=false
ZEN_MODULE_INVOICE_INTERAC_EMAIL=
ZEN_MODULE_INVOICE_COMPANY_NAME=
ZEN_MODULE_INVOICE_COMPANY_ADDRESS=
ZEN_MODULE_INVOICE_COMPANY_CITY=
ZEN_MODULE_INVOICE_COMPANY_PROVINCE=Québec
ZEN_MODULE_INVOICE_COMPANY_POSTAL_CODE=
ZEN_MODULE_INVOICE_COMPANY_COUNTRY=Canada
ZEN_MODULE_INVOICE_COMPANY_PHONE=
ZEN_MODULE_INVOICE_COMPANY_EMAIL=
#################################
@@ -0,0 +1,142 @@
# Invoice module Dashboard (Facturation)
This guide explains how to add a **Facturation** (Billing) section to your client dashboard so that logged-in users whose account is linked to a client can see and open their invoices.
## Prerequisites
- Auth and dashboard set up as in [Client dashboard and user features](../../features/auth/README-dashboard.md).
- Invoice and Clients modules enabled (`ZEN_MODULE_INVOICE=true`, clients are created by the invoice module).
- **Userclient link**: In the admin, a user must be linked to a client (e.g. in User edit, set “Client” to the corresponding client). The link is stored in `zen_clients.user_id`.
## API for “my invoices”
When a user is authenticated and their account is linked to a client, you can load that clients invoices with:
| Endpoint | Method | Auth | Description |
|----------|--------|------|-------------|
| `/zen/api/invoices/me` | GET | Session (user) | Returns the current users linked client (if any) and that clients invoices. |
**Query parameters (optional):**
- `page` Page number (default: 1).
- `limit` Items per page (default: 20).
- `status` Filter by status: `draft`, `sent`, `partial`, `paid`, `overdue`, `cancelled`.
- `sortBy` e.g. `created_at`, `due_date`, `issue_date`.
- `sortOrder` `ASC` or `DESC`.
**Example response (user has a linked client):**
```json
{
"success": true,
"linkedClient": {
"id": 1,
"client_number": "01",
"company_name": null,
"first_name": "Jane",
"last_name": "Doe",
"email": "jane@example.com"
},
"invoices": [
{
"id": 1,
"invoice_number": "01202501",
"token": "abc123...",
"client_id": 1,
"issue_date": "2025-01-15",
"due_date": "2025-02-15",
"status": "sent",
"total_amount": "1500.00",
...
}
],
"total": 1,
"totalPages": 1,
"page": 1,
"limit": 20
}
```
**When the user has no linked client:**
```json
{
"success": true,
"linkedClient": null,
"invoices": [],
"total": 0,
"totalPages": 0,
"page": 1,
"limit": 20
}
```
All requests must send the session cookie (e.g. `fetch(..., { credentials: 'include' })`).
## Adding the Facturation section to the dashboard
### 1. Protected page
Create a dashboard page that requires login and renders the Facturation section:
```js
// app/dashboard/invoices/page.js (Server Component)
import { protect } from '@hykocx/zen/auth';
import { ClientInvoicesSection } from '@hykocx/zen/invoice/dashboard';
export default async function DashboardInvoicesPage() {
await protect({ redirectTo: '/auth/login' });
return (
<div>
<h1>Facturation</h1>
<ClientInvoicesSection
invoicePageBasePath="/zen/invoice"
apiBasePath="/zen/api"
/>
</div>
);
}
```
- **`invoicePageBasePath`** Base path for the public invoice page. Links will be `{invoicePageBasePath}/{token}` (e.g. `/zen/invoice/abc123...`). Use the same base path as where you mount the Zen invoice public pages.
- **`apiBasePath`** Base path for the Zen API (default: `/zen/api`).
Optional props:
- **`title`** Section title (default: `"Facturation"`).
- **`emptyMessage`** Message when the client has no invoices.
- **`noClientMessage`** Message when the user has no linked client.
### 2. Link in the dashboard layout
In your dashboard layout or nav, add a link to the Facturation page:
```js
<Link href="/dashboard/invoices">Facturation</Link>
```
### 3. Public invoice page
Ensure the invoice public page is available at `{invoicePageBasePath}/{token}` (e.g. under `/zen/invoice/...`) so “Voir” and the invoice number open the correct page. This is usually done by mounting Zens module pages (e.g. `PublicPages` from `@hykocx/zen/modules/pages`) on a route like `/zen/invoice/[...slug]`.
## Behaviour summary
- **Logged in, linked to a client** → The section lists that clients invoices with links to view each invoice (by token).
- **Logged in, not linked to a client** → The section shows `noClientMessage` (e.g. “Aucun compte client n'est associé à votre compte…”).
- **Not logged in** → The dashboard page should not be reachable if you use `protect()` and redirect to login.
## Linking a user to a client
In the Zen admin:
1. Open **Users** and edit the user.
2. Set **Client** to the desired client (same person/company as the account).
3. Save.
Only one client can be linked per user. The same client can be linked to only one user.
## Security
- `GET /zen/api/invoices/me` requires a valid session (`auth: 'user'`). No admin role.
- Invoices returned are restricted to the client linked to the current user (`zen_clients.user_id = session.user.id`). No other clients invoices are exposed.
+262
View File
@@ -0,0 +1,262 @@
# Invoice Module
Full billing system with clients, items, categories, transactions, and recurrences. Supports Stripe and Interac payments, automatic interest on overdue invoices, email reminders, and PDF generation.
---
## Features
- **Invoices** with automatic numbering (`CLIENTYEAR##`, e.g. `0120250l`)
- **Public payment page** via secure token (Stripe, Interac, or manual)
- **Statuses**: `draft``sent``partial` / `overdue``paid` / `cancelled`
- **Reusable items** catalogued by categories
- **Transactions** recorded automatically (Stripe webhook) or manually
- **Recurrences**: automatic invoice generation at configurable intervals
- **Interest**: automatic interest on overdue invoices (configurable rate and grace period)
- **Email reminders**: automatic reminders before and after the due date
- **PDF**: invoice and payment receipt
- **Client dashboard**: "my invoices" portal for logged-in users
---
## Installation
### 1. Environment variables
Copy variables from [`.env.example`](.env.example) into your `.env`:
## 2. Stripe Webhook Configuration
**Get your webhook URL:**
- Production: `https://yourdomain.com/zen/api/webhook/stripe`
- Development: `http://localhost:3000/zen/api/webhook/stripe`
**Configure in Stripe Dashboard:**
1. Go to [Stripe Dashboard > Developers > Webhooks](https://dashboard.stripe.com/webhooks)
2. Click "Add endpoint"
3. Enter your webhook URL
4. Select events:
- `checkout.session.completed`
- `payment_intent.succeeded`
- `payment_intent.payment_failed`
5. Click "Add endpoint"
**Add webhook secret to `.env`:**
1. Click on the endpoint in Stripe Dashboard
2. Copy the "Signing secret" (starts with `whsec_`)
3. Add to `.env`: `STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxx`
**Test webhook (optional):**
```bash
stripe listen --forward-to localhost:3000/zen/api/webhook/stripe
```
### 3. Database tables
Tables are created automatically with `npx zen-db init`. For reference, here are the module tables:
| Table | Description |
|---|---|
| `zen_invoices` | Invoices |
| `zen_invoice_items` | Invoice line items |
| `zen_invoice_reminders` | Sent reminder history |
| `zen_invoice_interac` | Interac details per client |
| `zen_transactions` | Received payments |
| `zen_recurrences` | Recurring billing configurations |
| `zen_items` | Reusable item catalogue |
| `zen_categories` | Item categories |
> This module depends on `zen_clients` (**clients** module).
---
## Admin interface
| Page | URL |
|---|---|
| Invoice list | `/admin/invoice/invoices` |
| Create invoice | `/admin/invoice/invoices/new` |
| Edit invoice | `/admin/invoice/invoices/edit` |
| Item list | `/admin/invoice/items` |
| Create item | `/admin/invoice/items/new` |
| Edit item | `/admin/invoice/items/edit` |
| Category list | `/admin/invoice/categories` |
| Create category | `/admin/invoice/categories/new` |
| Edit category | `/admin/invoice/categories/edit` |
| Transactions | `/admin/invoice/transactions` |
| Add transaction | `/admin/invoice/transactions/new` |
| Recurrences | `/admin/invoice/recurrences` |
| Create recurrence | `/admin/invoice/recurrences/new` |
| Edit recurrence | `/admin/invoice/recurrences/edit` |
---
## Public pages (no authentication)
| URL | Description |
|---|---|
| `/invoice/:token` | Invoice payment page |
| `/invoice/:token/pdf` | Invoice PDF viewer |
| `/invoice/:token/receipt` | Payment receipt PDF |
Each invoice gets a unique 64-character hex token on creation. This token is the only access method for the client.
---
## Admin API (authentication required)
### Invoices
| Method | Route | Description |
|---|---|---|
| `GET` | `/zen/api/admin/invoices` | Invoice list (paginated) |
| `GET` | `/zen/api/admin/invoices?id={id}` | Invoice by ID |
| `POST` | `/zen/api/admin/invoices` | Create an invoice |
| `PUT` | `/zen/api/admin/invoices?id={id}` | Update an invoice |
| `DELETE` | `/zen/api/admin/invoices?id={id}` | Delete an invoice (rejected if paid) |
**List parameters:**
| Parameter | Default | Description |
|---|---|---|
| `page` | `1` | Current page |
| `limit` | `20` | Results per page |
| `search` | — | Text search |
| `status` | — | Filter by status |
| `client_id` | — | Filter by client |
| `sortBy` | `created_at` | Sort field |
| `sortOrder` | `DESC` | `ASC` or `DESC` |
**Invoice statuses:**
| Status | Description |
|---|---|
| `draft` | Draft — not sent to client |
| `sent` | Sent — awaiting payment |
| `partial` | Partially paid |
| `overdue` | Overdue (past due date) |
| `paid` | Paid in full |
| `cancelled` | Cancelled |
### Items
| Method | Route | Description |
|---|---|---|
| `GET` | `/zen/api/admin/items` | Item list |
| `GET` | `/zen/api/admin/items?id={id}` | Item by ID |
| `POST` | `/zen/api/admin/items` | Create an item |
| `PUT` | `/zen/api/admin/items?id={id}` | Update an item |
| `DELETE` | `/zen/api/admin/items?id={id}` | Delete an item |
### Categories
| Method | Route | Description |
|---|---|---|
| `GET` | `/zen/api/admin/categories` | Category list |
| `GET` | `/zen/api/admin/categories?id={id}` | Category by ID |
| `POST` | `/zen/api/admin/categories` | Create a category |
| `PUT` | `/zen/api/admin/categories?id={id}` | Update a category |
| `DELETE` | `/zen/api/admin/categories?id={id}` | Delete a category |
### Transactions
| Method | Route | Description |
|---|---|---|
| `GET` | `/zen/api/admin/transactions` | Transaction list |
| `GET` | `/zen/api/admin/transactions?id={id}` | Transaction by ID |
| `GET` | `/zen/api/admin/transactions?invoice_id={id}` | Transactions for an invoice |
| `GET` | `/zen/api/admin/transactions?client_id={id}` | Transactions for a client |
| `POST` | `/zen/api/admin/transactions` | Record a payment |
| `PUT` | `/zen/api/admin/transactions?id={id}` | Update a transaction |
> When creating a transaction, pass `send_confirmation_email: true` in the body to send a confirmation email to the client.
### Recurrences
| Method | Route | Description |
|---|---|---|
| `GET` | `/zen/api/admin/recurrences` | Recurrence list |
| `GET` | `/zen/api/admin/recurrences?id={id}` | Recurrence by ID |
| `POST` | `/zen/api/admin/recurrences` | Create a recurrence |
| `PUT` | `/zen/api/admin/recurrences?id={id}` | Update a recurrence |
| `DELETE` | `/zen/api/admin/recurrences?id={id}` | Delete a recurrence |
---
## Client API (user authentication required)
```
GET /zen/api/invoices/me
```
Returns invoices linked to the current user's client account. Invoices with `draft` status are never exposed.
**Parameters:** `page`, `limit`, `status`, `sortBy`, `sortOrder`
**Response:**
```json
{
"success": true,
"linkedClient": {
"id": 1,
"client_number": "01",
"company_name": "Acme Inc.",
"first_name": "Jean",
"last_name": "Tremblay",
"email": "jean@exemple.com"
},
"invoices": [...],
"total": 5,
"totalPages": 1,
"page": 1,
"limit": 20
}
```
> If the user account is not linked to a client, `linkedClient` is `null` and `invoices` is `[]`.
---
## Interest on overdue invoices
Interest is calculated automatically every 5 minutes (cron) on invoices with statuses `sent`, `partial`, and `overdue` whose due date has passed.
**Formula (simple interest):**
```
daily_rate = monthly_rate / 30.4375
daily_interest = principal × daily_rate
```
- `principal` = `total_amount - paid_amount`
- Calculation starts after the grace period (`ZEN_MODULE_INVOICE_INTEREST_GRACE_DAYS`)
- Interest is cumulative (added to the invoice's `interest_amount` field)
- One update per day per invoice (internal deduplication)
---
## Automatic reminders
The `invoice-reminders` cron runs every 5 minutes and sends emails:
- **Pre-due reminder**: X days before the due date (configured per invoice via `first_reminder_days`)
- **Overdue reminder**: after the due date
- **Admin notification**: email sent to `ZEN_MODULE_INVOICE_OVERDUE_EMAIL` when an invoice becomes overdue
One reminder of each type is sent per day per invoice.
---
## Recurrences
The `invoice-recurrences` cron runs every 5 minutes (between 8am and 5pm) and automatically creates new invoices based on the configured frequency. Generated invoices are created with `draft` status and can be sent manually.
---
## Invoice numbering
Format: `CLIENT_NUMBER + YEAR + SEQUENCE`
Example: client `01`, year `2025`, 3rd invoice → `01202503`
The sequence resets each year per client.
+338
View File
@@ -0,0 +1,338 @@
/**
* Invoice Module - Server Actions
* Server-side actions for invoice operations
*/
'use server';
import { getPublicBaseUrl } from '../../shared/lib/appConfig.js';
import { getInvoiceByToken, getInvoiceById, getInteracCredentialsByClientId } from './crud.js';
import { isEnabled as coreIsStripeEnabled, createCheckoutSession } from '../../core/payments/stripe.js';
import { generateInvoicePDF, getInvoicePDFFilename, generateReceiptPDF, getReceiptPDFFilename } from './pdf/generatePDF.js';
import { getTransactionsByInvoice } from './transactions/crud.js';
/**
* Resolve logo URL for PDF: relative paths (e.g. /assets/logo.png) must become absolute
* so the server can fetch the image; react-pdf treats plain paths as filesystem paths.
* @param {string} logo - Env value (URL or path)
* @returns {string}
*/
function resolveLogoUrlForPDF(logo) {
if (!logo || typeof logo !== 'string') return '';
const trimmed = logo.trim();
if (!trimmed) return '';
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) return trimmed;
const base = getPublicBaseUrl();
return `${base.startsWith('http') ? base : `https://${base}`}${trimmed.startsWith('/') ? trimmed : `/${trimmed}`}`;
}
/**
* Check if Stripe is enabled
* @returns {Promise<boolean>}
*/
export async function isStripeEnabled() {
return coreIsStripeEnabled();
}
/**
* Check if Interac is enabled
* @returns {Promise<boolean>}
*/
export async function isInteracEnabled() {
return process.env.ZEN_MODULE_INVOICE_INTERAC === 'true';
}
/**
* Get Interac email from environment
* @returns {Promise<string|null>}
*/
export async function getInteracEmail() {
return process.env.ZEN_MODULE_INVOICE_INTERAC_EMAIL || null;
}
/**
* Get public page config (logos and dashboard URL) for invoice public pages
* @returns {Promise<Object>}
*/
export async function getPublicPageConfig() {
return {
publicLogoWhite: process.env.ZEN_PUBLIC_LOGO_WHITE || '',
publicLogoBlack: process.env.ZEN_PUBLIC_LOGO_BLACK || '',
publicDashboardUrl: process.env.ZEN_PUBLIC_LOGO_URL || '',
};
}
/**
* Get invoice by token (public action)
* Used for public invoice payment pages
* @param {string} token - Invoice token
* @returns {Promise<Object>}
*/
export async function getInvoiceByTokenAction(token) {
try {
if (!token) {
return { success: false, error: 'Token is required' };
}
const invoice = await getInvoiceByToken(token);
if (!invoice) {
return { success: false, error: 'Invoice not found' };
}
return { success: true, invoice };
} catch (error) {
console.error('Error getting invoice by token:', error);
return { success: false, error: 'Failed to get invoice' };
}
}
/**
* Create Stripe checkout session (public action)
* Used for initiating Stripe payment flow
* @param {number} invoiceId - Invoice ID
* @param {string} token - Invoice token for redirect URLs
* @returns {Promise<Object>}
*/
export async function createStripeCheckoutSessionAction(invoiceId, token) {
try {
if (!invoiceId) {
return { success: false, error: 'Invoice ID is required' };
}
if (!coreIsStripeEnabled()) {
return { success: false, error: 'Stripe is not configured' };
}
// Get the full invoice with items
const invoice = await getInvoiceById(invoiceId);
if (!invoice) {
return { success: false, error: 'Invoice not found' };
}
if (!invoice.items || invoice.items.length === 0) {
return { success: false, error: 'Invoice has no items' };
}
// Get the base URL from environment or construct it
const baseUrl = getPublicBaseUrl();
// Create success and cancel URLs
const successUrl = `${baseUrl}/zen/invoice/${token || invoice.token}/?payment=success`;
const cancelUrl = `${baseUrl}/zen/invoice/${token || invoice.token}/?payment=cancelled`;
// Calculate remaining amount
const remainingAmount = parseFloat(invoice.total_amount) - parseFloat(invoice.paid_amount || 0);
// Build description from all invoice items
const itemsDescription = invoice.items.map(item => {
const itemLine = `${item.name}`;
if (item.description) {
return `${itemLine}: ${item.description}`;
}
return itemLine;
}).join(' | ');
// Create line items for Stripe
const lineItems = [{
price_data: {
currency: process.env.ZEN_CURRENCY?.toLowerCase() || 'cad',
product_data: {
name: `Invoice #${invoice.invoice_number}`,
description: itemsDescription,
},
unit_amount: Math.round(remainingAmount * 100), // Convert to cents
},
quantity: 1,
}];
// Create checkout session using core utility
const session = await createCheckoutSession({
lineItems,
successUrl,
cancelUrl,
customerEmail: invoice.client_email,
clientReferenceId: invoice.id.toString(),
metadata: {
invoice_id: invoice.id.toString(),
invoice_number: invoice.invoice_number,
},
});
if (!session || !session.url) {
return { success: false, error: 'Failed to create checkout session' };
}
return { success: true, url: session.url };
} catch (error) {
console.error('Error creating Stripe checkout session:', error);
return { success: false, error: error.message || 'Failed to create checkout session' };
}
}
/**
* Generate invoice PDF
* @param {number} invoiceId - Invoice ID
* @param {string} token - Optional invoice token for public access
* @returns {Promise<Object>}
*/
export async function generateInvoicePDFAction(invoiceId, token = null) {
try {
if (!invoiceId) {
return { success: false, error: 'Invoice ID is required' };
}
let invoice;
// If token provided, verify it matches
if (token) {
invoice = await getInvoiceByToken(token);
if (!invoice || invoice.id !== parseInt(invoiceId)) {
return { success: false, error: 'Invalid invoice access' };
}
} else {
// Otherwise just get by ID (for admin use)
invoice = await getInvoiceById(invoiceId);
}
if (!invoice) {
return { success: false, error: 'Invoice not found' };
}
if (!invoice.items || invoice.items.length === 0) {
return { success: false, error: 'Invoice has no items' };
}
const companyInfo = {
publicLogoBlack: resolveLogoUrlForPDF(process.env.ZEN_PUBLIC_LOGO_BLACK),
};
const pdfBuffer = await generateInvoicePDF(invoice, companyInfo);
const filename = getInvoicePDFFilename(invoice);
// Convert buffer to base64 for transport
const pdfBase64 = pdfBuffer.toString('base64');
return {
success: true,
pdf: pdfBase64,
filename,
contentType: 'application/pdf',
};
} catch (error) {
console.error('Error generating invoice PDF:', error);
return { success: false, error: error.message || 'Failed to generate PDF' };
}
}
/**
* Get Interac credentials for a client (public action)
* Used for displaying payment instructions on invoice payment page.
* Requires the invoice token to authorize access — the client ID is
* derived server-side from the token so callers cannot enumerate credentials
* for arbitrary clients.
* @param {string} token - Invoice token (public access proof)
* @returns {Promise<Object>}
*/
export async function getInteracCredentialsAction(token) {
try {
if (!token) {
return { success: false, error: 'Token is required' };
}
if (!(await isInteracEnabled())) {
return { success: false, error: 'Interac is not enabled' };
}
// Derive client ID from the token — never accept clientId from the caller
const invoice = await getInvoiceByToken(token);
if (!invoice) {
return { success: false, error: 'Invalid token' };
}
const clientId = invoice.client_id;
// Import the function to get or create credentials
const { getOrCreateInteracCredentials } = await import('./crud.js');
// This will get existing credentials or create new ones if they don't exist
const credentials = await getOrCreateInteracCredentials(clientId);
if (!credentials) {
return { success: false, error: 'Failed to get or create Interac credentials' };
}
return {
success: true,
credentials: {
security_question: credentials.security_question,
security_answer: credentials.security_answer
}
};
} catch (error) {
console.error('Error getting Interac credentials:', error);
return { success: false, error: 'Failed to get Interac credentials' };
}
}
/**
* Generate receipt PDF
* @param {number} invoiceId - Invoice ID
* @param {string} token - Optional invoice token for public access
* @returns {Promise<Object>}
*/
export async function generateReceiptPDFAction(invoiceId, token = null) {
try {
if (!invoiceId) {
return { success: false, error: 'Invoice ID is required' };
}
let invoice;
// If token provided, verify it matches
if (token) {
invoice = await getInvoiceByToken(token);
if (!invoice || invoice.id !== parseInt(invoiceId)) {
return { success: false, error: 'Invalid invoice access' };
}
} else {
// Otherwise just get by ID (for admin use)
invoice = await getInvoiceById(invoiceId);
}
if (!invoice) {
return { success: false, error: 'Invoice not found' };
}
if (!invoice.items || invoice.items.length === 0) {
return { success: false, error: 'Invoice has no items' };
}
if (invoice.status !== 'paid') {
return { success: false, error: 'Receipt can only be generated for paid invoices' };
}
// Get the latest transaction for this invoice
const transactions = await getTransactionsByInvoice(invoiceId);
const latestTransaction = transactions.length > 0 ? transactions[0] : null;
const companyInfo = {
publicLogoBlack: resolveLogoUrlForPDF(process.env.ZEN_PUBLIC_LOGO_BLACK),
};
const pdfBuffer = await generateReceiptPDF(invoice, latestTransaction, companyInfo);
const filename = getReceiptPDFFilename(invoice);
// Convert buffer to base64 for transport
const pdfBase64 = pdfBuffer.toString('base64');
return {
success: true,
pdf: pdfBase64,
filename,
contentType: 'application/pdf',
};
} catch (error) {
console.error('Error generating receipt PDF:', error);
return { success: false, error: error.message || 'Failed to generate receipt PDF' };
}
}
@@ -0,0 +1,613 @@
'use client';
import React, { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Button, Card, Input, Select, Textarea } from '../../../shared/components';
import { useToast } from '@hykocx/zen/toast';
import { formatDateForInput, subtractDays, getTodayString } from '../../../shared/lib/dates.js';
/**
* Invoice Create Page Component
* Page for creating a new invoice
*/
const InvoiceCreatePage = ({ user }) => {
const router = useRouter();
const toast = useToast();
const today = getTodayString();
const [clients, setClients] = useState([]);
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [formData, setFormData] = useState({
client_id: '',
due_date: '',
status: 'draft',
notes: '',
first_reminder_days: 30,
items: [{ name: '', description: '', quantity: 1, unit_price: 0 }]
});
const [errors, setErrors] = useState({});
const [selectedItemIds, setSelectedItemIds] = useState(['custom']); // Track selected item ID for each item row
const [timeInputModes, setTimeInputModes] = useState([false]); // Track if time input mode is enabled for each item
const [timeInputValues, setTimeInputValues] = useState([{ hours: '', minutes: '' }]); // Track hours/minutes for each item
// Convert hours and minutes to decimal quantity
const convertTimeToQuantity = (hours, minutes) => {
const h = parseFloat(hours) || 0;
const m = parseFloat(minutes) || 0;
const totalHours = h + (m / 60);
return parseFloat(totalHours.toFixed(4));
};
const statusOptions = [
{ value: 'draft', label: "Brouillon" },
{ value: 'sent', label: "Envoyée" },
{ value: 'paid', label: "Payée" },
{ value: 'overdue', label: "En retard" },
{ value: 'cancelled', label: "Annulée" }
];
const reminderOptions = [
{ value: 30, label: '30 jours' },
{ value: 14, label: '14 jours' },
{ value: 7, label: '7 jours' },
{ value: 3, label: '3 jours' }
];
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
try {
setLoading(true);
// Load clients
const clientsResponse = await fetch('/zen/api/admin/clients?limit=1000', {
credentials: 'include'
});
const clientsData = await clientsResponse.json();
if (clientsData.success) {
setClients(clientsData.clients || []);
} else {
toast.error("Échec du chargement des clients");
}
// Load items (active only)
const itemsResponse = await fetch('/zen/api/admin/items?limit=1000&is_active=true', {
credentials: 'include'
});
const itemsData = await itemsResponse.json();
if (itemsData.success) {
setItems(itemsData.items || []);
}
} catch (error) {
console.error('Error loading data:', error);
toast.error("Échec du chargement des données");
} finally {
setLoading(false);
}
};
const calculateTotals = () => {
const subtotal = formData.items.reduce((sum, item) => {
return sum + (parseFloat(item.quantity || 0) * parseFloat(item.unit_price || 0));
}, 0);
const total = subtotal;
return {
subtotal: parseFloat(subtotal.toFixed(2)),
total: parseFloat(total.toFixed(2))
};
};
// Calculate issue date based on due date and first reminder days
const calculateIssueDate = (dueDate, reminderDays) => {
if (!dueDate || !reminderDays) return '';
const issueDate = subtractDays(dueDate, parseInt(reminderDays));
return formatDateForInput(issueDate);
};
const handleInputChange = (field, value) => {
setFormData(prev => ({
...prev,
[field]: value
}));
if (errors[field]) {
setErrors(prev => ({
...prev,
[field]: null
}));
}
};
const handleItemChange = (index, field, value) => {
const newItems = [...formData.items];
newItems[index] = { ...newItems[index], [field]: value };
setFormData(prev => ({ ...prev, items: newItems }));
if (errors[`item_${index}_${field}`]) {
setErrors(prev => ({
...prev,
[`item_${index}_${field}`]: null
}));
}
};
// Toggle between quantity and time input mode
const toggleTimeInputMode = (index) => {
const newModes = [...timeInputModes];
newModes[index] = !newModes[index];
setTimeInputModes(newModes);
// If switching to time mode, initialize with current quantity converted to time
if (newModes[index]) {
const currentQty = parseFloat(formData.items[index].quantity) || 0;
const hours = Math.floor(currentQty);
const minutes = Math.round((currentQty - hours) * 60);
const newTimeValues = [...timeInputValues];
newTimeValues[index] = { hours: hours || '', minutes: minutes || '' };
setTimeInputValues(newTimeValues);
}
};
// Handle time input changes (hours or minutes)
const handleTimeInputChange = (index, field, value) => {
const newTimeValues = [...timeInputValues];
newTimeValues[index] = { ...newTimeValues[index], [field]: value };
setTimeInputValues(newTimeValues);
// Convert to quantity and update the item
const hours = field === 'hours' ? value : newTimeValues[index].hours;
const minutes = field === 'minutes' ? value : newTimeValues[index].minutes;
const quantity = convertTimeToQuantity(hours, minutes);
handleItemChange(index, 'quantity', quantity);
};
const handleSelectItem = (index, itemId) => {
// Update the selected item ID for this row
const newSelectedItemIds = [...selectedItemIds];
newSelectedItemIds[index] = itemId;
setSelectedItemIds(newSelectedItemIds);
if (itemId === '' || itemId === 'custom') {
// Reset to custom/empty
handleItemChange(index, 'name', '');
handleItemChange(index, 'description', '');
handleItemChange(index, 'unit_price', 0);
return;
}
const selectedItem = items.find(item => item.id === parseInt(itemId));
if (selectedItem) {
// Build full item name with category hierarchy
let fullItemName = '';
if (selectedItem.parent_category_title) {
fullItemName = `${selectedItem.parent_category_title} - ${selectedItem.category_title} - ${selectedItem.name}`;
} else if (selectedItem.category_title) {
fullItemName = `${selectedItem.category_title} - ${selectedItem.name}`;
} else {
fullItemName = selectedItem.name;
}
// Pre-fill fields from selected item
const newItems = [...formData.items];
newItems[index] = {
...newItems[index],
name: fullItemName,
description: selectedItem.description || '',
unit_price: selectedItem.unit_price
};
setFormData(prev => ({ ...prev, items: newItems }));
}
};
const addItem = () => {
setFormData(prev => ({
...prev,
items: [...prev.items, { name: '', description: '', quantity: 1, unit_price: 0 }]
}));
setSelectedItemIds(prev => [...prev, 'custom']);
setTimeInputModes(prev => [...prev, false]);
setTimeInputValues(prev => [...prev, { hours: '', minutes: '' }]);
};
const removeItem = (index) => {
if (formData.items.length > 1) {
setFormData(prev => ({
...prev,
items: prev.items.filter((_, i) => i !== index)
}));
setSelectedItemIds(prev => prev.filter((_, i) => i !== index));
setTimeInputModes(prev => prev.filter((_, i) => i !== index));
setTimeInputValues(prev => prev.filter((_, i) => i !== index));
}
};
const validateForm = () => {
const newErrors = {};
if (!formData.client_id) {
newErrors.client_id = "Le client est requis";
}
if (!formData.due_date) {
newErrors.due_date = "La date d'échéance est requise";
}
formData.items.forEach((item, index) => {
if (!item.name.trim()) {
newErrors[`item_${index}_name`] = "Le nom de l'article est requis";
}
if (!item.quantity || item.quantity <= 0) {
newErrors[`item_${index}_quantity`] = "La quantité doit être supérieure à 0";
}
if (item.unit_price < 0) {
newErrors[`item_${index}_unit_price`] = "Le prix unitaire ne peut pas être négatif";
}
});
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!validateForm()) {
return;
}
try {
setSaving(true);
// Calculate issue_date before submitting
const issue_date = calculateIssueDate(formData.due_date, formData.first_reminder_days);
const invoiceDataToSubmit = {
...formData,
issue_date
};
const response = await fetch('/zen/api/admin/invoices', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({ invoice: invoiceDataToSubmit }),
});
const data = await response.json();
if (data.success) {
toast.success("Facture créée avec succès");
router.push('/admin/invoice/invoices');
} else {
toast.error(data.error || "Échec de la création de la facture");
}
} catch (error) {
console.error('Error creating invoice:', error);
toast.error("Échec de la création de la facture");
} finally {
setSaving(false);
}
};
const totals = calculateTotals();
return (
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-lg sm:text-xl font-semibold text-neutral-900 dark:text-white">Créer une facture</h1>
<p className="mt-1 text-xs text-neutral-400">Remplissez les détails pour créer une nouvelle facture</p>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => router.push('/admin/invoice/invoices')}
>
Retour aux factures
</Button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-6">
{/* Invoice Information */}
<Card>
<div className="space-y-4">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-white">Informations de la facture</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Select
label="Client *"
value={formData.client_id}
onChange={(value) => handleInputChange('client_id', value)}
options={[
{ value: '', label: "Sélectionner un client" },
...clients.map(client => ({
value: client.id,
label: client.company_name || `${client.first_name} ${client.last_name}`
}))
]}
error={errors.client_id}
disabled={loading}
/>
<Select
label="Statut"
value={formData.status}
onChange={(value) => handleInputChange('status', value)}
options={statusOptions}
/>
<Input
label="Date d'échéance *"
type="date"
value={formData.due_date}
onChange={(value) => handleInputChange('due_date', value)}
error={errors.due_date}
/>
<Select
label="Premier rappel (jours avant l'échéance)"
value={formData.first_reminder_days}
onChange={(value) => handleInputChange('first_reminder_days', value)}
options={reminderOptions}
/>
</div>
{formData.due_date && formData.first_reminder_days && (
<div className="bg-blue-500/10 border border-blue-500/20 text-blue-400 px-4 py-3 rounded-lg">
<p className="text-sm">
<strong>Date d'émission calculée :</strong> {calculateIssueDate(formData.due_date, formData.first_reminder_days)}
<span className="ml-2 text-xs">(Date d'échéance moins les jours du premier rappel)</span>
</p>
</div>
)}
</div>
</Card>
{/* Items */}
<Card>
<div className="space-y-4">
<div className="flex justify-between items-center">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-white">Articles</h2>
</div>
<div className="space-y-4">
{formData.items.map((item, index) => (
<div key={index} className="border border-neutral-200 dark:border-neutral-700/50 rounded-lg p-4 bg-neutral-50 dark:bg-neutral-900/20">
<div className="flex justify-between items-start mb-4">
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">Article {index + 1}</h3>
{formData.items.length > 1 && (
<Button
type="button"
variant="danger"
size="sm"
onClick={() => removeItem(index)}
>
Supprimer
</Button>
)}
</div>
<div className="space-y-4">
<div className="grid gap-4 grid-cols-1 md:grid-cols-2">
<Select
label="Article *"
value={selectedItemIds[index] || 'custom'}
onChange={(value) => handleSelectItem(index, value)}
options={[
{ value: 'custom', label: "Personnalisé" },
...items.map(availableItem => {
// Build category hierarchy display
let categoryDisplay = '';
if (availableItem.parent_category_title) {
categoryDisplay = `${availableItem.parent_category_title} - ${availableItem.category_title} - `;
} else if (availableItem.category_title) {
categoryDisplay = `${availableItem.category_title} - `;
}
return {
value: availableItem.id,
label: `${categoryDisplay}${availableItem.name} - $${parseFloat(availableItem.unit_price).toFixed(2)}`
};
})
]}
/>
<Input
label="Nom de l'article *"
value={item.name}
onChange={(value) => handleItemChange(index, 'name', value)}
placeholder="Entrez le nom de l'article..."
error={errors[`item_${index}_name`]}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300">
Quantité *
</label>
<button
type="button"
onClick={() => toggleTimeInputMode(index)}
className={`text-xs px-2 py-1 rounded transition-colors ${
timeInputModes[index]
? 'bg-blue-500/20 text-blue-400 border border-blue-500/30'
: 'bg-neutral-100 dark:bg-neutral-700/50 text-neutral-600 dark:text-neutral-400 border border-neutral-200 dark:border-neutral-600/50 hover:bg-neutral-200 dark:hover:bg-neutral-700'
}`}
>
{timeInputModes[index] ? "Heures" : "Qté"}
</button>
</div>
{timeInputModes[index] ? (
<div className="space-y-2">
<div className="flex gap-2">
<div className="flex-1">
<Input
type="number"
min="0"
step="1"
value={timeInputValues[index]?.hours || ''}
onChange={(value) => handleTimeInputChange(index, 'hours', value)}
placeholder="0"
/>
<span className="text-xs text-neutral-500 mt-1 block">Heures</span>
</div>
<div className="flex-1">
<Input
type="number"
min="0"
max="59"
step="1"
value={timeInputValues[index]?.minutes || ''}
onChange={(value) => handleTimeInputChange(index, 'minutes', value)}
placeholder="0"
/>
<span className="text-xs text-neutral-500 mt-1 block">Minutes</span>
</div>
</div>
<div className="text-xs text-neutral-600 dark:text-neutral-400 bg-neutral-100 dark:bg-neutral-800/50 px-2 py-1 rounded">
= {item.quantity} unités
</div>
{errors[`item_${index}_quantity`] && (
<p className="text-sm text-red-600 dark:text-red-400">{errors[`item_${index}_quantity`]}</p>
)}
</div>
) : (
<Input
type="number"
step="0.01"
min="0"
value={item.quantity}
onChange={(value) => handleItemChange(index, 'quantity', value)}
error={errors[`item_${index}_quantity`]}
/>
)}
</div>
<Input
label="Prix unitaire *"
type="number"
step="0.01"
min="0"
value={item.unit_price}
onChange={(value) => handleItemChange(index, 'unit_price', value)}
error={errors[`item_${index}_unit_price`]}
/>
</div>
<Textarea
label="Description"
value={item.description}
onChange={(value) => handleItemChange(index, 'description', value)}
rows={2}
placeholder="Décrivez l'article ou le service..."
/>
<div className="text-right pt-2 border-t border-neutral-200 dark:border-neutral-700/50">
<span className="text-sm text-neutral-400">Total de l'article : </span>
<span className="text-lg font-semibold text-green-400">
${(parseFloat(item.quantity || 0) * parseFloat(item.unit_price || 0)).toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})}
</span>
</div>
</div>
</div>
))}
<div className="flex justify-end items-center">
<Button
type="button"
variant="secondary"
size="sm"
onClick={addItem}
>
+ Ajouter un article
</Button>
</div>
</div>
{/* Totals */}
<div className="pt-4 border-t border-neutral-200 dark:border-neutral-700/50">
<div className="flex justify-end">
<div className="w-full sm:w-80 space-y-2">
<div className="flex justify-between text-neutral-600 dark:text-neutral-300">
<span>Sous-total :</span>
<span className="font-medium">
${totals.subtotal.toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})}
</span>
</div>
<div className="flex justify-between text-neutral-900 dark:text-white text-lg font-semibold pt-2 border-t border-neutral-200 dark:border-neutral-700/50">
<span>Total :</span>
<span>
${totals.total.toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})}
</span>
</div>
</div>
</div>
</div>
</div>
</Card>
{/* Notes */}
<Card>
<div className="space-y-4">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-white">Notes</h2>
<Textarea
value={formData.notes}
onChange={(value) => handleInputChange('notes', value)}
rows={4}
placeholder="Ajoutez des notes supplémentaires ou des instructions de paiement..."
/>
</div>
</Card>
{/* Actions */}
<div className="flex items-center justify-end gap-3">
<Button
type="button"
variant="secondary"
onClick={() => router.push('/admin/invoice/invoices')}
disabled={saving}
>
Annuler
</Button>
<Button
type="submit"
variant="success"
loading={saving}
disabled={saving}
>
{saving ? "Création..." : "Créer une facture"}
</Button>
</div>
</form>
</div>
);
};
export default InvoiceCreatePage;
@@ -0,0 +1,788 @@
'use client';
import React, { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Link02Icon } from '../../../shared/Icons.js';
import { Button, Card, Input, Select, Textarea, Loading } from '../../../shared/components';
import { useToast } from '@hykocx/zen/toast';
import { formatDateForInput, subtractDays } from '../../../shared/lib/dates.js';
/**
* Invoice Edit Page Component
* Page for editing an existing invoice
*/
const InvoiceEditPage = ({ invoiceId, user }) => {
const router = useRouter();
const toast = useToast();
const [clients, setClients] = useState([]);
const [items, setItems] = useState([]);
const [invoice, setInvoice] = useState(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [formData, setFormData] = useState({
client_id: '',
issue_date: '',
due_date: '',
status: 'draft',
notes: '',
first_reminder_days: 30,
items: [{ name: '', description: '', quantity: 1, unit_price: 0 }]
});
const [errors, setErrors] = useState({});
const [selectedItemIds, setSelectedItemIds] = useState(['custom']); // Track selected item ID for each item row
const [timeInputModes, setTimeInputModes] = useState([false]); // Track if time input mode is enabled for each item
const [timeInputValues, setTimeInputValues] = useState([{ hours: '', minutes: '' }]); // Track hours/minutes for each item
// Convert hours and minutes to decimal quantity
const convertTimeToQuantity = (hours, minutes) => {
const h = parseFloat(hours) || 0;
const m = parseFloat(minutes) || 0;
const totalHours = h + (m / 60);
return parseFloat(totalHours.toFixed(4));
};
const statusOptions = [
{ value: 'draft', label: "Brouillon" },
{ value: 'sent', label: "Envoyée" },
{ value: 'paid', label: "Payée" },
{ value: 'overdue', label: "En retard" },
{ value: 'cancelled', label: "Annulée" }
];
const reminderOptions = [
{ value: 30, label: '30 jours' },
{ value: 14, label: '14 jours' },
{ value: 7, label: '7 jours' },
{ value: 3, label: '3 jours' }
];
useEffect(() => {
loadClientsAndInvoice();
}, [invoiceId]);
const loadClientsAndInvoice = async () => {
try {
setLoading(true);
// Load clients
const clientsResponse = await fetch('/zen/api/admin/clients?limit=1000', {
credentials: 'include'
});
const clientsData = await clientsResponse.json();
if (clientsData.success) {
setClients(clientsData.clients || []);
}
// Load items (active only) for draft invoices
const itemsResponse = await fetch('/zen/api/admin/items?limit=1000&is_active=true', {
credentials: 'include'
});
const itemsData = await itemsResponse.json();
if (itemsData.success) {
setItems(itemsData.items || []);
}
// Load invoice
const invoiceResponse = await fetch(`/zen/api/admin/invoices?id=${invoiceId}`, {
credentials: 'include'
});
const invoiceData = await invoiceResponse.json();
if (invoiceData.success && invoiceData.invoice) {
const inv = invoiceData.invoice;
setInvoice(inv);
setFormData({
client_id: inv.client_id,
issue_date: formatDateForInput(inv.issue_date),
due_date: formatDateForInput(inv.due_date),
status: inv.status || 'draft',
notes: inv.notes || '',
first_reminder_days: inv.first_reminder_days || 30,
items: inv.items?.map(item => ({
name: item.name || '',
description: item.description || '',
quantity: item.quantity || 1,
unit_price: item.unit_price || 0
})) || [{ name: '', description: '', quantity: 1, unit_price: 0 }]
});
// Initialize selectedItemIds with 'custom' for each existing item
setSelectedItemIds(new Array(inv.items?.length || 1).fill('custom'));
// Initialize time input modes and values
setTimeInputModes(new Array(inv.items?.length || 1).fill(false));
setTimeInputValues(new Array(inv.items?.length || 1).fill({ hours: '', minutes: '' }));
} else {
toast.error("Facture non trouvée");
}
} catch (error) {
console.error('Error loading data:', error);
toast.error("Échec du chargement de la facture");
} finally {
setLoading(false);
}
};
const calculateTotals = () => {
const subtotal = formData.items.reduce((sum, item) => {
return sum + (parseFloat(item.quantity || 0) * parseFloat(item.unit_price || 0));
}, 0);
const total = subtotal;
return {
subtotal: parseFloat(subtotal.toFixed(2)),
total: parseFloat(total.toFixed(2))
};
};
// Calculate issue date based on due date and first reminder days
const calculateIssueDate = (dueDate, reminderDays) => {
if (!dueDate || !reminderDays) return '';
const issueDate = subtractDays(dueDate, parseInt(reminderDays));
return formatDateForInput(issueDate);
};
const handleInputChange = (field, value) => {
setFormData(prev => {
const newFormData = {
...prev,
[field]: value
};
// Auto-update issue_date when due_date or first_reminder_days change
if (field === 'due_date' || field === 'first_reminder_days') {
const dueDate = field === 'due_date' ? value : newFormData.due_date;
const reminderDays = field === 'first_reminder_days' ? value : newFormData.first_reminder_days;
newFormData.issue_date = calculateIssueDate(dueDate, reminderDays);
}
return newFormData;
});
if (errors[field]) {
setErrors(prev => ({
...prev,
[field]: null
}));
}
};
const handleItemChange = (index, field, value) => {
const newItems = [...formData.items];
newItems[index] = { ...newItems[index], [field]: value };
setFormData(prev => ({ ...prev, items: newItems }));
if (errors[`item_${index}_${field}`]) {
setErrors(prev => ({
...prev,
[`item_${index}_${field}`]: null
}));
}
};
// Toggle between quantity and time input mode
const toggleTimeInputMode = (index) => {
const newModes = [...timeInputModes];
newModes[index] = !newModes[index];
setTimeInputModes(newModes);
// If switching to time mode, initialize with current quantity converted to time
if (newModes[index]) {
const currentQty = parseFloat(formData.items[index].quantity) || 0;
const hours = Math.floor(currentQty);
const minutes = Math.round((currentQty - hours) * 60);
const newTimeValues = [...timeInputValues];
newTimeValues[index] = { hours: hours || '', minutes: minutes || '' };
setTimeInputValues(newTimeValues);
}
};
// Handle time input changes (hours or minutes)
const handleTimeInputChange = (index, field, value) => {
const newTimeValues = [...timeInputValues];
newTimeValues[index] = { ...newTimeValues[index], [field]: value };
setTimeInputValues(newTimeValues);
// Convert to quantity and update the item
const hours = field === 'hours' ? value : newTimeValues[index].hours;
const minutes = field === 'minutes' ? value : newTimeValues[index].minutes;
const quantity = convertTimeToQuantity(hours, minutes);
handleItemChange(index, 'quantity', quantity);
};
const handleSelectItem = (index, itemId) => {
// Update the selected item ID for this row
const newSelectedItemIds = [...selectedItemIds];
newSelectedItemIds[index] = itemId;
setSelectedItemIds(newSelectedItemIds);
if (itemId === '' || itemId === 'custom') {
// Reset to custom/empty
handleItemChange(index, 'name', '');
handleItemChange(index, 'description', '');
handleItemChange(index, 'unit_price', 0);
return;
}
const selectedItem = items.find(item => item.id === parseInt(itemId));
if (selectedItem) {
// Build full item name with category hierarchy
let fullItemName = '';
if (selectedItem.parent_category_title) {
fullItemName = `${selectedItem.parent_category_title} - ${selectedItem.category_title} - ${selectedItem.name}`;
} else if (selectedItem.category_title) {
fullItemName = `${selectedItem.category_title} - ${selectedItem.name}`;
} else {
fullItemName = selectedItem.name;
}
// Pre-fill fields from selected item
const newItems = [...formData.items];
newItems[index] = {
...newItems[index],
name: fullItemName,
description: selectedItem.description || '',
unit_price: selectedItem.unit_price
};
setFormData(prev => ({ ...prev, items: newItems }));
}
};
const addItem = () => {
setFormData(prev => ({
...prev,
items: [...prev.items, { name: '', description: '', quantity: 1, unit_price: 0 }]
}));
setSelectedItemIds(prev => [...prev, 'custom']);
setTimeInputModes(prev => [...prev, false]);
setTimeInputValues(prev => [...prev, { hours: '', minutes: '' }]);
};
const removeItem = (index) => {
if (formData.items.length > 1) {
setFormData(prev => ({
...prev,
items: prev.items.filter((_, i) => i !== index)
}));
setSelectedItemIds(prev => prev.filter((_, i) => i !== index));
setTimeInputModes(prev => prev.filter((_, i) => i !== index));
setTimeInputValues(prev => prev.filter((_, i) => i !== index));
}
};
const validateForm = () => {
const newErrors = {};
if (!formData.client_id) {
newErrors.client_id = "Le client est requis";
}
if (!formData.due_date) {
newErrors.due_date = "La date d'échéance est requise";
}
formData.items.forEach((item, index) => {
if (!item.name.trim()) {
newErrors[`item_${index}_name`] = "Le nom de l'article est requis";
}
if (!item.quantity || item.quantity <= 0) {
newErrors[`item_${index}_quantity`] = "La quantité doit être supérieure à 0";
}
if (item.unit_price < 0) {
newErrors[`item_${index}_unit_price`] = "Le prix unitaire ne peut pas être négatif";
}
});
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleCopyPaymentLink = () => {
if (invoice && invoice.token) {
const paymentLink = `${window.location.origin}/zen/invoice/${invoice.token}`;
navigator.clipboard.writeText(paymentLink).then(() => {
toast.success("Lien de paiement copié dans le presse-papiers !");
}).catch(() => {
toast.error("Échec de la copie du lien de paiement");
});
}
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!validateForm()) {
return;
}
try {
setSaving(true);
// Base update data
const updateData = {
issue_date: formData.issue_date,
due_date: formData.due_date,
status: formData.status,
notes: formData.notes,
first_reminder_days: formData.first_reminder_days
};
// If invoice is not paid, include items in the update
if (invoice.status !== 'paid' && invoice.status !== 'partial') {
updateData.items = formData.items;
}
const response = await fetch('/zen/api/admin/invoices', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({ id: invoiceId, invoice: updateData }),
});
const data = await response.json();
if (data.success) {
toast.success("Facture mise à jour avec succès");
router.push('/admin/invoice/invoices');
} else {
toast.error(data.error || "Échec de la mise à jour de la facture");
}
} catch (error) {
console.error('Error updating invoice:', error);
toast.error("Échec de la mise à jour de la facture");
} finally {
setSaving(false);
}
};
const totals = calculateTotals();
if (loading) {
return (
<div className="min-h-64 flex items-center justify-center">
<Loading />
</div>
);
}
if (!invoice) {
return (
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-lg sm:text-xl font-semibold text-neutral-900 dark:text-white">Modifier la facture</h1>
<p className="mt-1 text-xs text-neutral-400">Facture non trouvée</p>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => router.push('/admin/invoice/invoices')}
>
Retour aux factures
</Button>
</div>
<Card>
<div className="bg-red-500/10 border border-red-500/20 text-red-400 px-4 py-3 rounded-lg">
<p className="font-medium">Facture non trouvée</p>
<p className="text-sm mt-1">La facture que vous recherchez n'existe pas ou a été supprimée.</p>
</div>
</Card>
</div>
);
}
return (
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-lg sm:text-xl font-semibold text-neutral-900 dark:text-white">Modifier la facture</h1>
<p className="mt-1 text-xs text-neutral-400">Facture # {invoice.invoice_number}</p>
</div>
<div className="flex items-center gap-3">
<Button
variant="secondary"
size="sm"
onClick={handleCopyPaymentLink}
icon={<Link02Icon className="w-4 h-4" />}
>
Copier le lien de paiement
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => router.push('/admin/invoice/invoices')}
>
← Retour aux factures
</Button>
</div>
</div>
{/* Note about items */}
{(invoice.status === 'paid' || invoice.status === 'partial') && (
<div className="bg-red-500/10 border border-red-500/20 text-red-400 px-4 py-3 rounded-lg">
<p className="text-sm">
<strong>Note :</strong> Les articles de la facture ne peuvent pas être modifiés une fois payés. Seuls le statut et les notes peuvent être mis à jour.
</p>
</div>
)}
{invoice.status !== 'paid' && invoice.status !== 'partial' && (
<div className="bg-green-500/10 border border-green-500/20 text-green-400 px-4 py-3 rounded-lg">
<p className="text-sm">
<strong>Modifiable :</strong> Cette facture n'est pas encore payée. Vous pouvez modifier tous les champs, y compris les articles, les dates, le statut et les notes.
</p>
</div>
)}
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-6">
{/* Invoice Information */}
<Card>
<div className="space-y-4">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-white">Informations de la facture</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Select
label="Client *"
value={formData.client_id}
onChange={(value) => handleInputChange('client_id', value)}
options={[
{ value: '', label: "Sélectionner un client" },
...clients.map(client => ({
value: client.id,
label: client.company_name || `${client.first_name} ${client.last_name}`
}))
]}
error={errors.client_id}
disabled={invoice.status === 'paid' || invoice.status === 'partial'}
/>
<Select
label="Statut"
value={formData.status}
onChange={(value) => handleInputChange('status', value)}
options={statusOptions}
/>
<Input
label="Date d'échéance *"
type="date"
value={formData.due_date}
onChange={(value) => handleInputChange('due_date', value)}
error={errors.due_date}
/>
<Select
label="Premier rappel (jours avant l'échéance)"
value={formData.first_reminder_days}
onChange={(value) => handleInputChange('first_reminder_days', value)}
options={reminderOptions}
/>
</div>
{formData.due_date && formData.first_reminder_days && (
<div className="bg-blue-500/10 border border-blue-500/20 text-blue-400 px-4 py-3 rounded-lg">
<p className="text-sm">
<strong>Date d'émission calculée :</strong> {calculateIssueDate(formData.due_date, formData.first_reminder_days)}
<span className="ml-2 text-xs">(Mise à jour automatique selon la date d'échéance et le premier rappel)</span>
</p>
</div>
)}
</div>
</Card>
{/* Items - Editable for unpaid, read-only for paid */}
<Card>
<div className="space-y-4">
<div className="flex justify-between items-center">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-white">
Articles {(invoice.status === 'paid' || invoice.status === 'partial') && '(Lecture seule)'}
</h2>
</div>
<div className="space-y-4">
{invoice.status !== 'paid' && invoice.status !== 'partial' ? (
// Editable items for unpaid invoices
<>
{formData.items.map((item, index) => (
<div key={index} className="border border-neutral-200 dark:border-neutral-700/50 rounded-lg p-4 bg-neutral-50 dark:bg-neutral-900/20">
<div className="flex justify-between items-start mb-4">
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">Article {index + 1}</h3>
{formData.items.length > 1 && (
<Button
type="button"
variant="danger"
size="sm"
onClick={() => removeItem(index)}
>
Supprimer
</Button>
)}
</div>
<div className="space-y-4">
<div className="grid gap-4 grid-cols-1 md:grid-cols-2">
<Select
label="Article *"
value={selectedItemIds[index] || 'custom'}
onChange={(value) => handleSelectItem(index, value)}
options={[
{ value: 'custom', label: "Personnalisé" },
...items.map(availableItem => {
// Build category hierarchy display
let categoryDisplay = '';
if (availableItem.parent_category_title) {
categoryDisplay = `${availableItem.parent_category_title} - ${availableItem.category_title} - `;
} else if (availableItem.category_title) {
categoryDisplay = `${availableItem.category_title} - `;
}
return {
value: availableItem.id,
label: `${categoryDisplay}${availableItem.name} - $${parseFloat(availableItem.unit_price).toFixed(2)}`
};
})
]}
/>
<Input
label="Nom de l'article *"
value={item.name}
onChange={(value) => handleItemChange(index, 'name', value)}
placeholder="Entrez le nom de l'article..."
error={errors[`item_${index}_name`]}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300">
Quantité *
</label>
<button
type="button"
onClick={() => toggleTimeInputMode(index)}
className={`text-xs px-2 py-1 rounded transition-colors ${
timeInputModes[index]
? 'bg-blue-500/20 text-blue-400 border border-blue-500/30'
: 'bg-neutral-100 dark:bg-neutral-700/50 text-neutral-600 dark:text-neutral-400 border border-neutral-200 dark:border-neutral-600/50 hover:bg-neutral-200 dark:hover:bg-neutral-700'
}`}
>
{timeInputModes[index] ? "Heures" : "Qté"}
</button>
</div>
{timeInputModes[index] ? (
<div className="space-y-2">
<div className="flex gap-2">
<div className="flex-1">
<Input
type="number"
min="0"
step="1"
value={timeInputValues[index]?.hours || ''}
onChange={(value) => handleTimeInputChange(index, 'hours', value)}
placeholder="0"
/>
<span className="text-xs text-neutral-500 mt-1 block">Heures</span>
</div>
<div className="flex-1">
<Input
type="number"
min="0"
max="59"
step="1"
value={timeInputValues[index]?.minutes || ''}
onChange={(value) => handleTimeInputChange(index, 'minutes', value)}
placeholder="0"
/>
<span className="text-xs text-neutral-500 mt-1 block">Minutes</span>
</div>
</div>
<div className="text-xs text-neutral-600 dark:text-neutral-400 bg-neutral-100 dark:bg-neutral-800/50 px-2 py-1 rounded">
= {item.quantity} unités
</div>
{errors[`item_${index}_quantity`] && (
<p className="text-sm text-red-600 dark:text-red-400">{errors[`item_${index}_quantity`]}</p>
)}
</div>
) : (
<Input
type="number"
step="0.01"
min="0"
value={item.quantity}
onChange={(value) => handleItemChange(index, 'quantity', value)}
error={errors[`item_${index}_quantity`]}
/>
)}
</div>
<Input
label="Prix unitaire *"
type="number"
step="0.01"
min="0"
value={item.unit_price}
onChange={(value) => handleItemChange(index, 'unit_price', value)}
error={errors[`item_${index}_unit_price`]}
/>
</div>
<Textarea
label="Description"
value={item.description}
onChange={(value) => handleItemChange(index, 'description', value)}
rows={2}
placeholder="Décrivez l'article ou le service..."
/>
<div className="text-right pt-2 border-t border-neutral-200 dark:border-neutral-700/50">
<span className="text-sm text-neutral-400">Total de l'article : </span>
<span className="text-lg font-semibold text-green-400">
${(parseFloat(item.quantity || 0) * parseFloat(item.unit_price || 0)).toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})}
</span>
</div>
</div>
</div>
))}
<div className="flex justify-end items-center">
<Button
type="button"
variant="secondary"
size="sm"
onClick={addItem}
>
+ Ajouter un article
</Button>
</div>
</>
) : (
// Read-only items for paid invoices
formData.items.map((item, index) => (
<div key={index} className="border border-neutral-200 dark:border-neutral-700/50 rounded-lg p-4 bg-neutral-50 dark:bg-neutral-900/20">
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-neutral-500 dark:text-neutral-400 mb-1">Nom de l'article</label>
<div className="text-neutral-900 dark:text-white font-medium">{item.name}</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-neutral-500 dark:text-neutral-400 mb-1">Quantité</label>
<div className="text-neutral-900 dark:text-white">{item.quantity}</div>
</div>
<div>
<label className="block text-sm font-medium text-neutral-500 dark:text-neutral-400 mb-1">Prix unitaire</label>
<div className="text-neutral-900 dark:text-white">
${parseFloat(item.unit_price).toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})}
</div>
</div>
</div>
{item.description && (
<div>
<label className="block text-sm font-medium text-neutral-500 dark:text-neutral-400 mb-1">Description</label>
<div className="text-neutral-600 dark:text-neutral-300">{item.description}</div>
</div>
)}
<div className="text-right pt-2 border-t border-neutral-200 dark:border-neutral-700/50">
<span className="text-sm text-neutral-400">Total de la ligne : </span>
<span className="text-lg font-semibold text-green-400">
${(parseFloat(item.quantity || 0) * parseFloat(item.unit_price || 0)).toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})}
</span>
</div>
</div>
</div>
))
)}
</div>
{/* Totals */}
<div className="pt-4 border-t border-neutral-200 dark:border-neutral-700/50">
<div className="flex justify-end">
<div className="w-full sm:w-80 space-y-2">
<div className="flex justify-between text-neutral-600 dark:text-neutral-300">
<span>Sous-total :</span>
<span className="font-medium">
${totals.subtotal.toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})}
</span>
</div>
<div className="flex justify-between text-neutral-900 dark:text-white text-lg font-semibold pt-2 border-t border-neutral-200 dark:border-neutral-700/50">
<span>Total :</span>
<span>
${totals.total.toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})}
</span>
</div>
</div>
</div>
</div>
</div>
</Card>
{/* Notes */}
<Card>
<div className="space-y-4">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-white">Notes</h2>
<Textarea
value={formData.notes}
onChange={(value) => handleInputChange('notes', value)}
rows={4}
placeholder="Ajoutez des notes supplémentaires ou des instructions de paiement..."
/>
</div>
</Card>
{/* Actions */}
<div className="flex items-center justify-end gap-3">
<Button
type="button"
variant="secondary"
onClick={() => router.push('/admin/invoice/invoices')}
disabled={saving}
>
Annuler
</Button>
<Button
type="submit"
variant="success"
loading={saving}
disabled={saving}
>
{saving ? "Mise à jour..." : "Mettre à jour la facture"}
</Button>
</div>
</form>
</div>
);
};
export default InvoiceEditPage;
@@ -0,0 +1,354 @@
'use client';
import React, { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { PlusSignCircleIcon, PencilEdit01Icon, Delete02Icon, Link02Icon, CoinsDollarIcon, Recycle03Icon, PackageIcon } from '../../../shared/Icons.js';
import {
Table,
Button,
Badge,
Card,
Pagination
} from '../../../shared/components';
import { useToast } from '@hykocx/zen/toast';
import { parseUTCDate, formatDateForInput, getDaysBetween, isOverdue, getTodayUTC } from '../../../shared/lib/dates.js';
/**
* Invoices List Page Component
* Displays list of invoices with pagination and sorting
*/
const InvoicesListPage = ({ user }) => {
const router = useRouter();
const toast = useToast();
const [invoices, setInvoices] = useState([]);
const [loading, setLoading] = useState(true);
const [deleting, setDeleting] = useState(false);
// Pagination state
const [pagination, setPagination] = useState({
page: 1,
limit: 20,
total: 0,
totalPages: 0
});
// Sort state
const [sortBy, setSortBy] = useState('created_at');
const [sortOrder, setSortOrder] = useState('desc');
// Table columns configuration
const columns = [
{
key: 'invoice_number',
label: "Facture #",
sortable: true,
render: (invoice) => (
<div>
<div className="text-sm font-mono font-semibold text-neutral-900 dark:text-white">{invoice.invoice_number}</div>
<div className="text-xs text-neutral-500 dark:text-gray-400">
Émise : {formatDateForInput(invoice.issue_date)}
</div>
</div>
),
skeleton: {
height: 'h-4',
width: '30%',
secondary: { height: 'h-3', width: '25%' }
}
},
{
key: 'client',
label: "Client",
sortable: false,
render: (invoice) => (
<div>
<div className="text-sm text-neutral-900 dark:text-white">
{invoice.company_name || `${invoice.first_name} ${invoice.last_name}`}
</div>
<div className="text-xs text-neutral-500 dark:text-gray-400">
{invoice.client_email}
</div>
</div>
),
skeleton: { height: 'h-4', width: '60%' }
},
{
key: 'total_amount',
label: "Montant",
sortable: true,
render: (invoice) => {
const principalAmount = parseFloat(invoice.total_amount);
const interestAmount = parseFloat(invoice.interest_amount || 0);
const totalWithInterest = principalAmount + interestAmount;
const hasInterest = interestAmount > 0;
return (
<div>
<div className={`text-sm font-semibold ${invoice.status === 'paid' ? 'text-green-400' : 'text-neutral-900 dark:text-white'}`}>
${totalWithInterest.toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})}
</div>
<div className="text-xs text-neutral-500 dark:text-gray-400">
{invoice.items_count} {invoice.items_count !== 1 ? 'articles' : 'article'}
{hasInterest && ` + Intérêt`}
</div>
</div>
);
},
skeleton: { height: 'h-4', width: '40%' }
},
{
key: 'due_date',
label: "Date d'échéance",
sortable: true,
render: (invoice) => {
const invoiceIsOverdue = isOverdue(invoice.due_date) && invoice.status !== 'paid';
const daysUntilDue = getDaysBetween(getTodayUTC(), invoice.due_date);
return (
<div>
<div className={`text-sm font-mono font-semibold ${invoiceIsOverdue ? 'text-red-400' : 'text-neutral-900 dark:text-white'}`}>
{formatDateForInput(invoice.due_date)}
</div>
{daysUntilDue > 0 && (
<div className="text-xs text-neutral-500 dark:text-gray-400">dans {daysUntilDue} jours</div>
)}
{invoiceIsOverdue && (
<div className="text-xs text-red-400">En retard</div>
)}
</div>
);
},
skeleton: { height: 'h-4', width: '35%' }
},
{
key: 'status',
label: "Statut",
sortable: true,
render: (invoice) => <InvoiceStatusBadge status={invoice.status} />,
skeleton: { height: 'h-6', width: '80px', className: 'rounded-full' }
},
{
key: 'actions',
label: "Actions",
render: (invoice) => {
const isPaid = invoice.status === 'paid' || invoice.status === 'partial';
return (
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleCopyPaymentLink(invoice)}
disabled={deleting}
icon={<Link02Icon className="w-3.5 h-3.5" />}
className=""
title="Copier le lien de paiement"
/>
<Button
variant="ghost"
size="sm"
onClick={() => handleEditInvoice(invoice)}
disabled={deleting}
icon={<PencilEdit01Icon className="w-3.5 h-3.5" />}
className=""
/>
{!isPaid && (
<Button
variant="danger"
size="sm"
onClick={() => handleDeleteInvoice(invoice)}
disabled={deleting}
icon={<Delete02Icon className="w-3.5 h-3.5" />}
className=""
/>
)}
</div>
);
},
skeleton: { height: 'h-8', width: '120px' }
}
];
useEffect(() => {
loadInvoices();
}, [sortBy, sortOrder, pagination.page, pagination.limit]);
const loadInvoices = async () => {
try {
setLoading(true);
const searchParams = new URLSearchParams({
page: pagination.page.toString(),
limit: pagination.limit.toString(),
sortBy,
sortOrder
});
const response = await fetch(`/zen/api/admin/invoices?${searchParams}`, {
credentials: 'include'
});
const data = await response.json();
if (data.success) {
setInvoices(data.invoices || []);
setPagination(prev => ({
...prev,
total: data.total || 0,
totalPages: data.totalPages || 0,
page: data.page || 1
}));
} else {
toast.error(data.error || "Échec du chargement des factures");
}
} catch (error) {
console.error('Error loading invoices:', error);
toast.error("Échec du chargement des factures");
} finally {
setLoading(false);
}
};
const handlePageChange = (newPage) => {
setPagination(prev => ({ ...prev, page: newPage }));
};
const handleLimitChange = (newLimit) => {
setPagination(prev => ({
...prev,
limit: newLimit,
page: 1
}));
};
const handleSort = (newSortBy) => {
const newSortOrder = sortBy === newSortBy && sortOrder === 'asc' ? 'desc' : 'asc';
setSortBy(newSortBy);
setSortOrder(newSortOrder);
};
const handleEditInvoice = (invoice) => {
router.push(`/admin/invoice/invoices/edit/${invoice.id}`);
};
const handleCopyPaymentLink = (invoice) => {
const paymentLink = `${window.location.origin}/zen/invoice/${invoice.token}`;
navigator.clipboard.writeText(paymentLink).then(() => {
toast.success("Lien de paiement copié dans le presse-papiers !");
}).catch(() => {
toast.error("Échec de la copie du lien de paiement");
});
};
const handleDeleteInvoice = async (invoice) => {
if (!confirm(`Êtes-vous sûr de vouloir supprimer la facture « ${invoice.invoice_number} » ?`)) {
return;
}
try {
setDeleting(true);
const response = await fetch(`/zen/api/admin/invoices?id=${invoice.id}`, {
method: 'DELETE',
credentials: 'include'
});
const data = await response.json();
if (data.success) {
toast.success("Facture supprimée avec succès");
loadInvoices();
} else {
toast.error(data.error || "Échec de la suppression de la facture");
}
} catch (error) {
console.error('Error deleting invoice:', error);
toast.error("Échec de la suppression de la facture");
} finally {
setDeleting(false);
}
};
return (
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-lg sm:text-xl font-semibold text-neutral-900 dark:text-white">Factures</h1>
<p className="mt-1 text-xs text-neutral-400">Gérez vos factures</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
onClick={() => router.push('/admin/invoice/invoices/new')}
icon={<PlusSignCircleIcon className="w-4 h-4" />}
>
Créer une facture
</Button>
<Button
variant="secondary"
onClick={() => router.push('/admin/invoice/transactions')}
icon={<CoinsDollarIcon className="w-4 h-4" />}
>
Transactions
</Button>
<Button
variant="secondary"
onClick={() => router.push('/admin/invoice/recurrences')}
icon={<Recycle03Icon className="w-4 h-4" />}
>
Récurrences
</Button>
<Button
variant="secondary"
onClick={() => router.push('/admin/invoice/items')}
icon={<PackageIcon className="w-4 h-4" />}
>
Articles
</Button>
</div>
</div>
{/* Invoices Table */}
<Card variant="default" padding="none">
<Table
columns={columns}
data={invoices}
loading={loading}
sortBy={sortBy}
sortOrder={sortOrder}
onSort={handleSort}
emptyMessage="Aucune facture trouvée"
emptyDescription="Créez votre première facture pour commencer"
/>
<Pagination
currentPage={pagination.page}
totalPages={pagination.totalPages}
onPageChange={handlePageChange}
onLimitChange={handleLimitChange}
limit={pagination.limit}
total={pagination.total}
loading={loading}
showPerPage={true}
showStats={true}
/>
</Card>
</div>
);
};
// Invoice Status Badge Component
const InvoiceStatusBadge = ({ status }) => {
const statusConfig = {
draft: { label: "Brouillon", color: 'default' },
sent: { label: "Envoyée", color: 'warning' },
paid: { label: "Payée", color: 'success' },
overdue: { label: "En retard", color: 'danger' },
cancelled: { label: "Annulée", color: 'default' }
};
const config = statusConfig[status] || statusConfig.draft;
return <Badge variant={config.color}>{config.label}</Badge>;
};
export default InvoicesListPage;
+7
View File
@@ -0,0 +1,7 @@
/**
* Invoice Admin Components
*/
export { default as InvoicesListPage } from './InvoicesListPage.js';
export { default as InvoiceCreatePage } from './InvoiceCreatePage.js';
export { default as InvoiceEditPage } from './InvoiceEditPage.js';
+882
View File
@@ -0,0 +1,882 @@
/**
* Invoice Module API Routes
* All API endpoints for the invoice module
*/
import { validateSession } from '../../features/auth/lib/session.js';
import { cookies } from 'next/headers';
import { getSessionCookieName } from '../../shared/lib/appConfig.js';
// Invoice CRUD
import {
createInvoice,
getInvoiceById,
getInvoices,
updateInvoice,
deleteInvoice,
} from './crud.js';
// Client lookup by user (for dashboard "my invoices")
import { getClientByUserId } from '../clients/crud.js';
// Item CRUD
import {
createItem,
getItemById,
getItems,
updateItem,
deleteItem,
} from './items/crud.js';
// Category CRUD
import {
createCategory,
getCategoryById,
getCategories,
updateCategory,
deleteCategory,
} from './categories/crud.js';
// Transaction CRUD
import {
createTransaction,
getTransactionById,
getTransactions,
updateTransaction,
} from './transactions/crud.js';
// Recurrence CRUD
import {
createRecurrence,
getRecurrenceById,
getRecurrences,
updateRecurrence,
deleteRecurrence,
} from './recurrences/crud.js';
// Stripe utilities from core
import { isEnabled as isStripeEnabled, verifyWebhookSignature } from '../../core/payments/stripe.js';
// Reminders for payment confirmation
import { sendPaymentConfirmation } from './reminders.js';
// ============================================================================
// Dashboard: My Invoices (authenticated user with linked client)
// ============================================================================
const COOKIE_NAME = getSessionCookieName();
/**
* GET /invoices/me List invoices for the current user's linked client.
* Requires auth. Returns linkedClient (or null) and invoices list.
*/
async function handleGetMyInvoices(request) {
try {
const cookieStore = await cookies();
const sessionToken = cookieStore.get(COOKIE_NAME)?.value;
if (!sessionToken) {
return { success: false, error: 'Unauthorized' };
}
const session = await validateSession(sessionToken);
if (!session?.user?.id) {
return { success: false, error: 'Unauthorized' };
}
const client = await getClientByUserId(session.user.id);
const url = new URL(request.url);
const page = parseInt(url.searchParams.get('page')) || 1;
const limit = parseInt(url.searchParams.get('limit')) || 20;
const statusParam = url.searchParams.get('status') || null;
const sortBy = url.searchParams.get('sortBy') || 'created_at';
const sortOrder = url.searchParams.get('sortOrder') || 'DESC';
// Clients must never see draft invoices; ignore status=draft if passed
const status = statusParam === 'draft' ? null : statusParam;
if (!client) {
return {
success: true,
linkedClient: null,
invoices: [],
total: 0,
totalPages: 0,
page,
limit,
};
}
const result = await getInvoices({
client_id: client.id,
page,
limit,
status,
statusNot: 'draft',
sortBy,
sortOrder,
});
const linkedClient = {
id: client.id,
client_number: client.client_number,
company_name: client.company_name,
first_name: client.first_name,
last_name: client.last_name,
email: client.email,
};
return {
success: true,
linkedClient,
invoices: result.invoices,
total: result.pagination.total,
totalPages: result.pagination.totalPages,
page: result.pagination.page,
limit: result.pagination.limit,
};
} catch (error) {
console.error('Error handling GET my invoices:', error);
return { success: false, error: error.message || 'Failed to fetch invoices' };
}
}
// ============================================================================
// Invoice Handlers
// ============================================================================
async function handleGetInvoices(request) {
try {
const url = new URL(request.url);
const id = url.searchParams.get('id');
if (id) {
const invoice = await getInvoiceById(parseInt(id));
if (!invoice) {
return { success: false, error: 'Invoice not found' };
}
return { success: true, invoice };
}
const page = parseInt(url.searchParams.get('page')) || 1;
const limit = parseInt(url.searchParams.get('limit')) || 20;
const search = url.searchParams.get('search') || '';
const status = url.searchParams.get('status') || null;
const client_id = url.searchParams.get('client_id') || null;
const sortBy = url.searchParams.get('sortBy') || 'created_at';
const sortOrder = url.searchParams.get('sortOrder') || 'DESC';
const result = await getInvoices({
page, limit, search, status,
client_id: client_id ? parseInt(client_id) : null,
sortBy, sortOrder
});
return {
success: true,
invoices: result.invoices,
total: result.pagination.total,
totalPages: result.pagination.totalPages,
page: result.pagination.page,
limit: result.pagination.limit
};
} catch (error) {
console.error('Error handling GET invoices:', error);
return { success: false, error: error.message || 'Failed to fetch invoices' };
}
}
async function handleCreateInvoice(request) {
try {
const body = await request.json();
// Accept both { invoice: {...} } and direct {...} format
const invoiceData = body.invoice || body;
if (!invoiceData || Object.keys(invoiceData).length === 0) {
return { success: false, error: 'Invoice data is required' };
}
const invoice = await createInvoice(invoiceData);
return { success: true, invoice, message: 'Invoice created successfully' };
} catch (error) {
console.error('Error creating invoice:', error);
return { success: false, error: error.message || 'Failed to create invoice' };
}
}
async function handleUpdateInvoice(request) {
try {
const url = new URL(request.url);
const body = await request.json();
// Accept ID from query param or body
const id = url.searchParams.get('id') || body.id;
// Accept both { invoice: {...} } and direct {...} format
const updates = body.invoice || (({ id, ...rest }) => rest)(body);
if (!id) {
return { success: false, error: 'Invoice ID is required' };
}
if (!updates || Object.keys(updates).length === 0) {
return { success: false, error: 'Update data is required' };
}
const existingInvoice = await getInvoiceById(parseInt(id));
if (!existingInvoice) {
return { success: false, error: 'Invoice not found' };
}
const invoice = await updateInvoice(parseInt(id), updates);
return { success: true, invoice, message: 'Invoice updated successfully' };
} catch (error) {
console.error('Error updating invoice:', error);
return { success: false, error: error.message || 'Failed to update invoice' };
}
}
async function handleDeleteInvoice(request) {
try {
const url = new URL(request.url);
const id = url.searchParams.get('id');
if (!id) {
return { success: false, error: 'Invoice ID is required' };
}
const existingInvoice = await getInvoiceById(parseInt(id));
if (!existingInvoice) {
return { success: false, error: 'Invoice not found' };
}
const deleted = await deleteInvoice(parseInt(id));
if (!deleted) {
return { success: false, error: 'Failed to delete invoice' };
}
return { success: true, message: 'Invoice deleted successfully' };
} catch (error) {
console.error('Error deleting invoice:', error);
if (error.message.includes('Cannot delete paid invoices')) {
return { success: false, error: error.message };
}
return { success: false, error: 'Failed to delete invoice' };
}
}
// ============================================================================
// Item Handlers
// ============================================================================
async function handleGetItems(request) {
try {
const url = new URL(request.url);
const id = url.searchParams.get('id');
if (id) {
const item = await getItemById(parseInt(id));
if (!item) {
return { success: false, error: 'Item not found' };
}
return { success: true, item };
}
const page = parseInt(url.searchParams.get('page')) || 1;
const limit = parseInt(url.searchParams.get('limit')) || 20;
const search = url.searchParams.get('search') || '';
const sortBy = url.searchParams.get('sortBy') || 'created_at';
const sortOrder = url.searchParams.get('sortOrder') || 'DESC';
const result = await getItems({ page, limit, search, sortBy, sortOrder });
return {
success: true,
items: result.items,
total: result.pagination.total,
totalPages: result.pagination.totalPages,
page: result.pagination.page,
limit: result.pagination.limit
};
} catch (error) {
console.error('Error handling GET items:', error);
return { success: false, error: 'Failed to fetch items' };
}
}
async function handleCreateItem(request) {
try {
const body = await request.json();
// Accept both { item: {...} } and direct {...} format
const itemData = body.item || body;
if (!itemData || Object.keys(itemData).length === 0) {
return { success: false, error: 'Item data is required' };
}
const item = await createItem(itemData);
return { success: true, item, message: 'Item created successfully' };
} catch (error) {
console.error('Error creating item:', error);
return { success: false, error: error.message || 'Failed to create item' };
}
}
async function handleUpdateItem(request) {
try {
const url = new URL(request.url);
const body = await request.json();
// Accept ID from query param or body
const id = url.searchParams.get('id') || body.id;
// Accept both { item: {...} } and direct {...} format
const updates = body.item || (({ id, ...rest }) => rest)(body);
if (!id) {
return { success: false, error: 'Item ID is required' };
}
if (!updates || Object.keys(updates).length === 0) {
return { success: false, error: 'Update data is required' };
}
const existingItem = await getItemById(parseInt(id));
if (!existingItem) {
return { success: false, error: 'Item not found' };
}
const item = await updateItem(parseInt(id), updates);
return { success: true, item, message: 'Item updated successfully' };
} catch (error) {
console.error('Error updating item:', error);
return { success: false, error: error.message || 'Failed to update item' };
}
}
async function handleDeleteItem(request) {
try {
const url = new URL(request.url);
const id = url.searchParams.get('id');
if (!id) {
return { success: false, error: 'Item ID is required' };
}
const existingItem = await getItemById(parseInt(id));
if (!existingItem) {
return { success: false, error: 'Item not found' };
}
const deleted = await deleteItem(parseInt(id));
if (!deleted) {
return { success: false, error: 'Failed to delete item' };
}
return { success: true, message: 'Item deleted successfully' };
} catch (error) {
console.error('Error deleting item:', error);
return { success: false, error: 'Failed to delete item' };
}
}
// ============================================================================
// Category Handlers
// ============================================================================
async function handleGetCategories(request) {
try {
const url = new URL(request.url);
const id = url.searchParams.get('id');
if (id) {
const category = await getCategoryById(parseInt(id));
if (!category) {
return { success: false, error: 'Category not found' };
}
return { success: true, category };
}
const page = parseInt(url.searchParams.get('page')) || 1;
const limit = parseInt(url.searchParams.get('limit')) || 20;
const search = url.searchParams.get('search') || '';
const sortBy = url.searchParams.get('sortBy') || 'created_at';
const sortOrder = url.searchParams.get('sortOrder') || 'DESC';
const result = await getCategories({ page, limit, search, sortBy, sortOrder });
return {
success: true,
categories: result.categories,
total: result.pagination.total,
totalPages: result.pagination.totalPages,
page: result.pagination.page,
limit: result.pagination.limit
};
} catch (error) {
console.error('Error handling GET categories:', error);
return { success: false, error: 'Failed to fetch categories' };
}
}
async function handleCreateCategory(request) {
try {
const body = await request.json();
// Accept both { category: {...} } and direct {...} format
const categoryData = body.category || body;
if (!categoryData || Object.keys(categoryData).length === 0) {
return { success: false, error: 'Category data is required' };
}
const category = await createCategory(categoryData);
return { success: true, category, message: 'Category created successfully' };
} catch (error) {
console.error('Error creating category:', error);
return { success: false, error: error.message || 'Failed to create category' };
}
}
async function handleUpdateCategory(request) {
try {
const url = new URL(request.url);
const body = await request.json();
// Accept ID from query param or body
const id = url.searchParams.get('id') || body.id;
// Accept both { category: {...} } and direct {...} format
const updates = body.category || (({ id, ...rest }) => rest)(body);
if (!id) {
return { success: false, error: 'Category ID is required' };
}
if (!updates || Object.keys(updates).length === 0) {
return { success: false, error: 'Update data is required' };
}
const existingCategory = await getCategoryById(parseInt(id));
if (!existingCategory) {
return { success: false, error: 'Category not found' };
}
const category = await updateCategory(parseInt(id), updates);
return { success: true, category, message: 'Category updated successfully' };
} catch (error) {
console.error('Error updating category:', error);
return { success: false, error: error.message || 'Failed to update category' };
}
}
async function handleDeleteCategory(request) {
try {
const url = new URL(request.url);
const id = url.searchParams.get('id');
if (!id) {
return { success: false, error: 'Category ID is required' };
}
const existingCategory = await getCategoryById(parseInt(id));
if (!existingCategory) {
return { success: false, error: 'Category not found' };
}
const deleted = await deleteCategory(parseInt(id));
if (!deleted) {
return { success: false, error: 'Failed to delete category' };
}
return { success: true, message: 'Category deleted successfully' };
} catch (error) {
console.error('Error deleting category:', error);
return { success: false, error: 'Failed to delete category' };
}
}
// ============================================================================
// Transaction Handlers
// ============================================================================
async function handleGetTransactions(request) {
try {
const url = new URL(request.url);
const id = url.searchParams.get('id');
if (id) {
const transaction = await getTransactionById(parseInt(id));
if (!transaction) {
return { success: false, error: 'Transaction not found' };
}
return { success: true, transaction };
}
const page = parseInt(url.searchParams.get('page')) || 1;
const limit = parseInt(url.searchParams.get('limit')) || 20;
const search = url.searchParams.get('search') || '';
const invoice_id = url.searchParams.get('invoice_id') || null;
const client_id = url.searchParams.get('client_id') || null;
const sortBy = url.searchParams.get('sortBy') || 'created_at';
const sortOrder = url.searchParams.get('sortOrder') || 'DESC';
const result = await getTransactions({
page, limit, search,
invoice_id: invoice_id ? parseInt(invoice_id) : null,
client_id: client_id ? parseInt(client_id) : null,
sortBy, sortOrder
});
return {
success: true,
transactions: result.transactions,
total: result.pagination.total,
totalPages: result.pagination.totalPages,
page: result.pagination.page,
limit: result.pagination.limit
};
} catch (error) {
console.error('Error handling GET transactions:', error);
return { success: false, error: 'Failed to fetch transactions' };
}
}
async function handleCreateTransaction(request) {
try {
const body = await request.json();
// Accept both { transaction: {...} } and direct {...} format
const transactionData = body.transaction || body;
if (!transactionData || Object.keys(transactionData).length === 0) {
return { success: false, error: 'Transaction data is required' };
}
const { send_confirmation_email, ...cleanTransactionData } = transactionData;
const transaction = await createTransaction(cleanTransactionData);
if (send_confirmation_email && transaction.invoice_id) {
try {
await sendPaymentConfirmation(transaction.invoice_id, transaction);
} catch (emailError) {
console.error('Failed to send payment confirmation email:', emailError);
}
}
return { success: true, transaction, message: 'Transaction created successfully' };
} catch (error) {
console.error('Error creating transaction:', error);
return { success: false, error: error.message || 'Failed to create transaction' };
}
}
async function handleUpdateTransaction(request) {
try {
const url = new URL(request.url);
const body = await request.json();
// Accept ID from query param or body
const id = url.searchParams.get('id') || body.id;
// Accept both { transaction: {...} } and direct {...} format
const updates = body.transaction || (({ id, ...rest }) => rest)(body);
if (!id) {
return { success: false, error: 'Transaction ID is required' };
}
if (!updates || Object.keys(updates).length === 0) {
return { success: false, error: 'Update data is required' };
}
const existingTransaction = await getTransactionById(parseInt(id));
if (!existingTransaction) {
return { success: false, error: 'Transaction not found' };
}
const transaction = await updateTransaction(parseInt(id), updates);
return { success: true, transaction, message: 'Transaction updated successfully' };
} catch (error) {
console.error('Error updating transaction:', error);
return { success: false, error: error.message || 'Failed to update transaction' };
}
}
// ============================================================================
// Recurrence Handlers
// ============================================================================
async function handleGetRecurrences(request) {
try {
const url = new URL(request.url);
const id = url.searchParams.get('id');
if (id) {
const recurrence = await getRecurrenceById(parseInt(id));
if (!recurrence) {
return { success: false, error: 'Recurrence not found' };
}
return { success: true, recurrence };
}
const page = parseInt(url.searchParams.get('page')) || 1;
const limit = parseInt(url.searchParams.get('limit')) || 20;
const search = url.searchParams.get('search') || '';
const sortBy = url.searchParams.get('sortBy') || 'created_at';
const sortOrder = url.searchParams.get('sortOrder') || 'DESC';
const result = await getRecurrences({ page, limit, search, sortBy, sortOrder });
return {
success: true,
recurrences: result.recurrences,
total: result.pagination.total,
totalPages: result.pagination.totalPages,
page: result.pagination.page,
limit: result.pagination.limit
};
} catch (error) {
console.error('Error handling GET recurrences:', error);
return { success: false, error: 'Failed to fetch recurrences' };
}
}
async function handleCreateRecurrence(request) {
try {
const body = await request.json();
// Accept both { recurrence: {...} } and direct {...} format
const recurrenceData = body.recurrence || body;
if (!recurrenceData || Object.keys(recurrenceData).length === 0) {
return { success: false, error: 'Recurrence data is required' };
}
const recurrence = await createRecurrence(recurrenceData);
return { success: true, recurrence, message: 'Recurrence created successfully' };
} catch (error) {
console.error('Error creating recurrence:', error);
return { success: false, error: error.message || 'Failed to create recurrence' };
}
}
async function handleUpdateRecurrence(request) {
try {
const url = new URL(request.url);
const body = await request.json();
// Accept ID from query param or body
const id = url.searchParams.get('id') || body.id;
// Accept both { recurrence: {...} } and direct {...} format
const updates = body.recurrence || (({ id, ...rest }) => rest)(body);
if (!id) {
return { success: false, error: 'Recurrence ID is required' };
}
if (!updates || Object.keys(updates).length === 0) {
return { success: false, error: 'Update data is required' };
}
const existingRecurrence = await getRecurrenceById(parseInt(id));
if (!existingRecurrence) {
return { success: false, error: 'Recurrence not found' };
}
const recurrence = await updateRecurrence(parseInt(id), updates);
return { success: true, recurrence, message: 'Recurrence updated successfully' };
} catch (error) {
console.error('Error updating recurrence:', error);
return { success: false, error: error.message || 'Failed to update recurrence' };
}
}
async function handleDeleteRecurrence(request) {
try {
const url = new URL(request.url);
const id = url.searchParams.get('id');
if (!id) {
return { success: false, error: 'Recurrence ID is required' };
}
const existingRecurrence = await getRecurrenceById(parseInt(id));
if (!existingRecurrence) {
return { success: false, error: 'Recurrence not found' };
}
const deleted = await deleteRecurrence(parseInt(id));
if (!deleted) {
return { success: false, error: 'Failed to delete recurrence' };
}
return { success: true, message: 'Recurrence deleted successfully' };
} catch (error) {
console.error('Error deleting recurrence:', error);
return { success: false, error: 'Failed to delete recurrence' };
}
}
// ============================================================================
// Stripe Webhook Handler
// ============================================================================
/**
* Handle successful checkout session (internal)
* @param {Object} session - Stripe checkout session
* @returns {Promise<Object>}
*/
async function handleCheckoutSessionCompleted(session) {
const invoiceId = parseInt(session.metadata.invoice_id);
if (!invoiceId) {
console.error('Invoice ID not found in session metadata');
return { success: false };
}
try {
// Get invoice
const invoice = await getInvoiceById(invoiceId);
if (!invoice) {
console.error(`Invoice ${invoiceId} not found`);
return { success: false };
}
// Create transaction
const transaction = await createTransaction({
invoice_id: invoiceId,
client_id: invoice.client_id,
amount: session.amount_total / 100, // Convert from cents
payment_method: 'stripe',
status: 'completed',
stripe_payment_intent_id: session.payment_intent,
notes: `Stripe checkout session: ${session.id}`,
});
// Send payment confirmation email
await sendPaymentConfirmation(invoiceId, transaction);
return { success: true, transaction };
} catch (error) {
console.error('Error handling checkout session:', error);
return { success: false, error: error.message };
}
}
/**
* Process Stripe webhook event
* @param {Object} event - Stripe webhook event
* @returns {Promise<Object>}
*/
async function processStripeEvent(event) {
switch (event.type) {
case 'checkout.session.completed':
return await handleCheckoutSessionCompleted(event.data.object);
case 'payment_intent.succeeded':
console.log('Payment intent succeeded:', event.data.object.id);
return { success: true };
case 'payment_intent.payment_failed':
console.error('Payment intent failed:', event.data.object.id);
return { success: true };
default:
console.log(`Unhandled Stripe event type: ${event.type}`);
return { handled: false };
}
}
/**
* Handle Stripe webhook HTTP request
*/
async function handleStripeWebhook(request) {
try {
const body = await request.text();
const signature = request.headers.get('stripe-signature');
if (!signature) {
return { error: 'Bad Request', message: 'Missing stripe-signature header' };
}
if (!process.env.STRIPE_WEBHOOK_SECRET) {
console.error('STRIPE_WEBHOOK_SECRET is not configured');
return { error: 'Internal Server Error', message: 'Webhook secret not configured' };
}
let event;
try {
event = await verifyWebhookSignature(body, signature);
} catch (err) {
console.error('Webhook signature verification failed:', err.message);
return { error: 'Bad Request', message: 'Webhook signature verification failed' };
}
const result = await processStripeEvent(event);
console.log(`Stripe webhook handled: ${event.type}`, result);
return { success: true, received: true, eventType: event.type, result };
} catch (error) {
console.error('Error handling Stripe webhook:', error);
return { error: 'Internal Server Error', message: 'Failed to process webhook' };
}
}
// ============================================================================
// Route Definitions
// ============================================================================
export default {
routes: [
// Webhook (public)
{ path: '/webhook/stripe', method: 'POST', handler: handleStripeWebhook, auth: 'public' },
// Invoices for current user's linked client (dashboard)
{ path: '/invoices/me', method: 'GET', handler: handleGetMyInvoices, auth: 'user' },
// Invoices (admin)
{ path: '/admin/invoices', method: 'GET', handler: handleGetInvoices, auth: 'admin' },
{ path: '/admin/invoices', method: 'POST', handler: handleCreateInvoice, auth: 'admin' },
{ path: '/admin/invoices', method: 'PUT', handler: handleUpdateInvoice, auth: 'admin' },
{ path: '/admin/invoices', method: 'DELETE', handler: handleDeleteInvoice, auth: 'admin' },
// Items (admin)
{ path: '/admin/items', method: 'GET', handler: handleGetItems, auth: 'admin' },
{ path: '/admin/items', method: 'POST', handler: handleCreateItem, auth: 'admin' },
{ path: '/admin/items', method: 'PUT', handler: handleUpdateItem, auth: 'admin' },
{ path: '/admin/items', method: 'DELETE', handler: handleDeleteItem, auth: 'admin' },
// Categories (admin)
{ path: '/admin/categories', method: 'GET', handler: handleGetCategories, auth: 'admin' },
{ path: '/admin/categories', method: 'POST', handler: handleCreateCategory, auth: 'admin' },
{ path: '/admin/categories', method: 'PUT', handler: handleUpdateCategory, auth: 'admin' },
{ path: '/admin/categories', method: 'DELETE', handler: handleDeleteCategory, auth: 'admin' },
// Transactions (admin)
{ path: '/admin/transactions', method: 'GET', handler: handleGetTransactions, auth: 'admin' },
{ path: '/admin/transactions', method: 'POST', handler: handleCreateTransaction, auth: 'admin' },
{ path: '/admin/transactions', method: 'PUT', handler: handleUpdateTransaction, auth: 'admin' },
// Recurrences (admin)
{ path: '/admin/recurrences', method: 'GET', handler: handleGetRecurrences, auth: 'admin' },
{ path: '/admin/recurrences', method: 'POST', handler: handleCreateRecurrence, auth: 'admin' },
{ path: '/admin/recurrences', method: 'PUT', handler: handleUpdateRecurrence, auth: 'admin' },
{ path: '/admin/recurrences', method: 'DELETE', handler: handleDeleteRecurrence, auth: 'admin' },
]
};
// Export individual handlers for direct use if needed
export {
handleGetMyInvoices,
handleGetInvoices,
handleCreateInvoice,
handleUpdateInvoice,
handleDeleteInvoice,
handleGetItems,
handleCreateItem,
handleUpdateItem,
handleDeleteItem,
handleGetCategories,
handleCreateCategory,
handleUpdateCategory,
handleDeleteCategory,
handleGetTransactions,
handleCreateTransaction,
handleUpdateTransaction,
handleGetRecurrences,
handleCreateRecurrence,
handleUpdateRecurrence,
handleDeleteRecurrence,
handleStripeWebhook,
};
@@ -0,0 +1,266 @@
'use client';
import React, { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { PlusSignCircleIcon, PencilEdit01Icon, Delete02Icon } from '../../../../shared/Icons.js';
import {
Table,
Button,
StatusBadge,
Card,
Pagination
} from '../../../../shared/components';
import { useToast } from '@hykocx/zen/toast';
/**
* Categories List Page Component
* Displays list of categories with pagination and sorting
*/
const CategoriesListPage = ({ user }) => {
const router = useRouter();
const toast = useToast();
const [categories, setCategories] = useState([]);
const [loading, setLoading] = useState(true);
const [deleting, setDeleting] = useState(false);
// Pagination state
const [pagination, setPagination] = useState({
page: 1,
limit: 20,
total: 0,
totalPages: 0
});
// Sort state
const [sortBy, setSortBy] = useState('title');
const [sortOrder, setSortOrder] = useState('asc');
// Table columns configuration
const columns = [
{
key: 'title',
label: "Titre",
sortable: true,
render: (category) => (
<div>
<div className="text-sm font-semibold text-neutral-900 dark:text-white">{category.title}</div>
{category.parent_title && (
<div className="text-xs text-neutral-500 dark:text-gray-400">
Sous-catégorie de : {category.parent_title}
</div>
)}
</div>
),
skeleton: {
height: 'h-4',
width: '40%',
secondary: { height: 'h-3', width: '30%' }
}
},
{
key: 'description',
label: "Description",
sortable: false,
render: (category) => (
<div className="text-sm text-neutral-600 dark:text-gray-300 max-w-xs truncate">
{category.description || <span className="text-neutral-400 dark:text-gray-500">-</span>}
</div>
),
skeleton: { height: 'h-4', width: '60%' }
},
{
key: 'subcategory_count',
label: "Sous-catégories",
sortable: false,
render: (category) => (
<div className="text-sm text-neutral-600 dark:text-gray-300">
{category.subcategory_count || 0}
</div>
),
skeleton: { height: 'h-4', width: '30px' }
},
{
key: 'items_count',
label: "Articles",
sortable: false,
render: (category) => (
<div className="text-sm text-neutral-600 dark:text-gray-300">
{category.items_count || 0}
</div>
),
skeleton: { height: 'h-4', width: '30px' }
},
{
key: 'is_active',
label: "Statut",
sortable: true,
render: (category) => (
<StatusBadge variant={category.is_active ? 'success' : 'default'}>
{category.is_active ? "Actif" : "Inactif"}
</StatusBadge>
),
skeleton: { height: 'h-6', width: '70px', className: 'rounded-full' }
},
{
key: 'actions',
label: "Actions",
render: (category) => (
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleEditCategory(category)}
disabled={deleting}
icon={<PencilEdit01Icon className="w-4 h-4" />}
className="p-2"
/>
<Button
variant="danger"
size="sm"
onClick={() => handleDeleteCategory(category)}
disabled={deleting}
icon={<Delete02Icon className="w-4 h-4" />}
className="p-2"
/>
</div>
),
skeleton: { height: 'h-8', width: '80px' }
}
];
useEffect(() => {
loadCategories();
}, [sortBy, sortOrder, pagination.page, pagination.limit]);
const loadCategories = async () => {
try {
setLoading(true);
const searchParams = new URLSearchParams({
page: pagination.page.toString(),
limit: pagination.limit.toString(),
sortBy,
sortOrder
});
const response = await fetch(`/zen/api/admin/categories?${searchParams}`, {
credentials: 'include'
});
const data = await response.json();
if (data.success) {
setCategories(data.categories || []);
setPagination(prev => ({
...prev,
total: data.total || 0,
totalPages: data.totalPages || 0,
page: data.page || 1
}));
} else {
toast.error(data.error || "Échec du chargement des catégories");
}
} catch (error) {
console.error('Error loading categories:', error);
toast.error("Échec du chargement des catégories");
} finally {
setLoading(false);
}
};
const handlePageChange = (newPage) => {
setPagination(prev => ({ ...prev, page: newPage }));
};
const handleLimitChange = (newLimit) => {
setPagination(prev => ({
...prev,
limit: newLimit,
page: 1
}));
};
const handleSort = (newSortBy) => {
const newSortOrder = sortBy === newSortBy && sortOrder === 'asc' ? 'desc' : 'asc';
setSortBy(newSortBy);
setSortOrder(newSortOrder);
};
const handleEditCategory = (category) => {
router.push(`/admin/invoice/categories/edit/${category.id}`);
};
const handleDeleteCategory = async (category) => {
if (!confirm(`Êtes-vous sûr de vouloir supprimer la catégorie "${category.title}" ?`)) {
return;
}
try {
setDeleting(true);
const response = await fetch(`/zen/api/admin/categories?id=${category.id}`, {
method: 'DELETE',
credentials: 'include'
});
const data = await response.json();
if (data.success) {
toast.success("Catégorie supprimée avec succès");
loadCategories();
} else {
toast.error(data.error || "Échec de la suppression de la catégorie");
}
} catch (error) {
console.error('Error deleting category:', error);
toast.error("Échec de la suppression de la catégorie");
} finally {
setDeleting(false);
}
};
return (
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-lg sm:text-xl font-semibold text-neutral-900 dark:text-white">Catégories</h1>
<p className="mt-1 text-xs text-neutral-400">Gérez les catégories d'articles</p>
</div>
<Button
onClick={() => router.push('/admin/invoice/categories/new')}
icon={<PlusSignCircleIcon className="w-4 h-4" />}
>
Créer une catégorie
</Button>
</div>
{/* Categories Table */}
<Card variant="default" padding="none">
<Table
columns={columns}
data={categories}
loading={loading}
sortBy={sortBy}
sortOrder={sortOrder}
onSort={handleSort}
emptyMessage="Aucune catégorie trouvée"
emptyDescription="Créez votre première catégorie pour organiser les articles"
/>
<Pagination
currentPage={pagination.page}
totalPages={pagination.totalPages}
onPageChange={handlePageChange}
onLimitChange={handleLimitChange}
limit={pagination.limit}
total={pagination.total}
loading={loading}
showPerPage={true}
showStats={true}
/>
</Card>
</div>
);
};
export default CategoriesListPage;
@@ -0,0 +1,231 @@
'use client';
import React, { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Button, Card, Input, Select, Textarea } from '../../../../shared/components';
import { useToast } from '@hykocx/zen/toast';
/**
* Category Create Page Component
* Page for creating a new category
*/
const CategoryCreatePage = ({ user }) => {
const router = useRouter();
const toast = useToast();
const [categories, setCategories] = useState([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [formData, setFormData] = useState({
title: '',
description: '',
parent_id: '',
is_active: true
});
const [errors, setErrors] = useState({});
useEffect(() => {
loadCategories();
}, []);
const loadCategories = async () => {
try {
setLoading(true);
const response = await fetch('/zen/api/admin/categories?limit=1000&is_active=true', {
credentials: 'include'
});
const data = await response.json();
if (data.success) {
setCategories(data.categories || []);
} else {
toast.error("Échec du chargement des catégories");
}
} catch (error) {
console.error('Error loading categories:', error);
toast.error("Échec du chargement des données");
} finally {
setLoading(false);
}
};
const handleInputChange = (field, value) => {
setFormData(prev => ({
...prev,
[field]: value
}));
if (errors[field]) {
setErrors(prev => ({
...prev,
[field]: null
}));
}
};
const handleCheckboxChange = (e) => {
const { name, checked } = e.target;
setFormData(prev => ({
...prev,
[name]: checked
}));
};
const validateForm = () => {
const newErrors = {};
if (!formData.title.trim()) {
newErrors.title = "Le titre de la catégorie est requis";
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!validateForm()) {
return;
}
try {
setSaving(true);
const submitData = {
...formData,
parent_id: formData.parent_id === '' || formData.parent_id === 'null' ? null : parseInt(formData.parent_id)
};
const response = await fetch('/zen/api/admin/categories', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify(submitData)
});
const data = await response.json();
if (data.success) {
toast.success("Catégorie créée avec succès");
router.push('/admin/invoice/categories');
} else {
toast.error(data.message || "Échec de la création de la catégorie");
}
} catch (error) {
console.error('Error creating category:', error);
toast.error("Échec de la création de la catégorie");
} finally {
setSaving(false);
}
};
return (
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-lg sm:text-xl font-semibold text-neutral-900 dark:text-white">Créer une catégorie</h1>
<p className="mt-1 text-xs text-neutral-400">Remplissez les détails pour créer une nouvelle catégorie</p>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => router.push('/admin/invoice/categories')}
>
Retour aux catégories
</Button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-6">
{/* Category Information */}
<Card>
<div className="space-y-4">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-white">Informations de la catégorie</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="md:col-span-2">
<Input
label="Titre *"
value={formData.title}
onChange={(value) => handleInputChange('title', value)}
placeholder="Saisir le titre de la catégorie..."
error={errors.title}
/>
</div>
<div className="md:col-span-2">
<Textarea
label="Description"
value={formData.description}
onChange={(value) => handleInputChange('description', value)}
rows={3}
placeholder="Décrivez la catégorie..."
/>
</div>
<Select
label="Catégorie parente"
value={formData.parent_id || ''}
onChange={(value) => handleInputChange('parent_id', value)}
options={[
{ value: '', label: "Aucune (niveau supérieur)" },
...categories.map((category) => ({
value: category.id,
label: category.parent_title
? `${category.title} (${category.parent_title})`
: category.title
}))
]}
disabled={loading}
/>
<div className="flex items-center pt-6">
<label className="flex items-center cursor-pointer">
<input
type="checkbox"
name="is_active"
checked={formData.is_active}
onChange={handleCheckboxChange}
className="w-5 h-5 text-blue-600 bg-neutral-100 dark:bg-neutral-800 border-neutral-300 dark:border-neutral-700 rounded focus:ring-2 focus:ring-blue-500"
/>
<span className="ml-2 text-sm text-neutral-700 dark:text-neutral-300">
Actif (disponible à l'utilisation)
</span>
</label>
</div>
</div>
</div>
</Card>
{/* Actions */}
<div className="flex items-center justify-end gap-3">
<Button
type="button"
variant="secondary"
onClick={() => router.push('/admin/invoice/categories')}
disabled={saving}
>
Annuler
</Button>
<Button
type="submit"
variant="success"
loading={saving}
disabled={saving}
>
{saving ? "Création..." : "Créer la catégorie"}
</Button>
</div>
</form>
</div>
);
};
export default CategoryCreatePage;
@@ -0,0 +1,288 @@
'use client';
import React, { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Button, Card, Input, Select, Textarea, Loading } from '../../../../shared/components';
import { useToast } from '@hykocx/zen/toast';
/**
* Category Edit Page Component
* Page for editing an existing category
*/
const CategoryEditPage = ({ categoryId, user }) => {
const router = useRouter();
const toast = useToast();
const [categories, setCategories] = useState([]);
const [category, setCategory] = useState(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [formData, setFormData] = useState({
title: '',
description: '',
parent_id: '',
is_active: true
});
const [errors, setErrors] = useState({});
useEffect(() => {
loadCategoriesAndCategory();
}, [categoryId]);
const loadCategoriesAndCategory = async () => {
try {
setLoading(true);
// Load categories
const categoriesResponse = await fetch('/zen/api/admin/categories?limit=1000&is_active=true', {
credentials: 'include'
});
const categoriesData = await categoriesResponse.json();
if (categoriesData.success) {
// Filter out the current category (can't be its own parent)
const filteredCategories = categoriesData.categories?.filter(cat => cat.id !== parseInt(categoryId)) || [];
setCategories(filteredCategories);
}
// Load category
const categoryResponse = await fetch(`/zen/api/admin/categories?id=${categoryId}`, {
credentials: 'include'
});
const categoryData = await categoryResponse.json();
if (categoryData.success && categoryData.category) {
const loadedCategory = categoryData.category;
setCategory(loadedCategory);
setFormData({
title: loadedCategory.title || '',
description: loadedCategory.description || '',
parent_id: loadedCategory.parent_id || '',
is_active: loadedCategory.is_active !== undefined ? loadedCategory.is_active : true
});
} else {
toast.error("Catégorie introuvable");
}
} catch (error) {
console.error('Error loading data:', error);
toast.error("Échec du chargement de la catégorie");
} finally {
setLoading(false);
}
};
const handleInputChange = (field, value) => {
setFormData(prev => ({
...prev,
[field]: value
}));
if (errors[field]) {
setErrors(prev => ({
...prev,
[field]: null
}));
}
};
const handleCheckboxChange = (e) => {
const { name, checked } = e.target;
setFormData(prev => ({
...prev,
[name]: checked
}));
};
const validateForm = () => {
const newErrors = {};
if (!formData.title.trim()) {
newErrors.title = "Le titre de la catégorie est requis";
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!validateForm()) {
return;
}
try {
setSaving(true);
const submitData = {
...formData,
parent_id: formData.parent_id === '' || formData.parent_id === 'null' ? null : parseInt(formData.parent_id)
};
const response = await fetch(`/zen/api/admin/categories?id=${categoryId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify(submitData)
});
const data = await response.json();
if (data.success) {
toast.success("Catégorie mise à jour avec succès");
router.push('/admin/invoice/categories');
} else {
toast.error(data.message || "Échec de la mise à jour de la catégorie");
}
} catch (error) {
console.error('Error updating category:', error);
toast.error("Échec de la mise à jour de la catégorie");
} finally {
setSaving(false);
}
};
if (loading) {
return (
<div className="min-h-64 flex items-center justify-center">
<Loading />
</div>
);
}
if (!category) {
return (
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-lg sm:text-xl font-semibold text-neutral-900 dark:text-white">Modifier la catégorie</h1>
<p className="mt-1 text-xs text-neutral-400">Catégorie introuvable</p>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => router.push('/admin/invoice/categories')}
>
Retour aux catégories
</Button>
</div>
<Card>
<div className="bg-red-500/10 border border-red-500/20 text-red-400 px-4 py-3 rounded-lg">
<p className="font-medium">Catégorie introuvable</p>
<p className="text-sm mt-1">La catégorie que vous recherchez n'existe pas ou a été supprimée.</p>
</div>
</Card>
</div>
);
}
return (
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-lg sm:text-xl font-semibold text-neutral-900 dark:text-white">Modifier la catégorie</h1>
<p className="mt-1 text-xs text-neutral-400">Catégorie : {category.title}</p>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => router.push('/admin/invoice/categories')}
>
← Retour aux catégories
</Button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-6">
{/* Category Information */}
<Card>
<div className="space-y-4">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-white">Informations de la catégorie</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="md:col-span-2">
<Input
label="Titre *"
value={formData.title}
onChange={(value) => handleInputChange('title', value)}
placeholder="Saisir le titre de la catégorie..."
error={errors.title}
/>
</div>
<div className="md:col-span-2">
<Textarea
label="Description"
value={formData.description}
onChange={(value) => handleInputChange('description', value)}
rows={3}
placeholder="Décrivez la catégorie..."
/>
</div>
<Select
label="Catégorie parente"
value={formData.parent_id || ''}
onChange={(value) => handleInputChange('parent_id', value)}
options={[
{ value: '', label: "Aucune (niveau supérieur)" },
...categories.map((cat) => ({
value: cat.id,
label: cat.parent_title
? `${cat.title} (${cat.parent_title})`
: cat.title
}))
]}
disabled={loading}
/>
<div className="flex items-center pt-6">
<label className="flex items-center cursor-pointer">
<input
type="checkbox"
name="is_active"
checked={formData.is_active}
onChange={handleCheckboxChange}
className="w-5 h-5 text-blue-600 bg-neutral-100 dark:bg-neutral-800 border-neutral-300 dark:border-neutral-700 rounded focus:ring-2 focus:ring-blue-500"
/>
<span className="ml-2 text-sm text-neutral-700 dark:text-neutral-300">
Actif (disponible à l'utilisation)
</span>
</label>
</div>
</div>
</div>
</Card>
{/* Actions */}
<div className="flex items-center justify-end gap-3">
<Button
type="button"
variant="secondary"
onClick={() => router.push('/admin/invoice/categories')}
disabled={saving}
>
Annuler
</Button>
<Button
type="submit"
variant="success"
loading={saving}
disabled={saving}
>
{saving ? "Mise à jour..." : "Mettre à jour la catégorie"}
</Button>
</div>
</form>
</div>
);
};
export default CategoryEditPage;
@@ -0,0 +1,9 @@
/**
* Categories Admin Components
* Part of Invoice Module
*/
export { default as CategoriesListPage } from './CategoriesListPage.js';
export { default as CategoryCreatePage } from './CategoryCreatePage.js';
export { default as CategoryEditPage } from './CategoryEditPage.js';
+317
View File
@@ -0,0 +1,317 @@
/**
* Categories Module - CRUD Operations
* Create, Read, Update, Delete operations for categories
* Part of Invoice Module
*/
import { query } from '@hykocx/zen/database';
/**
* Create a new category
* @param {Object} categoryData - Category data
* @returns {Promise<Object>} Created category
*/
export async function createCategory(categoryData) {
const {
title,
description = null,
parent_id = null,
is_active = true,
} = categoryData;
// Validate required fields
if (!title) {
throw new Error('Title is required');
}
// Validate parent_id if provided
if (parent_id !== null) {
const parentResult = await query(
`SELECT id FROM zen_items_category WHERE id = $1`,
[parent_id]
);
if (parentResult.rows.length === 0) {
throw new Error('Parent category not found');
}
}
const result = await query(
`INSERT INTO zen_items_category (title, description, parent_id, is_active)
VALUES ($1, $2, $3, $4)
RETURNING *`,
[title, description, parent_id, is_active]
);
return result.rows[0];
}
/**
* Get category by ID
* @param {number} id - Category ID
* @returns {Promise<Object|null>}
*/
export async function getCategoryById(id) {
const result = await query(
`SELECT c.*,
p.title as parent_title
FROM zen_items_category c
LEFT JOIN zen_items_category p ON c.parent_id = p.id
WHERE c.id = $1`,
[id]
);
return result.rows[0] || null;
}
/**
* Get all categories with pagination and filters
* @param {Object} options - Query options
* @returns {Promise<Object>} Categories and metadata
*/
export async function getCategories(options = {}) {
const {
page = 1,
limit = 50,
search = '',
parent_id = null,
is_active = null,
sortBy = 'title',
sortOrder = 'ASC'
} = options;
const offset = (page - 1) * limit;
// Build where conditions
const conditions = [];
const params = [];
let paramIndex = 1;
if (search) {
conditions.push(`(c.title ILIKE $${paramIndex} OR c.description ILIKE $${paramIndex})`);
params.push(`%${search}%`);
paramIndex++;
}
if (parent_id !== null) {
if (parent_id === 'null') {
conditions.push(`c.parent_id IS NULL`);
} else {
conditions.push(`c.parent_id = $${paramIndex}`);
params.push(parent_id);
paramIndex++;
}
}
if (is_active !== null) {
conditions.push(`c.is_active = $${paramIndex}`);
params.push(is_active);
paramIndex++;
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
// Get total count
const countResult = await query(
`SELECT COUNT(*)
FROM zen_items_category c
${whereClause}`,
params
);
const total = parseInt(countResult.rows[0].count);
// Get categories with parent info
const categoriesResult = await query(
`SELECT c.*,
p.title as parent_title,
(SELECT COUNT(*) FROM zen_items_category WHERE parent_id = c.id) as subcategory_count,
(SELECT COUNT(*) FROM zen_items WHERE category_id = c.id) as items_count
FROM zen_items_category c
LEFT JOIN zen_items_category p ON c.parent_id = p.id
${whereClause}
ORDER BY c.${sortBy} ${sortOrder}
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
[...params, limit, offset]
);
return {
categories: categoriesResult.rows,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit)
}
};
}
/**
* Get all active categories (for dropdowns/selects)
* @returns {Promise<Array>}
*/
export async function getActiveCategories() {
const result = await query(
`SELECT c.id, c.title, c.parent_id, p.title as parent_title
FROM zen_items_category c
LEFT JOIN zen_items_category p ON c.parent_id = p.id
WHERE c.is_active = true
ORDER BY c.title ASC`
);
return result.rows;
}
/**
* Get subcategories of a category
* @param {number} parentId - Parent category ID
* @returns {Promise<Array>}
*/
export async function getSubcategories(parentId) {
const result = await query(
`SELECT * FROM zen_items_category
WHERE parent_id = $1 AND is_active = true
ORDER BY title ASC`,
[parentId]
);
return result.rows;
}
/**
* Update category
* @param {number} id - Category ID
* @param {Object} updates - Fields to update
* @returns {Promise<Object>} Updated category
*/
export async function updateCategory(id, updates) {
const allowedFields = [
'title', 'description', 'parent_id', 'is_active'
];
// Validate parent_id if being updated
if (updates.parent_id !== undefined && updates.parent_id !== null) {
// Prevent category from being its own parent
if (updates.parent_id === id) {
throw new Error('Category cannot be its own parent');
}
// Check if parent exists
const parentResult = await query(
`SELECT id FROM zen_items_category WHERE id = $1`,
[updates.parent_id]
);
if (parentResult.rows.length === 0) {
throw new Error('Parent category not found');
}
// Prevent circular references (parent cannot be a child of this category)
const checkCircular = await query(
`WITH RECURSIVE category_tree AS (
SELECT id, parent_id FROM zen_items_category WHERE id = $1
UNION ALL
SELECT c.id, c.parent_id
FROM zen_items_category c
INNER JOIN category_tree ct ON c.id = ct.parent_id
)
SELECT id FROM category_tree WHERE id = $2`,
[updates.parent_id, id]
);
if (checkCircular.rows.length > 0) {
throw new Error('Cannot create circular category reference');
}
}
const setFields = [];
const values = [];
let paramIndex = 1;
for (const [key, value] of Object.entries(updates)) {
if (allowedFields.includes(key)) {
setFields.push(`${key} = $${paramIndex}`);
values.push(value);
paramIndex++;
}
}
if (setFields.length === 0) {
throw new Error('No valid fields to update');
}
// Add updated_at
setFields.push(`updated_at = CURRENT_TIMESTAMP`);
// Add ID parameter
values.push(id);
const result = await query(
`UPDATE zen_items_category
SET ${setFields.join(', ')}
WHERE id = $${paramIndex}
RETURNING *`,
values
);
return result.rows[0];
}
/**
* Delete category (soft delete by setting is_active to false)
* @param {number} id - Category ID
* @returns {Promise<Object>} Updated category
*/
export async function deleteCategory(id) {
// Check if category has items
const itemsResult = await query(
`SELECT COUNT(*) FROM zen_items WHERE category_id = $1`,
[id]
);
const itemsCount = parseInt(itemsResult.rows[0].count);
if (itemsCount > 0) {
throw new Error(`Cannot delete category with ${itemsCount} associated items`);
}
// Check if category has subcategories
const subcategoriesResult = await query(
`SELECT COUNT(*) FROM zen_items_category WHERE parent_id = $1`,
[id]
);
const subcategoriesCount = parseInt(subcategoriesResult.rows[0].count);
if (subcategoriesCount > 0) {
throw new Error(`Cannot delete category with ${subcategoriesCount} subcategories`);
}
return await updateCategory(id, { is_active: false });
}
/**
* Permanently delete category (use with caution!)
* @param {number} id - Category ID
* @returns {Promise<boolean>} Success status
*/
export async function permanentlyDeleteCategory(id) {
// Check if category has items
const itemsResult = await query(
`SELECT COUNT(*) FROM zen_items WHERE category_id = $1`,
[id]
);
const itemsCount = parseInt(itemsResult.rows[0].count);
if (itemsCount > 0) {
throw new Error(`Cannot delete category with ${itemsCount} associated items`);
}
const result = await query(
`DELETE FROM zen_items_category WHERE id = $1`,
[id]
);
return result.rowCount > 0;
}
+78
View File
@@ -0,0 +1,78 @@
/**
* Categories Module - Database
* Database initialization and tables for categories
* Part of Invoice Module
*/
import { query } from '@hykocx/zen/database';
/**
* Check if a table exists in the database
* @param {string} tableName - Name of the table to check
* @returns {Promise<boolean>}
*/
async function tableExists(tableName) {
const result = await query(
`SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = $1
)`,
[tableName]
);
return result.rows[0].exists;
}
/**
* Create categories table
* @returns {Promise<Object>}
*/
export async function createCategoriesTable() {
const tableName = 'zen_items_category';
const exists = await tableExists(tableName);
if (exists) {
console.log(`- Table already exists: ${tableName}`);
return { created: false, tableName };
}
await query(`
CREATE TABLE zen_items_category (
id SERIAL PRIMARY KEY,
title VARCHAR(255) NOT NULL,
description TEXT,
parent_id INTEGER REFERENCES zen_items_category(id) ON DELETE SET NULL,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
)
`);
// Create index on title for fast lookups
await query(`
CREATE INDEX idx_zen_items_category_title ON zen_items_category(title)
`);
// Create index on parent_id for hierarchy queries
await query(`
CREATE INDEX idx_zen_items_category_parent_id ON zen_items_category(parent_id)
`);
console.log(`✓ Created table: ${tableName}`);
return { created: true, tableName };
}
/**
* Drop categories table (use with caution!)
* @returns {Promise<void>}
*/
export async function dropCategoriesTable() {
const tableName = 'zen_items_category';
const exists = await tableExists(tableName);
if (exists) {
await query(`DROP TABLE IF EXISTS ${tableName} CASCADE`);
console.log(`✓ Dropped table: ${tableName}`);
}
}
+44
View File
@@ -0,0 +1,44 @@
/**
* Invoice Module Cron Configuration
* Defines scheduled tasks for the invoice module
*/
import { processInvoiceReminders } from './reminders.js';
import { processAllInvoiceInterest } from './interest.js';
import { processRecurrences } from './recurrences/processor.js';
export default {
jobs: [
{
name: 'invoice-reminders',
description: 'Send invoice reminders for pending and overdue invoices',
// Every 5 minutes between 5 AM and 11 PM
schedule: '*/5 * * * *',
handler: processInvoiceReminders,
timezone: process.env.ZEN_TIMEZONE || 'America/Toronto',
},
{
name: 'invoice-interest',
description: 'Calculate and apply interest to overdue invoices',
// Every 5 minutes (interest calculation is debounced internally)
schedule: '*/5 * * * *',
handler: async () => {
const summary = await processAllInvoiceInterest();
// Only log if something was actually processed
if (summary.updated > 0 || summary.errors > 0) {
console.log('[Invoice Interest] Processed:', summary);
}
return summary;
},
timezone: process.env.ZEN_TIMEZONE || 'America/Toronto',
},
{
name: 'invoice-recurrences',
description: 'Process recurring invoices and create new invoices',
// Every 5 minutes between 5 AM and 5 PM
schedule: '*/5 8-17 * * *',
handler: processRecurrences,
timezone: process.env.ZEN_TIMEZONE || 'America/Toronto',
},
]
};
+721
View File
@@ -0,0 +1,721 @@
/**
* Invoice Module - CRUD Operations
* Create, Read, Update, Delete operations for invoices
*/
import { query } from '@hykocx/zen/database';
import crypto from 'crypto';
import { getTodayUTC, subtractDays, formatDateForInput } from '../../shared/lib/dates.js';
/**
* Generate invoice number
* Format: CLIENTNUMBER + YEAR + NUMBER (e.g., 01202501)
* @param {string} clientNumber - Client number (e.g., '01')
* @returns {Promise<string>}
*/
async function generateInvoiceNumber(clientNumber) {
const currentYear = getTodayUTC().getFullYear().toString();
const prefix = `${clientNumber}${currentYear}`;
// Get last invoice for this client and year
const result = await query(
`SELECT invoice_number FROM zen_invoices
WHERE invoice_number LIKE $1
ORDER BY invoice_number DESC
LIMIT 1`,
[`${prefix}%`]
);
let nextNumber = 1;
if (result.rows.length > 0) {
const lastInvoice = result.rows[0].invoice_number;
const lastNumber = parseInt(lastInvoice.substring(prefix.length));
nextNumber = lastNumber + 1;
}
// Format: CLIENTNUMBER + YEAR + NUMBER (e.g., 01202501)
return `${prefix}${nextNumber.toString().padStart(2, '0')}`;
}
/**
* Generate unique token for invoice payment page
* @returns {string}
*/
function generateInvoiceToken() {
return crypto.randomBytes(32).toString('hex');
}
/**
* Calculate invoice totals
* @param {Array} items - Invoice items
* @returns {Object} Calculated totals
*/
function calculateInvoiceTotals(items) {
const subtotal = items.reduce((sum, item) => {
return sum + (item.quantity * item.unit_price);
}, 0);
const total = subtotal;
return {
subtotal: parseFloat(subtotal.toFixed(2)),
total: parseFloat(total.toFixed(2))
};
}
/**
* Calculate issue date based on due date and first reminder days
* @param {string} dueDate - Due date (YYYY-MM-DD)
* @param {number} reminderDays - First reminder days
* @returns {string} Calculated issue date (YYYY-MM-DD)
*/
function calculateIssueDate(dueDate, reminderDays) {
const issueDate = subtractDays(dueDate, parseInt(reminderDays));
return formatDateForInput(issueDate);
}
/**
* Create a new invoice
* @param {Object} invoiceData - Invoice data
* @returns {Promise<Object>} Created invoice with items
*/
export async function createInvoice(invoiceData) {
const {
client_id,
due_date,
items = [],
notes = null,
status = 'draft',
first_reminder_days = 30,
is_recurring = false,
recurring_frequency = null,
recurring_end_date = null,
} = invoiceData;
// Calculate issue_date automatically from due_date and first_reminder_days
// Allow override if issue_date is explicitly provided (for backward compatibility)
let issue_date = invoiceData.issue_date;
if (!issue_date && due_date && first_reminder_days) {
issue_date = calculateIssueDate(due_date, first_reminder_days);
}
// Validate required fields
if (!client_id || !issue_date || !due_date || items.length === 0) {
throw new Error('Client, due date, and at least one item are required');
}
// Get client to generate invoice number
const clientResult = await query(
`SELECT client_number FROM zen_clients WHERE id = $1`,
[client_id]
);
if (clientResult.rows.length === 0) {
throw new Error('Client not found');
}
const clientNumber = clientResult.rows[0].client_number;
// Generate invoice number and token
const invoice_number = await generateInvoiceNumber(clientNumber);
const token = generateInvoiceToken();
// Calculate totals (tax rate is always 0)
const totals = calculateInvoiceTotals(items);
// Check if Interac is enabled and create credentials if needed
const interacEnabled = process.env.ZEN_MODULE_INVOICE_INTERAC === 'true';
if (interacEnabled) {
try {
await getOrCreateInteracCredentials(client_id);
} catch (error) {
console.warn('Failed to create Interac credentials:', error.message);
// Don't fail invoice creation if Interac credentials fail
}
}
// Start transaction
await query('BEGIN');
try {
// Create invoice (tax_rate and tax_amount are always 0)
const invoiceResult = await query(
`INSERT INTO zen_invoices (
invoice_number, token, client_id, issue_date, due_date,
subtotal, tax_rate, tax_amount, total_amount, notes, status,
first_reminder_days, is_recurring, recurring_frequency, recurring_end_date
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
RETURNING *`,
[
invoice_number, token, client_id, issue_date, due_date,
totals.subtotal, 0, 0, totals.total, notes, status,
first_reminder_days, is_recurring, recurring_frequency, recurring_end_date
]
);
const invoice = invoiceResult.rows[0];
// Create invoice items
const createdItems = [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
const itemTotal = item.quantity * item.unit_price;
const itemResult = await query(
`INSERT INTO zen_invoice_items (
invoice_id, name, description, quantity, unit_price, total, sort_order
) VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *`,
[
invoice.id,
item.name,
item.description || null,
item.quantity,
item.unit_price,
itemTotal,
i
]
);
createdItems.push(itemResult.rows[0]);
}
await query('COMMIT');
return {
...invoice,
items: createdItems
};
} catch (error) {
await query('ROLLBACK');
throw error;
}
}
/**
* Get invoice by ID
* @param {number} id - Invoice ID
* @returns {Promise<Object|null>}
*/
export async function getInvoiceById(id) {
const invoiceResult = await query(
`SELECT i.*,
c.client_number, c.first_name, c.last_name, c.email as client_email,
c.company_name, c.address, c.city, c.province, c.postal_code, c.country
FROM zen_invoices i
JOIN zen_clients c ON i.client_id = c.id
WHERE i.id = $1`,
[id]
);
if (invoiceResult.rows.length === 0) {
return null;
}
const invoice = invoiceResult.rows[0];
// Get invoice items
const itemsResult = await query(
`SELECT * FROM zen_invoice_items WHERE invoice_id = $1 ORDER BY sort_order`,
[id]
);
// Get reminders
const remindersResult = await query(
`SELECT * FROM zen_invoice_reminders WHERE invoice_id = $1 ORDER BY sent_at DESC`,
[id]
);
return {
...invoice,
items: itemsResult.rows,
reminders: remindersResult.rows
};
}
/**
* Get invoice by token
* @param {string} token - Invoice token
* @returns {Promise<Object|null>}
*/
export async function getInvoiceByToken(token) {
const invoiceResult = await query(
`SELECT i.*,
c.client_number, c.first_name, c.last_name, c.email as client_email,
c.company_name, c.address, c.city, c.province, c.postal_code, c.country
FROM zen_invoices i
JOIN zen_clients c ON i.client_id = c.id
WHERE i.token = $1`,
[token]
);
if (invoiceResult.rows.length === 0) {
return null;
}
const invoice = invoiceResult.rows[0];
// Get invoice items
const itemsResult = await query(
`SELECT * FROM zen_invoice_items WHERE invoice_id = $1 ORDER BY sort_order`,
[invoice.id]
);
return {
...invoice,
items: itemsResult.rows
};
}
/**
* Get invoice by invoice number
* @param {string} invoiceNumber - Invoice number
* @returns {Promise<Object|null>}
*/
export async function getInvoiceByNumber(invoiceNumber) {
const invoiceResult = await query(
`SELECT i.*,
c.client_number, c.first_name, c.last_name, c.email as client_email,
c.company_name
FROM zen_invoices i
JOIN zen_clients c ON i.client_id = c.id
WHERE i.invoice_number = $1`,
[invoiceNumber]
);
if (invoiceResult.rows.length === 0) {
return null;
}
const invoice = invoiceResult.rows[0];
// Get invoice items
const itemsResult = await query(
`SELECT * FROM zen_invoice_items WHERE invoice_id = $1 ORDER BY sort_order`,
[invoice.id]
);
return {
...invoice,
items: itemsResult.rows
};
}
const ALLOWED_INVOICE_SORT_COLUMNS = new Set([
'created_at', 'updated_at', 'invoice_number', 'due_date',
'issue_date', 'total_amount', 'status', 'paid_amount'
]);
const ALLOWED_SORT_ORDERS = new Set(['ASC', 'DESC']);
const MAX_PAGE_LIMIT = 100;
/**
* Get all invoices with pagination and filters
* @param {Object} options - Query options
* @returns {Promise<Object>} Invoices and metadata
*/
export async function getInvoices(options = {}) {
const {
page = 1,
limit = 20,
search = '',
status = null,
statusNot = null,
client_id = null,
sortBy = 'created_at',
sortOrder = 'DESC'
} = options;
// Whitelist sortBy and sortOrder to prevent SQL injection via column/direction interpolation
const safeSortBy = ALLOWED_INVOICE_SORT_COLUMNS.has(sortBy) ? sortBy : 'created_at';
const safeSortOrder = ALLOWED_SORT_ORDERS.has(String(sortOrder).toUpperCase()) ? String(sortOrder).toUpperCase() : 'DESC';
// Cap pagination limit to prevent DoS via oversized result sets
const safeLimit = Math.min(Math.max(1, parseInt(limit) || 20), MAX_PAGE_LIMIT);
const offset = (page - 1) * safeLimit;
// Build where conditions
const conditions = [];
const params = [];
let paramIndex = 1;
if (search) {
conditions.push(`(
i.invoice_number ILIKE $${paramIndex} OR
c.first_name ILIKE $${paramIndex} OR
c.last_name ILIKE $${paramIndex} OR
c.company_name ILIKE $${paramIndex}
)`);
params.push(`%${search}%`);
paramIndex++;
}
if (status) {
conditions.push(`i.status = $${paramIndex}`);
params.push(status);
paramIndex++;
}
if (statusNot) {
conditions.push(`i.status != $${paramIndex}`);
params.push(statusNot);
paramIndex++;
}
if (client_id) {
conditions.push(`i.client_id = $${paramIndex}`);
params.push(client_id);
paramIndex++;
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
// Get total count
const countResult = await query(
`SELECT COUNT(*)
FROM zen_invoices i
JOIN zen_clients c ON i.client_id = c.id
${whereClause}`,
params
);
const total = parseInt(countResult.rows[0].count);
// Get invoices
const invoicesResult = await query(
`SELECT i.*,
c.client_number, c.first_name, c.last_name, c.email as client_email, c.company_name,
(SELECT COUNT(*) FROM zen_invoice_items WHERE invoice_id = i.id) as items_count
FROM zen_invoices i
JOIN zen_clients c ON i.client_id = c.id
${whereClause}
ORDER BY i.${safeSortBy} ${safeSortOrder}
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
[...params, safeLimit, offset]
);
return {
invoices: invoicesResult.rows,
pagination: {
page,
limit: safeLimit,
total,
totalPages: Math.ceil(total / safeLimit)
}
};
}
/**
* Update invoice
* @param {number} id - Invoice ID
* @param {Object} updates - Fields to update
* @returns {Promise<Object>} Updated invoice
*/
export async function updateInvoice(id, updates) {
// Get current invoice to check status
const currentInvoice = await getInvoiceById(id);
if (!currentInvoice) {
throw new Error('Invoice not found');
}
const allowedFields = [
'issue_date', 'due_date', 'notes', 'status', 'paid_at',
'first_reminder_days', 'is_recurring',
'recurring_frequency', 'recurring_end_date'
];
// Auto-calculate issue_date if due_date or first_reminder_days are being updated
// but issue_date is not explicitly provided
if ((updates.due_date || updates.first_reminder_days) && !updates.issue_date) {
const dueDate = updates.due_date || currentInvoice.due_date.toISOString().split('T')[0];
const reminderDays = updates.first_reminder_days || currentInvoice.first_reminder_days;
updates.issue_date = calculateIssueDate(dueDate, reminderDays);
}
const setFields = [];
const values = [];
let paramIndex = 1;
for (const [key, value] of Object.entries(updates)) {
if (allowedFields.includes(key)) {
setFields.push(`${key} = $${paramIndex}`);
values.push(value);
paramIndex++;
}
}
if (setFields.length === 0 && !updates.items) {
throw new Error('No valid fields to update');
}
// Allow updating items if invoice is not paid (not 'paid' or 'partial' status)
const canEditItems = currentInvoice.status !== 'paid' && currentInvoice.status !== 'partial';
if (canEditItems && updates.items && Array.isArray(updates.items)) {
// Validate items
if (updates.items.length === 0) {
throw new Error('Invoice must have at least one item');
}
for (const item of updates.items) {
if (!item.name || !item.quantity || !item.unit_price) {
throw new Error('Each item must have a name, quantity, and unit_price');
}
}
// Calculate totals from items
const totals = calculateInvoiceTotals(updates.items);
// Add totals to update
setFields.push(`subtotal = $${paramIndex}`);
values.push(totals.subtotal);
paramIndex++;
setFields.push(`total_amount = $${paramIndex}`);
values.push(totals.total);
paramIndex++;
// Delete existing items
await query(
`DELETE FROM zen_invoice_items WHERE invoice_id = $1`,
[id]
);
// Insert new items
for (let i = 0; i < updates.items.length; i++) {
const item = updates.items[i];
const itemTotal = parseFloat((item.quantity * item.unit_price).toFixed(2));
await query(
`INSERT INTO zen_invoice_items
(invoice_id, name, description, quantity, unit_price, total, sort_order)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[
id,
item.name,
item.description || null,
item.quantity,
item.unit_price,
itemTotal,
i
]
);
}
}
// Update invoice fields if there are any
if (setFields.length > 0) {
// Add updated_at
setFields.push(`updated_at = CURRENT_TIMESTAMP`);
// Add ID parameter
values.push(id);
await query(
`UPDATE zen_invoices
SET ${setFields.join(', ')}
WHERE id = $${paramIndex}`,
values
);
}
// Return updated invoice with items
return await getInvoiceById(id);
}
/**
* Mark invoice as paid
* @param {number} id - Invoice ID
* @param {number} amount - Amount paid
* @returns {Promise<Object>} Updated invoice
*/
export async function markInvoiceAsPaid(id, amount) {
const invoice = await getInvoiceById(id);
if (!invoice) {
throw new Error('Invoice not found');
}
const newPaidAmount = parseFloat(invoice.paid_amount) + parseFloat(amount);
// Total amount owed includes interest
const totalAmountOwed = parseFloat(invoice.total_amount) + parseFloat(invoice.interest_amount || 0);
const isFullyPaid = newPaidAmount >= totalAmountOwed;
const result = await query(
`UPDATE zen_invoices
SET paid_amount = $1,
status = $2,
paid_at = $3,
updated_at = CURRENT_TIMESTAMP
WHERE id = $4
RETURNING *`,
[
newPaidAmount,
isFullyPaid ? 'paid' : 'partial',
isFullyPaid ? getTodayUTC() : invoice.paid_at,
id
]
);
return result.rows[0];
}
/**
* Add reminder to invoice
* @param {number} invoiceId - Invoice ID
* @param {string} reminderType - Reminder type
* @param {number} daysBefore - Days before due date
* @returns {Promise<Object>} Created reminder
*/
export async function addInvoiceReminder(invoiceId, reminderType, daysBefore) {
const result = await query(
`INSERT INTO zen_invoice_reminders (invoice_id, reminder_type, days_before)
VALUES ($1, $2, $3)
RETURNING *`,
[invoiceId, reminderType, daysBefore]
);
return result.rows[0];
}
/**
* Delete invoice
* @param {number} id - Invoice ID
* @returns {Promise<boolean>} Success status
* @throws {Error} If invoice is paid or has payments
*/
export async function deleteInvoice(id) {
// Check if invoice exists and get its status
const invoiceResult = await query(
`SELECT status, paid_amount FROM zen_invoices WHERE id = $1`,
[id]
);
if (invoiceResult.rows.length === 0) {
throw new Error('Invoice not found');
}
const invoice = invoiceResult.rows[0];
// Prevent deletion if invoice has been paid (fully or partially)
if (invoice.status === 'paid' || invoice.status === 'partial' || parseFloat(invoice.paid_amount) > 0) {
throw new Error('Cannot delete paid invoices. Paid invoices must be kept for accounting records.');
}
const result = await query(
`DELETE FROM zen_invoices WHERE id = $1`,
[id]
);
return result.rowCount > 0;
}
/**
* Nature-related words for Interac security answers
*/
const NATURE_WORDS = [
'forest', 'ocean', 'mountain', 'river', 'lake', 'tree', 'flower', 'leaf',
'stone', 'cloud', 'rain', 'snow', 'sun', 'moon', 'star', 'wind',
'earth', 'sky', 'valley', 'meadow'
];
/**
* Generate random digits using a cryptographically secure source
* @param {number} length - Number of digits to generate
* @returns {string}
*/
function generateRandomDigits(length) {
let result = '';
for (let i = 0; i < length; i++) {
result += crypto.randomInt(0, 10);
}
return result;
}
/**
* Generate Interac security question
* Format: 4 random digits + 2-digit client number
* @param {string} clientNumber - Client number (e.g., '01')
* @returns {string}
*/
function generateInteracQuestion(clientNumber) {
const randomDigits = generateRandomDigits(4);
const paddedClientNumber = clientNumber.padStart(2, '0');
return randomDigits + paddedClientNumber;
}
/**
* Generate Interac security answer
* Format: nature word + 4 random digits
* @returns {string}
*/
function generateInteracAnswer() {
const randomWord = NATURE_WORDS[crypto.randomInt(0, NATURE_WORDS.length)];
const randomDigits = generateRandomDigits(4);
return randomWord + randomDigits;
}
/**
* Get Interac credentials by client ID
* @param {number} clientId - Client ID
* @returns {Promise<Object|null>}
*/
export async function getInteracCredentialsByClientId(clientId) {
const result = await query(
`SELECT * FROM zen_invoice_interac WHERE client_id = $1`,
[clientId]
);
return result.rows.length > 0 ? result.rows[0] : null;
}
/**
* Create Interac credentials for a client
* @param {number} clientId - Client ID
* @returns {Promise<Object>}
*/
export async function createInteracCredentials(clientId) {
// Get client to access client number
const clientResult = await query(
`SELECT client_number FROM zen_clients WHERE id = $1`,
[clientId]
);
if (clientResult.rows.length === 0) {
throw new Error('Client not found');
}
const clientNumber = clientResult.rows[0].client_number;
// Generate question and answer
const securityQuestion = generateInteracQuestion(clientNumber);
const securityAnswer = generateInteracAnswer();
// Insert credentials
const result = await query(
`INSERT INTO zen_invoice_interac (client_id, security_question, security_answer)
VALUES ($1, $2, $3)
RETURNING *`,
[clientId, securityQuestion, securityAnswer]
);
return result.rows[0];
}
/**
* Get or create Interac credentials for a client
* @param {number} clientId - Client ID
* @returns {Promise<Object>}
*/
export async function getOrCreateInteracCredentials(clientId) {
// Check if credentials already exist
let credentials = await getInteracCredentialsByClientId(clientId);
// If not, create them
if (!credentials) {
credentials = await createInteracCredentials(clientId);
}
return credentials;
}
@@ -0,0 +1,258 @@
'use client';
/**
* Client Invoices Section (Facturation)
* Displays the current user's invoices when their account is linked to a client.
* Uses the same table layout and styling as the admin InvoicesListPage.
*/
import React, { useState, useEffect } from 'react';
import {
Table,
Card,
Pagination,
Badge,
} from '../../../shared/components/index.js';
import { formatDateForInput, isOverdue, getDaysBetween, getTodayUTC } from '../../../shared/lib/dates.js';
import { formatCurrency } from '../../../shared/utils/currency.js';
// Same status badge as admin InvoicesListPage (partial added for client view)
function InvoiceStatusBadge({ status }) {
const statusConfig = {
draft: { label: 'Brouillon', color: 'default' },
sent: { label: 'À payer', color: 'warning' },
partial: { label: 'Partiel', color: 'info' },
paid: { label: 'Payée', color: 'success' },
overdue: { label: 'En retard', color: 'danger' },
cancelled: { label: 'Annulée', color: 'default' },
};
const config = statusConfig[status] || statusConfig.draft;
return <Badge variant={config.color}>{config.label}</Badge>;
}
export default function ClientInvoicesSection({
invoicePageBasePath = '/zen/invoice',
apiBasePath = '/zen/api',
emptyMessage = 'Aucune facture pour le moment.',
noClientMessage = "Aucun compte client n'est associé à votre compte. Contactez l'administrateur pour faire le lien.",
newTab = false,
}) {
const [data, setData] = useState({
linkedClient: null,
invoices: [],
total: 0,
totalPages: 0,
page: 1,
limit: 20,
});
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [sortBy, setSortBy] = useState('created_at');
const [sortOrder, setSortOrder] = useState('desc');
const fetchInvoices = async (opts = {}) => {
const page = opts.page ?? data.page;
const limit = opts.limit ?? data.limit;
const sort = opts.sortBy ?? sortBy;
const order = opts.sortOrder ?? sortOrder;
setLoading(true);
setError(null);
try {
const base = typeof window !== 'undefined' ? window.location.origin : '';
const params = new URLSearchParams({
page: String(page),
limit: String(limit),
sortBy: sort,
sortOrder: order,
});
const res = await fetch(`${base}${apiBasePath}/invoices/me?${params}`, {
credentials: 'include',
});
const json = await res.json();
if (!res.ok || !json.success) {
throw new Error(json.error || json.message || 'Failed to load invoices');
}
setData({
linkedClient: json.linkedClient ?? null,
invoices: json.invoices ?? [],
total: json.total ?? 0,
totalPages: json.totalPages ?? 0,
page: json.page ?? 1,
limit: json.limit ?? 20,
});
} catch (err) {
setError(err.message);
setData((prev) => ({ ...prev, invoices: [], linkedClient: null }));
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchInvoices({ page: 1 });
}, []);
const handlePageChange = (newPage) => {
fetchInvoices({ page: newPage, sortBy, sortOrder });
};
const handleLimitChange = (newLimit) => {
fetchInvoices({ page: 1, limit: newLimit, sortBy, sortOrder });
};
const handleSort = (newSortBy) => {
const newSortOrder = sortBy === newSortBy && sortOrder === 'asc' ? 'desc' : 'asc';
setSortBy(newSortBy);
setSortOrder(newSortOrder);
fetchInvoices({ page: data.page, sortBy: newSortBy, sortOrder: newSortOrder });
};
const getViewUrl = (invoice) => {
const base = typeof window !== 'undefined' ? window.location.origin : '';
const path = invoicePageBasePath.startsWith('http')
? invoicePageBasePath
: `${base}${invoicePageBasePath}`;
return `${path}/${invoice.token}`;
};
const getPdfUrl = (invoice) => `${getViewUrl(invoice)}/pdf`;
const getViewActionLabel = (invoice) => {
const payableStatuses = ['sent', 'partial', 'overdue'];
return payableStatuses.includes(invoice.status) ? 'Payer' : 'Voir';
};
const tableEmptyMessage = error
? error
: !data.linkedClient && !loading
? noClientMessage
: emptyMessage;
// Exclude drafts: clients should never see draft invoices (API also filters, this is a safeguard)
const visibleInvoices = (data.invoices || []).filter((inv) => inv.status !== 'draft');
// Same column structure as admin InvoicesListPage (no client column, actions = Voir only)
const columns = [
{
key: 'invoice_number',
label: 'Facture #',
sortable: true,
render: (invoice) => (
<div>
<span className="text-sm font-mono font-semibold text-neutral-900 dark:text-white">
{invoice.invoice_number}
</span>
<div className="text-xs text-neutral-500 dark:text-neutral-400">
Émise : {formatDateForInput(invoice.issue_date)}
</div>
</div>
),
skeleton: { height: 'h-4', width: '30%', secondary: { height: 'h-3', width: '25%' } },
},
{
key: 'total_amount',
label: 'Montant',
sortable: true,
render: (invoice) => {
const principalAmount = parseFloat(invoice.total_amount);
const interestAmount = parseFloat(invoice.interest_amount || 0);
const totalWithInterest = principalAmount + interestAmount;
const hasInterest = interestAmount > 0;
const itemsCount = invoice.items_count ?? (invoice.items?.length ?? 0);
return (
<div>
<div className={`text-sm font-semibold ${invoice.status === 'paid' ? 'text-green-600 dark:text-green-400' : 'text-neutral-900 dark:text-white'}`}>
{formatCurrency(totalWithInterest)}
</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{itemsCount} {itemsCount !== 1 ? 'articles' : 'article'}
{hasInterest && ' + Intérêt'}
</div>
</div>
);
},
skeleton: { height: 'h-4', width: '40%' },
},
{
key: 'due_date',
label: "Date d'échéance",
sortable: true,
render: (invoice) => {
const invoiceIsOverdue = isOverdue(invoice.due_date) && invoice.status !== 'paid';
const daysUntilDue = getDaysBetween(getTodayUTC(), invoice.due_date);
return (
<div>
<div className={`text-sm font-mono font-semibold ${invoiceIsOverdue ? 'text-red-600 dark:text-red-400' : 'text-neutral-900 dark:text-white'}`}>
{formatDateForInput(invoice.due_date)}
</div>
{daysUntilDue > 0 && (
<div className="text-xs text-neutral-500 dark:text-neutral-400">dans {daysUntilDue} jours</div>
)}
{invoiceIsOverdue && (
<div className="text-xs text-red-600 dark:text-red-400">En retard</div>
)}
</div>
);
},
skeleton: { height: 'h-4', width: '35%' },
},
{
key: 'status',
label: 'Statut',
sortable: true,
render: (invoice) => <InvoiceStatusBadge status={invoice.status} />,
skeleton: { height: 'h-6', width: '80px', className: 'rounded-full' },
},
{
key: 'actions',
label: 'Actions',
render: (invoice) => (
<div className="flex items-center gap-2">
<a
href={getViewUrl(invoice)}
target={newTab ? '_blank' : '_self'}
rel={newTab ? 'noopener noreferrer' : 'noreferrer'}
className="inline-flex items-center justify-center font-medium rounded-xl transition-all duration-200 px-3 py-2 text-xs border border-neutral-300 text-neutral-700 hover:bg-neutral-100 dark:border-neutral-700/50 dark:text-white dark:hover:bg-neutral-800/80 dark:bg-neutral-800/60"
>
{getViewActionLabel(invoice)}
</a>
<a
href={getPdfUrl(invoice)}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center font-medium rounded-xl transition-all duration-200 px-3 py-2 text-xs border border-neutral-300 text-neutral-700 hover:bg-neutral-100 dark:border-neutral-700/50 dark:text-white dark:hover:bg-neutral-800/80 dark:bg-neutral-800/60"
>
PDF
</a>
</div>
),
skeleton: { height: 'h-8', width: '140px' },
},
];
return (
<Card variant="default" padding="none">
<Table
columns={columns}
data={visibleInvoices}
loading={loading}
sortBy={sortBy}
sortOrder={sortOrder}
onSort={handleSort}
emptyMessage={tableEmptyMessage}
emptyDescription="Vos factures apparaîtront ici"
/>
<Pagination
currentPage={data.page}
totalPages={data.totalPages}
onPageChange={handlePageChange}
onLimitChange={handleLimitChange}
limit={data.limit}
total={data.total}
loading={loading}
showPerPage={true}
showStats={true}
/>
</Card>
);
}
@@ -0,0 +1,46 @@
'use client';
/**
* Invoice Status Dashboard Widget
* Displays pending and overdue invoice counts
*/
import { StatCard } from '../../../shared/components';
import { Invoice03Icon, Notification01Icon } from '../../../shared/Icons.js';
export default function InvoicesWidget({ stats }) {
const loading = !stats;
const widgets = [
{
title: 'Factures en attente',
value: loading ? '-' : String(stats.pendingInvoices),
icon: Invoice03Icon,
color: 'text-yellow-400',
bgColor: 'bg-yellow-500/10',
},
{
title: 'Factures en retard',
value: loading ? '-' : String(stats.overdueInvoices),
icon: Notification01Icon,
color: 'text-red-400',
bgColor: 'bg-red-500/10',
},
];
return (
<>
{widgets.map((widget, index) => (
<StatCard
key={`invoice-status-${index}`}
title={widget.title}
value={widget.value}
icon={widget.icon}
color={widget.color}
bgColor={widget.bgColor}
loading={loading}
/>
))}
</>
);
}
@@ -0,0 +1,77 @@
'use client';
/**
* Invoice Revenue Dashboard Widget
* Displays revenue statistics (month, year, total)
*/
import { StatCard } from '../../../shared/components';
import { CoinsDollarIcon, ChartBarLineIcon } from '../../../shared/Icons.js';
/**
* Format currency value
*/
function formatCurrency(value, symbol = '$') {
return `${value.toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})} ${symbol}`;
}
/**
* Format percentage change
*/
function formatPercentage(change) {
const sign = change > 0 ? '+' : '';
return `${sign}${change.toFixed(2)}%`;
}
export default function RevenueWidget({ stats }) {
const loading = !stats;
const widgets = [
{
title: 'Revenus ce mois',
value: loading ? '-' : formatCurrency(stats.revenueThisMonth, stats.currencySymbol),
change: loading ? '' : formatPercentage(stats.monthChangePercent),
changeType: stats?.monthChangePercent >= 0 ? 'increase' : 'decrease',
icon: CoinsDollarIcon,
color: 'text-green-400',
bgColor: 'bg-green-500/10',
},
{
title: 'Revenus de l\'année en cours',
value: loading ? '-' : formatCurrency(stats.revenueYear, stats.currencySymbol),
change: loading ? '' : formatPercentage(stats.yearChangePercent),
changeType: stats?.yearChangePercent >= 0 ? 'increase' : 'decrease',
icon: ChartBarLineIcon,
color: 'text-blue-400',
bgColor: 'bg-blue-500/10',
},
{
title: 'Revenus totaux',
value: loading ? '-' : formatCurrency(stats.totalRevenue, stats.currencySymbol),
icon: ChartBarLineIcon,
color: 'text-cyan-400',
bgColor: 'bg-cyan-500/10',
},
];
return (
<>
{widgets.map((widget, index) => (
<StatCard
key={`revenue-${index}`}
title={widget.title}
value={widget.value}
change={widget.change}
changeType={widget.changeType}
icon={widget.icon}
color={widget.color}
bgColor={widget.bgColor}
loading={loading}
/>
))}
</>
);
}
+14
View File
@@ -0,0 +1,14 @@
/**
* Invoice Dashboard Module
* Exports dashboard widgets and actions
*/
// Dashboard widgets (client components)
export { default as RevenueWidget } from './RevenueWidget.js';
export { default as InvoicesWidget } from './InvoicesWidget.js';
// Client dashboard: invoices for the current user's linked client (Facturation section)
export { default as ClientInvoicesSection } from './ClientInvoicesSection.js';
// Dashboard stats action is exported separately to avoid 'use server' conflicts
// Import from '@hykocx/zen/modules/invoice/dashboard/actions' or via modules.actions.js
@@ -0,0 +1,142 @@
/**
* Invoice Dashboard Stats Actions
* Server-side actions for invoice dashboard statistics
*/
'use server';
import { query } from '@hykocx/zen/database';
import { getTodayUTC } from '../../../shared/lib/dates.js';
/**
* Get invoice dashboard statistics
* @returns {Promise<Object>}
*/
export async function getInvoiceDashboardStats() {
const today = getTodayUTC();
const currentYear = today.getFullYear();
const currentMonth = today.getMonth() + 1;
const firstDayOfMonth = new Date(currentYear, currentMonth - 1, 1);
const firstDayOfLastMonth = new Date(currentYear, currentMonth - 2, 1);
const lastDayOfLastMonth = new Date(currentYear, currentMonth - 1, 0);
const firstDayOfYear = new Date(currentYear, 0, 1);
const firstDayOfLastYear = new Date(currentYear - 1, 0, 1);
const lastDayOfLastYear = new Date(currentYear - 1, 11, 31);
try {
// Revenue this month
const revenueThisMonthResult = await query(
`SELECT COALESCE(SUM(amount), 0) as total
FROM zen_transactions
WHERE status = 'completed'
AND transaction_date >= $1
AND transaction_date < $2`,
[firstDayOfMonth, today]
);
const revenueThisMonth = parseFloat(revenueThisMonthResult.rows[0].total) || 0;
// Revenue last month (for comparison)
const revenueLastMonthResult = await query(
`SELECT COALESCE(SUM(amount), 0) as total
FROM zen_transactions
WHERE status = 'completed'
AND transaction_date >= $1
AND transaction_date <= $2`,
[firstDayOfLastMonth, lastDayOfLastMonth]
);
const revenueLastMonth = parseFloat(revenueLastMonthResult.rows[0].total) || 0;
// Calculate month change percentage
let monthChangePercent = 0;
if (revenueLastMonth > 0) {
monthChangePercent = ((revenueThisMonth - revenueLastMonth) / revenueLastMonth) * 100;
} else if (revenueThisMonth > 0) {
monthChangePercent = 100;
}
// Current year revenue
const revenueYearResult = await query(
`SELECT COALESCE(SUM(amount), 0) as total
FROM zen_transactions
WHERE status = 'completed'
AND transaction_date >= $1
AND transaction_date < $2`,
[firstDayOfYear, today]
);
const revenueYear = parseFloat(revenueYearResult.rows[0].total) || 0;
// Revenue last year (for comparison)
const revenueLastYearResult = await query(
`SELECT COALESCE(SUM(amount), 0) as total
FROM zen_transactions
WHERE status = 'completed'
AND transaction_date >= $1
AND transaction_date <= $2`,
[firstDayOfLastYear, lastDayOfLastYear]
);
const revenueLastYear = parseFloat(revenueLastYearResult.rows[0].total) || 0;
// Calculate year change percentage
let yearChangePercent = 0;
if (revenueLastYear > 0) {
yearChangePercent = ((revenueYear - revenueLastYear) / revenueLastYear) * 100;
} else if (revenueYear > 0) {
yearChangePercent = 100;
}
// Pending invoices
const pendingInvoicesResult = await query(
`SELECT COUNT(*) as count FROM zen_invoices WHERE status = 'sent'`
);
const pendingInvoices = parseInt(pendingInvoicesResult.rows[0].count) || 0;
// Overdue invoices
const overdueInvoicesResult = await query(
`SELECT COUNT(*) as count
FROM zen_invoices
WHERE due_date < $1
AND status NOT IN ('paid', 'cancelled')`,
[today]
);
const overdueInvoices = parseInt(overdueInvoicesResult.rows[0].count) || 0;
// Total revenue (all time)
const totalRevenueResult = await query(
`SELECT COALESCE(SUM(amount), 0) as total
FROM zen_transactions
WHERE status = 'completed'`
);
const totalRevenue = parseFloat(totalRevenueResult.rows[0].total) || 0;
return {
success: true,
stats: {
revenueThisMonth,
monthChangePercent,
revenueYear,
yearChangePercent,
pendingInvoices,
overdueInvoices,
totalRevenue,
currencySymbol: process.env.ZEN_CURRENCY_SYMBOL || '$',
}
};
} catch (error) {
console.error('Error getting invoice dashboard stats:', error);
return {
success: false,
error: error.message || 'Failed to get invoice statistics',
stats: {
revenueThisMonth: 0,
monthChangePercent: 0,
revenueYear: 0,
yearChangePercent: 0,
pendingInvoices: 0,
overdueInvoices: 0,
totalRevenue: 0,
currencySymbol: '$',
}
};
}
}
+235
View File
@@ -0,0 +1,235 @@
/**
* Invoice Module - Database
* Database initialization and tables for invoice module
*/
import { query } from '@hykocx/zen/database';
/**
* Check if a table exists in the database
* @param {string} tableName - Name of the table to check
* @returns {Promise<boolean>}
*/
async function tableExists(tableName) {
const result = await query(
`SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = $1
)`,
[tableName]
);
return result.rows[0].exists;
}
/**
* Create all invoice-related tables (includes clients, items, and transactions)
* @returns {Promise<Object>}
*/
export async function createTables() {
const created = [];
const skipped = [];
// Import clients, items, categories, transactions and recurrences table creation
const { createClientsTable } = await import('../clients/db.js');
const { createItemsTable } = await import('./items/db.js');
const { createCategoriesTable } = await import('./categories/db.js');
const { createTransactionsTable } = await import('./transactions/db.js');
const { createRecurrencesTable } = await import('./recurrences/db.js');
// Create clients table first (dependency for invoices)
console.log('\n--- Clients (Invoice Module) ---');
const clientsResult = await createClientsTable();
if (clientsResult.created) created.push(clientsResult.tableName);
else skipped.push(clientsResult.tableName);
// Create categories table first (dependency for items)
console.log('\n--- Categories (Invoice Module) ---');
const categoriesResult = await createCategoriesTable();
if (categoriesResult.created) created.push(categoriesResult.tableName);
else skipped.push(categoriesResult.tableName);
// Create items table (depends on categories)
console.log('\n--- Items (Invoice Module) ---');
const itemsResult = await createItemsTable();
if (itemsResult.created) created.push(itemsResult.tableName);
else skipped.push(itemsResult.tableName);
// 1. Main invoices table
const invoicesTableExists = await tableExists('zen_invoices');
if (!invoicesTableExists) {
await query(`
CREATE TABLE zen_invoices (
id SERIAL PRIMARY KEY,
invoice_number VARCHAR(50) UNIQUE NOT NULL,
token VARCHAR(64) UNIQUE NOT NULL,
client_id INTEGER NOT NULL REFERENCES zen_clients(id) ON DELETE RESTRICT,
issue_date DATE NOT NULL,
due_date DATE NOT NULL,
subtotal DECIMAL(10, 2) NOT NULL DEFAULT 0,
tax_rate DECIMAL(5, 2) DEFAULT 0,
tax_amount DECIMAL(10, 2) DEFAULT 0,
total_amount DECIMAL(10, 2) NOT NULL,
paid_amount DECIMAL(10, 2) DEFAULT 0,
interest_amount DECIMAL(10, 2) DEFAULT 0,
interest_last_calculated DATE,
notes TEXT,
status VARCHAR(50) DEFAULT 'draft',
paid_at TIMESTAMPTZ,
first_reminder_days INTEGER DEFAULT 30,
is_recurring BOOLEAN DEFAULT false,
recurring_frequency VARCHAR(50),
recurring_end_date DATE,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
)
`);
await query(`CREATE INDEX idx_zen_invoices_invoice_number ON zen_invoices(invoice_number)`);
await query(`CREATE INDEX idx_zen_invoices_token ON zen_invoices(token)`);
await query(`CREATE INDEX idx_zen_invoices_client_id ON zen_invoices(client_id)`);
await query(`CREATE INDEX idx_zen_invoices_status ON zen_invoices(status)`);
await query(`CREATE INDEX idx_zen_invoices_due_date ON zen_invoices(due_date)`);
await query(`CREATE INDEX idx_zen_invoices_interest_calc ON zen_invoices(status, due_date, interest_last_calculated) WHERE status IN ('sent', 'partial', 'overdue')`);
created.push('zen_invoices');
console.log('✓ Created table: zen_invoices');
} else {
skipped.push('zen_invoices');
console.log('- Table already exists: zen_invoices');
}
// 2. Invoice items table
const itemsTableExists = await tableExists('zen_invoice_items');
if (!itemsTableExists) {
await query(`
CREATE TABLE zen_invoice_items (
id SERIAL PRIMARY KEY,
invoice_id INTEGER NOT NULL REFERENCES zen_invoices(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
description TEXT,
quantity DECIMAL(10, 2) NOT NULL,
unit_price DECIMAL(10, 2) NOT NULL,
total DECIMAL(10, 2) NOT NULL,
sort_order INTEGER DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
)
`);
await query(`CREATE INDEX idx_zen_invoice_items_invoice_id ON zen_invoice_items(invoice_id)`);
created.push('zen_invoice_items');
console.log('✓ Created table: zen_invoice_items');
} else {
skipped.push('zen_invoice_items');
console.log('- Table already exists: zen_invoice_items');
}
// 3. Invoice reminders table
const remindersTableExists = await tableExists('zen_invoice_reminders');
if (!remindersTableExists) {
await query(`
CREATE TABLE zen_invoice_reminders (
id SERIAL PRIMARY KEY,
invoice_id INTEGER NOT NULL REFERENCES zen_invoices(id) ON DELETE CASCADE,
reminder_type VARCHAR(50) NOT NULL,
days_before INTEGER NOT NULL,
sent_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
)
`);
await query(`CREATE INDEX idx_zen_invoice_reminders_invoice_id ON zen_invoice_reminders(invoice_id)`);
await query(`CREATE INDEX idx_zen_invoice_reminders_sent_at ON zen_invoice_reminders(sent_at)`);
created.push('zen_invoice_reminders');
console.log('✓ Created table: zen_invoice_reminders');
} else {
skipped.push('zen_invoice_reminders');
console.log('- Table already exists: zen_invoice_reminders');
}
// 4. Interac credentials table
const interacTableExists = await tableExists('zen_invoice_interac');
if (!interacTableExists) {
await query(`
CREATE TABLE zen_invoice_interac (
id SERIAL PRIMARY KEY,
client_id INTEGER NOT NULL UNIQUE REFERENCES zen_clients(id) ON DELETE CASCADE,
security_question VARCHAR(255) NOT NULL,
security_answer VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
)
`);
await query(`CREATE INDEX idx_zen_invoice_interac_client_id ON zen_invoice_interac(client_id)`);
created.push('zen_invoice_interac');
console.log('✓ Created table: zen_invoice_interac');
} else {
skipped.push('zen_invoice_interac');
console.log('- Table already exists: zen_invoice_interac');
}
// 5. Transactions table (depends on invoices)
console.log('\n--- Transactions (Invoice Module) ---');
const transactionsResult = await createTransactionsTable();
if (transactionsResult.created) created.push(transactionsResult.tableName);
else skipped.push(transactionsResult.tableName);
// 6. Recurrences table (depends on clients and invoices)
console.log('\n--- Recurrences (Invoice Module) ---');
const recurrencesResult = await createRecurrencesTable();
if (recurrencesResult.created) created.push(recurrencesResult.tableName);
else skipped.push(recurrencesResult.tableName);
return { created, skipped };
}
/**
* Drop all invoice-related tables (includes clients, items, and transactions)
* @returns {Promise<void>}
*/
export async function dropTables() {
// Import drop functions
const { dropClientsTable } = await import('../clients/db.js');
const { dropItemsTable } = await import('./items/db.js');
const { dropCategoriesTable } = await import('./categories/db.js');
const { dropTransactionsTable } = await import('./transactions/db.js');
const { dropRecurrencesTable } = await import('./recurrences/db.js');
// Drop recurrences first (depends on invoices)
await dropRecurrencesTable();
// Drop transactions (depends on invoices)
await dropTransactionsTable();
// Drop invoice tables
const tables = [
'zen_invoice_reminders',
'zen_invoice_interac',
'zen_invoice_items',
'zen_invoices'
];
for (const tableName of tables) {
const exists = await tableExists(tableName);
if (exists) {
await query(`DROP TABLE IF EXISTS ${tableName} CASCADE`);
console.log(`✓ Dropped table: ${tableName}`);
}
}
// Drop items (depends on categories)
await dropItemsTable();
// Drop categories
await dropCategoriesTable();
// Drop clients last
await dropClientsTable();
}
// Backward compatibility aliases
export const createInvoiceTables = createTables;
export const dropInvoiceTables = dropTables;
@@ -0,0 +1,121 @@
/**
* Admin Overdue Notification Email Template
* Sent to administrators when an invoice becomes overdue
*/
import { Button, Section, Text, Link } from "@react-email/components";
import { BaseLayout } from "@hykocx/zen/email/templates";
import { formatDateForDisplay } from "../../../shared/lib/dates.js";
const LOCALE = 'fr-FR';
export const AdminOverdueNotificationEmail = ({
clientName,
invoiceNumber,
invoiceAmount,
interestAmount = 0,
totalAmountOwed,
dueDate,
daysOverdue,
invoiceUrl,
companyName,
clientEmail,
clientPhone,
}) => {
const appName = companyName || process.env.ZEN_NAME || 'ZEN';
const hasInterest = parseFloat(interestAmount) > 0;
const formattedDueDate = formatDateForDisplay(dueDate, LOCALE, {
year: 'numeric',
month: 'long',
day: 'numeric'
});
const daysOverdueText = daysOverdue === 1 ? 'jour de retard' : 'jours de retard';
return (
<BaseLayout
preview={`Alerte : facture #${invoiceNumber} en retard de ${daysOverdue} ${daysOverdueText}.`}
title="Alerte : facture en retard"
companyName={companyName}
supportSection={false}
>
<Text className="text-[14px] leading-[24px] text-neutral-600 mt-[4px] mb-[24px]">
Une facture est maintenant en retard de <span className="font-medium text-neutral-900">{daysOverdue} {daysOverdueText}</span>.
</Text>
<Section style={{ border: '1px solid #E5E5E5' }} className="bg-red-50 rounded-[12px] p-[20px] my-[24px]">
<Text className="text-[12px] font-medium text-red-400 m-0 mb-[12px] uppercase tracking-wider">
Détails
</Text>
<Text className="text-[13px] text-neutral-900 m-0 mb-[6px]">
<span className="text-neutral-500 inline-block w-[180px]">Numéro de facture :</span>
#{invoiceNumber}
</Text>
<Text className="text-[13px] text-neutral-900 m-0 mb-[6px]">
<span className="text-neutral-500 inline-block w-[180px]">Client :</span>
{clientName}
</Text>
<Text className="text-[13px] text-neutral-900 m-0 mb-[6px]">
<span className="text-neutral-500 inline-block w-[180px]">Courriel :</span>
<Link href={`mailto:${clientEmail}`} className="text-neutral-900 underline">
{clientEmail}
</Link>
</Text>
{clientPhone && (
<Text className="text-[13px] text-neutral-900 m-0 mb-[6px]">
<span className="text-neutral-500 inline-block w-[180px]">Téléphone :</span>
{clientPhone}
</Text>
)}
<Text className="text-[13px] text-neutral-900 m-0 mb-[6px]">
<span className="text-neutral-500 inline-block w-[180px]">Montant initial :</span>
{invoiceAmount}
</Text>
{hasInterest && (
<>
<Text className="text-[13px] text-neutral-900 m-0 mb-[6px]">
<span className="text-neutral-500 inline-block w-[180px]">Frais d'intérêt :</span>
<span className="text-red-600">{interestAmount}</span>
</Text>
<Text style={{ borderTop: '1px solid #E5E5E5' }} className="text-[13px] font-medium text-neutral-900 m-0 mb-[6px] pt-[10px] mt-[6px]">
<span className="text-neutral-500 font-normal inline-block w-[180px]">Total dû :</span>
<span className="text-red-700">{totalAmountOwed}</span>
</Text>
</>
)}
{!hasInterest && (
<Text className="text-[13px] font-medium text-neutral-900 m-0 mb-[6px]">
<span className="text-neutral-500 font-normal inline-block w-[180px]">Montant dû :</span>
<span className="text-red-700">{invoiceAmount}</span>
</Text>
)}
<Text className="text-[13px] text-neutral-900 m-0">
<span className="text-neutral-500 inline-block w-[180px]">Date d'échéance :</span>
{formattedDueDate}
</Text>
</Section>
<Section className="mt-[4px] mb-[32px]">
<Button
href={invoiceUrl}
className="bg-neutral-900 rounded-[8px] text-white font-medium px-[20px] py-[11px] no-underline text-[13px]"
>
Voir la facture
</Button>
</Section>
<Text className="text-[12px] leading-[20px] text-neutral-400 m-0">
Lien :{' '}
<Link href={invoiceUrl} className="text-neutral-400 underline break-all">
{invoiceUrl}
</Link>
</Text>
<Text className="text-[12px] leading-[20px] text-neutral-400 m-0 mt-[8px]">
Notification automatique une facture est devenue en retard dans votre système.
</Text>
</BaseLayout>
);
};
@@ -0,0 +1,110 @@
/**
* Invoice Overdue Email Template
*/
import { Button, Section, Text, Link } from "@react-email/components";
import { BaseLayout } from "@hykocx/zen/email/templates";
import { formatDateForDisplay } from "../../../shared/lib/dates.js";
const LOCALE = 'fr-FR';
export const InvoiceOverdueEmail = ({
clientName,
invoiceNumber,
invoiceAmount,
interestAmount = 0,
totalAmountOwed,
dueDate,
daysOverdue,
paymentUrl,
companyName,
graceDays = 3,
interestRate = 1
}) => {
const appName = companyName || process.env.ZEN_NAME || 'ZEN';
const supportEmail = process.env.ZEN_SUPPORT_EMAIL || 'support@zenya.test';
const hasInterest = parseFloat(interestAmount) > 0;
const formattedDueDate = formatDateForDisplay(dueDate, LOCALE, {
year: 'numeric',
month: 'long',
day: 'numeric'
});
const daysOverdueText = daysOverdue === 1 ? 'jour de retard' : 'jours de retard';
return (
<BaseLayout
preview={`Votre facture est en retard de ${daysOverdue} ${daysOverdueText}.`}
title="Paiement en retard"
companyName={companyName}
supportSection={true}
supportEmail={supportEmail}
>
<Text className="text-[14px] leading-[24px] text-neutral-600 mt-[4px] mb-[24px]">
Bonjour {clientName}, votre facture accuse un retard de <span className="font-medium text-neutral-900">{daysOverdue} {daysOverdueText}</span>. Nous vous prions de porter une attention immédiate à ce dossier.
</Text>
<Section style={{ border: '1px solid #E5E5E5' }} className="bg-red-50 rounded-[12px] p-[20px] my-[24px]">
<Text className="text-[12px] font-medium text-red-400 m-0 mb-[12px] uppercase tracking-wider">
Détails de la facture
</Text>
<Text className="text-[13px] text-neutral-900 m-0 mb-[6px]">
<span className="text-neutral-500 inline-block w-[180px]">Numéro de facture :</span>
#{invoiceNumber}
</Text>
<Text className="text-[13px] text-neutral-900 m-0 mb-[6px]">
<span className="text-neutral-500 inline-block w-[180px]">Montant initial :</span>
{invoiceAmount}
</Text>
{hasInterest && (
<>
<Text className="text-[13px] text-neutral-900 m-0 mb-[6px]">
<span className="text-neutral-500 inline-block w-[180px]">Frais d'intérêt :</span>
<span className="text-red-600">{interestAmount}</span>
</Text>
<Text style={{ borderTop: '1px solid #E5E5E5' }} className="text-[13px] font-medium text-neutral-900 m-0 mb-[6px] pt-[10px] mt-[6px]">
<span className="text-neutral-500 font-normal inline-block w-[180px]">Total dû :</span>
<span className="text-red-700">{totalAmountOwed}</span>
</Text>
</>
)}
{!hasInterest && (
<Text className="text-[13px] font-medium text-neutral-900 m-0 mb-[6px]">
<span className="text-neutral-500 font-normal inline-block w-[180px]">Montant dû :</span>
<span className="text-red-700">{invoiceAmount}</span>
</Text>
)}
<Text className="text-[13px] text-neutral-900 m-0">
<span className="text-neutral-500 inline-block w-[180px]">Date d'échéance initiale :</span>
{formattedDueDate}
</Text>
</Section>
<Section className="mt-[28px] mb-[32px]">
<Button
href={paymentUrl}
className="bg-neutral-900 rounded-[8px] text-white font-medium px-[20px] py-[11px] no-underline text-[13px]"
>
Payer maintenant
</Button>
</Section>
<Text className="text-[12px] leading-[20px] text-neutral-500 m-0 mb-[8px]">
Des intérêts de {interestRate}% par mois ({interestRate * 12}% par an) sont appliqués sur tout solde impayé au-delà de {graceDays} jour{graceDays > 1 ? 's' : ''} de retard.
</Text>
<Text className="text-[12px] leading-[20px] text-neutral-400 m-0">
Si vous avez déjà effectué le paiement, ignorez ce courriel. Si vous rencontrez des difficultés, contactez-nous pour convenir d'un arrangement.
</Text>
<Text className="text-[12px] leading-[20px] text-neutral-400 m-0 mt-[8px]">
Lien :{' '}
<Link href={paymentUrl} className="text-neutral-400 underline break-all">
{paymentUrl}
</Link>
</Text>
</BaseLayout>
);
};
@@ -0,0 +1,116 @@
/**
* Invoice Payment Confirmation Email Template
*/
import { Button, Section, Text } from "@react-email/components";
import { BaseLayout } from "@hykocx/zen/email/templates";
import { formatDateForDisplay } from "../../../shared/lib/dates.js";
const LOCALE = 'fr-FR';
export const InvoicePaymentConfirmationEmail = ({
clientName,
invoiceNumber,
paidAmount,
paymentDate,
paymentMethod,
transactionNumber,
companyName,
totalAmount = null,
remainingBalance = null,
isFullPayment = null,
invoiceUrl
}) => {
const appName = companyName || process.env.ZEN_NAME || 'ZEN';
const supportEmail = process.env.ZEN_SUPPORT_EMAIL || 'support@zenya.test';
const formattedPaymentDate = formatDateForDisplay(paymentDate, LOCALE, {
year: 'numeric',
month: 'long',
day: 'numeric'
});
const partialLabel = isFullPayment !== null && !isFullPayment ? 'partiel ' : '';
const hasRemainingBalance = remainingBalance !== null && parseFloat(remainingBalance) > 0;
return (
<BaseLayout
preview={`Merci ! Nous avons bien reçu votre ${partialLabel}paiement.`}
title="Paiement confirmé"
companyName={companyName}
supportSection={true}
supportEmail={supportEmail}
>
<Text className="text-[14px] leading-[24px] text-neutral-600 mt-[4px] mb-[24px]">
Bonjour {clientName}, merci ! Nous avons bien reçu votre {partialLabel}paiement.
</Text>
<Section style={{ border: '1px solid #E5E5E5' }} className="bg-green-50 rounded-[12px] p-[20px] my-[24px]">
<Text className="text-[12px] font-medium text-green-500 m-0 mb-[12px] uppercase tracking-wider">
Détails du paiement
</Text>
<Text className="text-[13px] text-neutral-900 m-0 mb-[6px]">
<span className="text-neutral-500 inline-block w-[180px]">Numéro de facture :</span>
#{invoiceNumber}
</Text>
<Text className="text-[13px] font-medium text-neutral-900 m-0 mb-[6px]">
<span className="text-neutral-500 font-normal inline-block w-[180px]">Montant payé :</span>
<span className="text-green-700">{paidAmount}</span>
</Text>
{totalAmount && (
<Text className="text-[13px] text-neutral-900 m-0 mb-[6px]">
<span className="text-neutral-500 inline-block w-[180px]">Total de la facture :</span>
{totalAmount}
</Text>
)}
{hasRemainingBalance && (
<Text className="text-[13px] font-medium text-neutral-900 m-0 mb-[6px]">
<span className="text-neutral-500 font-normal inline-block w-[180px]">Solde restant :</span>
<span className="text-orange-600">{remainingBalance}</span>
</Text>
)}
{isFullPayment !== null && (
<Text className="text-[13px] text-neutral-900 m-0 mb-[6px]">
<span className="text-neutral-500 inline-block w-[180px]">Statut :</span>
<span className={`font-medium ${isFullPayment ? 'text-green-700' : 'text-orange-600'}`}>
{isFullPayment ? 'Payé en entier' : 'Paiement partiel'}
</span>
</Text>
)}
<Text className="text-[13px] text-neutral-900 m-0 mb-[6px]">
<span className="text-neutral-500 inline-block w-[180px]">Date de paiement :</span>
{formattedPaymentDate}
</Text>
<Text className="text-[13px] text-neutral-900 m-0 mb-[6px]">
<span className="text-neutral-500 inline-block w-[180px]">Mode de paiement :</span>
{paymentMethod}
</Text>
{transactionNumber && (
<Text className="text-[13px] text-neutral-900 m-0">
<span className="text-neutral-500 inline-block w-[180px]">Numéro de transaction :</span>
{transactionNumber}
</Text>
)}
</Section>
{hasRemainingBalance && (
<Text className="text-[13px] leading-[22px] text-orange-600 m-0 mb-[24px]">
Un solde de {remainingBalance} est toujours sur cette facture.
</Text>
)}
<Section className="mt-[4px] mb-[32px]">
<Button
href={invoiceUrl}
className="bg-neutral-900 rounded-[8px] text-white font-medium px-[20px] py-[11px] no-underline text-[13px]"
>
Voir la facture
</Button>
</Section>
<Text className="text-[12px] leading-[20px] text-neutral-400 m-0">
Merci pour votre confiance. Nous apprécions votre {partialLabel}paiement.
</Text>
</BaseLayout>
);
};
@@ -0,0 +1,100 @@
/**
* Invoice Receipt Email Template
* For manual receipt sending
*/
import { Section, Text, Row, Column } from "@react-email/components";
import { BaseLayout } from "@hykocx/zen/email/templates";
import { formatDateForDisplay } from "../../../shared/lib/dates.js";
const LOCALE = 'fr-FR';
export const InvoiceReceiptEmail = ({
clientName,
invoiceNumber,
paidAmount,
paymentDate,
paymentMethod,
transactionNumber,
items = [],
companyName,
}) => {
const appName = companyName || process.env.ZEN_NAME || 'ZEN';
const supportEmail = process.env.ZEN_SUPPORT_EMAIL || 'support@zenya.test';
const formattedPaymentDate = formatDateForDisplay(paymentDate, LOCALE, {
year: 'numeric',
month: 'long',
day: 'numeric'
});
return (
<BaseLayout
preview={`Reçu - Facture ${invoiceNumber}`}
title="Reçu de paiement"
companyName={companyName}
supportSection={true}
supportEmail={supportEmail}
>
<Text className="text-[14px] leading-[24px] text-neutral-600 mt-[4px] mb-[24px]">
Bonjour {clientName}, merci pour votre paiement. Voici votre reçu pour vos dossiers.
</Text>
<Section style={{ border: '1px solid #E5E5E5' }} className="bg-neutral-100 rounded-[12px] p-[20px] my-[24px]">
<Text className="text-[12px] font-medium text-neutral-400 m-0 mb-[12px] uppercase tracking-wider">
Détails du paiement
</Text>
<Text className="text-[13px] text-neutral-900 m-0 mb-[6px]">
<span className="text-neutral-500 inline-block w-[180px]">Numéro de facture :</span>
#{invoiceNumber}
</Text>
{transactionNumber && (
<Text className="text-[13px] text-neutral-900 m-0 mb-[6px]">
<span className="text-neutral-500 inline-block w-[180px]">Numéro de transaction :</span>
{transactionNumber}
</Text>
)}
<Text className="text-[13px] text-neutral-900 m-0 mb-[6px]">
<span className="text-neutral-500 inline-block w-[180px]">Date de paiement :</span>
{formattedPaymentDate}
</Text>
<Text className="text-[13px] text-neutral-900 m-0 mb-[0px]">
<span className="text-neutral-500 inline-block w-[180px]">Mode de paiement :</span>
{paymentMethod}
</Text>
{items && items.length > 0 && (
<>
<Text className="text-[12px] font-medium text-neutral-400 m-0 mt-[20px] mb-[10px] uppercase tracking-wider">
Articles
</Text>
{items.map((item, index) => (
<Row key={index} style={{ borderBottom: '1px solid #E5E5E5' }} className="mb-[8px] pb-[8px]">
<Column className="text-[13px] text-neutral-700">
{item.name}
{item.description && (
<Text className="text-[11px] text-neutral-400 m-0 mt-[2px]">{item.description}</Text>
)}
</Column>
<Column className="text-[13px] text-neutral-500 text-center">
{item.quantity} × {item.unit_price}
</Column>
<Column className="text-[13px] text-neutral-900 font-medium text-right">{item.total}</Column>
</Row>
))}
</>
)}
<Text style={{ borderTop: '1px solid #E5E5E5' }} className="text-[13px] font-medium text-neutral-900 m-0 mt-[16px] pt-[16px]">
<span className="text-neutral-500 inline-block w-[180px]">Total payé :</span>
<span className="font-semibold">{paidAmount}</span>
</Text>
</Section>
<Text className="text-[12px] leading-[20px] text-neutral-400 m-0">
Conservez ce reçu pour vos dossiers. Pour toute question, n'hésitez pas à nous contacter.
</Text>
</BaseLayout>
);
};
@@ -0,0 +1,106 @@
/**
* Invoice Reminder Email Template
*/
import { Button, Section, Text, Link } from "@react-email/components";
import { BaseLayout } from "@hykocx/zen/email/templates";
import { formatDateForDisplay } from "../../../shared/lib/dates.js";
const LOCALE = 'fr-FR';
export const InvoiceReminderEmail = ({
clientName,
invoiceNumber,
invoiceAmount,
dueDate,
daysUntilDue,
paymentUrl,
companyName,
isFirstReminder = false,
graceDays = 3,
interestRate = 1
}) => {
const appName = companyName || process.env.ZEN_NAME || 'ZEN';
const supportEmail = process.env.ZEN_SUPPORT_EMAIL || 'support@zenya.test';
const showInterestWarning = daysUntilDue === 1;
const formattedDueDate = formatDateForDisplay(dueDate, LOCALE, {
year: 'numeric',
month: 'long',
day: 'numeric'
});
const preview = isFirstReminder
? `Une nouvelle facture a été émise. Paiement attendu avant le ${formattedDueDate}.`
: `Rappel : votre facture est due avant le ${formattedDueDate} (${daysUntilDue} jours).`;
const title = isFirstReminder
? 'Nouvelle facture disponible'
: 'Rappel de paiement';
return (
<BaseLayout
preview={preview}
title={title}
companyName={companyName}
supportSection={true}
supportEmail={supportEmail}
>
<Text className="text-[14px] leading-[24px] text-neutral-600 mt-[4px] mb-[24px]">
Bonjour {clientName},{' '}
{isFirstReminder ? (
<>une nouvelle facture a été émise pour vous. Le paiement est attendu avant le <span className="font-medium text-neutral-900">{formattedDueDate}</span>.</>
) : (
<>votre facture est due avant le <span className="font-medium text-neutral-900">{formattedDueDate}</span>.</>
)}
</Text>
<Section style={{ border: '1px solid #E5E5E5' }} className="bg-neutral-100 rounded-[12px] p-[20px] my-[24px]">
<Text className="text-[12px] font-medium text-neutral-400 m-0 mb-[12px] uppercase tracking-wider">
Détails de la facture
</Text>
<Text className="text-[13px] text-neutral-900 m-0 mb-[6px]">
<span className="text-neutral-500 inline-block w-[150px]">Numéro de facture :</span>
#{invoiceNumber}
</Text>
<Text className="text-[13px] font-medium text-neutral-900 m-0 mb-[6px]">
<span className="text-neutral-500 font-normal inline-block w-[150px]">Montant :</span>
{invoiceAmount}
</Text>
<Text className="text-[13px] text-neutral-900 m-0">
<span className="text-neutral-500 inline-block w-[150px]">Date d'échéance :</span>
{formattedDueDate}
</Text>
</Section>
<Section className="mt-[28px] mb-[32px]">
<Button
href={paymentUrl}
className="bg-neutral-900 rounded-[8px] text-white font-medium px-[20px] py-[11px] no-underline text-[13px]"
>
{isFirstReminder ? 'Voir et payer la facture' : 'Payer la facture'}
</Button>
</Section>
{showInterestWarning && (
<Text className="text-[12px] leading-[20px] text-neutral-500 m-0 mb-[8px]">
Des intérêts de {interestRate}% par mois ({interestRate * 12}% par an) seront appliqués sur tout solde impayé au-delà de {graceDays} jour{graceDays > 1 ? 's' : ''} de retard.
</Text>
)}
<Text className="text-[12px] leading-[20px] text-neutral-400 m-0">
{isFirstReminder
? "Si vous avez des questions concernant cette facture, n'hésitez pas à nous contacter."
: "Si vous avez déjà effectué le paiement, ignorez ce rappel."}
</Text>
<Text className="text-[12px] leading-[20px] text-neutral-400 m-0 mt-[8px]">
Lien :{' '}
<Link href={paymentUrl} className="text-neutral-400 underline break-all">
{paymentUrl}
</Link>
</Text>
</BaseLayout>
);
};
+9
View File
@@ -0,0 +1,9 @@
/**
* Invoice Email Templates
*/
export { InvoiceReminderEmail } from './InvoiceReminderEmail';
export { InvoiceOverdueEmail } from './InvoiceOverdueEmail';
export { InvoicePaymentConfirmationEmail } from './InvoicePaymentConfirmationEmail';
export { InvoiceReceiptEmail } from './InvoiceReceiptEmail';
export { AdminOverdueNotificationEmail } from './AdminOverdueNotificationEmail';
+173
View File
@@ -0,0 +1,173 @@
/**
* Invoice Module Entry Point
* Includes Clients, Items, and Transactions functionality
*/
// Invoice DB
export {
createTables,
dropTables,
// Backward compatibility aliases
createInvoiceTables,
dropInvoiceTables,
} from './db.js';
// Clients - Re-export from clients module for backward compatibility
export {
createClientsTable,
dropClientsTable,
createClient,
getClientById,
getClientByNumber,
getClientByUserId,
getClients,
updateClient,
deleteClient,
linkClientToUser,
unlinkClientFromUser,
} from '../clients/index.js';
// Invoice CRUD
export {
createInvoice,
getInvoiceById,
getInvoiceByToken,
getInvoiceByNumber,
getInvoices,
updateInvoice,
markInvoiceAsPaid,
addInvoiceReminder,
deleteInvoice,
getInteracCredentialsByClientId,
createInteracCredentials,
getOrCreateInteracCredentials,
} from './crud.js';
// Invoice Email & Reminders
export {
processInvoiceReminders,
sendPaymentConfirmation,
sendReceiptEmail,
} from './reminders.js';
// Stripe utilities - use @hykocx/zen/stripe directly
// Invoice-specific Stripe functions are in api.js and actions.js
// Cron jobs - defined in cron.config.js, managed by @hykocx/zen/cron
// Invoice Interest
export {
getInterestConfig,
calculateInvoiceInterest,
processAllInvoiceInterest,
getTotalAmountOwed,
getInvoiceAmountBreakdown,
} from './interest.js';
// Items DB
export {
createItemsTable,
dropItemsTable,
} from './items/db.js';
// Items CRUD
export {
createItem,
getItemById,
getItemBySku,
getItems,
getActiveItems,
getItemsByCategory,
updateItem,
deleteItem,
permanentlyDeleteItem,
} from './items/crud.js';
// Categories DB
export {
createCategoriesTable,
dropCategoriesTable,
} from './categories/db.js';
// Categories CRUD
export {
createCategory,
getCategoryById,
getCategories,
getActiveCategories,
getSubcategories,
updateCategory,
deleteCategory,
permanentlyDeleteCategory,
} from './categories/crud.js';
// Transactions DB
export {
createTransactionsTable,
dropTransactionsTable,
} from './transactions/db.js';
// Transactions CRUD
export {
createTransaction,
getTransactionById,
getTransactionByNumber,
getTransactions,
getTransactionsByInvoice,
getTransactionsByClient,
updateTransaction,
deleteTransaction,
PAYMENT_METHODS,
TRANSACTION_STATUS,
} from './transactions/crud.js';
// Recurrences DB
export {
createRecurrencesTable,
dropRecurrencesTable,
} from './recurrences/db.js';
// Recurrences CRUD
export {
createRecurrence,
getRecurrenceById,
getRecurrences,
getActiveRecurrences,
updateRecurrence,
deleteRecurrence,
deactivateRecurrence,
calculateNextDueDate,
calculateFirstDueDate,
updateRecurrenceAfterInvoiceCreation,
} from './recurrences/crud.js';
// Recurrences Processor
export {
processRecurrences,
shouldCreateInvoice,
} from './recurrences/processor.js';
// Export email templates
export * from './email/index.js';
// Export metadata generators
export {
generateInvoicePaymentMetadata,
generateInvoicePDFMetadata,
generateReceiptPDFMetadata,
getInvoiceStatus,
} from './metadata.js';
// Export admin components (invoice, items, categories, transactions, recurrences)
// Note: Client admin components are now in the clients module
export * from './admin/index.js';
export * from './items/admin/index.js';
export * from './categories/admin/index.js';
export * from './transactions/admin/index.js';
export * from './recurrences/admin/index.js';
// NOTE: Public pages are NOT exported here to avoid conflicts with server actions
// Import them from '@hykocx/zen/modules/pages' instead
// NOTE: Server actions are NOT exported here to avoid conflicts with client components
// Import them from '@hykocx/zen/modules/invoice/actions' instead
+278
View File
@@ -0,0 +1,278 @@
/**
* Invoice Interest System
* Automated interest calculation for overdue invoices
*/
import { query } from '@hykocx/zen/database';
import { getTodayUTC, getDaysBetween } from '../../shared/lib/dates.js';
/**
* Get interest configuration from environment variables
* @returns {Object} Interest configuration
*/
export function getInterestConfig() {
const rawRate = parseFloat(process.env.ZEN_MODULE_INVOICE_INTEREST_RATE || '1');
const rawGrace = parseInt(process.env.ZEN_MODULE_INVOICE_INTEREST_GRACE_DAYS || '3', 10);
// Reject NaN or non-positive rates to prevent silent miscalculations
const monthlyRate = Number.isFinite(rawRate) && rawRate > 0 ? rawRate : 1;
const graceDays = Number.isInteger(rawGrace) && rawGrace >= 0 ? rawGrace : 3;
return {
enabled: process.env.ZEN_MODULE_INVOICE_INTEREST_ENABLED !== 'false', // Default: true
monthlyRate,
graceDays,
};
}
/**
* Calculate daily interest rate from monthly rate
* @param {number} monthlyRate - Monthly interest rate (e.g., 1 for 1%)
* @returns {number} Daily interest rate
*/
function getDailyRate(monthlyRate) {
// Average days per month = 365.25 / 12 = 30.4375
return monthlyRate / 30.4375;
}
/**
* Calculate interest for a single invoice
* @param {Object} invoice - Invoice object
* @returns {Object} Interest calculation result
*/
export function calculateInvoiceInterest(invoice) {
const config = getInterestConfig();
if (!config.enabled) {
return {
interestAmount: 0,
daysOverdue: 0,
daysCharged: 0,
shouldUpdate: false,
};
}
const today = getTodayUTC();
const todayString = `${today.getUTCFullYear()}-${String(today.getUTCMonth() + 1).padStart(2, '0')}-${String(today.getUTCDate()).padStart(2, '0')}`;
// Calculate days overdue (past due date)
const daysOverdue = getDaysBetween(invoice.due_date, today);
// If not overdue yet, no interest
if (daysOverdue <= 0) {
return {
interestAmount: parseFloat(invoice.interest_amount || 0),
daysOverdue: 0,
daysCharged: 0,
shouldUpdate: false,
};
}
// Calculate days that should be charged (after grace period)
const daysCharged = Math.max(0, daysOverdue - config.graceDays);
// If still in grace period, no new interest
if (daysCharged <= 0) {
return {
interestAmount: parseFloat(invoice.interest_amount || 0),
daysOverdue,
daysCharged: 0,
shouldUpdate: false,
};
}
// Determine how many days to calculate interest for
let daysToCalculate = daysCharged;
// If we've calculated before, only calculate new days
if (invoice.interest_last_calculated) {
const lastCalcDate = new Date(invoice.interest_last_calculated);
const daysSinceLastCalc = getDaysBetween(lastCalcDate, today);
// If already calculated today, skip
if (daysSinceLastCalc <= 0) {
return {
interestAmount: parseFloat(invoice.interest_amount || 0),
daysOverdue,
daysCharged,
shouldUpdate: false,
};
}
daysToCalculate = daysSinceLastCalc;
}
// Calculate principal (original amount - paid amount, excluding existing interest)
const principal = parseFloat(invoice.total_amount) - parseFloat(invoice.paid_amount || 0);
// If fully paid, no interest
if (principal <= 0) {
return {
interestAmount: parseFloat(invoice.interest_amount || 0),
daysOverdue,
daysCharged,
shouldUpdate: false,
};
}
// Calculate new interest (simple interest)
const dailyRate = getDailyRate(config.monthlyRate) / 100; // Convert percentage to decimal
const newInterest = principal * dailyRate * daysToCalculate;
// Add to existing interest
const totalInterest = parseFloat(invoice.interest_amount || 0) + newInterest;
return {
interestAmount: parseFloat(totalInterest.toFixed(2)),
daysOverdue,
daysCharged,
newInterest: parseFloat(newInterest.toFixed(2)),
shouldUpdate: true,
calculationDate: todayString,
};
}
/**
* Update invoice interest in database
* @param {number} invoiceId - Invoice ID
* @param {number} interestAmount - New interest amount
* @param {string} calculationDate - Calculation date (YYYY-MM-DD)
* @returns {Promise<void>}
*/
async function updateInvoiceInterest(invoiceId, interestAmount, calculationDate) {
await query(
`UPDATE zen_invoices
SET interest_amount = $1,
interest_last_calculated = $2,
updated_at = CURRENT_TIMESTAMP
WHERE id = $3`,
[interestAmount, calculationDate, invoiceId]
);
}
/**
* Process interest for all eligible invoices
* This should be run daily via cron job
* @returns {Promise<Object>} Summary of interest processed
*/
export async function processAllInvoiceInterest() {
const config = getInterestConfig();
if (!config.enabled) {
console.log('[Interest] Interest calculation is disabled');
return {
processed: 0,
updated: 0,
totalInterestAdded: 0,
errors: 0,
};
}
const today = getTodayUTC();
const todayString = `${today.getUTCFullYear()}-${String(today.getUTCMonth() + 1).padStart(2, '0')}-${String(today.getUTCDate()).padStart(2, '0')}`;
const summary = {
processed: 0,
updated: 0,
totalInterestAdded: 0,
errors: 0,
};
try {
// Get all invoices that are unpaid and past due date
// Status: sent, partial, overdue
const result = await query(
`SELECT i.*
FROM zen_invoices i
WHERE i.status IN ('sent', 'partial', 'overdue')
AND i.due_date < $1::date
AND (i.total_amount - i.paid_amount) > 0
ORDER BY i.due_date ASC`,
[todayString]
);
const invoices = result.rows;
// Only log if there are invoices to process
if (invoices.length > 0) {
console.log(`[Interest] Processing invoice interest... (UTC Date: ${todayString})`);
console.log(`[Interest] Config: Rate=${config.monthlyRate}%/month, Grace=${config.graceDays} days`);
console.log(`[Interest] Found ${invoices.length} overdue invoices to process`);
}
for (const invoice of invoices) {
summary.processed++;
try {
const calculation = calculateInvoiceInterest(invoice);
if (calculation.shouldUpdate) {
await updateInvoiceInterest(
invoice.id,
calculation.interestAmount,
calculation.calculationDate
);
summary.updated++;
summary.totalInterestAdded += calculation.newInterest;
console.log(
`[Interest] Invoice ${invoice.invoice_number}: Added $${calculation.newInterest.toFixed(2)} ` +
`(${calculation.daysCharged} days @ ${config.monthlyRate}%/mo), Total: $${calculation.interestAmount.toFixed(2)}`
);
}
} catch (error) {
summary.errors++;
console.error(`[Interest] Error processing invoice ${invoice.invoice_number}:`, error);
}
}
// Only log completion if there were updates or errors
if (summary.updated > 0 || summary.errors > 0) {
console.log(
`[Interest] Processing complete: ${summary.updated} invoices updated, ` +
`$${summary.totalInterestAdded.toFixed(2)} total interest added`
);
}
return summary;
} catch (error) {
console.error('[Interest] Error processing invoice interest:', error);
throw error;
}
}
/**
* Get total amount owed for an invoice (including interest)
* @param {Object} invoice - Invoice object
* @returns {number} Total amount owed
*/
export function getTotalAmountOwed(invoice) {
const principal = parseFloat(invoice.total_amount);
const interest = parseFloat(invoice.interest_amount || 0);
const paid = parseFloat(invoice.paid_amount || 0);
return Math.max(0, principal + interest - paid);
}
/**
* Get breakdown of invoice amounts
* @param {Object} invoice - Invoice object
* @returns {Object} Amount breakdown
*/
export function getInvoiceAmountBreakdown(invoice) {
const principal = parseFloat(invoice.total_amount);
const interest = parseFloat(invoice.interest_amount || 0);
const paid = parseFloat(invoice.paid_amount || 0);
const totalOwed = getTotalAmountOwed(invoice);
return {
principal,
interest,
totalWithInterest: principal + interest,
paid,
totalOwed,
isPaid: totalOwed <= 0,
};
}
@@ -0,0 +1,255 @@
'use client';
import React, { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Button, Card, Input, Select, Textarea } from '../../../../shared/components';
import { useToast } from '@hykocx/zen/toast';
/**
* Item Create Page Component
* Page for creating a new item
*/
const ItemCreatePage = ({ user }) => {
const router = useRouter();
const toast = useToast();
const [categories, setCategories] = useState([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [formData, setFormData] = useState({
name: '',
description: '',
unit_price: '',
sku: '',
category_id: '',
is_active: true
});
const [errors, setErrors] = useState({});
useEffect(() => {
loadCategories();
}, []);
const loadCategories = async () => {
try {
setLoading(true);
const response = await fetch('/zen/api/admin/categories?limit=1000&is_active=true', {
credentials: 'include'
});
const data = await response.json();
if (data.success) {
setCategories(data.categories || []);
} else {
toast.error("Échec du chargement des catégories");
}
} catch (error) {
console.error('Error loading categories:', error);
toast.error("Échec du chargement des données");
} finally {
setLoading(false);
}
};
const handleInputChange = (field, value) => {
setFormData(prev => ({
...prev,
[field]: value
}));
if (errors[field]) {
setErrors(prev => ({
...prev,
[field]: null
}));
}
};
const handleCheckboxChange = (e) => {
const { name, checked } = e.target;
setFormData(prev => ({
...prev,
[name]: checked
}));
};
const validateForm = () => {
const newErrors = {};
if (!formData.name.trim()) {
newErrors.name = "Le nom de l'article est requis";
}
if (!formData.unit_price || formData.unit_price <= 0) {
newErrors.unit_price = "Le prix unitaire doit être supérieur à 0";
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!validateForm()) {
return;
}
try {
setSaving(true);
const submitData = {
...formData,
category_id: formData.category_id === '' || formData.category_id === 'null' ? null : parseInt(formData.category_id)
};
const response = await fetch('/zen/api/admin/items', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify(submitData)
});
const data = await response.json();
if (data.success) {
toast.success("Article créé avec succès");
router.push('/admin/invoice/items');
} else {
toast.error(data.message || "Échec de la création de l'article");
}
} catch (error) {
console.error('Error creating item:', error);
toast.error("Échec de la création de l'article");
} finally {
setSaving(false);
}
};
return (
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-lg sm:text-xl font-semibold text-neutral-900 dark:text-white">Créer un article</h1>
<p className="mt-1 text-xs text-neutral-400">Remplissez les détails pour créer un nouvel article</p>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => router.push('/admin/invoice/items')}
>
Retour aux articles
</Button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-6">
{/* Item Information */}
<Card>
<div className="space-y-4">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-white">Informations de l'article</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="md:col-span-2">
<Input
label="Nom *"
value={formData.name}
onChange={(value) => handleInputChange('name', value)}
placeholder="Saisir le nom de l'article..."
error={errors.name}
/>
</div>
<div className="md:col-span-2">
<Textarea
label="Description"
value={formData.description}
onChange={(value) => handleInputChange('description', value)}
rows={3}
placeholder="Décrivez l'article..."
/>
</div>
<Input
label="Prix unitaire *"
type="number"
value={formData.unit_price}
onChange={(value) => handleInputChange('unit_price', value)}
min="0"
step="0.01"
placeholder="0.00"
error={errors.unit_price}
/>
<Input
label="SKU"
value={formData.sku}
onChange={(value) => handleInputChange('sku', value)}
placeholder="Saisir le SKU..."
/>
<Select
label="Catégorie"
value={formData.category_id || ''}
onChange={(value) => handleInputChange('category_id', value)}
options={[
{ value: '', label: "Aucune" },
...categories.map((category) => ({
value: category.id,
label: category.parent_title
? `${category.title} (${category.parent_title})`
: category.title
}))
]}
disabled={loading}
/>
<div className="flex items-center pt-6">
<label className="flex items-center cursor-pointer">
<input
type="checkbox"
name="is_active"
checked={formData.is_active}
onChange={handleCheckboxChange}
className="w-5 h-5 text-blue-600 bg-neutral-100 dark:bg-neutral-800 border-neutral-300 dark:border-neutral-700 rounded focus:ring-2 focus:ring-blue-500"
/>
<span className="ml-2 text-sm text-neutral-700 dark:text-neutral-300">
Actif (disponible à l'utilisation)
</span>
</label>
</div>
</div>
</div>
</Card>
{/* Actions */}
<div className="flex items-center justify-end gap-3">
<Button
type="button"
variant="secondary"
onClick={() => router.push('/admin/invoice/items')}
disabled={saving}
>
Annuler
</Button>
<Button
type="submit"
variant="success"
loading={saving}
disabled={saving}
>
{saving ? "Création..." : "Créer l'article"}
</Button>
</div>
</form>
</div>
);
};
export default ItemCreatePage;
@@ -0,0 +1,312 @@
'use client';
import React, { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Button, Card, Input, Select, Textarea, Loading } from '../../../../shared/components';
import { useToast } from '@hykocx/zen/toast';
/**
* Item Edit Page Component
* Page for editing an existing item
*/
const ItemEditPage = ({ itemId, user }) => {
const router = useRouter();
const toast = useToast();
const [categories, setCategories] = useState([]);
const [item, setItem] = useState(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [formData, setFormData] = useState({
name: '',
description: '',
unit_price: '',
sku: '',
category_id: '',
is_active: true
});
const [errors, setErrors] = useState({});
useEffect(() => {
loadCategoriesAndItem();
}, [itemId]);
const loadCategoriesAndItem = async () => {
try {
setLoading(true);
// Load categories
const categoriesResponse = await fetch('/zen/api/admin/categories?limit=1000&is_active=true', {
credentials: 'include'
});
const categoriesData = await categoriesResponse.json();
if (categoriesData.success) {
setCategories(categoriesData.categories || []);
}
// Load item
const itemResponse = await fetch(`/zen/api/admin/items?id=${itemId}`, {
credentials: 'include'
});
const itemData = await itemResponse.json();
if (itemData.success && itemData.item) {
const loadedItem = itemData.item;
setItem(loadedItem);
setFormData({
name: loadedItem.name || '',
description: loadedItem.description || '',
unit_price: loadedItem.unit_price || '',
sku: loadedItem.sku || '',
category_id: loadedItem.category_id || '',
is_active: loadedItem.is_active !== undefined ? loadedItem.is_active : true
});
} else {
toast.error("Article introuvable");
}
} catch (error) {
console.error('Error loading data:', error);
toast.error("Échec du chargement de l'article");
} finally {
setLoading(false);
}
};
const handleInputChange = (field, value) => {
setFormData(prev => ({
...prev,
[field]: value
}));
if (errors[field]) {
setErrors(prev => ({
...prev,
[field]: null
}));
}
};
const handleCheckboxChange = (e) => {
const { name, checked } = e.target;
setFormData(prev => ({
...prev,
[name]: checked
}));
};
const validateForm = () => {
const newErrors = {};
if (!formData.name.trim()) {
newErrors.name = "Le nom de l'article est requis";
}
if (!formData.unit_price || formData.unit_price <= 0) {
newErrors.unit_price = "Le prix unitaire doit être supérieur à 0";
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!validateForm()) {
return;
}
try {
setSaving(true);
const submitData = {
...formData,
category_id: formData.category_id === '' || formData.category_id === 'null' ? null : parseInt(formData.category_id)
};
const response = await fetch(`/zen/api/admin/items?id=${itemId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify(submitData)
});
const data = await response.json();
if (data.success) {
toast.success("Article mis à jour avec succès");
router.push('/admin/invoice/items');
} else {
toast.error(data.message || "Échec de la mise à jour de l'article");
}
} catch (error) {
console.error('Error updating item:', error);
toast.error("Échec de la mise à jour de l'article");
} finally {
setSaving(false);
}
};
if (loading) {
return (
<div className="min-h-64 flex items-center justify-center">
<Loading />
</div>
);
}
if (!item) {
return (
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-lg sm:text-xl font-semibold text-neutral-900 dark:text-white">Modifier l'article</h1>
<p className="mt-1 text-xs text-neutral-400">Article introuvable</p>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => router.push('/admin/invoice/items')}
>
← Retour aux articles
</Button>
</div>
<Card>
<div className="bg-red-500/10 border border-red-500/20 text-red-400 px-4 py-3 rounded-lg">
<p className="font-medium">Article introuvable</p>
<p className="text-sm mt-1">L'article que vous recherchez n'existe pas ou a été supprimé.</p>
</div>
</Card>
</div>
);
}
return (
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-lg sm:text-xl font-semibold text-neutral-900 dark:text-white">Modifier l'article</h1>
<p className="mt-1 text-xs text-neutral-400">Article : {item.name}</p>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => router.push('/admin/invoice/items')}
>
Retour aux articles
</Button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-6">
{/* Item Information */}
<Card>
<div className="space-y-4">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-white">Informations de l'article</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="md:col-span-2">
<Input
label="Nom *"
value={formData.name}
onChange={(value) => handleInputChange('name', value)}
placeholder="Saisir le nom de l'article..."
error={errors.name}
/>
</div>
<div className="md:col-span-2">
<Textarea
label="Description"
value={formData.description}
onChange={(value) => handleInputChange('description', value)}
rows={3}
placeholder="Décrivez l'article..."
/>
</div>
<Input
label="Prix unitaire *"
type="number"
value={formData.unit_price}
onChange={(value) => handleInputChange('unit_price', value)}
min="0"
step="0.01"
placeholder="0.00"
error={errors.unit_price}
/>
<Input
label="SKU"
value={formData.sku}
onChange={(value) => handleInputChange('sku', value)}
placeholder="Saisir le SKU..."
/>
<Select
label="Catégorie"
value={formData.category_id || ''}
onChange={(value) => handleInputChange('category_id', value)}
options={[
{ value: '', label: "Aucune" },
...categories.map((category) => ({
value: category.id,
label: category.parent_title
? `${category.title} (${category.parent_title})`
: category.title
}))
]}
disabled={loading}
/>
<div className="flex items-center pt-6">
<label className="flex items-center cursor-pointer">
<input
type="checkbox"
name="is_active"
checked={formData.is_active}
onChange={handleCheckboxChange}
className="w-5 h-5 text-blue-600 bg-neutral-100 dark:bg-neutral-800 border-neutral-300 dark:border-neutral-700 rounded focus:ring-2 focus:ring-blue-500"
/>
<span className="ml-2 text-sm text-neutral-700 dark:text-neutral-300">
Actif (disponible à l'utilisation)
</span>
</label>
</div>
</div>
</div>
</Card>
{/* Actions */}
<div className="flex items-center justify-end gap-3">
<Button
type="button"
variant="secondary"
onClick={() => router.push('/admin/invoice/items')}
disabled={saving}
>
Annuler
</Button>
<Button
type="submit"
variant="success"
loading={saving}
disabled={saving}
>
{saving ? "Mise à jour..." : "Mettre à jour l'article"}
</Button>
</div>
</form>
</div>
);
};
export default ItemEditPage;
@@ -0,0 +1,267 @@
'use client';
import React, { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { PlusSignCircleIcon, PencilEdit01Icon, Delete02Icon } from '../../../../shared/Icons.js';
import {
Table,
Button,
StatusBadge,
Card,
Pagination
} from '../../../../shared/components';
import { formatCurrency } from '../../../../shared/utils/currency.js';
import { useToast } from '@hykocx/zen/toast';
/**
* Items List Page Component
* Displays list of items with pagination and sorting
*/
const ItemsListPage = ({ user }) => {
const router = useRouter();
const toast = useToast();
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(true);
const [deleting, setDeleting] = useState(false);
// Pagination state
const [pagination, setPagination] = useState({
page: 1,
limit: 20,
total: 0,
totalPages: 0
});
// Sort state
const [sortBy, setSortBy] = useState('created_at');
const [sortOrder, setSortOrder] = useState('desc');
// Table columns configuration
const columns = [
{
key: 'name',
label: "Nom",
sortable: true,
render: (item) => (
<div>
<div className="text-sm font-semibold text-neutral-900 dark:text-white">{item.name}</div>
{item.sku && (
<div className="text-xs text-neutral-500 dark:text-gray-400 font-mono">SKU : {item.sku}</div>
)}
</div>
),
skeleton: {
height: 'h-4',
width: '40%',
secondary: { height: 'h-3', width: '30%' }
}
},
{
key: 'description',
label: "Description",
sortable: false,
render: (item) => (
<div className="text-sm text-neutral-600 dark:text-gray-300 max-w-xs truncate">
{item.description || <span className="text-neutral-400 dark:text-gray-500">-</span>}
</div>
),
skeleton: { height: 'h-4', width: '60%' }
},
{
key: 'category_id',
label: "Catégorie",
sortable: true,
render: (item) => (
item.category_title ? (
<StatusBadge variant="info">{item.category_title}</StatusBadge>
) : (
<span className="text-sm text-neutral-400 dark:text-gray-500">-</span>
)
),
skeleton: { height: 'h-6', width: '80px', className: 'rounded-full' }
},
{
key: 'unit_price',
label: "Prix unitaire",
sortable: true,
render: (item) => (
<div className="text-sm font-semibold text-green-400">
{formatCurrency(item.unit_price)}
</div>
),
skeleton: { height: 'h-4', width: '35%' }
},
{
key: 'is_active',
label: "Statut",
sortable: true,
render: (item) => (
<StatusBadge variant={item.is_active ? 'success' : 'default'}>
{item.is_active ? "Actif" : "Inactif"}
</StatusBadge>
),
skeleton: { height: 'h-6', width: '70px', className: 'rounded-full' }
},
{
key: 'actions',
label: "Actions",
render: (item) => (
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleEditItem(item)}
disabled={deleting}
icon={<PencilEdit01Icon className="w-4 h-4" />}
className="p-2"
/>
<Button
variant="danger"
size="sm"
onClick={() => handleDeleteItem(item)}
disabled={deleting}
icon={<Delete02Icon className="w-4 h-4" />}
className="p-2"
/>
</div>
),
skeleton: { height: 'h-8', width: '80px' }
}
];
useEffect(() => {
loadItems();
}, [sortBy, sortOrder, pagination.page, pagination.limit]);
const loadItems = async () => {
try {
setLoading(true);
const searchParams = new URLSearchParams({
page: pagination.page.toString(),
limit: pagination.limit.toString(),
sortBy,
sortOrder
});
const response = await fetch(`/zen/api/admin/items?${searchParams}`, {
credentials: 'include'
});
const data = await response.json();
if (data.success) {
setItems(data.items || []);
setPagination(prev => ({
...prev,
total: data.total || 0,
totalPages: data.totalPages || 0,
page: data.page || 1
}));
} else {
toast.error(data.error || "Échec du chargement des articles");
}
} catch (error) {
console.error('Error loading items:', error);
toast.error("Échec du chargement des articles");
} finally {
setLoading(false);
}
};
const handlePageChange = (newPage) => {
setPagination(prev => ({ ...prev, page: newPage }));
};
const handleLimitChange = (newLimit) => {
setPagination(prev => ({
...prev,
limit: newLimit,
page: 1
}));
};
const handleSort = (newSortBy) => {
const newSortOrder = sortBy === newSortBy && sortOrder === 'asc' ? 'desc' : 'asc';
setSortBy(newSortBy);
setSortOrder(newSortOrder);
};
const handleEditItem = (item) => {
router.push(`/admin/invoice/items/edit/${item.id}`);
};
const handleDeleteItem = async (item) => {
if (!confirm(`Êtes-vous sûr de vouloir supprimer l'article "${item.name}" ?`)) {
return;
}
try {
setDeleting(true);
const response = await fetch(`/zen/api/admin/items?id=${item.id}`, {
method: 'DELETE',
credentials: 'include'
});
const data = await response.json();
if (data.success) {
toast.success("Article supprimé avec succès");
loadItems();
} else {
toast.error(data.error || "Échec de la suppression de l'article");
}
} catch (error) {
console.error('Error deleting item:', error);
toast.error("Échec de la suppression de l'article");
} finally {
setDeleting(false);
}
};
return (
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-lg sm:text-xl font-semibold text-neutral-900 dark:text-white">Articles</h1>
<p className="mt-1 text-xs text-neutral-400">Gérez votre catalogue d'articles</p>
</div>
<Button
onClick={() => router.push('/admin/invoice/items/new')}
icon={<PlusSignCircleIcon className="w-4 h-4" />}
>
Créer un article
</Button>
</div>
{/* Items Table */}
<Card variant="default" padding="none">
<Table
columns={columns}
data={items}
loading={loading}
sortBy={sortBy}
sortOrder={sortOrder}
onSort={handleSort}
emptyMessage="Aucun article trouvé"
emptyDescription="Créez votre premier article pour commencer"
/>
<Pagination
currentPage={pagination.page}
totalPages={pagination.totalPages}
onPageChange={handlePageChange}
onLimitChange={handleLimitChange}
limit={pagination.limit}
total={pagination.total}
loading={loading}
showPerPage={true}
showStats={true}
/>
</Card>
</div>
);
};
export default ItemsListPage;
+9
View File
@@ -0,0 +1,9 @@
/**
* Items Admin Components
* Part of Invoice Module
*/
export { default as ItemsListPage } from './ItemsListPage.js';
export { default as ItemCreatePage } from './ItemCreatePage.js';
export { default as ItemEditPage } from './ItemEditPage.js';
+248
View File
@@ -0,0 +1,248 @@
/**
* Items Module - CRUD Operations
* Create, Read, Update, Delete operations for items
* Part of Invoice Module
*/
import { query } from '@hykocx/zen/database';
/**
* Create a new item
* @param {Object} itemData - Item data
* @returns {Promise<Object>} Created item
*/
export async function createItem(itemData) {
const {
name,
description = null,
unit_price,
sku = null,
category_id = null,
is_active = true,
} = itemData;
// Validate required fields
if (!name || unit_price === undefined || unit_price === null) {
throw new Error('Name and unit price are required');
}
// Validate price is positive
if (unit_price < 0) {
throw new Error('Unit price must be positive');
}
const result = await query(
`INSERT INTO zen_items (name, description, unit_price, sku, category_id, is_active)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *`,
[name, description, unit_price, sku, category_id, is_active]
);
return result.rows[0];
}
/**
* Get item by ID
* @param {number} id - Item ID
* @returns {Promise<Object|null>}
*/
export async function getItemById(id) {
const result = await query(
`SELECT * FROM zen_items WHERE id = $1`,
[id]
);
return result.rows[0] || null;
}
/**
* Get item by SKU
* @param {string} sku - Item SKU
* @returns {Promise<Object|null>}
*/
export async function getItemBySku(sku) {
const result = await query(
`SELECT * FROM zen_items WHERE sku = $1`,
[sku]
);
return result.rows[0] || null;
}
/**
* Get all items with pagination and filters
* @param {Object} options - Query options
* @returns {Promise<Object>} Items and metadata
*/
export async function getItems(options = {}) {
const {
page = 1,
limit = 50,
search = '',
category_id = null,
is_active = null,
sortBy = 'name',
sortOrder = 'ASC'
} = options;
const offset = (page - 1) * limit;
// Build where conditions
const conditions = [];
const params = [];
let paramIndex = 1;
if (search) {
conditions.push(`(i.name ILIKE $${paramIndex} OR i.description ILIKE $${paramIndex} OR i.sku ILIKE $${paramIndex})`);
params.push(`%${search}%`);
paramIndex++;
}
if (category_id) {
conditions.push(`i.category_id = $${paramIndex}`);
params.push(category_id);
paramIndex++;
}
if (is_active !== null) {
conditions.push(`i.is_active = $${paramIndex}`);
params.push(is_active);
paramIndex++;
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
// Get total count
const countResult = await query(
`SELECT COUNT(*) FROM zen_items i ${whereClause}`,
params
);
const total = parseInt(countResult.rows[0].count);
// Get items with category info
const itemsResult = await query(
`SELECT i.*, c.title as category_title, c.parent_id, p.title as parent_category_title
FROM zen_items i
LEFT JOIN zen_items_category c ON i.category_id = c.id
LEFT JOIN zen_items_category p ON c.parent_id = p.id
${whereClause}
ORDER BY i.${sortBy} ${sortOrder}
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
[...params, limit, offset]
);
return {
items: itemsResult.rows,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit)
}
};
}
/**
* Get all active items (for dropdowns/selects)
* @returns {Promise<Array>}
*/
export async function getActiveItems() {
const result = await query(
`SELECT i.id, i.name, i.description, i.unit_price, i.sku, i.category_id,
c.title as category_title, c.parent_id, p.title as parent_category_title
FROM zen_items i
LEFT JOIN zen_items_category c ON i.category_id = c.id
LEFT JOIN zen_items_category p ON c.parent_id = p.id
WHERE i.is_active = true
ORDER BY i.name ASC`
);
return result.rows;
}
/**
* Get items by category
* @param {number} categoryId - Category ID
* @returns {Promise<Array>}
*/
export async function getItemsByCategory(categoryId) {
const result = await query(
`SELECT i.*, c.title as category_title, c.parent_id, p.title as parent_category_title
FROM zen_items i
LEFT JOIN zen_items_category c ON i.category_id = c.id
LEFT JOIN zen_items_category p ON c.parent_id = p.id
WHERE i.category_id = $1 AND i.is_active = true
ORDER BY i.name ASC`,
[categoryId]
);
return result.rows;
}
/**
* Update item
* @param {number} id - Item ID
* @param {Object} updates - Fields to update
* @returns {Promise<Object>} Updated item
*/
export async function updateItem(id, updates) {
const allowedFields = [
'name', 'description', 'unit_price', 'sku', 'category_id', 'is_active'
];
const setFields = [];
const values = [];
let paramIndex = 1;
for (const [key, value] of Object.entries(updates)) {
if (allowedFields.includes(key)) {
setFields.push(`${key} = $${paramIndex}`);
values.push(value);
paramIndex++;
}
}
if (setFields.length === 0) {
throw new Error('No valid fields to update');
}
// Add updated_at
setFields.push(`updated_at = CURRENT_TIMESTAMP`);
// Add ID parameter
values.push(id);
const result = await query(
`UPDATE zen_items
SET ${setFields.join(', ')}
WHERE id = $${paramIndex}
RETURNING *`,
values
);
return result.rows[0];
}
/**
* Delete item (soft delete by setting is_active to false)
* @param {number} id - Item ID
* @returns {Promise<Object>} Updated item
*/
export async function deleteItem(id) {
return await updateItem(id, { is_active: false });
}
/**
* Permanently delete item (use with caution!)
* @param {number} id - Item ID
* @returns {Promise<boolean>} Success status
*/
export async function permanentlyDeleteItem(id) {
const result = await query(
`DELETE FROM zen_items WHERE id = $1`,
[id]
);
return result.rowCount > 0;
}
+85
View File
@@ -0,0 +1,85 @@
/**
* Items Module - Database
* Database initialization and tables for items
* Part of Invoice Module
*/
import { query } from '@hykocx/zen/database';
/**
* Check if a table exists in the database
* @param {string} tableName - Name of the table to check
* @returns {Promise<boolean>}
*/
async function tableExists(tableName) {
const result = await query(
`SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = $1
)`,
[tableName]
);
return result.rows[0].exists;
}
/**
* Create items table
* @returns {Promise<Object>}
*/
export async function createItemsTable() {
const tableName = 'zen_items';
const exists = await tableExists(tableName);
if (exists) {
console.log(`- Table already exists: ${tableName}`);
return { created: false, tableName };
}
await query(`
CREATE TABLE zen_items (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT,
unit_price DECIMAL(10, 2) NOT NULL,
sku VARCHAR(100),
category_id INTEGER REFERENCES zen_items_category(id) ON DELETE SET NULL,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
)
`);
// Create index on name for fast lookups
await query(`
CREATE INDEX idx_zen_items_name ON zen_items(name)
`);
// Create index on sku for fast lookups
await query(`
CREATE INDEX idx_zen_items_sku ON zen_items(sku)
`);
// Create index on category_id
await query(`
CREATE INDEX idx_zen_items_category_id ON zen_items(category_id)
`);
console.log(`✓ Created table: ${tableName}`);
return { created: true, tableName };
}
/**
* Drop items table (use with caution!)
* @returns {Promise<void>}
*/
export async function dropItemsTable() {
const tableName = 'zen_items';
const exists = await tableExists(tableName);
if (exists) {
await query(`DROP TABLE IF EXISTS ${tableName} CASCADE`);
console.log(`✓ Dropped table: ${tableName}`);
}
}
+233
View File
@@ -0,0 +1,233 @@
/**
* Invoice Metadata Utilities
* Functions to generate dynamic metadata for invoice pages
*/
import { generateMetadata, generateRobots } from '../../shared/lib/metadata/index.js';
import { getInvoiceByToken } from './crud.js';
import { formatCurrency } from '../../shared/utils/currency.js';
import { getAppName } from '../../shared/lib/appConfig.js';
/**
* Generate metadata for invoice payment page
* This function should be used in Next.js page.js or layout.js files
*
* Example usage in Next.js:
* ```
* export async function generateMetadata({ params }) {
* return await generateInvoicePaymentMetadata(params.token);
* }
* ```
*
* @param {string} token - Invoice token
* @param {Object} options - Generation options
* @returns {Promise<Object>} Next.js metadata object
*/
export async function generateInvoicePaymentMetadata(token, options = {}) {
try {
// Fetch invoice data
const invoice = await getInvoiceByToken(token);
if (!invoice) {
return generateMetadata({
title: 'Facture non trouvée',
description: "La facture que vous recherchez n'a pas pu être trouvée.",
robots: generateRobots({ index: false, follow: false }),
}, options);
}
const isPaid = invoice.status === 'paid';
const remainingAmount = parseFloat(invoice.total_amount) - parseFloat(invoice.paid_amount || 0);
const totalAmount = parseFloat(invoice.total_amount);
const appName = options.appName || getAppName();
// Generate title based on invoice status (using existing translation keys)
let title;
if (isPaid) {
title = 'Facture payée';
} else if (remainingAmount > 0) {
title = 'Facture à payer';
} else {
title = 'Facture';
}
// Generate description
const formattedAmount = formatCurrency(totalAmount);
let description;
if (isPaid) {
description = `Facture # ${invoice.invoice_number} - ${formattedAmount} - Payée`;
} else {
description = `Facture # ${invoice.invoice_number} - ${formattedAmount}`;
}
return generateMetadata({
title,
description,
openGraph: {
title,
description,
type: 'website',
},
robots: generateRobots({
index: false, // Don't index invoice payment pages
follow: false
}),
}, { ...options, appName });
} catch (error) {
console.error('Error generating invoice metadata:', error);
return generateMetadata({
title: 'Facture',
description: 'Consultez et payez votre facture en ligne.',
robots: generateRobots({ index: false, follow: false }),
}, options);
}
}
/**
* Generate metadata for invoice PDF viewer page
*
* Example usage in Next.js:
* ```
* export async function generateMetadata({ params }) {
* return await generateInvoicePDFMetadata(params.token);
* }
* ```
*
* @param {string} token - Invoice token
* @param {Object} options - Generation options
* @returns {Promise<Object>} Next.js metadata object
*/
export async function generateInvoicePDFMetadata(token, options = {}) {
try {
// Fetch invoice data
const invoice = await getInvoiceByToken(token);
if (!invoice) {
return generateMetadata({
title: 'Facture non trouvée',
description: "La facture que vous recherchez n'a pas pu être trouvée.",
robots: generateRobots({ index: false, follow: false }),
}, options);
}
const appName = options.appName || getAppName();
return generateMetadata({
title: `Facture # ${invoice.invoice_number}`,
description: `Facture # ${invoice.invoice_number} - ${formatCurrency(invoice.total_amount)}`,
robots: generateRobots({
index: false, // Don't index invoice PDFs
follow: false
}),
}, { ...options, appName });
} catch (error) {
console.error('Error generating invoice PDF metadata:', error);
return generateMetadata({
title: 'Facture',
description: 'Consultez et payez votre facture en ligne.',
robots: generateRobots({ index: false, follow: false }),
}, options);
}
}
/**
* Generate metadata for receipt PDF viewer page
*
* Example usage in Next.js:
* ```
* export async function generateMetadata({ params }) {
* return await generateReceiptPDFMetadata(params.token);
* }
* ```
*
* @param {string} token - Invoice token
* @param {Object} options - Generation options
* @returns {Promise<Object>} Next.js metadata object
*/
export async function generateReceiptPDFMetadata(token, options = {}) {
try {
// Fetch invoice data
const invoice = await getInvoiceByToken(token);
if (!invoice) {
return generateMetadata({
title: 'Reçu non trouvé',
description: "Le reçu que vous recherchez n'a pas pu être trouvé.",
robots: generateRobots({ index: false, follow: false }),
}, options);
}
const appName = options.appName || getAppName();
// Check if invoice is paid
if (invoice.status !== 'paid') {
return generateMetadata({
title: 'Reçu non disponible',
description: "Le reçu n'est disponible que pour les factures payées.",
robots: generateRobots({ index: false, follow: false }),
}, { ...options, appName });
}
return generateMetadata({
title: `Reçu # ${invoice.invoice_number}`,
description: `Reçu # ${invoice.invoice_number} - ${formatCurrency(invoice.total_amount)}`,
robots: generateRobots({
index: false, // Don't index receipts
follow: false
}),
}, { ...options, appName });
} catch (error) {
console.error('Error generating receipt metadata:', error);
return generateMetadata({
title: 'Reçu',
description: 'Consultez le reçu de paiement.',
robots: generateRobots({ index: false, follow: false }),
}, options);
}
}
/**
* Get invoice status for quick checks
* @param {string} token - Invoice token
* @returns {Promise<Object|null>} Invoice status object or null if not found
*/
export async function getInvoiceStatus(token) {
try {
const invoice = await getInvoiceByToken(token);
if (!invoice) {
return null;
}
const isPaid = invoice.status === 'paid';
const remainingAmount = parseFloat(invoice.total_amount) - parseFloat(invoice.paid_amount || 0);
return {
invoiceNumber: invoice.invoice_number,
totalAmount: parseFloat(invoice.total_amount),
paidAmount: parseFloat(invoice.paid_amount || 0),
remainingAmount,
status: invoice.status,
isPaid,
dueDate: invoice.due_date,
isOverdue: new Date(invoice.due_date) < new Date() && !isPaid,
};
} catch (error) {
console.error('Error fetching invoice status:', error);
return null;
}
}
/**
* Metadata configuration for module registration
* This object maps route types to their metadata generator functions
*/
export default {
// Route type -> metadata generator function
payment: generateInvoicePaymentMetadata,
pdf: generateInvoicePDFMetadata,
receipt: generateReceiptPDFMetadata,
};
+112
View File
@@ -0,0 +1,112 @@
/**
* Invoice Module Configuration
* Complete module configuration including navigation, pages, and settings
*
* This file is used by both server and client:
* - Server: navigation, publicRoutes, basic info
* - Client: adminPages, publicPages (lazy-loaded components)
*/
import { lazy } from 'react';
export default {
// Basic module info
name: 'invoice',
displayName: 'Facturation',
version: '1.0.0',
description: 'Gestion de la facturation (inclut clients, articles, catégories, transactions, et récurrences)',
// Module dependencies (other modules this depends on)
dependencies: ['clients'],
// Environment variables this module uses
envVars: [
'STRIPE_SECRET_KEY',
'STRIPE_PUBLISHABLE_KEY',
'STRIPE_WEBHOOK_SECRET',
'ZEN_INTERAC_ENABLED',
'ZEN_INTERAC_EMAIL',
'ZEN_INTEREST_ENABLED',
'ZEN_INTEREST_RATE',
'ZEN_INTEREST_GRACE_DAYS',
],
// Admin navigation section
navigation: {
id: 'invoice',
title: 'Facturation',
icon: 'Invoice03Icon',
items: [
{
name: 'Factures',
href: '/admin/invoice/invoices',
icon: 'Invoice03Icon',
},
{
name: 'Articles',
href: '/admin/invoice/items',
icon: 'PackageIcon',
},
{
name: 'Catégories',
href: '/admin/invoice/categories',
icon: 'Layers01Icon',
},
{
name: 'Récurrences',
href: '/admin/invoice/recurrences',
icon: 'Recycle03Icon',
},
{
name: 'Transactions',
href: '/admin/invoice/transactions',
icon: 'CoinsDollarIcon',
}
]
},
// Admin pages (lazy-loaded for client-side rendering)
adminPages: {
// Invoice pages
'/admin/invoice/invoices': lazy(() => import('./admin/InvoicesListPage.js')),
'/admin/invoice/invoices/new': lazy(() => import('./admin/InvoiceCreatePage.js')),
'/admin/invoice/invoices/edit': lazy(() => import('./admin/InvoiceEditPage.js')),
// Item pages
'/admin/invoice/items': lazy(() => import('./items/admin/ItemsListPage.js')),
'/admin/invoice/items/new': lazy(() => import('./items/admin/ItemCreatePage.js')),
'/admin/invoice/items/edit': lazy(() => import('./items/admin/ItemEditPage.js')),
// Category pages
'/admin/invoice/categories': lazy(() => import('./categories/admin/CategoriesListPage.js')),
'/admin/invoice/categories/new': lazy(() => import('./categories/admin/CategoryCreatePage.js')),
'/admin/invoice/categories/edit': lazy(() => import('./categories/admin/CategoryEditPage.js')),
// Transaction pages
'/admin/invoice/transactions': lazy(() => import('./transactions/admin/TransactionsListPage.js')),
'/admin/invoice/transactions/new': lazy(() => import('./transactions/admin/TransactionCreatePage.js')),
// Recurrence pages
'/admin/invoice/recurrences': lazy(() => import('./recurrences/admin/RecurrencesListPage.js')),
'/admin/invoice/recurrences/new': lazy(() => import('./recurrences/admin/RecurrenceCreatePage.js')),
'/admin/invoice/recurrences/edit': lazy(() => import('./recurrences/admin/RecurrenceEditPage.js')),
},
// Public pages (lazy-loaded for client-side rendering)
publicPages: {
default: lazy(() => import('./pages/InvoicePublicPages.js')),
},
// Public routes metadata (for SEO and route matching)
publicRoutes: [
{ pattern: ':token', description: 'Invoice payment page' },
{ pattern: ':token/pdf', description: 'Invoice PDF viewer' },
{ pattern: ':token/receipt', description: 'Receipt PDF viewer' },
],
// Dashboard widgets (lazy-loaded for dashboard display)
dashboardWidgets: [
lazy(() => import('./dashboard/RevenueWidget.js')),
lazy(() => import('./dashboard/InvoicesWidget.js')),
],
};
@@ -0,0 +1,106 @@
'use client';
import React, { useState, useEffect } from 'react';
import { Loading } from '../../../shared/components';
/**
* Invoice PDF Viewer Page
* Displays invoice PDF in an iframe for viewing and downloading
*/
const InvoicePDFViewerPage = ({
invoiceId,
token,
generateInvoicePDFAction,
}) => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [pdfUrl, setPdfUrl] = useState(null);
const [filename, setFilename] = useState('invoice.pdf');
useEffect(() => {
loadPDF();
// Cleanup: revoke object URL when component unmounts
return () => {
if (pdfUrl) {
URL.revokeObjectURL(pdfUrl);
}
};
}, [invoiceId, token]);
const loadPDF = async () => {
try {
setLoading(true);
setError(null);
if (!generateInvoicePDFAction) {
throw new Error('PDF generation action is not configured');
}
const result = await generateInvoicePDFAction(invoiceId, token);
if (!result.success) {
throw new Error(result.error || 'Failed to generate PDF');
}
// Convert base64 to blob
const binaryString = atob(result.pdf);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
// Create File object instead of Blob to preserve filename
const fileName = result.filename || 'invoice.pdf';
const file = new File([bytes], fileName, { type: 'application/pdf' });
// Create object URL
const url = URL.createObjectURL(file);
setPdfUrl(url);
setFilename(fileName);
} catch (err) {
console.error('Error loading PDF:', err);
setError(err.message || 'Échec du chargement du PDF');
} finally {
setLoading(false);
}
};
if (loading) {
return (
<div className="min-h-64 flex items-center justify-center bg-white dark:bg-black">
<Loading />
</div>
);
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center p-4 bg-white dark:bg-black">
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-lg p-8 max-w-md w-full text-center shadow-sm dark:shadow-none">
<h1 className="text-2xl font-semibold text-neutral-900 dark:text-white mb-4">Erreur</h1>
<p className="text-neutral-600 dark:text-neutral-400 mb-6">{error}</p>
<button
onClick={loadPDF}
className="px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors"
>
Réessayer
</button>
</div>
</div>
);
}
return (
<div className="h-screen w-screen bg-white dark:bg-black">
<iframe
src={pdfUrl}
className="w-full h-full border-0"
title={filename}
/>
</div>
);
};
export default InvoicePDFViewerPage;
@@ -0,0 +1,273 @@
'use client';
import React, { useState, useEffect } from 'react';
import PaymentPage from './PaymentPage.js';
import InvoicePDFViewerPage from './InvoicePDFViewerPage.js';
import ReceiptPDFViewerPage from './ReceiptPDFViewerPage.js';
import ThemeToggle from './ThemeToggle.js';
import { Loading } from '../../../shared/components';
/**
* Invoice Public Pages Router
* Handles routing for public invoice pages
* Routes:
* - /zen/invoice/{token}/ - View invoice and payment details
* - /zen/invoice/{token}/pdf - View invoice PDF
* - /zen/invoice/{token}/receipt - View receipt PDF (paid invoices only)
*/
const InvoicePublicPages = ({
path = [],
getInvoiceByTokenAction,
createStripeCheckoutSessionAction,
generateInvoicePDFAction,
generateReceiptPDFAction,
getInteracCredentialsAction,
stripeEnabled = false,
interacEnabled = false,
interacEmail = null,
publicLogoWhite = '',
publicLogoBlack = '',
publicDashboardUrl = '',
}) => {
const [invoice, setInvoice] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [paymentStatus, setPaymentStatus] = useState(null);
const [interacCredentials, setInteracCredentials] = useState(null);
// Path structure: ['invoice', token, ...additional]
const token = path[1];
const action = path[2]; // Could be 'pdf', 'receipt', or other actions
useEffect(() => {
if (token) {
// Only load invoice if not viewing PDF or receipt
if (action !== 'pdf' && action !== 'receipt') {
loadInvoice();
// Check for payment status in URL
if (typeof window !== 'undefined') {
const urlParams = new URLSearchParams(window.location.search);
const payment = urlParams.get('payment');
if (payment === 'success') {
setPaymentStatus('success');
} else if (payment === 'cancelled') {
setPaymentStatus('cancelled');
}
}
} else {
// For PDF/receipt viewer, we need to load the invoice to get the ID
loadInvoice();
}
} else {
setLoading(false);
setError("Lien de facture invalide");
}
}, [token, action]);
const loadInvoice = async () => {
try {
setLoading(true);
setError(null);
if (!getInvoiceByTokenAction) {
throw new Error('getInvoiceByTokenAction is required');
}
const result = await getInvoiceByTokenAction(token);
if (result.success && result.invoice) {
setInvoice(result.invoice);
// Load Interac credentials if enabled
if (interacEnabled && getInteracCredentialsAction && token) {
try {
const credentialsResult = await getInteracCredentialsAction(token);
if (credentialsResult.success && credentialsResult.credentials) {
setInteracCredentials(credentialsResult.credentials);
}
} catch (credErr) {
console.warn('Failed to load Interac credentials:', credErr);
// Don't fail the whole page if credentials fail to load
}
}
} else {
setError(result.error || "Facture non trouvée");
}
} catch (err) {
console.error('Error loading invoice:', err);
setError("Échec du chargement de la facture");
} finally {
setLoading(false);
}
};
const handlePaymentSubmit = async (paymentMethod) => {
try {
if (paymentMethod === 'stripe' && stripeEnabled) {
if (!createStripeCheckoutSessionAction) {
throw new Error('Stripe is not configured');
}
const result = await createStripeCheckoutSessionAction(invoice.id, token);
if (result.success && result.url) {
// Redirect to Stripe Checkout
window.location.href = result.url;
} else {
throw new Error(result.error || 'Failed to create checkout session');
}
} else if (paymentMethod === 'interac' && interacEnabled) {
// For Interac, instructions are already displayed on the page
// Just show a confirmation message
alert('Please follow the Interac e-Transfer instructions displayed below to complete your payment.');
} else {
// For other payment methods, you might want to show instructions
// or redirect to a payment instructions page
alert('Please contact us for payment instructions.');
}
} catch (err) {
console.error('Payment error:', err);
alert(err.message || 'Payment processing failed');
}
};
if (loading) {
return (
<div className="min-h-64 flex items-center justify-center bg-white dark:bg-black">
<Loading />
</div>
);
}
// Show PDF viewer
if (action === 'pdf') {
if (loading) {
return (
<div className="min-h-64 flex items-center justify-center bg-white dark:bg-black">
<Loading />
</div>
);
}
if (error || !invoice) {
return (
<>
<div className="min-h-screen flex items-center justify-center p-4 bg-white dark:bg-black">
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-lg p-8 max-w-md w-full text-center shadow-sm dark:shadow-none">
<h1 className="text-2xl font-semibold text-neutral-900 dark:text-white mb-4">Facture non trouvée</h1>
<p className="text-neutral-600 dark:text-neutral-400">
{error || "La facture que vous recherchez n'existe pas ou a été supprimée."}
</p>
</div>
</div>
<ThemeToggle />
</>
);
}
return (
<>
<InvoicePDFViewerPage
invoiceId={invoice.id}
token={token}
generateInvoicePDFAction={generateInvoicePDFAction}
/>
<ThemeToggle />
</>
);
}
// Show Receipt viewer
if (action === 'receipt') {
if (loading) {
return (
<div className="min-h-64 flex items-center justify-center bg-white dark:bg-black">
<Loading />
</div>
);
}
if (error || !invoice) {
return (
<>
<div className="min-h-screen flex items-center justify-center p-4 bg-white dark:bg-black">
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-lg p-8 max-w-md w-full text-center shadow-sm dark:shadow-none">
<h1 className="text-2xl font-semibold text-neutral-900 dark:text-white mb-4">Facture non trouvée</h1>
<p className="text-neutral-600 dark:text-neutral-400">
{error || "La facture que vous recherchez n'existe pas ou a été supprimée."}
</p>
</div>
</div>
<ThemeToggle />
</>
);
}
// Check if invoice is paid
if (invoice.status !== 'paid') {
return (
<>
<div className="min-h-screen flex items-center justify-center p-4 bg-white dark:bg-black">
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-lg p-8 max-w-md w-full text-center shadow-sm dark:shadow-none">
<h1 className="text-2xl font-semibold text-neutral-900 dark:text-white mb-4">Reçu non disponible</h1>
<p className="text-neutral-600 dark:text-neutral-400">
Le reçu ne peut être généré que pour les factures payées.
</p>
</div>
</div>
<ThemeToggle />
</>
);
}
return (
<>
<ReceiptPDFViewerPage
invoiceId={invoice.id}
token={token}
generateReceiptPDFAction={generateReceiptPDFAction}
/>
<ThemeToggle />
</>
);
}
// Show payment page
if (error || !invoice) {
return (
<>
<div className="min-h-screen flex items-center justify-center p-4 bg-white dark:bg-black">
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-lg p-8 max-w-md w-full text-center shadow-sm dark:shadow-none">
<h1 className="text-2xl font-semibold text-neutral-900 dark:text-white mb-4">Facture non trouvée</h1>
<p className="text-neutral-600 dark:text-neutral-400">
{error || "La facture que vous recherchez n'existe pas ou a été supprimée."}
</p>
</div>
</div>
<ThemeToggle />
</>
);
}
return (
<>
<PaymentPage
invoice={invoice}
onPaymentSubmit={handlePaymentSubmit}
stripeEnabled={stripeEnabled}
interacEnabled={interacEnabled}
interacEmail={interacEmail}
interacCredentials={interacCredentials}
paymentStatus={paymentStatus}
token={token}
publicLogoWhite={publicLogoWhite}
publicLogoBlack={publicLogoBlack}
publicDashboardUrl={publicDashboardUrl}
/>
<ThemeToggle />
</>
);
};
export default InvoicePublicPages;
+410
View File
@@ -0,0 +1,410 @@
'use client';
import React, { useState } from 'react';
import { formatCurrency } from '../../../shared/utils/currency.js';
import { Card, Table, Select } from '../../../shared/components/index.js';
import { parseUTCDate, formatDateForDisplay, getDaysBetween, isOverdue, getTodayUTC } from '../../../shared/lib/dates.js';
const DATE_LOCALE = 'fr-FR';
/**
* Public Invoice Payment Page Component
* Allows clients to view and pay invoices via public link
*/
const PaymentPage = ({
invoice,
onPaymentSubmit,
stripeEnabled = false,
interacEnabled = false,
interacEmail = null,
interacCredentials = null,
paymentStatus = null,
token = null,
publicLogoWhite = '',
publicLogoBlack = '',
publicDashboardUrl = '',
}) => {
const [paymentMethod, setPaymentMethod] = useState(
stripeEnabled ? 'stripe' : (interacEnabled ? 'interac' : 'other')
);
const [isProcessing, setIsProcessing] = useState(false);
// Payment method options for Select component
const paymentMethodOptions = [
...(stripeEnabled ? [{ value: 'stripe', label: "Carte de crédit" }] : []),
...(interacEnabled ? [{ value: 'interac', label: "Virement Interac" }] : []),
{ value: 'other', label: "Autre" }
];
if (!invoice) {
return (
<div className="min-h-screen flex items-center justify-center p-4 bg-white dark:bg-black">
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-lg p-8 max-w-md w-full text-center shadow-sm dark:shadow-none">
<h1 className="text-2xl font-semibold text-neutral-900 dark:text-white mb-4">Facture non trouvée</h1>
<p className="text-neutral-600 dark:text-neutral-400">
La facture que vous recherchez n'existe pas ou a été supprimée.
</p>
</div>
</div>
);
}
const clientName = invoice.company_name || `${invoice.first_name} ${invoice.last_name}`;
const isPaid = invoice.status === 'paid';
const invoiceIsOverdue = isOverdue(invoice.due_date) && !isPaid;
const principalAmount = parseFloat(invoice.total_amount);
const interestAmount = parseFloat(invoice.interest_amount || 0);
const totalWithInterest = principalAmount + interestAmount;
const paidAmount = parseFloat(invoice.paid_amount || 0);
const remainingAmount = totalWithInterest - paidAmount;
const totalAmount = totalWithInterest;
const hasInterest = interestAmount > 0;
const handlePayment = async () => {
if (onPaymentSubmit) {
setIsProcessing(true);
try {
await onPaymentSubmit(paymentMethod);
} finally {
setIsProcessing(false);
}
}
};
// Calculate days remaining
const daysRemaining = getDaysBetween(getTodayUTC(), invoice.due_date);
const dueDateFormatted = formatDateForDisplay(invoice.due_date, DATE_LOCALE);
// Table columns configuration for invoice items
const invoiceItemsColumns = [
{
key: 'name',
label: "Description",
render: (item) => (
<div className="break-words">
<p className="text-neutral-900 dark:text-white font-medium break-words">{item.name}</p>
{item.description && (
<p className="text-sm text-neutral-600 dark:text-neutral-400 break-words">{item.description}</p>
)}
</div>
)
},
{
key: 'quantity',
label: "Quantité",
noWrap: false,
render: (item) => (
<div className="text-left text-neutral-900 dark:text-white">{item.quantity}</div>
)
},
{
key: 'unit_price',
label: "Prix unitaire",
noWrap: false,
render: (item) => (
<div className="text-left text-neutral-900 dark:text-white">{formatCurrency(item.unit_price)}</div>
)
},
{
key: 'total',
label: "Total",
headerAlign: 'right',
noWrap: false,
render: (item) => (
<div className="text-right text-neutral-900 dark:text-white font-medium">{formatCurrency(item.total)}</div>
)
}
];
const hasLogo = publicLogoWhite || publicLogoBlack;
const logoHref = publicDashboardUrl && publicDashboardUrl.trim() !== '' ? publicDashboardUrl.trim() : null;
return (
<div className="min-h-screen bg-neutral-100 dark:bg-black">
<div className="max-w-7xl mx-auto px-4 py-16 lg:py-24 sm:px-6 lg:px-8">
<div className='flex flex-col gap-10'>
{/* Invoice to Pay Header */}
<div className="flex flex-col gap-[8px] w-full pr-[16%] text-left items-start justify-start py-4">
{/* Public logo (black in light theme, white in dark theme); link to dashboard when URL configured */}
{hasLogo && (
<div className="flex justify-start items-center pb-4">
{logoHref ? (
<a href={logoHref} className="focus:outline-none focus:ring-2 focus:ring-neutral-400 dark:focus:ring-neutral-500 rounded">
{publicLogoBlack && (
<img
src={publicLogoBlack}
alt=""
className="max-h-16 dark:hidden object-contain object-left"
/>
)}
{publicLogoWhite && (
<img
src={publicLogoWhite}
alt=""
className="max-h-16 hidden dark:block object-contain object-left"
/>
)}
</a>
) : (
<>
{publicLogoBlack && (
<img
src={publicLogoBlack}
alt=""
className="max-h-16 dark:hidden object-contain object-left"
/>
)}
{publicLogoWhite && (
<img
src={publicLogoWhite}
alt=""
className="max-h-16 hidden dark:block object-contain object-left"
/>
)}
</>
)}
</div>
)}
{remainingAmount > 0 && (
<>
<h1 className="text-neutral-900 dark:text-white font-bold text-left text-4xl">Facture à payer</h1>
<p className="text-xs text-neutral-600 dark:text-neutral-400">
Vous avez une facture à payer de {formatCurrency(totalAmount)} avant le {dueDateFormatted}.
</p>
</>
)}
{remainingAmount === 0 && (
<>
<h1 className="text-neutral-900 dark:text-white font-bold text-left text-4xl">Facture payée</h1>
<p className="text-xs text-neutral-600 dark:text-neutral-400">
Vous avez une facture de {formatCurrency(totalAmount)} qui a été payée.
</p>
</>
)}
</div>
{(paymentStatus === 'success' || paymentStatus === 'cancelled' || (invoiceIsOverdue && !isPaid)) && (
<div className="flex flex-col gap-2">
{/* Status Badge */}
{paymentStatus === 'success' && (
<Card padding='sm' spacing='sm' variant='success'>
<p className="text-green-700 dark:text-green-400 font-medium">✓ Paiement effectué avec succès !</p>
<p className="text-sm text-neutral-600 dark:text-neutral-400">Vous recevrez un email de confirmation sous peu.</p>
</Card>
)}
{paymentStatus === 'cancelled' && (
<Card padding='sm' spacing='sm' variant='warning'>
<p className="text-yellow-700 dark:text-yellow-400 font-medium">Le paiement a été annulé</p>
<p className="text-sm text-neutral-600 dark:text-neutral-400">Vous pouvez réessayer ci-dessous.</p>
</Card>
)}
{(invoiceIsOverdue && !isPaid) && (
<Card padding='sm' spacing='sm' variant='danger'>
<p className="text-red-700 dark:text-red-400 font-medium">Cette facture est en retard. Veuillez la payer le plus rapidement possible.</p>
</Card>
)}
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Invoice Details */}
<div className="lg:col-span-2 space-y-6">
{/* Invoice Amount Section */}
<Card padding='sm' spacing='sm'>
<h2 className="text-neutral-900 dark:text-white text-sm font-semibold">Montant de la facture</h2>
<div className="text-neutral-900 dark:text-white font-bold text-left text-2xl">
{formatCurrency(totalAmount)}
</div>
{remainingAmount > 0 && (
<p className="text-left text-xs text-neutral-600 dark:text-neutral-400">
Payable avant le {dueDateFormatted} ({daysRemaining > 0 ? `${daysRemaining} jours restants` : 'En retard'})
</p>
)}
{remainingAmount === 0 && (
<p className="text-left text-xs text-neutral-600 dark:text-neutral-400">
Payable avant le {dueDateFormatted}
</p>
)}
</Card>
{/* Downloads Section */}
<Card padding='sm' spacing='sm'>
<h2 className="text-neutral-900 dark:text-white text-sm font-semibold">Téléchargements</h2>
<div className="flex flex-col gap-2 items-start justify-start">
<button
onClick={() => {
const pdfUrl = token
? `/zen/invoice/${token}/pdf`
: `/zen/invoice/${invoice.token}/pdf`;
window.open(pdfUrl, '_blank');
}}
className="p-0 text-sm text-blue-600 hover:text-blue-700 dark:text-blue-500 dark:hover:text-blue-400 hover:underline cursor-pointer"
>
Facture # {invoice.invoice_number}
</button>
{isPaid && (
<button
onClick={() => {
const receiptUrl = token
? `/zen/invoice/${token}/receipt`
: `/zen/invoice/${invoice.token}/receipt`;
window.open(receiptUrl, '_blank');
}}
className="p-0 text-sm text-blue-600 hover:text-blue-700 dark:text-blue-500 dark:hover:text-blue-400 hover:underline cursor-pointer"
>
Reçu # {invoice.invoice_number}
</button>
)}
</div>
</Card>
{/* Items Table */}
<Card padding="none">
<Table
columns={invoiceItemsColumns}
data={invoice.items}
emptyMessage="Aucun article trouvé"
emptyDescription="Cette facture n'a aucun article"
/>
{/* Totals Section */}
{invoice.items && invoice.items.length > 0 && (
<div className="border-t border-neutral-200 dark:border-neutral-700/30 -mt-4">
<div className="px-6 py-4">
<div className="flex justify-end">
<div className="w-full md:w-64 space-y-2">
{/* Subtotal */}
<div className="flex justify-between items-center">
<span className="text-sm font-medium text-neutral-600 dark:text-gray-300">Sous-total</span>
<span className="text-sm font-medium text-neutral-900 dark:text-white">
{formatCurrency(invoice.items.reduce((sum, item) => sum + parseFloat(item.total || 0), 0))}
</span>
</div>
{/* Interest (if applicable) */}
{hasInterest && (
<div className="flex justify-between items-center">
<span className="text-sm font-medium text-yellow-600 dark:text-yellow-400">Intérêt</span>
<span className="text-sm font-medium text-yellow-600 dark:text-yellow-400">
{formatCurrency(interestAmount)}
</span>
</div>
)}
{/* Total */}
<div className="flex justify-between items-center border-t border-neutral-300 dark:border-neutral-600/50 pt-2">
<span className="text-base font-semibold text-neutral-900 dark:text-white">Total</span>
<span className="text-base font-semibold text-neutral-900 dark:text-white">
{formatCurrency(totalWithInterest)}
</span>
</div>
</div>
</div>
</div>
</div>
)}
</Card>
{/* Notes */}
{invoice.notes && (
<Card>
<h3 className="text-lg font-medium text-neutral-900 dark:text-white mb-2">Notes</h3>
<p className="text-neutral-600 dark:text-neutral-400 whitespace-pre-wrap">{invoice.notes}</p>
</Card>
)}
</div>
{/* Payment Section */}
<div className="lg:col-span-1 flex flex-col gap-4">
<Card padding="sm" spacing="sm">
{isPaid ? (
<div className="text-center py-8">
<div className="w-16 h-16 bg-green-600 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<p className="text-green-700 dark:text-green-400 font-medium mb-2">Payé en totalité</p>
<p className="text-sm text-neutral-600 dark:text-neutral-400">
Payé le {formatDateForDisplay(invoice.paid_at, DATE_LOCALE)}
</p>
</div>
) : (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<p className="block text-xs font-medium text-neutral-600 dark:text-neutral-400">Montant à payer</p>
<p className="text-3xl font-bold text-neutral-900 dark:text-white">
{formatCurrency(remainingAmount)}
</p>
</div>
<Select
label="Méthode de paiement"
value={paymentMethod}
onChange={setPaymentMethod}
options={paymentMethodOptions}
placeholder="Sélectionner une méthode de paiement..."
required
/>
{(paymentMethod === 'stripe') && (
<button
onClick={handlePayment}
disabled={isProcessing}
className="w-full cursor-pointer px-6 py-3 bg-blue-600 hover:bg-blue-700 disabled:bg-neutral-400 dark:disabled:bg-neutral-700 disabled:cursor-not-allowed text-white rounded-lg font-medium transition-colors"
>
{isProcessing ? "Traitement..." : `Payer ${formatCurrency(remainingAmount)}`}
</button>
)}
{paymentMethod === 'other' && (
<p className="text-xs text-neutral-500 dark:text-neutral-400 text-center">
Veuillez nous contacter pour les instructions de paiement.
</p>
)}
</div>
)}
</Card>
{paymentMethod === 'interac' && interacEnabled && interacEmail && interacCredentials && (
<Card padding='sm' spacing='sm'>
<h2 className="text-neutral-900 dark:text-white text-sm font-semibold">Virement Interac</h2>
<p className="text-sm text-neutral-600 dark:text-neutral-400">
Veuillez envoyer un virement Interac aux informations suivantes :
</p>
<div className="space-y-2">
<div className='bg-neutral-100 dark:bg-neutral-800/50 border border-neutral-200 dark:border-neutral-700 rounded-lg p-2'>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-1">Courriel</p>
<p className="text-sm text-neutral-900 dark:text-white font-mono">{interacEmail}</p>
</div>
<div className='bg-neutral-100 dark:bg-neutral-800/50 border border-neutral-200 dark:border-neutral-700 rounded-lg p-2'>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-1">Question de sécurité</p>
<p className="text-sm text-neutral-900 dark:text-white font-mono">{interacCredentials.security_question}</p>
</div>
<div className='bg-neutral-100 dark:bg-neutral-800/50 border border-neutral-200 dark:border-neutral-700 rounded-lg p-2'>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-1">Réponse</p>
<p className="text-sm text-neutral-900 dark:text-white font-mono">{interacCredentials.security_answer}</p>
</div>
</div>
<div className="mt-4">
<p className="text-xs text-neutral-600 dark:text-neutral-400">
Votre facture sera marquée comme payée une fois que nous aurons traité votre paiement. Veuillez noter que cela peut prendre un certain temps.
</p>
</div>
</Card>
)}
</div>
</div>
</div>
</div>
</div>
);
};
export default PaymentPage;
@@ -0,0 +1,106 @@
'use client';
import React, { useState, useEffect } from 'react';
import { Loading } from '../../../shared/components';
/**
* Receipt PDF Viewer Page
* Displays receipt PDF in an iframe for viewing and downloading
*/
const ReceiptPDFViewerPage = ({
invoiceId,
token,
generateReceiptPDFAction,
}) => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [pdfUrl, setPdfUrl] = useState(null);
const [filename, setFilename] = useState('receipt.pdf');
useEffect(() => {
loadPDF();
// Cleanup: revoke object URL when component unmounts
return () => {
if (pdfUrl) {
URL.revokeObjectURL(pdfUrl);
}
};
}, [invoiceId, token]);
const loadPDF = async () => {
try {
setLoading(true);
setError(null);
if (!generateReceiptPDFAction) {
throw new Error('Receipt PDF generation action is not configured');
}
const result = await generateReceiptPDFAction(invoiceId, token);
if (!result.success) {
throw new Error(result.error || 'Failed to generate receipt PDF');
}
// Convert base64 to blob
const binaryString = atob(result.pdf);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
// Create File object instead of Blob to preserve filename
const fileName = result.filename || 'receipt.pdf';
const file = new File([bytes], fileName, { type: 'application/pdf' });
// Create object URL
const url = URL.createObjectURL(file);
setPdfUrl(url);
setFilename(fileName);
} catch (err) {
console.error('Error loading receipt PDF:', err);
setError(err.message || 'Échec du chargement du reçu PDF');
} finally {
setLoading(false);
}
};
if (loading) {
return (
<div className="min-h-64 flex items-center justify-center bg-white dark:bg-black">
<Loading />
</div>
);
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center p-4 bg-white dark:bg-black">
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-lg p-8 max-w-md w-full text-center shadow-sm dark:shadow-none">
<h1 className="text-2xl font-semibold text-neutral-900 dark:text-white mb-4">Erreur</h1>
<p className="text-neutral-600 dark:text-neutral-400 mb-6">{error}</p>
<button
onClick={loadPDF}
className="px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors"
>
Réessayer
</button>
</div>
</div>
);
}
return (
<div className="h-screen w-screen bg-white dark:bg-black">
<iframe
src={pdfUrl}
className="w-full h-full border-0"
title={filename}
/>
</div>
);
};
export default ReceiptPDFViewerPage;
+46
View File
@@ -0,0 +1,46 @@
'use client';
import { useState, useEffect } from 'react';
function SunIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" width={20} height={20} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" />
</svg>
);
}
function MoonIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" width={20} height={20} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
);
}
export default function ThemeToggle() {
const [dark, setDark] = useState(false);
useEffect(() => {
setDark(document.documentElement.classList.contains('dark'));
}, []);
function toggle() {
const next = !dark;
setDark(next);
document.documentElement.classList.toggle('dark', next);
localStorage.setItem('theme', next ? 'dark' : 'light');
}
return (
<button
type="button"
onClick={toggle}
aria-label="Toggle theme"
className="fixed cursor-pointer bottom-5 right-5 z-50 flex h-10 w-10 items-center justify-center rounded-full border border-neutral-200 bg-neutral-50 text-neutral-600 transition-colors hover:bg-neutral-100 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300 dark:hover:bg-neutral-800"
>
{dark ? <SunIcon /> : <MoonIcon />}
</button>
);
}
+10
View File
@@ -0,0 +1,10 @@
'use client';
/**
* Invoice Public Pages
*/
export { default as PaymentPage } from './PaymentPage.js';
export { default as InvoicePublicPages } from './InvoicePublicPages.js';
export { default as InvoicePDFViewerPage } from './InvoicePDFViewerPage.js';
export { default as ReceiptPDFViewerPage } from './ReceiptPDFViewerPage.js';
@@ -0,0 +1,334 @@
/**
* Invoice PDF Template
* React-PDF template for generating invoice PDFs
*/
import React from 'react';
import {
Document,
Page,
Text,
View,
StyleSheet,
Image,
} from '@react-pdf/renderer';
import { formatDateForDisplay } from '../../../shared/lib/dates.js';
const LOCALE = 'fr-FR';
// Create styles - Simple black and white design
const styles = StyleSheet.create({
page: {
padding: 30,
fontSize: 10,
fontFamily: 'Helvetica',
color: '#000',
},
// Header section
invoiceTitle: {
fontSize: 16,
fontWeight: 'bold',
marginBottom: 10,
},
datesSection: {
marginBottom: 25,
fontSize: 9,
},
dateItem: {
marginBottom: 3,
},
dateLabel: {
fontWeight: 'normal',
},
// Company and Client Info
infoSection: {
flexDirection: 'row',
marginBottom: 25,
},
companySection: {
width: '50%',
paddingRight: 15,
},
clientSection: {
width: '50%',
paddingLeft: 15,
},
sectionTitle: {
fontSize: 10,
fontWeight: 'bold',
marginBottom: 6,
},
infoText: {
fontSize: 9,
marginBottom: 2,
lineHeight: 1.3,
},
// Price notice
priceNotice: {
fontSize: 15,
fontWeight: 'bold',
marginBottom: 20,
textAlign: 'left',
},
// Table
table: {
marginTop: 10,
marginBottom: 20,
},
tableHeader: {
flexDirection: 'row',
paddingBottom: 10,
borderBottomWidth: 1,
borderBottomColor: '#E5E5E5',
fontSize: 9,
fontWeight: 'bold',
},
tableRow: {
flexDirection: 'row',
paddingVertical: 10,
borderBottomWidth: 1,
borderBottomColor: '#E5E5E5',
fontSize: 9,
},
tableColDescription: {
width: '50%',
},
tableColQuantity: {
width: '15%',
textAlign: 'center',
},
tableColPrice: {
width: '17.5%',
textAlign: 'right',
},
tableColTotal: {
width: '17.5%',
textAlign: 'right',
},
itemDescription: {
fontSize: 8,
marginTop: 2,
lineHeight: 1.2,
},
// Totals
totalsSection: {
marginTop: 15,
marginBottom: 30,
alignItems: 'flex-end',
},
totalRow: {
flexDirection: 'row',
paddingVertical: 5,
width: '35%',
fontSize: 10,
},
totalLabel: {
width: '60%',
textAlign: 'right',
paddingRight: 15,
},
totalValue: {
width: '40%',
textAlign: 'right',
},
// Notes section
notesSection: {
marginTop: 20,
marginBottom: 20,
},
notesTitle: {
fontSize: 10,
fontWeight: 'bold',
marginBottom: 6,
},
notesText: {
fontSize: 9,
lineHeight: 1.4,
},
logoSection: {
position: 'absolute',
top: 30,
right: 30,
alignItems: 'flex-end',
},
logoImage: {
maxWidth: 120,
maxHeight: 40,
objectFit: 'contain',
},
});
/**
* Format currency
*/
const formatCurrency = (amount) => {
const currency = process.env.ZEN_CURRENCY_SYMBOL || '$';
return `${currency}${parseFloat(amount).toFixed(2)}`;
};
/**
* Format date (using UTC date utilities)
*/
const formatDate = (dateString) => {
return formatDateForDisplay(dateString, LOCALE, {
year: 'numeric',
month: 'long',
day: 'numeric',
});
};
/**
* Invoice PDF Document Component
*/
const InvoicePDFTemplate = ({ invoice, companyInfo = {} }) => {
if (!invoice) {
return null;
}
const clientName = invoice.company_name || `${invoice.first_name} ${invoice.last_name}`;
const principalAmount = parseFloat(invoice.total_amount);
const interestAmount = parseFloat(invoice.interest_amount || 0);
const totalWithInterest = principalAmount + interestAmount;
const paidAmount = parseFloat(invoice.paid_amount || 0);
const remainingAmount = totalWithInterest - paidAmount;
const hasInterest = interestAmount > 0;
// Default company info
const company = {
name: companyInfo.name || process.env.ZEN_MODULE_INVOICE_COMPANY_NAME || 'Your Company',
address: companyInfo.address || process.env.ZEN_MODULE_INVOICE_COMPANY_ADDRESS || '',
city: companyInfo.city || process.env.ZEN_MODULE_INVOICE_COMPANY_CITY || '',
province: companyInfo.province || process.env.ZEN_MODULE_INVOICE_COMPANY_PROVINCE || '',
postalCode: companyInfo.postalCode || process.env.ZEN_MODULE_INVOICE_COMPANY_POSTAL_CODE || '',
country: companyInfo.country || process.env.ZEN_MODULE_INVOICE_COMPANY_COUNTRY || '',
phone: companyInfo.phone || process.env.ZEN_MODULE_INVOICE_COMPANY_PHONE || '',
email: companyInfo.email || process.env.ZEN_MODULE_INVOICE_COMPANY_EMAIL || '',
};
const logoBlackUrl = companyInfo.publicLogoBlack || '';
return (
<Document>
<Page size="LETTER" style={styles.page}>
{/* Logo top right */}
{logoBlackUrl && (
<View style={styles.logoSection}>
<Image style={styles.logoImage} src={logoBlackUrl} />
</View>
)}
{/* Header: Invoice Number and Dates */}
<View>
<Text style={styles.invoiceTitle}>FACTURE #{invoice.invoice_number}</Text>
<View style={styles.datesSection}>
<Text style={styles.dateItem}>
<Text style={styles.dateLabel}>Date d'émission : </Text>
<Text style={{ fontWeight: 'bold' }}>{formatDate(invoice.issue_date)}</Text>
</Text>
<Text style={styles.dateItem}>
<Text style={styles.dateLabel}>Date d'échéance : </Text>
<Text style={{ fontWeight: 'bold' }}>{formatDate(invoice.due_date)}</Text>
</Text>
</View>
</View>
{/* Company and Client Info - Side by Side */}
<View style={styles.infoSection}>
{/* Company Info */}
<View style={styles.companySection}>
<Text style={styles.sectionTitle}>{company.name}</Text>
{company.address && <Text style={styles.infoText}>{company.address}</Text>}
{(company.city || company.province || company.postalCode) && (
<Text style={styles.infoText}>
{[company.city, company.province, company.postalCode]
.filter(Boolean)
.join(', ')}
</Text>
)}
{company.country && <Text style={styles.infoText}>{company.country}</Text>}
{company.phone && <Text style={styles.infoText}>{company.phone}</Text>}
{company.email && <Text style={styles.infoText}>{company.email}</Text>}
</View>
{/* Client Info */}
<View style={styles.clientSection}>
<Text style={styles.sectionTitle}>Facturé à :</Text>
<Text style={styles.infoText}>{clientName}</Text>
{invoice.address && <Text style={styles.infoText}>{invoice.address}</Text>}
{(invoice.city || invoice.province || invoice.postal_code) && (
<Text style={styles.infoText}>
{[invoice.city, invoice.province, invoice.postal_code]
.filter(Boolean)
.join(', ')}
</Text>
)}
{invoice.country && <Text style={styles.infoText}>{invoice.country}</Text>}
{invoice.client_email && <Text style={styles.infoText}>{invoice.client_email}</Text>}
</View>
</View>
{/* Price to be paid notice */}
<Text style={styles.priceNotice}>
{formatCurrency(remainingAmount > 0 ? remainingAmount : totalWithInterest)} à payer avant {formatDate(invoice.due_date)}
</Text>
{/* Items Table */}
<View style={styles.table}>
<View style={styles.tableHeader}>
<Text style={styles.tableColDescription}>Description</Text>
<Text style={styles.tableColQuantity}>Qté</Text>
<Text style={styles.tableColPrice}>Prix unitaire</Text>
<Text style={styles.tableColTotal}>Total</Text>
</View>
{invoice.items && invoice.items.map((item, index) => (
<View key={index} style={styles.tableRow}>
<View style={styles.tableColDescription}>
<Text style={{ fontWeight: 'bold' }}>{item.name}</Text>
{item.description && (
<Text style={styles.itemDescription}>
{item.description}
</Text>
)}
</View>
<Text style={styles.tableColQuantity}>{item.quantity}</Text>
<Text style={styles.tableColPrice}>{formatCurrency(item.unit_price)}</Text>
<Text style={styles.tableColTotal}>{formatCurrency(item.total)}</Text>
</View>
))}
</View>
{/* Totals - Simple Subtotal and Total */}
<View style={styles.totalsSection}>
<View style={styles.totalRow}>
<Text style={styles.totalLabel}>Sous-total</Text>
<Text style={styles.totalValue}>{formatCurrency(invoice.subtotal)}</Text>
</View>
{hasInterest && (
<View style={styles.totalRow}>
<Text style={styles.totalLabel}>Intérêts</Text>
<Text style={styles.totalValue}>{formatCurrency(interestAmount)}</Text>
</View>
)}
<View style={[styles.totalRow, { fontWeight: 'bold', fontSize: 12 }]}>
<Text style={styles.totalLabel}>Total</Text>
<Text style={styles.totalValue}>{formatCurrency(totalWithInterest)}</Text>
</View>
</View>
{/* Notes Section */}
{invoice.notes && (
<View style={styles.notesSection}>
<Text style={styles.notesTitle}>Notes</Text>
<Text style={styles.notesText}>{invoice.notes}</Text>
</View>
)}
</Page>
</Document>
);
};
export default InvoicePDFTemplate;
@@ -0,0 +1,392 @@
/**
* Receipt PDF Template
* React-PDF template for generating receipt PDFs
*/
import React from 'react';
import {
Document,
Page,
Text,
View,
StyleSheet,
Image,
} from '@react-pdf/renderer';
import { formatDateForDisplay } from '../../../shared/lib/dates.js';
const LOCALE = 'fr-FR';
// Create styles - Simple black and white design
const styles = StyleSheet.create({
page: {
padding: 30,
fontSize: 10,
fontFamily: 'Helvetica',
color: '#000',
},
// Header section
receiptTitle: {
fontSize: 16,
fontWeight: 'bold',
marginBottom: 10,
},
paidBadge: {
fontSize: 14,
fontWeight: 'bold',
marginBottom: 10,
color: '#059669', // Green color for PAID
},
datesSection: {
marginBottom: 25,
fontSize: 9,
},
dateItem: {
marginBottom: 3,
},
dateLabel: {
fontWeight: 'normal',
},
// Transaction section
transactionSection: {
marginBottom: 25,
padding: 10,
backgroundColor: '#f3f4f6',
borderRadius: 4,
},
transactionTitle: {
fontSize: 10,
fontWeight: 'bold',
marginBottom: 6,
},
transactionItem: {
fontSize: 9,
marginBottom: 3,
},
// Company and Client Info
infoSection: {
flexDirection: 'row',
marginBottom: 25,
},
companySection: {
width: '50%',
paddingRight: 15,
},
clientSection: {
width: '50%',
paddingLeft: 15,
},
sectionTitle: {
fontSize: 10,
fontWeight: 'bold',
marginBottom: 6,
},
infoText: {
fontSize: 9,
marginBottom: 2,
lineHeight: 1.3,
},
// Price notice
priceNotice: {
fontSize: 15,
fontWeight: 'bold',
marginBottom: 20,
textAlign: 'left',
color: '#059669', // Green for paid amount
},
// Table
table: {
marginTop: 10,
marginBottom: 20,
},
tableHeader: {
flexDirection: 'row',
paddingBottom: 10,
borderBottomWidth: 1,
borderBottomColor: '#E5E5E5',
fontSize: 9,
fontWeight: 'bold',
},
tableRow: {
flexDirection: 'row',
paddingVertical: 10,
borderBottomWidth: 1,
borderBottomColor: '#E5E5E5',
fontSize: 9,
},
tableColDescription: {
width: '50%',
},
tableColQuantity: {
width: '15%',
textAlign: 'center',
},
tableColPrice: {
width: '17.5%',
textAlign: 'right',
},
tableColTotal: {
width: '17.5%',
textAlign: 'right',
},
itemDescription: {
fontSize: 8,
marginTop: 2,
lineHeight: 1.2,
},
// Totals
totalsSection: {
marginTop: 15,
marginBottom: 30,
alignItems: 'flex-end',
},
totalRow: {
flexDirection: 'row',
paddingVertical: 5,
width: '35%',
fontSize: 10,
},
totalLabel: {
width: '60%',
textAlign: 'right',
paddingRight: 15,
},
totalValue: {
width: '40%',
textAlign: 'right',
},
// Notes section
notesSection: {
marginTop: 20,
marginBottom: 20,
},
notesTitle: {
fontSize: 10,
fontWeight: 'bold',
marginBottom: 6,
},
notesText: {
fontSize: 9,
lineHeight: 1.4,
},
logoSection: {
position: 'absolute',
top: 30,
right: 30,
alignItems: 'flex-end',
},
logoImage: {
maxWidth: 120,
maxHeight: 40,
objectFit: 'contain',
},
});
/**
* Format currency
*/
const formatCurrency = (amount) => {
const currency = process.env.ZEN_CURRENCY_SYMBOL || '$';
return `${currency}${parseFloat(amount).toFixed(2)}`;
};
/**
* Format date (using UTC date utilities)
*/
const formatDate = (dateString) => {
return formatDateForDisplay(dateString, LOCALE, {
year: 'numeric',
month: 'long',
day: 'numeric',
});
};
const PAYMENT_METHOD_LABELS = {
stripe: 'Carte de crédit (Stripe)',
cash: 'Comptant',
check: 'Chèque',
wire: 'Virement bancaire',
interac: 'Virement Interac',
other: 'Autre',
};
/**
* Format payment method for display
*/
const formatPaymentMethod = (method) => {
return PAYMENT_METHOD_LABELS[method] || method;
};
/**
* Receipt PDF Document Component
*/
const ReceiptPDFTemplate = ({ invoice, transaction, companyInfo = {} }) => {
if (!invoice) {
return null;
}
const clientName = invoice.company_name || `${invoice.first_name} ${invoice.last_name}`;
const interestAmount = parseFloat(invoice.interest_amount || 0);
const hasInterest = interestAmount > 0;
const totalWithInterest = parseFloat(invoice.total_amount) + interestAmount;
// Default company info
const company = {
name: companyInfo.name || process.env.ZEN_MODULE_INVOICE_COMPANY_NAME || 'Your Company',
address: companyInfo.address || process.env.ZEN_MODULE_INVOICE_COMPANY_ADDRESS || '',
city: companyInfo.city || process.env.ZEN_MODULE_INVOICE_COMPANY_CITY || '',
province: companyInfo.province || process.env.ZEN_MODULE_INVOICE_COMPANY_PROVINCE || '',
postalCode: companyInfo.postalCode || process.env.ZEN_MODULE_INVOICE_COMPANY_POSTAL_CODE || '',
country: companyInfo.country || process.env.ZEN_MODULE_INVOICE_COMPANY_COUNTRY || '',
phone: companyInfo.phone || process.env.ZEN_MODULE_INVOICE_COMPANY_PHONE || '',
email: companyInfo.email || process.env.ZEN_MODULE_INVOICE_COMPANY_EMAIL || '',
};
const logoBlackUrl = companyInfo.publicLogoBlack || '';
return (
<Document>
<Page size="LETTER" style={styles.page}>
{/* Logo top right */}
{logoBlackUrl && (
<View style={styles.logoSection}>
<Image style={styles.logoImage} src={logoBlackUrl} />
</View>
)}
{/* Header: Receipt Number and Status */}
<View>
<Text style={styles.receiptTitle}>REÇU #{invoice.invoice_number}</Text>
<Text style={styles.paidBadge}>PAYÉ</Text>
<View style={styles.datesSection}>
<Text style={styles.dateItem}>
<Text style={styles.dateLabel}>Date de facture : </Text>
<Text style={{ fontWeight: 'bold' }}>{formatDate(invoice.issue_date)}</Text>
</Text>
{transaction && (
<Text style={styles.dateItem}>
<Text style={styles.dateLabel}>Date de paiement : </Text>
<Text style={{ fontWeight: 'bold' }}>{formatDate(transaction.transaction_date)}</Text>
</Text>
)}
</View>
</View>
{/* Transaction Information */}
{transaction && (
<View style={styles.transactionSection}>
<Text style={styles.transactionTitle}>Informations de paiement</Text>
<Text style={styles.transactionItem}>
<Text style={styles.dateLabel}>Numéro de transaction : </Text>
<Text style={{ fontWeight: 'bold' }}>{transaction.transaction_number}</Text>
</Text>
<Text style={styles.transactionItem}>
<Text style={styles.dateLabel}>Mode de paiement : </Text>
<Text style={{ fontWeight: 'bold' }}>{formatPaymentMethod(transaction.payment_method)}</Text>
</Text>
<Text style={styles.transactionItem}>
<Text style={styles.dateLabel}>Montant payé : </Text>
<Text style={{ fontWeight: 'bold' }}>{formatCurrency(transaction.amount)}</Text>
</Text>
</View>
)}
{/* Company and Client Info - Side by Side */}
<View style={styles.infoSection}>
{/* Company Info */}
<View style={styles.companySection}>
<Text style={styles.sectionTitle}>{company.name}</Text>
{company.address && <Text style={styles.infoText}>{company.address}</Text>}
{(company.city || company.province || company.postalCode) && (
<Text style={styles.infoText}>
{[company.city, company.province, company.postalCode]
.filter(Boolean)
.join(', ')}
</Text>
)}
{company.country && <Text style={styles.infoText}>{company.country}</Text>}
{company.phone && <Text style={styles.infoText}>{company.phone}</Text>}
{company.email && <Text style={styles.infoText}>{company.email}</Text>}
</View>
{/* Client Info */}
<View style={styles.clientSection}>
<Text style={styles.sectionTitle}>Payé par :</Text>
<Text style={styles.infoText}>{clientName}</Text>
{invoice.address && <Text style={styles.infoText}>{invoice.address}</Text>}
{(invoice.city || invoice.province || invoice.postal_code) && (
<Text style={styles.infoText}>
{[invoice.city, invoice.province, invoice.postal_code]
.filter(Boolean)
.join(', ')}
</Text>
)}
{invoice.country && <Text style={styles.infoText}>{invoice.country}</Text>}
{invoice.client_email && <Text style={styles.infoText}>{invoice.client_email}</Text>}
</View>
</View>
{/* Amount paid notice */}
<Text style={styles.priceNotice}>
{formatCurrency(transaction ? transaction.amount : invoice.total_amount)} payé en entier
</Text>
{/* Items Table */}
<View style={styles.table}>
<View style={styles.tableHeader}>
<Text style={styles.tableColDescription}>Description</Text>
<Text style={styles.tableColQuantity}>Qté</Text>
<Text style={styles.tableColPrice}>Prix unitaire</Text>
<Text style={styles.tableColTotal}>Total</Text>
</View>
{invoice.items && invoice.items.map((item, index) => (
<View key={index} style={styles.tableRow}>
<View style={styles.tableColDescription}>
<Text style={{ fontWeight: 'bold' }}>{item.name}</Text>
{item.description && (
<Text style={styles.itemDescription}>
{item.description}
</Text>
)}
</View>
<Text style={styles.tableColQuantity}>{item.quantity}</Text>
<Text style={styles.tableColPrice}>{formatCurrency(item.unit_price)}</Text>
<Text style={styles.tableColTotal}>{formatCurrency(item.total)}</Text>
</View>
))}
</View>
{/* Totals - Simple Subtotal and Total */}
<View style={styles.totalsSection}>
<View style={styles.totalRow}>
<Text style={styles.totalLabel}>Sous-total</Text>
<Text style={styles.totalValue}>{formatCurrency(invoice.subtotal)}</Text>
</View>
{hasInterest && (
<View style={styles.totalRow}>
<Text style={styles.totalLabel}>Intérêts</Text>
<Text style={styles.totalValue}>{formatCurrency(interestAmount)}</Text>
</View>
)}
<View style={[styles.totalRow, { fontWeight: 'bold', fontSize: 12 }]}>
<Text style={styles.totalLabel}>Total payé</Text>
<Text style={styles.totalValue}>{formatCurrency(totalWithInterest)}</Text>
</View>
</View>
{/* Notes Section */}
{invoice.notes && (
<View style={styles.notesSection}>
<Text style={styles.notesTitle}>Notes</Text>
<Text style={styles.notesText}>{invoice.notes}</Text>
</View>
)}
</Page>
</Document>
);
};
export default ReceiptPDFTemplate;
+102
View File
@@ -0,0 +1,102 @@
/**
* Invoice PDF Generation Utility
* Server-side PDF generation using core PDF utilities
*/
import { renderToBuffer, createElement } from '../../../core/pdf/index.js';
import InvoicePDFTemplate from './InvoicePDFTemplate.jsx';
import ReceiptPDFTemplate from './ReceiptPDFTemplate.jsx';
import { getTodayString } from '../../../shared/lib/dates.js';
/**
* Generate PDF buffer from invoice data (French labels and date formatting in templates)
* @param {Object} invoice - Invoice data with items
* @param {Object} companyInfo - Optional company information
* @returns {Promise<Buffer>} PDF buffer
*/
export async function generateInvoicePDF(invoice, companyInfo = {}) {
if (!invoice) {
throw new Error('Invoice data is required');
}
if (!invoice.items || invoice.items.length === 0) {
throw new Error('Invoice must have items');
}
try {
// Create PDF document using core utility
const pdfDocument = createElement(InvoicePDFTemplate, {
invoice,
companyInfo,
});
// Render to buffer using core utility
const buffer = await renderToBuffer(pdfDocument);
return buffer;
} catch (error) {
console.error('Error generating PDF:', error);
throw new Error(`Failed to generate PDF: ${error.message}`);
}
}
/**
* Generate receipt PDF buffer from invoice and transaction data (French labels and dates in templates)
* @param {Object} invoice - Invoice data with items
* @param {Object} transaction - Transaction data (optional)
* @param {Object} companyInfo - Optional company information
* @returns {Promise<Buffer>} PDF buffer
*/
export async function generateReceiptPDF(invoice, transaction = null, companyInfo = {}) {
if (!invoice) {
throw new Error('Invoice data is required');
}
if (!invoice.items || invoice.items.length === 0) {
throw new Error('Invoice must have items');
}
if (invoice.status !== 'paid') {
throw new Error('Receipt can only be generated for paid invoices');
}
try {
// Create PDF document using core utility
const pdfDocument = createElement(ReceiptPDFTemplate, {
invoice,
transaction,
companyInfo,
});
// Render to buffer using core utility
const buffer = await renderToBuffer(pdfDocument);
return buffer;
} catch (error) {
console.error('Error generating receipt PDF:', error);
throw new Error(`Failed to generate receipt PDF: ${error.message}`);
}
}
/**
* Get PDF filename for invoice
* @param {Object} invoice - Invoice data
* @returns {string} Filename
*/
export function getInvoicePDFFilename(invoice) {
const invoiceNumber = invoice.invoice_number || invoice.id;
const date = getTodayString();
return `invoice-${invoiceNumber}-${date}.pdf`;
}
/**
* Get PDF filename for receipt
* @param {Object} invoice - Invoice data
* @returns {string} Filename
*/
export function getReceiptPDFFilename(invoice) {
const invoiceNumber = invoice.invoice_number || invoice.id;
const date = getTodayString();
return `receipt-${invoiceNumber}-${date}.pdf`;
}
@@ -0,0 +1,691 @@
'use client';
import React, { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Button, Card, Input, Select, Textarea } from '../../../../shared/components';
import { useToast } from '@hykocx/zen/toast';
import { parseUTCDate, formatDateForDisplay, formatDateForInput, getTodayUTC } from '../../../../shared/lib/dates.js';
/**
* Recurrence Create Page Component
* Page for creating a new invoice recurrence
*/
const RecurrenceCreatePage = ({ user }) => {
const router = useRouter();
const toast = useToast();
const [clients, setClients] = useState([]);
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [formData, setFormData] = useState({
client_id: '',
frequency_type: 'months',
frequency_value: 1,
recurrence_day: 1,
first_reminder_days: 30,
first_occurrence_date: '',
use_custom_first_date: false,
notes: '',
items: [{ name: '', description: '', quantity: 1, unit_price: 0 }]
});
const [errors, setErrors] = useState({});
const [selectedItemIds, setSelectedItemIds] = useState(['custom']);
const frequencyTypeOptions = [
{ value: 'days', label: "jours" },
{ value: 'months', label: "mois" },
{ value: 'years', label: "ans" }
];
const reminderOptions = [
{ value: 30, label: '30 jours' },
{ value: 14, label: '14 jours' },
{ value: 7, label: '7 jours' },
{ value: 3, label: '3 jours' }
];
// Generate day options (1-28)
const dayOptions = Array.from({ length: 28 }, (_, i) => ({
value: i + 1,
label: `Jour ${i + 1}`
}));
// Generate frequency value options (1-12 for months/years, 1-90 for days)
const getFrequencyValueOptions = () => {
const maxValue = formData.frequency_type === 'days' ? 90 : 12;
return Array.from({ length: maxValue }, (_, i) => ({
value: i + 1,
label: i + 1
}));
};
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
try {
setLoading(true);
// Load clients
const clientsResponse = await fetch('/zen/api/admin/clients?limit=1000', {
credentials: 'include'
});
const clientsData = await clientsResponse.json();
if (clientsData.success) {
setClients(clientsData.clients || []);
} else {
toast.error("Échec du chargement des clients");
}
// Load items (active only)
const itemsResponse = await fetch('/zen/api/admin/items?limit=1000&is_active=true', {
credentials: 'include'
});
const itemsData = await itemsResponse.json();
if (itemsData.success) {
setItems(itemsData.items || []);
}
} catch (error) {
console.error('Error loading data:', error);
toast.error("Échec du chargement des données");
} finally {
setLoading(false);
}
};
// Generate possible first occurrence dates based on recurrence settings
const getFirstOccurrenceOptions = () => {
if (!formData.frequency_type || !formData.recurrence_day) {
return [];
}
if (formData.frequency_type === 'days') {
return []; // Not applicable for days-based recurrence
}
const targetDay = parseInt(formData.recurrence_day);
const today = getTodayUTC();
const options = [];
// Generate 24 months (2 years) of options
for (let i = 0; i < 24; i++) {
const date = new Date(today);
date.setUTCDate(targetDay);
date.setUTCMonth(today.getUTCMonth() + i);
const dateStr = formatDateForInput(date);
const label = formatDateForDisplay(date, 'fr-FR', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
options.push({ value: dateStr, label });
}
return options;
};
// Calculate first due date for preview
const calculateFirstDueDate = () => {
if (!formData.frequency_type || !formData.frequency_value || !formData.recurrence_day) {
return null;
}
// If using custom first date, use it
if (formData.use_custom_first_date && formData.first_occurrence_date) {
return parseUTCDate(formData.first_occurrence_date);
}
const today = getTodayUTC();
if (formData.frequency_type === 'days') {
const dueDate = new Date(today);
dueDate.setUTCDate(dueDate.getUTCDate() + parseInt(formData.frequency_value));
return dueDate;
}
// For months/years, find the next occurrence of recurrence_day
const targetDay = parseInt(formData.recurrence_day);
const currentDay = today.getUTCDate();
let firstDueDate = new Date(today);
firstDueDate.setUTCDate(targetDay);
// If the target day has already passed this month, move to next period
if (currentDay >= targetDay) {
if (formData.frequency_type === 'months') {
firstDueDate.setUTCMonth(firstDueDate.getUTCMonth() + 1);
} else if (formData.frequency_type === 'years') {
firstDueDate.setUTCFullYear(firstDueDate.getUTCFullYear() + 1);
}
}
return firstDueDate;
};
// Calculate invoice creation date for preview
const calculateCreationDate = () => {
const dueDate = calculateFirstDueDate();
if (!dueDate) return null;
const creationDate = new Date(dueDate);
creationDate.setUTCDate(creationDate.getUTCDate() - parseInt(formData.first_reminder_days));
return creationDate;
};
const formatDate = (date) => {
if (!date) return '';
return formatDateForDisplay(date, 'fr-FR', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
};
const handleInputChange = (field, value) => {
setFormData(prev => ({
...prev,
[field]: value
}));
if (errors[field]) {
setErrors(prev => ({
...prev,
[field]: null
}));
}
};
const handleItemsChange = (newItems) => {
setFormData(prev => ({ ...prev, items: newItems }));
};
const handleItemChange = (index, field, value) => {
const newItems = [...formData.items];
newItems[index] = { ...newItems[index], [field]: value };
setFormData(prev => ({ ...prev, items: newItems }));
if (errors[`item_${index}_${field}`]) {
setErrors(prev => ({
...prev,
[`item_${index}_${field}`]: null
}));
}
};
const handleSelectItem = (index, itemId) => {
const newSelectedItemIds = [...selectedItemIds];
newSelectedItemIds[index] = itemId;
setSelectedItemIds(newSelectedItemIds);
if (itemId === '' || itemId === 'custom') {
handleItemChange(index, 'name', '');
handleItemChange(index, 'description', '');
handleItemChange(index, 'unit_price', 0);
return;
}
const selectedItem = items.find(item => item.id === parseInt(itemId));
if (selectedItem) {
let fullItemName = '';
if (selectedItem.parent_category_title) {
fullItemName = `${selectedItem.parent_category_title} - ${selectedItem.category_title} - ${selectedItem.name}`;
} else if (selectedItem.category_title) {
fullItemName = `${selectedItem.category_title} - ${selectedItem.name}`;
} else {
fullItemName = selectedItem.name;
}
const newItems = [...formData.items];
newItems[index] = {
...newItems[index],
name: fullItemName,
description: selectedItem.description || '',
unit_price: selectedItem.unit_price
};
setFormData(prev => ({ ...prev, items: newItems }));
}
};
const addItem = () => {
setFormData(prev => ({
...prev,
items: [...prev.items, { name: '', description: '', quantity: 1, unit_price: 0 }]
}));
setSelectedItemIds(prev => [...prev, 'custom']);
};
const removeItem = (index) => {
if (formData.items.length > 1) {
setFormData(prev => ({
...prev,
items: prev.items.filter((_, i) => i !== index)
}));
setSelectedItemIds(prev => prev.filter((_, i) => i !== index));
}
};
const calculateItemTotal = (item) => {
return parseFloat(item.quantity || 0) * parseFloat(item.unit_price || 0);
};
const calculateTotals = () => {
const subtotal = formData.items.reduce((sum, item) => {
return sum + calculateItemTotal(item);
}, 0);
return {
subtotal: parseFloat(subtotal.toFixed(2)),
total: parseFloat(subtotal.toFixed(2))
};
};
const validateForm = () => {
const newErrors = {};
if (!formData.client_id) {
newErrors.client_id = 'Le client est requis';
}
if (!formData.frequency_type) {
newErrors.frequency_type = 'Le type de fréquence est requis';
}
if (!formData.frequency_value || formData.frequency_value < 1) {
newErrors.frequency_value = 'La valeur de fréquence doit être au moins 1';
}
if (!formData.recurrence_day || formData.recurrence_day < 1 || formData.recurrence_day > 28) {
newErrors.recurrence_day = 'Le jour de récurrence doit être entre 1 et 28';
}
// Validate custom first occurrence date if enabled
if (formData.use_custom_first_date && !formData.first_occurrence_date) {
newErrors.first_occurrence_date = 'La date de première occurrence est requise lorsque la date personnalisée est activée';
}
// Validate items
formData.items.forEach((item, index) => {
if (!item.name.trim()) {
newErrors[`item_${index}_name`] = 'Le nom de l\'article est requis';
}
if (!item.quantity || item.quantity <= 0) {
newErrors[`item_${index}_quantity`] = 'La quantité doit être supérieure à 0';
}
if (item.unit_price < 0) {
newErrors[`item_${index}_unit_price`] = 'Le prix unitaire ne peut pas être négatif';
}
});
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!validateForm()) {
return;
}
try {
setSaving(true);
// Prepare data - remove use_custom_first_date flag and clear first_occurrence_date if not using custom
const submitData = { ...formData };
if (!submitData.use_custom_first_date) {
submitData.first_occurrence_date = null;
}
delete submitData.use_custom_first_date;
const response = await fetch('/zen/api/admin/recurrences', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({ recurrence: submitData }),
});
const data = await response.json();
if (data.success) {
toast.success("Récurrence créée avec succès");
router.push('/admin/invoice/recurrences');
} else {
toast.error(data.error || 'Échec de la création de la récurrence');
}
} catch (error) {
console.error('Error creating recurrence:', error);
toast.error("Échec de la création de la récurrence");
} finally {
setSaving(false);
}
};
const firstDueDate = calculateFirstDueDate();
const creationDate = calculateCreationDate();
const totals = calculateTotals();
return (
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-lg sm:text-xl font-semibold text-neutral-900 dark:text-white">Créer une récurrence</h1>
<p className="mt-1 text-xs text-neutral-400">Configurer la génération automatique de factures</p>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => router.push('/admin/invoice/recurrences')}
>
Retour aux récurrences
</Button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-6">
{/* Recurrence Information */}
<Card>
<div className="space-y-4">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-white">Informations de la récurrence</h2>
<Select
label="Client *"
value={formData.client_id}
onChange={(value) => handleInputChange('client_id', value)}
options={[
{ value: '', label: 'Sélectionner un client' },
...clients.map(client => ({
value: client.id,
label: client.company_name || `${client.first_name} ${client.last_name}`
}))
]}
error={errors.client_id}
disabled={loading}
/>
</div>
</Card>
{/* Schedule */}
<Card>
<div className="space-y-4">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-white">Planification</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Select
label="Type de fréquence *"
value={formData.frequency_type}
onChange={(value) => handleInputChange('frequency_type', value)}
options={frequencyTypeOptions}
error={errors.frequency_type}
/>
<Select
label="Valeur de fréquence *"
value={formData.frequency_value}
onChange={(value) => handleInputChange('frequency_value', value)}
options={getFrequencyValueOptions()}
error={errors.frequency_value}
/>
{formData.frequency_type !== 'days' && (
<Select
label="Jour de récurrence (1-28) *"
value={formData.recurrence_day}
onChange={(value) => handleInputChange('recurrence_day', value)}
options={dayOptions}
error={errors.recurrence_day}
description="Jour du mois pour créer la facture (limité à 1-28 pour cohérence)"
/>
)}
<Select
label="Premier rappel (jours avant l'échéance)"
value={formData.first_reminder_days}
onChange={(value) => handleInputChange('first_reminder_days', value)}
options={reminderOptions}
description="La facture sera créée ce nombre de jours avant la date d'échéance"
/>
</div>
{/* Custom First Occurrence Date */}
{formData.frequency_type !== 'days' && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<input
type="checkbox"
id="use_custom_first_date"
checked={formData.use_custom_first_date}
onChange={(e) => handleInputChange('use_custom_first_date', e.target.checked)}
className="w-4 h-4 rounded border-neutral-300 dark:border-neutral-600 bg-neutral-100 dark:bg-neutral-800 text-blue-500 focus:ring-blue-500 focus:ring-offset-neutral-900"
/>
<label htmlFor="use_custom_first_date" className="text-sm text-neutral-700 dark:text-neutral-300">
Indiquer une date de première occurrence personnalisée
</label>
</div>
{formData.use_custom_first_date && (
<Select
label="Date de première occurrence *"
value={formData.first_occurrence_date}
onChange={(value) => handleInputChange('first_occurrence_date', value)}
options={[
{ value: '', label: 'Sélectionner une date' },
...getFirstOccurrenceOptions()
]}
error={errors.first_occurrence_date}
description="Choisir quand la première facture doit être à échéance"
/>
)}
</div>
)}
{/* Real-time Preview */}
{firstDueDate && (
<div className="bg-blue-500/10 border border-blue-500/20 text-blue-400 px-4 py-3 rounded-lg space-y-2">
<div className="text-sm">
<strong>Date d'échéance de la première facture :</strong> {formatDate(firstDueDate)}
{formData.use_custom_first_date && formData.first_occurrence_date && (
<span className="ml-2 text-xs">(Date personnalisée)</span>
)}
</div>
{creationDate && (
<div className="text-sm">
<strong>Date de création de la facture :</strong> {formatDate(creationDate)}
<span className="ml-2 text-xs">(Date d'échéance moins {formData.first_reminder_days} jours)</span>
</div>
)}
</div>
)}
</div>
</Card>
{/* Items */}
<Card>
<div className="space-y-4">
<div className="flex justify-between items-center">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-white">Items</h2>
</div>
<div className="space-y-4">
{formData.items.map((item, index) => (
<div key={index} className="border border-neutral-200 dark:border-neutral-700/50 rounded-lg p-4 bg-neutral-50 dark:bg-neutral-900/20">
<div className="flex justify-between items-start mb-4">
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">Article {index + 1}</h3>
{formData.items.length > 1 && (
<Button
type="button"
variant="danger"
size="sm"
onClick={() => removeItem(index)}
>
Supprimer
</Button>
)}
</div>
<div className="space-y-4">
<div className="grid gap-4 grid-cols-1 md:grid-cols-2">
<Select
label="Item *"
value={selectedItemIds[index] || 'custom'}
onChange={(value) => handleSelectItem(index, value)}
options={[
{ value: 'custom', label: 'Custom' },
...items.map(availableItem => {
let categoryDisplay = '';
if (availableItem.parent_category_title) {
categoryDisplay = `${availableItem.parent_category_title} - ${availableItem.category_title} - `;
} else if (availableItem.category_title) {
categoryDisplay = `${availableItem.category_title} - `;
}
return {
value: availableItem.id,
label: `${categoryDisplay}${availableItem.name} - $${parseFloat(availableItem.unit_price).toFixed(2)}`
};
})
]}
disabled={loading}
/>
<Input
label="Item Name *"
value={item.name}
onChange={(value) => handleItemChange(index, 'name', value)}
placeholder="Enter item name..."
error={errors[`item_${index}_name`]}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label="Quantity *"
type="number"
step="0.01"
min="0"
value={item.quantity}
onChange={(value) => handleItemChange(index, 'quantity', value)}
error={errors[`item_${index}_quantity`]}
/>
<Input
label="Unit Price *"
type="number"
step="0.01"
min="0"
value={item.unit_price}
onChange={(value) => handleItemChange(index, 'unit_price', value)}
error={errors[`item_${index}_unit_price`]}
/>
</div>
<Textarea
label="Description"
value={item.description}
onChange={(value) => handleItemChange(index, 'description', value)}
rows={2}
placeholder="Describe the item or service..."
/>
<div className="text-right pt-2 border-t border-neutral-200 dark:border-neutral-700/50">
<span className="text-sm text-neutral-400">Total article : </span>
<span className="text-lg font-semibold text-green-400">
${calculateItemTotal(item).toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})}
</span>
</div>
</div>
</div>
))}
<div className="flex justify-end items-center">
<Button
type="button"
variant="secondary"
size="sm"
onClick={addItem}
>
+ Add Item
</Button>
</div>
</div>
{/* Totals */}
<div className="pt-4 border-t border-neutral-200 dark:border-neutral-700/50">
<div className="flex justify-end">
<div className="w-full sm:w-80 space-y-2">
<div className="flex justify-between text-neutral-600 dark:text-neutral-300">
<span>Sous-total :</span>
<span className="font-medium">
${totals.subtotal.toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})}
</span>
</div>
<div className="flex justify-between text-neutral-900 dark:text-white text-lg font-semibold pt-2 border-t border-neutral-200 dark:border-neutral-700/50">
<span>Total :</span>
<span>
${totals.total.toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})}
</span>
</div>
</div>
</div>
</div>
</div>
</Card>
{/* Notes */}
<Card>
<div className="space-y-4">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-white">Notes</h2>
<Textarea
value={formData.notes}
onChange={(value) => handleInputChange('notes', value)}
rows={4}
placeholder="Add any additional notes or payment instructions..."
/>
</div>
</Card>
{/* Actions */}
<div className="flex items-center justify-end gap-3">
<Button
type="button"
variant="secondary"
onClick={() => router.push('/admin/invoice/recurrences')}
disabled={saving}
>
Annuler
</Button>
<Button
type="submit"
variant="success"
loading={saving}
disabled={saving}
>
{saving ? 'Création...' : 'Créer la récurrence'}
</Button>
</div>
</form>
</div>
);
};
export default RecurrenceCreatePage;
@@ -0,0 +1,765 @@
'use client';
import React, { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Button, Card, Input, Select, Textarea, Loading } from '../../../../shared/components';
import { useToast } from '@hykocx/zen/toast';
import { parseUTCDate, formatDateForDisplay, formatDateForInput, getTodayUTC } from '../../../../shared/lib/dates.js';
/**
* Recurrence Edit Page Component
* Page for editing an existing invoice recurrence
*/
const RecurrenceEditPage = ({ id, user }) => {
const router = useRouter();
const toast = useToast();
const [clients, setClients] = useState([]);
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [recurrence, setRecurrence] = useState(null);
const [formData, setFormData] = useState({
client_id: '',
frequency_type: 'months',
frequency_value: 1,
recurrence_day: 1,
first_reminder_days: 30,
first_occurrence_date: '',
use_custom_first_date: false,
notes: '',
items: [{ name: '', description: '', quantity: 1, unit_price: 0 }]
});
const [errors, setErrors] = useState({});
const [selectedItemIds, setSelectedItemIds] = useState(['custom']);
const frequencyTypeOptions = [
{ value: 'days', label: "jours" },
{ value: 'months', label: "mois" },
{ value: 'years', label: "ans" }
];
const reminderOptions = [
{ value: 30, label: '30 jours' },
{ value: 14, label: '14 jours' },
{ value: 7, label: '7 jours' },
{ value: 3, label: '3 jours' }
];
// Generate day options (1-28)
const dayOptions = Array.from({ length: 28 }, (_, i) => ({
value: i + 1,
label: `Jour ${i + 1}`
}));
// Generate frequency value options (1-12 for months/years, 1-90 for days)
const getFrequencyValueOptions = () => {
const maxValue = formData.frequency_type === 'days' ? 90 : 12;
return Array.from({ length: maxValue }, (_, i) => ({
value: i + 1,
label: i + 1
}));
};
useEffect(() => {
loadData();
}, [id]);
const loadData = async () => {
try {
setLoading(true);
// Load recurrence
const recurrenceResponse = await fetch(`/zen/api/admin/recurrences?id=${id}`, {
credentials: 'include'
});
const recurrenceData = await recurrenceResponse.json();
if (!recurrenceData.success) {
toast.error('Échec du chargement de la récurrence');
router.push('/admin/invoice/recurrences');
return;
}
const rec = recurrenceData.recurrence;
setRecurrence(rec);
// Populate form
setFormData({
client_id: rec.client_id || '',
frequency_type: rec.frequency_type || 'months',
frequency_value: rec.frequency_value || 1,
recurrence_day: rec.recurrence_day || 1,
first_reminder_days: rec.first_reminder_days || 30,
first_occurrence_date: rec.first_occurrence_date || '',
use_custom_first_date: !!rec.first_occurrence_date,
notes: rec.notes || '',
items: rec.items || [{ name: '', description: '', quantity: 1, unit_price: 0 }]
});
// Load clients
const clientsResponse = await fetch('/zen/api/admin/clients?limit=1000', {
credentials: 'include'
});
const clientsData = await clientsResponse.json();
if (clientsData.success) {
setClients(clientsData.clients || []);
} else {
toast.error("Échec du chargement des clients");
}
// Load items (active only)
const itemsResponse = await fetch('/zen/api/admin/items?limit=1000&is_active=true', {
credentials: 'include'
});
const itemsData = await itemsResponse.json();
if (itemsData.success) {
setItems(itemsData.items || []);
}
} catch (error) {
console.error('Error loading data:', error);
toast.error("Échec du chargement des données");
router.push('/admin/invoice/recurrences');
} finally {
setLoading(false);
}
};
// Generate possible first occurrence dates based on recurrence settings
const getFirstOccurrenceOptions = () => {
if (!formData.frequency_type || !formData.recurrence_day) {
return [];
}
if (formData.frequency_type === 'days') {
return []; // Not applicable for days-based recurrence
}
const targetDay = parseInt(formData.recurrence_day);
const today = getTodayUTC();
const options = [];
// Generate 24 months (2 years) of options
for (let i = 0; i < 24; i++) {
const date = new Date(today);
date.setUTCDate(targetDay);
date.setUTCMonth(today.getUTCMonth() + i);
const dateStr = formatDateForInput(date);
const label = formatDateForDisplay(date, 'fr-FR', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
options.push({ value: dateStr, label });
}
return options;
};
// Calculate first due date for preview
const calculateFirstDueDate = () => {
if (!formData.frequency_type || !formData.frequency_value || !formData.recurrence_day) {
return null;
}
// If using custom first date, use it
if (formData.use_custom_first_date && formData.first_occurrence_date) {
return parseUTCDate(formData.first_occurrence_date);
}
const today = getTodayUTC();
if (formData.frequency_type === 'days') {
const dueDate = new Date(today);
dueDate.setUTCDate(dueDate.getUTCDate() + parseInt(formData.frequency_value));
return dueDate;
}
// For months/years, find the next occurrence of recurrence_day
const targetDay = parseInt(formData.recurrence_day);
const currentDay = today.getUTCDate();
let firstDueDate = new Date(today);
firstDueDate.setUTCDate(targetDay);
// If the target day has already passed this month, move to next period
if (currentDay >= targetDay) {
if (formData.frequency_type === 'months') {
firstDueDate.setUTCMonth(firstDueDate.getUTCMonth() + 1);
} else if (formData.frequency_type === 'years') {
firstDueDate.setUTCFullYear(firstDueDate.getUTCFullYear() + 1);
}
}
return firstDueDate;
};
// Calculate invoice creation date for preview
const calculateCreationDate = () => {
const dueDate = calculateFirstDueDate();
if (!dueDate) return null;
const creationDate = new Date(dueDate);
creationDate.setUTCDate(creationDate.getUTCDate() - parseInt(formData.first_reminder_days));
return creationDate;
};
const formatDate = (date) => {
if (!date) return '';
if (typeof date === 'string') {
date = parseUTCDate(date);
}
return formatDateForDisplay(date, 'fr-FR', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
};
const handleInputChange = (field, value) => {
setFormData(prev => ({
...prev,
[field]: value
}));
if (errors[field]) {
setErrors(prev => ({
...prev,
[field]: null
}));
}
};
const handleItemsChange = (newItems) => {
setFormData(prev => ({ ...prev, items: newItems }));
};
const handleItemChange = (index, field, value) => {
const newItems = [...formData.items];
newItems[index] = { ...newItems[index], [field]: value };
setFormData(prev => ({ ...prev, items: newItems }));
if (errors[`item_${index}_${field}`]) {
setErrors(prev => ({
...prev,
[`item_${index}_${field}`]: null
}));
}
};
const handleSelectItem = (index, itemId) => {
const newSelectedItemIds = [...selectedItemIds];
newSelectedItemIds[index] = itemId;
setSelectedItemIds(newSelectedItemIds);
if (itemId === '' || itemId === 'custom') {
handleItemChange(index, 'name', '');
handleItemChange(index, 'description', '');
handleItemChange(index, 'unit_price', 0);
return;
}
const selectedItem = items.find(item => item.id === parseInt(itemId));
if (selectedItem) {
let fullItemName = '';
if (selectedItem.parent_category_title) {
fullItemName = `${selectedItem.parent_category_title} - ${selectedItem.category_title} - ${selectedItem.name}`;
} else if (selectedItem.category_title) {
fullItemName = `${selectedItem.category_title} - ${selectedItem.name}`;
} else {
fullItemName = selectedItem.name;
}
const newItems = [...formData.items];
newItems[index] = {
...newItems[index],
name: fullItemName,
description: selectedItem.description || '',
unit_price: selectedItem.unit_price
};
setFormData(prev => ({ ...prev, items: newItems }));
}
};
const addItem = () => {
setFormData(prev => ({
...prev,
items: [...prev.items, { name: '', description: '', quantity: 1, unit_price: 0 }]
}));
setSelectedItemIds(prev => [...prev, 'custom']);
};
const removeItem = (index) => {
if (formData.items.length > 1) {
setFormData(prev => ({
...prev,
items: prev.items.filter((_, i) => i !== index)
}));
setSelectedItemIds(prev => prev.filter((_, i) => i !== index));
}
};
const calculateItemTotal = (item) => {
return parseFloat(item.quantity || 0) * parseFloat(item.unit_price || 0);
};
const calculateTotals = () => {
const subtotal = formData.items.reduce((sum, item) => {
return sum + calculateItemTotal(item);
}, 0);
return {
subtotal: parseFloat(subtotal.toFixed(2)),
total: parseFloat(subtotal.toFixed(2))
};
};
const validateForm = () => {
const newErrors = {};
if (!formData.client_id) {
newErrors.client_id = 'Le client est requis';
}
if (!formData.frequency_type) {
newErrors.frequency_type = 'Le type de fréquence est requis';
}
if (!formData.frequency_value || formData.frequency_value < 1) {
newErrors.frequency_value = 'La valeur de fréquence doit être au moins 1';
}
if (!formData.recurrence_day || formData.recurrence_day < 1 || formData.recurrence_day > 28) {
newErrors.recurrence_day = 'Le jour de récurrence doit être entre 1 et 28';
}
// Validate custom first occurrence date if enabled
if (formData.use_custom_first_date && !formData.first_occurrence_date) {
newErrors.first_occurrence_date = 'La date de première occurrence est requise lorsque la date personnalisée est activée';
}
// Validate items
formData.items.forEach((item, index) => {
if (!item.name.trim()) {
newErrors[`item_${index}_name`] = 'Le nom de l\'article est requis';
}
if (!item.quantity || item.quantity <= 0) {
newErrors[`item_${index}_quantity`] = 'La quantité doit être supérieure à 0';
}
if (item.unit_price < 0) {
newErrors[`item_${index}_unit_price`] = 'Le prix unitaire ne peut pas être négatif';
}
});
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!validateForm()) {
return;
}
try {
setSaving(true);
// Prepare data - remove use_custom_first_date flag and clear first_occurrence_date if not using custom
const submitData = { ...formData };
if (!submitData.use_custom_first_date) {
submitData.first_occurrence_date = null;
}
delete submitData.use_custom_first_date;
const response = await fetch('/zen/api/admin/recurrences', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
id: id,
recurrence: submitData
}),
});
const data = await response.json();
if (data.success) {
toast.success("Récurrence mise à jour avec succès");
router.push('/admin/invoice/recurrences');
} else {
toast.error(data.error || 'Échec de la mise à jour de la récurrence');
}
} catch (error) {
console.error('Error updating recurrence:', error);
toast.error("Échec de la mise à jour de la récurrence");
} finally {
setSaving(false);
}
};
if (loading) {
return (
<div className="min-h-64 flex items-center justify-center">
<Loading />
</div>
);
}
if (!recurrence) {
return null;
}
const nextDueDate = calculateFirstDueDate();
const creationDate = calculateCreationDate();
const totals = calculateTotals();
return (
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-lg sm:text-xl font-semibold text-neutral-900 dark:text-white">Modifier la récurrence #{recurrence.id}</h1>
<p className="mt-1 text-xs text-neutral-400">
{recurrence.company_name || `${recurrence.first_name} ${recurrence.last_name}`}
</p>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => router.push('/admin/invoice/recurrences')}
>
Retour aux récurrences
</Button>
</div>
{/* Last Invoice Info */}
{recurrence.last_invoice_number && (
<Card variant="info">
<div className="space-y-2">
<h3 className="text-sm font-semibold text-blue-300">Dernière facture créée</h3>
<div className="flex items-center justify-between text-sm">
<span className="text-neutral-300">Numéro de facture :</span>
<button
className="font-mono text-blue-400 hover:text-blue-300 underline"
onClick={() => router.push(`/admin/invoice/invoices/edit/${recurrence.last_invoice_id}`)}
>
{recurrence.last_invoice_number}
</button>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-neutral-300">Créée le :</span>
<span className="text-neutral-400">{formatDate(recurrence.last_invoice_date)}</span>
</div>
</div>
</Card>
)}
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-6">
{/* Recurrence Information */}
<Card>
<div className="space-y-4">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-white">Informations de la récurrence</h2>
<Select
label="Client *"
value={formData.client_id}
onChange={(value) => handleInputChange('client_id', value)}
options={[
{ value: '', label: 'Sélectionner un client' },
...clients.map(client => ({
value: client.id,
label: client.company_name || `${client.first_name} ${client.last_name}`
}))
]}
error={errors.client_id}
/>
</div>
</Card>
{/* Schedule */}
<Card>
<div className="space-y-4">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-white">Planification</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Select
label="Type de fréquence *"
value={formData.frequency_type}
onChange={(value) => handleInputChange('frequency_type', value)}
options={frequencyTypeOptions}
error={errors.frequency_type}
/>
<Select
label="Valeur de fréquence *"
value={formData.frequency_value}
onChange={(value) => handleInputChange('frequency_value', value)}
options={getFrequencyValueOptions()}
error={errors.frequency_value}
/>
{formData.frequency_type !== 'days' && (
<Select
label="Jour de récurrence (1-28) *"
value={formData.recurrence_day}
onChange={(value) => handleInputChange('recurrence_day', value)}
options={dayOptions}
error={errors.recurrence_day}
description="Jour du mois pour créer la facture (limité à 1-28 pour cohérence)"
/>
)}
<Select
label="Premier rappel (jours avant l'échéance)"
value={formData.first_reminder_days}
onChange={(value) => handleInputChange('first_reminder_days', value)}
options={reminderOptions}
description="La facture sera créée ce nombre de jours avant la date d'échéance"
/>
</div>
{/* Custom First Occurrence Date */}
{formData.frequency_type !== 'days' && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<input
type="checkbox"
id="use_custom_first_date"
checked={formData.use_custom_first_date}
onChange={(e) => handleInputChange('use_custom_first_date', e.target.checked)}
className="w-4 h-4 rounded border-neutral-300 dark:border-neutral-600 bg-neutral-100 dark:bg-neutral-800 text-blue-500 focus:ring-blue-500 focus:ring-offset-neutral-900"
/>
<label htmlFor="use_custom_first_date" className="text-sm text-neutral-700 dark:text-neutral-300">
Indiquer une date de première occurrence personnalisée
</label>
</div>
{formData.use_custom_first_date && (
<Select
label="Date de première occurrence *"
value={formData.first_occurrence_date}
onChange={(value) => handleInputChange('first_occurrence_date', value)}
options={[
{ value: '', label: 'Sélectionner une date' },
...getFirstOccurrenceOptions()
]}
error={errors.first_occurrence_date}
description="Choisir quand la première facture doit être à échéance"
/>
)}
</div>
)}
{/* Real-time Preview */}
<div className="bg-blue-500/10 border border-blue-500/20 text-blue-400 px-4 py-3 rounded-lg space-y-2">
{recurrence.next_due_date && (
<div className="text-sm">
<strong>Prochaine date d'échéance actuelle :</strong> {formatDate(recurrence.next_due_date)}
</div>
)}
{nextDueDate && (
<div className="text-sm">
<strong>Nouvelle prochaine date d'échéance :</strong> {formatDate(nextDueDate)}
<span className="ml-2 text-xs">
{formData.use_custom_first_date && formData.first_occurrence_date
? '(Date personnalisée)'
: '(Si on part d\'aujourd\'hui)'}
</span>
</div>
)}
{creationDate && (
<div className="text-sm">
<strong>Date de création de la facture :</strong> {formatDate(creationDate)}
<span className="ml-2 text-xs">(Date d'échéance moins {formData.first_reminder_days} jours)</span>
</div>
)}
</div>
</div>
</Card>
{/* Items */}
<Card>
<div className="space-y-4">
<div className="flex justify-between items-center">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-white">Items</h2>
<Button
type="button"
variant="secondary"
size="sm"
onClick={addItem}
>
+ Add Item
</Button>
</div>
<div className="space-y-4">
{formData.items.map((item, index) => (
<div key={index} className="border border-neutral-200 dark:border-neutral-700/50 rounded-lg p-4 bg-neutral-50 dark:bg-neutral-900/20">
<div className="flex justify-between items-start mb-4">
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">Article {index + 1}</h3>
{formData.items.length > 1 && (
<Button
type="button"
variant="danger"
size="sm"
onClick={() => removeItem(index)}
>
Supprimer
</Button>
)}
</div>
<div className="space-y-4">
<div className="grid gap-4 grid-cols-1 md:grid-cols-2">
<Select
label="Item *"
value={selectedItemIds[index] || 'custom'}
onChange={(value) => handleSelectItem(index, value)}
options={[
{ value: 'custom', label: 'Custom' },
...items.map(availableItem => {
let categoryDisplay = '';
if (availableItem.parent_category_title) {
categoryDisplay = `${availableItem.parent_category_title} - ${availableItem.category_title} - `;
} else if (availableItem.category_title) {
categoryDisplay = `${availableItem.category_title} - `;
}
return {
value: availableItem.id,
label: `${categoryDisplay}${availableItem.name} - $${parseFloat(availableItem.unit_price).toFixed(2)}`
};
})
]}
/>
<Input
label="Item Name *"
value={item.name}
onChange={(value) => handleItemChange(index, 'name', value)}
placeholder="Enter item name..."
error={errors[`item_${index}_name`]}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label="Quantity *"
type="number"
step="0.01"
min="0"
value={item.quantity}
onChange={(value) => handleItemChange(index, 'quantity', value)}
error={errors[`item_${index}_quantity`]}
/>
<Input
label="Unit Price *"
type="number"
step="0.01"
min="0"
value={item.unit_price}
onChange={(value) => handleItemChange(index, 'unit_price', value)}
error={errors[`item_${index}_unit_price`]}
/>
</div>
<Textarea
label="Description"
value={item.description}
onChange={(value) => handleItemChange(index, 'description', value)}
rows={2}
placeholder="Describe the item or service..."
/>
<div className="text-right pt-2 border-t border-neutral-200 dark:border-neutral-700/50">
<span className="text-sm text-neutral-400">Total article : </span>
<span className="text-lg font-semibold text-green-400">
${calculateItemTotal(item).toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})}
</span>
</div>
</div>
</div>
))}
</div>
{/* Totals */}
<div className="pt-4 border-t border-neutral-200 dark:border-neutral-700/50">
<div className="flex justify-end">
<div className="w-full sm:w-80 space-y-2">
<div className="flex justify-between text-neutral-600 dark:text-neutral-300">
<span>Sous-total :</span>
<span className="font-medium">
${totals.subtotal.toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})}
</span>
</div>
<div className="flex justify-between text-neutral-900 dark:text-white text-lg font-semibold pt-2 border-t border-neutral-200 dark:border-neutral-700/50">
<span>Total :</span>
<span>
${totals.total.toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})}
</span>
</div>
</div>
</div>
</div>
</div>
</Card>
{/* Notes */}
<Card>
<div className="space-y-4">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-white">Notes</h2>
<Textarea
value={formData.notes}
onChange={(value) => handleInputChange('notes', value)}
rows={4}
placeholder="Add any additional notes or payment instructions..."
/>
</div>
</Card>
{/* Actions */}
<div className="flex items-center justify-end gap-3">
<Button
type="button"
variant="secondary"
onClick={() => router.push('/admin/invoice/recurrences')}
disabled={saving}
>
Annuler
</Button>
<Button
type="submit"
variant="success"
loading={saving}
disabled={saving}
>
{saving ? 'Enregistrement...' : 'Enregistrer les modifications'}
</Button>
</div>
</form>
</div>
);
};
export default RecurrenceEditPage;
@@ -0,0 +1,360 @@
'use client';
import React, { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { PlusSignCircleIcon, PencilEdit01Icon, Delete02Icon, Tick02Icon, Cancel01Icon } from '../../../../shared/Icons.js';
import {
Table,
Button,
StatusBadge,
Card,
Pagination
} from '../../../../shared/components';
import { useToast } from '@hykocx/zen/toast';
import { formatDateShort } from '../../../../shared/lib/dates.js';
const DATE_LOCALE = 'fr-FR';
/**
* Recurrences List Page Component
* Displays list of invoice recurrences with pagination and sorting
*/
const RecurrencesListPage = ({ user }) => {
const router = useRouter();
const toast = useToast();
const [recurrences, setRecurrences] = useState([]);
const [loading, setLoading] = useState(true);
const [deleting, setDeleting] = useState(false);
const [toggling, setToggling] = useState(null);
// Pagination state
const [pagination, setPagination] = useState({
page: 1,
limit: 20,
total: 0,
totalPages: 0
});
// Sort state
const [sortBy, setSortBy] = useState('id');
const [sortOrder, setSortOrder] = useState('desc');
// Format frequency for display
const formatFrequency = (recurrence) => {
const { frequency_type, frequency_value, recurrence_day } = recurrence;
if (frequency_type === 'days') {
return `Chaque ${frequency_value} ${frequency_value > 1 ? 'jours' : 'jour'}`;
} else if (frequency_type === 'months') {
return `Chaque ${frequency_value} ${frequency_value > 1 ? 'mois' : 'mois'} (jour ${recurrence_day})`;
} else if (frequency_type === 'years') {
return `Chaque ${frequency_value} ${frequency_value > 1 ? 'années' : 'année'} (jour ${recurrence_day})`;
}
return '-';
};
// Format date for display
const formatDate = (dateString) => {
if (!dateString) return '-';
return formatDateShort(dateString, DATE_LOCALE);
};
// Table columns configuration
const columns = [
{
key: 'id',
label: "ID",
sortable: true,
render: (recurrence) => (
<div>
<div className="text-sm font-semibold text-neutral-900 dark:text-white font-mono">#{recurrence.id}</div>
<div className="text-xs text-neutral-500 dark:text-gray-400">
{recurrence.company_name || `${recurrence.first_name} ${recurrence.last_name}`}
</div>
</div>
),
skeleton: {
height: 'h-4',
width: '40%',
secondary: { height: 'h-3', width: '30%' }
}
},
{
key: 'frequency',
label: "Fréquence",
sortable: false,
render: (recurrence) => (
<div className="text-sm text-neutral-600 dark:text-gray-300">
{formatFrequency(recurrence)}
</div>
),
skeleton: { height: 'h-4', width: '50%' }
},
{
key: 'next_due_date',
label: "Prochaine date d'échéance",
sortable: true,
render: (recurrence) => (
<div className="text-sm text-neutral-600 dark:text-gray-300">
{formatDate(recurrence.next_due_date)}
</div>
),
skeleton: { height: 'h-4', width: '35%' }
},
{
key: 'last_invoice_date',
label: "Dernière facture",
sortable: true,
render: (recurrence) => (
<div>
{recurrence.last_invoice_number ? (
<>
<div className="text-sm text-blue-400 font-mono">
{recurrence.last_invoice_number}
</div>
<div className="text-xs text-neutral-500 dark:text-gray-400">
{formatDate(recurrence.last_invoice_date)}
</div>
</>
) : (
<span className="text-sm text-neutral-400 dark:text-gray-500">Pas encore créé</span>
)}
</div>
),
skeleton: {
height: 'h-4',
width: '40%',
secondary: { height: 'h-3', width: '30%' }
}
},
{
key: 'is_active',
label: "Statut",
sortable: true,
render: (recurrence) => (
<StatusBadge variant={recurrence.is_active ? 'success' : 'default'}>
{recurrence.is_active ? "Actif" : "Inactif"}
</StatusBadge>
),
skeleton: { height: 'h-6', width: '70px', className: 'rounded-full' }
},
{
key: 'actions',
label: "Actions",
render: (recurrence) => (
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleToggleStatus(recurrence)}
disabled={toggling === recurrence.id}
icon={recurrence.is_active ?
<Cancel01Icon className="w-4 h-4" /> :
<Tick02Icon className="w-4 h-4" />
}
className="p-2"
title={recurrence.is_active ? "Désactiver" : "Activer"}
/>
<Button
variant="ghost"
size="sm"
onClick={() => handleEditRecurrence(recurrence)}
disabled={deleting || toggling}
icon={<PencilEdit01Icon className="w-4 h-4" />}
className="p-2"
/>
<Button
variant="danger"
size="sm"
onClick={() => handleDeleteRecurrence(recurrence)}
disabled={deleting || toggling}
icon={<Delete02Icon className="w-4 h-4" />}
className="p-2"
/>
</div>
),
skeleton: { height: 'h-8', width: '120px' }
}
];
useEffect(() => {
loadRecurrences();
}, [sortBy, sortOrder, pagination.page, pagination.limit]);
const loadRecurrences = async () => {
try {
setLoading(true);
const searchParams = new URLSearchParams({
page: pagination.page.toString(),
limit: pagination.limit.toString(),
sortBy,
sortOrder
});
const response = await fetch(`/zen/api/admin/recurrences?${searchParams}`, {
credentials: 'include'
});
const data = await response.json();
if (data.success) {
setRecurrences(data.recurrences || []);
setPagination(prev => ({
...prev,
total: data.total || 0,
totalPages: data.totalPages || 0,
page: data.page || 1
}));
} else {
toast.error(data.error || "Échec du chargement des récurrences");
}
} catch (error) {
console.error('Error loading recurrences:', error);
toast.error("Échec du chargement des récurrences");
} finally {
setLoading(false);
}
};
const handlePageChange = (newPage) => {
setPagination(prev => ({ ...prev, page: newPage }));
};
const handleLimitChange = (newLimit) => {
setPagination(prev => ({
...prev,
limit: newLimit,
page: 1
}));
};
const handleSort = (newSortBy) => {
const newSortOrder = sortBy === newSortBy && sortOrder === 'asc' ? 'desc' : 'asc';
setSortBy(newSortBy);
setSortOrder(newSortOrder);
};
const handleEditRecurrence = (recurrence) => {
router.push(`/admin/invoice/recurrences/edit/${recurrence.id}`);
};
const handleToggleStatus = async (recurrence) => {
const action = recurrence.is_active ? 'deactivate' : 'activate';
const actionLabel = recurrence.is_active ? 'désactiver' : 'activer';
const clientName = recurrence.company_name || `${recurrence.first_name} ${recurrence.last_name}`;
if (!confirm(`Êtes-vous sûr de vouloir ${actionLabel} la récurrence #${recurrence.id} pour ${clientName} ?`)) {
return;
}
try {
setToggling(recurrence.id);
const response = await fetch(`/zen/api/admin/recurrences`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
id: recurrence.id,
recurrence: {
is_active: !recurrence.is_active
}
}),
});
const data = await response.json();
if (data.success) {
toast.success(`Récurrence ${actionLabel} avec succès`);
loadRecurrences();
} else {
toast.error(data.error || `Échec de ${actionLabel} la récurrence`);
}
} catch (error) {
console.error(`Error ${action}ing recurrence:`, error);
toast.error(`Échec de ${actionLabel} la récurrence`);
} finally {
setToggling(null);
}
};
const handleDeleteRecurrence = async (recurrence) => {
const clientName = recurrence.company_name || `${recurrence.first_name} ${recurrence.last_name}`;
if (!confirm(`Êtes-vous sûr de vouloir supprimer définitivement la récurrence #${recurrence.id} pour ${clientName} ?\n\nCette action est irréversible !`)) {
return;
}
try {
setDeleting(true);
const response = await fetch(`/zen/api/admin/recurrences?id=${recurrence.id}`, {
method: 'DELETE',
credentials: 'include'
});
const data = await response.json();
if (data.success) {
toast.success("Récurrence supprimée définitivement avec succès");
loadRecurrences();
} else {
toast.error(data.error || "Échec de la suppression de la récurrence");
}
} catch (error) {
console.error('Error deleting recurrence:', error);
toast.error("Échec de la suppression de la récurrence");
} finally {
setDeleting(false);
}
};
return (
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-lg sm:text-xl font-semibold text-neutral-900 dark:text-white">Récurrences</h1>
<p className="mt-1 text-xs text-neutral-400">Gérez les factures récurrentes</p>
</div>
<Button
onClick={() => router.push('/admin/invoice/recurrences/new')}
icon={<PlusSignCircleIcon className="w-4 h-4" />}
>
Créer une récurrence
</Button>
</div>
{/* Recurrences Table */}
<Card variant="default" padding="none">
<Table
columns={columns}
data={recurrences}
loading={loading}
sortBy={sortBy}
sortOrder={sortOrder}
onSort={handleSort}
emptyMessage="Aucune récurrence trouvée"
emptyDescription="Créez votre première récurrence pour générer automatiquement des factures"
/>
<Pagination
currentPage={pagination.page}
totalPages={pagination.totalPages}
onPageChange={handlePageChange}
onLimitChange={handleLimitChange}
limit={pagination.limit}
total={pagination.total}
loading={loading}
showPerPage={true}
showStats={true}
/>
</Card>
</div>
);
};
export default RecurrencesListPage;
@@ -0,0 +1,9 @@
/**
* Recurrence Admin Components
* Part of Invoice Module
*/
export { default as RecurrencesListPage } from './RecurrencesListPage.js';
export { default as RecurrenceCreatePage } from './RecurrenceCreatePage.js';
export { default as RecurrenceEditPage } from './RecurrenceEditPage.js';
+369
View File
@@ -0,0 +1,369 @@
/**
* Recurrences Module - CRUD Operations
* Create, Read, Update, Delete operations for invoice recurrences
* Part of Invoice Module
*/
import { query } from '@hykocx/zen/database';
import {
parseUTCDate,
formatDateForInput,
getTodayUTC,
addDays
} from '../../../shared/lib/dates.js';
/**
* Calculate next due date based on recurrence settings
* @param {Object} recurrence - Recurrence object with frequency settings
* @param {Date|string} fromDate - Starting date (defaults to today)
* @returns {string} Next due date in YYYY-MM-DD format
*/
export function calculateNextDueDate(recurrence, fromDate = null) {
const { frequency_type, frequency_value, recurrence_day } = recurrence;
let baseDate = fromDate ? parseUTCDate(fromDate) : getTodayUTC();
// If there's a last invoice date, start from there
if (recurrence.last_invoice_date && !fromDate) {
baseDate = parseUTCDate(recurrence.last_invoice_date);
}
let nextDate = new Date(baseDate);
if (frequency_type === 'days') {
// Add X days using UTC methods
nextDate.setUTCDate(nextDate.getUTCDate() + parseInt(frequency_value));
} else if (frequency_type === 'months') {
// Add X months and set to recurrence_day using UTC methods
nextDate.setUTCMonth(nextDate.getUTCMonth() + parseInt(frequency_value));
nextDate.setUTCDate(Math.min(parseInt(recurrence_day), 28));
} else if (frequency_type === 'years') {
// Add X years and set to recurrence_day using UTC methods
nextDate.setUTCFullYear(nextDate.getUTCFullYear() + parseInt(frequency_value));
nextDate.setUTCDate(Math.min(parseInt(recurrence_day), 28));
}
return formatDateForInput(nextDate);
}
/**
* Calculate first due date for a new recurrence
* @param {Object} recurrence - Recurrence settings
* @returns {string} First due date in YYYY-MM-DD format
*/
export function calculateFirstDueDate(recurrence) {
const { frequency_type, recurrence_day, first_occurrence_date } = recurrence;
// If a custom first occurrence date is specified, use it
if (first_occurrence_date) {
return formatDateForInput(parseUTCDate(first_occurrence_date));
}
const today = getTodayUTC();
if (frequency_type === 'days') {
// For days-based recurrence, due date is today + frequency_value days
const dueDate = new Date(today);
dueDate.setUTCDate(dueDate.getUTCDate() + parseInt(recurrence.frequency_value));
return formatDateForInput(dueDate);
}
// For months/years, find the next occurrence of recurrence_day
const targetDay = parseInt(recurrence_day);
const currentDay = today.getUTCDate();
let firstDueDate = new Date(today);
firstDueDate.setUTCDate(targetDay);
// If the target day has already passed this month, move to next period
if (currentDay >= targetDay) {
if (frequency_type === 'months') {
firstDueDate.setUTCMonth(firstDueDate.getUTCMonth() + 1);
} else if (frequency_type === 'years') {
firstDueDate.setUTCFullYear(firstDueDate.getUTCFullYear() + 1);
}
}
return formatDateForInput(firstDueDate);
}
/**
* Create a new recurrence
* @param {Object} recurrenceData - Recurrence data
* @returns {Promise<Object>} Created recurrence
*/
export async function createRecurrence(recurrenceData) {
const {
client_id,
frequency_type,
frequency_value,
recurrence_day,
first_reminder_days = 30,
first_occurrence_date = null,
items = [],
notes = null,
is_active = true,
} = recurrenceData;
// Validate required fields
if (!client_id || !frequency_type || !frequency_value || !recurrence_day) {
throw new Error('Client, frequency type, frequency value, and recurrence day are required');
}
// Validate frequency_type
if (!['days', 'months', 'years'].includes(frequency_type)) {
throw new Error('Frequency type must be days, months, or years');
}
// Validate frequency_value
if (frequency_value < 1) {
throw new Error('Frequency value must be at least 1');
}
// Validate recurrence_day
if (recurrence_day < 1 || recurrence_day > 28) {
throw new Error('Recurrence day must be between 1 and 28');
}
// Validate items
if (!Array.isArray(items) || items.length === 0) {
throw new Error('At least one item is required');
}
// Calculate next_due_date for new recurrence
const next_due_date = calculateFirstDueDate({
frequency_type,
frequency_value,
recurrence_day,
first_occurrence_date
});
const result = await query(
`INSERT INTO zen_invoice_recurrences
(client_id, frequency_type, frequency_value, recurrence_day,
first_reminder_days, first_occurrence_date, items, notes, is_active, next_due_date)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING *`,
[client_id, frequency_type, frequency_value, recurrence_day,
first_reminder_days, first_occurrence_date, JSON.stringify(items), notes, is_active, next_due_date]
);
return result.rows[0];
}
/**
* Get recurrence by ID
* @param {number} id - Recurrence ID
* @returns {Promise<Object|null>}
*/
export async function getRecurrenceById(id) {
const result = await query(
`SELECT r.*,
c.company_name, c.first_name, c.last_name, c.email as client_email,
i.invoice_number as last_invoice_number
FROM zen_invoice_recurrences r
LEFT JOIN zen_clients c ON r.client_id = c.id
LEFT JOIN zen_invoices i ON r.last_invoice_id = i.id
WHERE r.id = $1`,
[id]
);
return result.rows[0] || null;
}
/**
* Get all recurrences with pagination and filters
* @param {Object} options - Query options
* @returns {Promise<Object>} Recurrences and metadata
*/
export async function getRecurrences(options = {}) {
const {
page = 1,
limit = 50,
search = '',
client_id = null,
is_active = null,
sortBy = 'id',
sortOrder = 'DESC'
} = options;
const offset = (page - 1) * limit;
// Build where conditions
const conditions = [];
const params = [];
let paramIndex = 1;
if (search) {
conditions.push(`(c.company_name ILIKE $${paramIndex} OR c.first_name ILIKE $${paramIndex} OR c.last_name ILIKE $${paramIndex} OR c.email ILIKE $${paramIndex})`);
params.push(`%${search}%`);
paramIndex++;
}
if (client_id) {
conditions.push(`r.client_id = $${paramIndex}`);
params.push(client_id);
paramIndex++;
}
if (is_active !== null) {
conditions.push(`r.is_active = $${paramIndex}`);
params.push(is_active);
paramIndex++;
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
// Get total count (need to join with clients for search)
const countResult = await query(
`SELECT COUNT(*)
FROM zen_invoice_recurrences r
LEFT JOIN zen_clients c ON r.client_id = c.id
${whereClause}`,
params
);
const total = parseInt(countResult.rows[0].count);
// Get recurrences with client info
const recurrencesResult = await query(
`SELECT r.*,
c.company_name, c.first_name, c.last_name, c.email as client_email,
i.invoice_number as last_invoice_number
FROM zen_invoice_recurrences r
LEFT JOIN zen_clients c ON r.client_id = c.id
LEFT JOIN zen_invoices i ON r.last_invoice_id = i.id
${whereClause}
ORDER BY r.${sortBy} ${sortOrder}
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
[...params, limit, offset]
);
return {
recurrences: recurrencesResult.rows,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit)
}
};
}
/**
* Get all active recurrences (for cron processing)
* @returns {Promise<Array>}
*/
export async function getActiveRecurrences() {
const result = await query(
`SELECT r.*,
c.company_name, c.first_name, c.last_name, c.email as client_email,
c.client_number
FROM zen_invoice_recurrences r
LEFT JOIN zen_clients c ON r.client_id = c.id
WHERE r.is_active = true
ORDER BY r.next_due_date ASC`
);
return result.rows;
}
/**
* Update recurrence
* @param {number} id - Recurrence ID
* @param {Object} updates - Fields to update
* @returns {Promise<Object>} Updated recurrence
*/
export async function updateRecurrence(id, updates) {
const allowedFields = [
'client_id', 'frequency_type', 'frequency_value', 'recurrence_day',
'first_reminder_days', 'first_occurrence_date', 'items', 'notes', 'is_active',
'last_invoice_id', 'last_invoice_date', 'next_due_date'
];
const setFields = [];
const values = [];
let paramIndex = 1;
for (const [key, value] of Object.entries(updates)) {
if (allowedFields.includes(key)) {
// Handle JSONB fields
if (key === 'items' && typeof value === 'object') {
setFields.push(`${key} = $${paramIndex}`);
values.push(JSON.stringify(value));
} else {
setFields.push(`${key} = $${paramIndex}`);
values.push(value);
}
paramIndex++;
}
}
if (setFields.length === 0) {
throw new Error('No valid fields to update');
}
// Add updated_at
setFields.push(`updated_at = CURRENT_TIMESTAMP`);
// Add ID parameter
values.push(id);
const result = await query(
`UPDATE zen_invoice_recurrences
SET ${setFields.join(', ')}
WHERE id = $${paramIndex}
RETURNING *`,
values
);
return result.rows[0];
}
/**
* Update recurrence after invoice creation
* @param {number} id - Recurrence ID
* @param {number} invoiceId - Created invoice ID
* @param {string} invoiceDate - Invoice due date
* @returns {Promise<Object>} Updated recurrence
*/
export async function updateRecurrenceAfterInvoiceCreation(id, invoiceId, invoiceDate) {
// Get the recurrence to calculate next due date
const recurrence = await getRecurrenceById(id);
if (!recurrence) {
throw new Error('Recurrence not found');
}
// Calculate next due date from the invoice date
const next_due_date = calculateNextDueDate(recurrence, invoiceDate);
return await updateRecurrence(id, {
last_invoice_id: invoiceId,
last_invoice_date: invoiceDate,
next_due_date
});
}
/**
* Soft delete recurrence (set is_active to false)
* @param {number} id - Recurrence ID
* @returns {Promise<Object>} Updated recurrence
*/
export async function deactivateRecurrence(id) {
return await updateRecurrence(id, { is_active: false });
}
/**
* Delete recurrence permanently
* @param {number} id - Recurrence ID
* @returns {Promise<boolean>} Success status
*/
export async function deleteRecurrence(id) {
const result = await query(
`DELETE FROM zen_invoice_recurrences WHERE id = $1`,
[id]
);
return result.rowCount > 0;
}
+98
View File
@@ -0,0 +1,98 @@
/**
* Recurrences Module - Database
* Database initialization and tables for invoice recurrences
* Part of Invoice Module
*/
import { query } from '@hykocx/zen/database';
/**
* Check if a table exists in the database
* @param {string} tableName - Name of the table to check
* @returns {Promise<boolean>}
*/
async function tableExists(tableName) {
const result = await query(
`SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = $1
)`,
[tableName]
);
return result.rows[0].exists;
}
/**
* Create recurrences table
* @returns {Promise<Object>}
*/
export async function createRecurrencesTable() {
const tableName = 'zen_invoice_recurrences';
const exists = await tableExists(tableName);
if (exists) {
console.log(`- Table already exists: ${tableName}`);
return { created: false, tableName };
}
await query(`
CREATE TABLE zen_invoice_recurrences (
id SERIAL PRIMARY KEY,
client_id INTEGER NOT NULL REFERENCES zen_clients(id) ON DELETE RESTRICT,
frequency_type VARCHAR(20) NOT NULL CHECK (frequency_type IN ('days', 'months', 'years')),
frequency_value INTEGER NOT NULL CHECK (frequency_value > 0),
recurrence_day INTEGER NOT NULL CHECK (recurrence_day >= 1 AND recurrence_day <= 28),
first_reminder_days INTEGER DEFAULT 30,
first_occurrence_date DATE,
items JSONB NOT NULL DEFAULT '[]'::jsonb,
notes TEXT,
is_active BOOLEAN DEFAULT true,
last_invoice_id INTEGER REFERENCES zen_invoices(id) ON DELETE SET NULL,
last_invoice_date DATE,
next_due_date DATE,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
)
`);
// Create index on client_id for fast lookups
await query(`
CREATE INDEX idx_zen_invoice_recurrences_client_id ON zen_invoice_recurrences(client_id)
`);
// Create index on is_active for filtering active recurrences
await query(`
CREATE INDEX idx_zen_invoice_recurrences_is_active ON zen_invoice_recurrences(is_active)
`);
// Create index on next_due_date for cron processing
await query(`
CREATE INDEX idx_zen_invoice_recurrences_next_due_date ON zen_invoice_recurrences(next_due_date)
`);
// Create composite index for active recurrences with upcoming due dates
await query(`
CREATE INDEX idx_zen_invoice_recurrences_active_next_due
ON zen_invoice_recurrences(is_active, next_due_date)
WHERE is_active = true
`);
console.log(`✓ Created table: ${tableName}`);
return { created: true, tableName };
}
/**
* Drop recurrences table (use with caution!)
* @returns {Promise<void>}
*/
export async function dropRecurrencesTable() {
const tableName = 'zen_invoice_recurrences';
const exists = await tableExists(tableName);
if (exists) {
await query(`DROP TABLE IF EXISTS ${tableName} CASCADE`);
console.log(`✓ Dropped table: ${tableName}`);
}
}
@@ -0,0 +1,119 @@
/**
* Recurrence Processor
* Processes active recurrences and creates invoices automatically
* Part of Invoice Module
*/
import { getActiveRecurrences, updateRecurrenceAfterInvoiceCreation } from './crud.js';
import { createInvoice } from '../crud.js';
import { parseUTCDate, formatDateForInput, getTodayUTC } from '../../../shared/lib/dates.js';
/**
* Check if an invoice should be created for a recurrence
* @param {Object} recurrence - Recurrence object
* @param {Date} today - Current date
* @returns {boolean} Whether to create invoice
*/
export function shouldCreateInvoice(recurrence, today = null) {
if (!recurrence.next_due_date) {
return false;
}
const nextDueDate = parseUTCDate(recurrence.next_due_date);
const reminderDays = parseInt(recurrence.first_reminder_days) || 0;
// Calculate when the invoice should be created (due date - reminder days)
const creationDate = new Date(nextDueDate);
creationDate.setUTCDate(creationDate.getUTCDate() - reminderDays);
// Create invoice if today is on or after the creation date
// and before or on the due date
const todayDate = today || getTodayUTC();
const todayStr = formatDateForInput(todayDate);
const creationDateStr = formatDateForInput(creationDate);
const nextDueDateStr = formatDateForInput(nextDueDate);
return todayStr >= creationDateStr && todayStr <= nextDueDateStr;
}
/**
* Create invoice from recurrence
* @param {Object} recurrence - Recurrence object
* @returns {Promise<Object>} Created invoice
*/
async function createInvoiceFromRecurrence(recurrence) {
const invoiceData = {
client_id: recurrence.client_id,
due_date: recurrence.next_due_date,
first_reminder_days: recurrence.first_reminder_days,
items: recurrence.items || [],
notes: recurrence.notes,
status: 'sent', // Auto-created invoices are marked as sent
};
// Create the invoice
const invoice = await createInvoice(invoiceData);
return invoice;
}
/**
* Process all active recurrences and create invoices as needed
* @returns {Promise<Object>} Summary of processing
*/
export async function processRecurrences() {
const summary = {
processed: 0,
created: 0,
skipped: 0,
errors: []
};
try {
// Get all active recurrences
const recurrences = await getActiveRecurrences();
console.log(`[Recurrence Processor] Found ${recurrences.length} active recurrences`);
const today = getTodayUTC();
for (const recurrence of recurrences) {
summary.processed++;
try {
// Check if invoice should be created
if (shouldCreateInvoice(recurrence, today)) {
console.log(`[Recurrence Processor] Creating invoice for recurrence #${recurrence.id}`);
// Create the invoice
const invoice = await createInvoiceFromRecurrence(recurrence);
// Update recurrence with last invoice info
await updateRecurrenceAfterInvoiceCreation(
recurrence.id,
invoice.id,
invoice.due_date
);
summary.created++;
console.log(`[Recurrence Processor] Created invoice ${invoice.invoice_number} for recurrence #${recurrence.id}`);
} else {
summary.skipped++;
console.log(`[Recurrence Processor] Skipped recurrence #${recurrence.id} - not due yet`);
}
} catch (error) {
console.error(`[Recurrence Processor] Error processing recurrence #${recurrence.id}:`, error);
summary.errors.push({
recurrence_id: recurrence.id,
error: error.message
});
}
}
return summary;
} catch (error) {
console.error('[Recurrence Processor] Fatal error:', error);
throw error;
}
}
+510
View File
@@ -0,0 +1,510 @@
/**
* Invoice Reminders System
* Automated reminder system for invoices
*/
import { query } from '@hykocx/zen/database';
import { getInvoiceById, addInvoiceReminder, updateInvoice } from './crud.js';
import { sendEmail } from '@hykocx/zen/email';
import { render } from '@react-email/components';
import { InvoiceReminderEmail } from './email/InvoiceReminderEmail';
import { InvoiceOverdueEmail } from './email/InvoiceOverdueEmail';
import { AdminOverdueNotificationEmail } from './email/AdminOverdueNotificationEmail';
import { formatCurrency } from '../../shared/utils/currency.js';
import { getAppName, getPublicBaseUrl } from '../../shared/lib/appConfig.js';
import { getDaysBetween, getTodayUTC } from '../../shared/lib/dates.js';
import { getInterestConfig } from './interest.js';
/**
* Get invoices that need reminders
* @returns {Promise<Array>}
*/
async function getInvoicesNeedingReminders() {
// Get today's date in UTC as string (YYYY-MM-DD)
const today = getTodayUTC();
const todayString = `${today.getUTCFullYear()}-${String(today.getUTCMonth() + 1).padStart(2, '0')}-${String(today.getUTCDate()).padStart(2, '0')}`;
// Get invoices that are sent or partially paid, not draft/paid/cancelled
// Exclude invoices that already received a reminder today (to prevent spam)
const result = await query(
`SELECT i.*,
c.first_name, c.last_name, c.company_name, c.email as client_email,
(SELECT COUNT(*) FROM zen_invoice_reminders WHERE invoice_id = i.id AND reminder_type = 'reminder') as reminder_count,
(SELECT MAX(days_before) FROM zen_invoice_reminders WHERE invoice_id = i.id AND reminder_type = 'reminder') as last_reminder_days,
(SELECT MAX(sent_at) FROM zen_invoice_reminders WHERE invoice_id = i.id AND reminder_type = 'reminder') as last_reminder_sent_at
FROM zen_invoices i
JOIN zen_clients c ON i.client_id = c.id
WHERE i.status IN ('sent', 'partial')
AND i.due_date >= $1::date
AND NOT EXISTS (
SELECT 1 FROM zen_invoice_reminders
WHERE invoice_id = i.id
AND reminder_type = 'reminder'
AND DATE(sent_at AT TIME ZONE 'UTC') = $1::date
)
ORDER BY i.due_date ASC`,
[todayString]
);
return result.rows;
}
/**
* Get overdue invoices
* @returns {Promise<Array>}
*/
async function getOverdueInvoices() {
// Get today's date in UTC as string (YYYY-MM-DD)
const today = getTodayUTC();
const todayString = `${today.getUTCFullYear()}-${String(today.getUTCMonth() + 1).padStart(2, '0')}-${String(today.getUTCDate()).padStart(2, '0')}`;
const result = await query(
`SELECT i.*,
c.first_name, c.last_name, c.company_name, c.email as client_email,
($1::date - i.due_date)::INTEGER as days_overdue,
(SELECT sent_at FROM zen_invoice_reminders
WHERE invoice_id = i.id AND reminder_type = 'overdue'
ORDER BY sent_at DESC LIMIT 1) as last_overdue_reminder
FROM zen_invoices i
JOIN zen_clients c ON i.client_id = c.id
WHERE i.status IN ('sent', 'partial')
AND i.due_date < $1::date
ORDER BY i.due_date ASC`,
[todayString]
);
return result.rows;
}
/**
* Determine which reminder to send based on days until due date
* @param {number} daysUntilDue - Days until due date
* @param {number} firstReminderDays - First reminder days setting
* @param {number} lastReminderDays - Last reminder sent (days before)
* @returns {number|null} Days before to send, or null if no reminder needed
*/
function getNextReminderDays(daysUntilDue, firstReminderDays, lastReminderDays = null) {
// Reminder sequence based on first reminder setting
const reminderSequences = {
30: [30, 14, 7, 3, 1],
14: [14, 7, 3, 1],
7: [7, 3, 1],
3: [3, 1],
};
const sequence = reminderSequences[firstReminderDays] || [7, 3, 1];
// Find the closest reminder threshold that is >= daysUntilDue
// Example: if daysUntilDue = 13, and sequence is [30, 14, 7, 3, 1]
// We want to send the 14-day reminder (the smallest threshold >= 13)
let closestReminder = null;
for (const days of sequence) {
if (days >= daysUntilDue) {
// This reminder threshold is valid (>= daysUntilDue)
// Keep track of the smallest one
if (closestReminder === null || days < closestReminder) {
closestReminder = days;
}
}
}
// If we found a reminder threshold and haven't sent it yet
if (closestReminder !== null) {
// Don't send if we already sent this reminder or a smaller one
if (lastReminderDays === null || lastReminderDays > closestReminder) {
return closestReminder;
}
}
return null;
}
/**
* Send reminder email for an invoice
* @param {Object} invoice - Invoice object with client data
* @param {number} daysUntilDue - Days until due date (actual days remaining)
* @param {number} reminderDays - Reminder threshold used (to store in DB)
* @returns {Promise<boolean>}
*/
async function sendReminderEmail(invoice, daysUntilDue, reminderDays) {
try {
const clientName = invoice.company_name || `${invoice.first_name} ${invoice.last_name}`;
const paymentUrl = `${getPublicBaseUrl()}/zen/invoice/${invoice.token}`;
const appName = getAppName();
const interestConfig = getInterestConfig();
// Check if this is the first reminder (no reminders sent yet)
const isFirstReminder = parseInt(invoice.reminder_count) === 0;
const emailHtml = await render(
InvoiceReminderEmail({
clientName,
invoiceNumber: invoice.invoice_number,
invoiceAmount: formatCurrency(invoice.total_amount),
dueDate: invoice.due_date,
daysUntilDue,
paymentUrl,
companyName: appName,
isFirstReminder,
graceDays: interestConfig.graceDays,
interestRate: interestConfig.monthlyRate,
})
);
// Use different subject based on whether it's the first notification or a reminder
const subject = isFirstReminder
? `Une nouvelle facture est arrivée ! #${invoice.invoice_number}`
: `Rappel : Facture #${invoice.invoice_number} échéance dans ${daysUntilDue} jours`;
await sendEmail({
to: invoice.client_email,
subject,
html: emailHtml,
});
// Log reminder with the threshold used (not actual days)
// Example: If invoice is due in 13 days and we sent the 14-day reminder,
// we record 14 so we don't send this reminder again
await addInvoiceReminder(invoice.id, 'reminder', reminderDays);
return true;
} catch (error) {
console.error(`Failed to send reminder for invoice ${invoice.invoice_number}:`, error);
return false;
}
}
/**
* Send overdue email for an invoice
* @param {Object} invoice - Invoice object with client data
* @param {number} daysOverdue - Days overdue
* @returns {Promise<boolean>}
*/
async function sendOverdueEmail(invoice, daysOverdue) {
try {
const clientName = invoice.company_name || `${invoice.first_name} ${invoice.last_name}`;
const paymentUrl = `${getPublicBaseUrl()}/zen/invoice/${invoice.token}`;
const appName = getAppName();
const interestConfig = getInterestConfig();
// Calculate amounts
const principalAmount = parseFloat(invoice.total_amount);
const interestAmount = parseFloat(invoice.interest_amount || 0);
const totalOwed = principalAmount + interestAmount;
const emailHtml = await render(
InvoiceOverdueEmail({
clientName,
invoiceNumber: invoice.invoice_number,
invoiceAmount: formatCurrency(principalAmount),
interestAmount: formatCurrency(interestAmount),
totalAmountOwed: formatCurrency(totalOwed),
dueDate: invoice.due_date,
daysOverdue,
paymentUrl,
companyName: appName,
graceDays: interestConfig.graceDays,
interestRate: interestConfig.monthlyRate,
})
);
const subject = `EN RETARD : Facture ${invoice.invoice_number} - ${daysOverdue} jours en retard`;
await sendEmail({
to: invoice.client_email,
subject,
html: emailHtml,
});
// Log reminder
await addInvoiceReminder(invoice.id, 'overdue', 0);
// Update invoice status to overdue
await updateInvoice(invoice.id, { status: 'overdue' });
return true;
} catch (error) {
console.error(`Failed to send overdue notice for invoice ${invoice.invoice_number}:`, error);
return false;
}
}
/**
* Send admin notification email when an invoice becomes overdue
* @param {Object} invoice - Invoice object with client data
* @param {number} daysOverdue - Days overdue
* @returns {Promise<boolean>}
*/
async function sendAdminOverdueNotification(invoice, daysOverdue) {
try {
const adminEmail = process.env.ZEN_MODULE_INVOICE_OVERDUE_EMAIL;
// Skip if no admin email is configured
if (!adminEmail) {
console.log(`[Admin Notification] No ZEN_MODULE_INVOICE_OVERDUE_EMAIL configured, skipping admin notification for invoice ${invoice.invoice_number}`);
return false;
}
const clientName = invoice.company_name || `${invoice.first_name} ${invoice.last_name}`;
const invoiceUrl = `${getPublicBaseUrl()}/zen/invoice/${invoice.token}`;
const appName = getAppName();
const interestConfig = getInterestConfig();
// Calculate amounts
const principalAmount = parseFloat(invoice.total_amount);
const interestAmount = parseFloat(invoice.interest_amount || 0);
const totalOwed = principalAmount + interestAmount;
const emailHtml = await render(
AdminOverdueNotificationEmail({
clientName,
invoiceNumber: invoice.invoice_number,
invoiceAmount: formatCurrency(principalAmount),
interestAmount: formatCurrency(interestAmount),
totalAmountOwed: formatCurrency(totalOwed),
dueDate: invoice.due_date,
daysOverdue,
invoiceUrl,
companyName: appName,
clientEmail: invoice.client_email,
clientPhone: invoice.phone || null,
})
);
const subject = `Admin : Facture ${invoice.invoice_number} est maintenant en retard`;
await sendEmail({
to: adminEmail,
subject,
html: emailHtml,
});
console.log(`[Admin Notification] Sent admin notification for invoice ${invoice.invoice_number} to ${adminEmail}`);
return true;
} catch (error) {
console.error(`Failed to send admin notification for invoice ${invoice.invoice_number}:`, error);
return false;
}
}
/**
* Sleep/delay utility function
* @param {number} ms - Milliseconds to sleep
* @returns {Promise<void>}
*/
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* Process all invoice reminders
* This should be run daily via a cron job
* @returns {Promise<Object>} Summary of reminders sent
*/
export async function processInvoiceReminders() {
const today = getTodayUTC();
const todayString = `${today.getUTCFullYear()}-${String(today.getUTCMonth() + 1).padStart(2, '0')}-${String(today.getUTCDate()).padStart(2, '0')}`;
console.log(`Processing invoice reminders... (UTC Date: ${todayString})`);
const summary = {
reminders_sent: 0,
overdue_notices_sent: 0,
errors: 0,
};
try {
// Process regular reminders
const invoices = await getInvoicesNeedingReminders();
console.log(`[Reminders] Found ${invoices.length} invoices eligible for reminders (already filtered: no reminders sent today)`);
for (const invoice of invoices) {
const daysUntilDue = getDaysBetween(today, invoice.due_date);
// Debug: Log all invoice data
console.log(`[Invoice ${invoice.invoice_number}] ID: ${invoice.id}, Due Date: ${invoice.due_date}, Days Until Due: ${daysUntilDue}`);
console.log(`[Invoice ${invoice.invoice_number}] Reminder Count: ${invoice.reminder_count}, Last Reminder Days: ${invoice.last_reminder_days}, First Reminder Days: ${invoice.first_reminder_days}`);
const nextReminderDays = getNextReminderDays(
daysUntilDue,
invoice.first_reminder_days,
invoice.last_reminder_days
);
console.log(`[Invoice ${invoice.invoice_number}] Next reminder to send: ${nextReminderDays}`);
if (nextReminderDays !== null) {
console.log(`[Invoice ${invoice.invoice_number}] SENDING reminder for ${nextReminderDays}-day threshold`);
const sent = await sendReminderEmail(invoice, daysUntilDue, nextReminderDays);
if (sent) {
summary.reminders_sent++;
console.log(`[Invoice ${invoice.invoice_number}] Reminder successfully sent and logged`);
// Wait 900ms between emails to respect rate limits (max 2 emails per second)
await sleep(900);
} else {
summary.errors++;
}
} else {
console.log(`[Invoice ${invoice.invoice_number}] No reminder needed - already sent or not due yet`);
}
}
// Process overdue invoices
const overdueInvoices = await getOverdueInvoices();
for (const invoice of overdueInvoices) {
const daysOverdue = invoice.days_overdue;
// Check if this is the first time the invoice is overdue
const isFirstTimeOverdue = daysOverdue === 1 && !invoice.last_overdue_reminder;
// Send overdue notice if:
// - It's 1 day overdue and no overdue notice sent yet
// - Or it's been 7 days since last overdue notice
const shouldSend =
isFirstTimeOverdue ||
(invoice.last_overdue_reminder &&
getDaysBetween(invoice.last_overdue_reminder, getTodayUTC()) >= 7);
if (shouldSend) {
// Send overdue email to client
const sent = await sendOverdueEmail(invoice, daysOverdue);
if (sent) {
summary.overdue_notices_sent++;
// Send admin notification if this is the first time overdue
if (isFirstTimeOverdue) {
await sendAdminOverdueNotification(invoice, daysOverdue);
}
// Wait 900ms between emails to respect rate limits (max 2 emails per second)
await sleep(900);
} else {
summary.errors++;
}
}
}
console.log(`Reminders processed: ${summary.reminders_sent} reminders, ${summary.overdue_notices_sent} overdue notices`);
return summary;
} catch (error) {
console.error('Error processing invoice reminders:', error);
throw error;
}
}
/**
* Send payment confirmation email
* @param {number} invoiceId - Invoice ID
* @param {Object} transaction - Transaction object
* @returns {Promise<boolean>}
*/
export async function sendPaymentConfirmation(invoiceId, transaction) {
try {
const invoice = await getInvoiceById(invoiceId);
if (!invoice) {
throw new Error('Invoice not found');
}
const { InvoicePaymentConfirmationEmail } = await import('./email/InvoicePaymentConfirmationEmail');
const clientName = invoice.company_name || `${invoice.first_name} ${invoice.last_name}`;
const appName = getAppName();
// Calculate remaining balance and payment status
const totalAmount = parseFloat(invoice.total_amount);
const paidAmount = parseFloat(invoice.paid_amount || 0);
const remainingBalance = totalAmount - paidAmount;
const isFullPayment = remainingBalance <= 0;
const emailHtml = await render(
InvoicePaymentConfirmationEmail({
clientName,
invoiceNumber: invoice.invoice_number,
paidAmount: formatCurrency(transaction.amount),
totalAmount: formatCurrency(totalAmount),
remainingBalance: formatCurrency(remainingBalance),
isFullPayment,
paymentDate: transaction.transaction_date,
paymentMethod: transaction.payment_method,
transactionNumber: transaction.transaction_number,
companyName: appName,
invoiceUrl: `${getPublicBaseUrl()}/zen/invoice/${invoice.token}`,
})
);
const subject = `Paiement reçu ! #${invoice.invoice_number}`;
await sendEmail({
to: invoice.client_email,
subject,
html: emailHtml,
});
return true;
} catch (error) {
console.error(`Failed to send payment confirmation:`, error);
return false;
}
}
/**
* Send manual receipt email
* @param {number} invoiceId - Invoice ID
* @param {Object} transaction - Transaction object
* @returns {Promise<boolean>}
*/
export async function sendReceiptEmail(invoiceId, transaction) {
try {
const invoice = await getInvoiceById(invoiceId);
if (!invoice) {
throw new Error('Invoice not found');
}
const { InvoiceReceiptEmail } = await import('./email/InvoiceReceiptEmail');
const clientName = invoice.company_name || `${invoice.first_name} ${invoice.last_name}`;
const appName = getAppName();
// Format items for receipt
const formattedItems = invoice.items.map(item => ({
name: item.name,
description: item.description,
quantity: item.quantity,
unit_price: formatCurrency(item.unit_price),
total: formatCurrency(item.total),
}));
const emailHtml = await render(
InvoiceReceiptEmail({
clientName,
invoiceNumber: invoice.invoice_number,
paidAmount: formatCurrency(transaction.amount),
paymentDate: transaction.transaction_date,
paymentMethod: transaction.payment_method,
transactionNumber: transaction.transaction_number,
items: formattedItems,
companyName: appName,
})
);
const subject = `Reçu de paiement #${invoice.invoice_number}`;
await sendEmail({
to: invoice.client_email,
subject,
html: emailHtml,
});
return true;
} catch (error) {
console.error(`Failed to send receipt:`, error);
return false;
}
}
@@ -0,0 +1,371 @@
'use client';
import React, { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Button, Card, Input, Select, Textarea } from '../../../../shared/components';
import { useToast } from '@hykocx/zen/toast';
import { formatCurrency } from '../../../../shared/utils/currency.js';
import { getTodayString, formatDateShort } from '../../../../shared/lib/dates.js';
/**
* Transaction Create Page Component
* Page for creating a new transaction
*/
const TransactionCreatePage = ({ user }) => {
const router = useRouter();
const toast = useToast();
const today = getTodayString();
const [invoices, setInvoices] = useState([]);
const [selectedInvoice, setSelectedInvoice] = useState(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [formData, setFormData] = useState({
invoice_id: '',
amount: '',
payment_method: 'cash',
transaction_date: today,
notes: '',
send_confirmation_email: true
});
const [errors, setErrors] = useState({});
const paymentMethodOptions = [
{ value: 'stripe', label: 'Stripe' },
{ value: 'cash', label: 'Cash' },
{ value: 'check', label: 'Check' },
{ value: 'wire', label: 'Wire Transfer' },
{ value: 'interac', label: 'Interac' },
{ value: 'other', label: "Autre" }
];
useEffect(() => {
loadInvoices();
}, []);
const loadInvoices = async () => {
try {
setLoading(true);
// Load unpaid and partial invoices only
const response = await fetch('/zen/api/admin/invoices?limit=1000', {
credentials: 'include'
});
const data = await response.json();
if (data.success) {
// Filter to only show unpaid and partial invoices
const unpaidInvoices = (data.invoices || []).filter(
invoice => invoice.status === 'sent' ||
invoice.status === 'partial' ||
invoice.status === 'overdue'
);
setInvoices(unpaidInvoices);
} else {
toast.error("Échec du chargement des factures");
}
} catch (error) {
console.error('Error loading invoices:', error);
toast.error("Échec du chargement des données");
} finally {
setLoading(false);
}
};
const handleInputChange = (field, value) => {
setFormData(prev => ({
...prev,
[field]: value
}));
if (errors[field]) {
setErrors(prev => ({
...prev,
[field]: null
}));
}
};
const handleCheckboxChange = (e) => {
const { name, checked } = e.target;
setFormData(prev => ({
...prev,
[name]: checked
}));
};
const handleInvoiceChange = (invoiceId) => {
handleInputChange('invoice_id', invoiceId);
if (invoiceId) {
const invoice = invoices.find(inv => inv.id === parseInt(invoiceId));
setSelectedInvoice(invoice);
// Auto-fill amount with remaining balance
if (invoice) {
const remainingBalance = parseFloat(invoice.total_amount) - parseFloat(invoice.paid_amount || 0);
handleInputChange('amount', remainingBalance.toFixed(2));
}
} else {
setSelectedInvoice(null);
handleInputChange('amount', '');
}
};
const getRemainingBalance = () => {
if (!selectedInvoice) return 0;
return parseFloat(selectedInvoice.total_amount) - parseFloat(selectedInvoice.paid_amount || 0);
};
const getPaymentType = () => {
if (!selectedInvoice || !formData.amount) return null;
const amount = parseFloat(formData.amount);
const remaining = getRemainingBalance();
if (amount >= remaining) {
return { type: 'full', label: 'Paiement complet', color: 'text-green-400' };
} else {
return { type: 'partial', label: 'Paiement partiel', color: 'text-yellow-400' };
}
};
const validateForm = () => {
const newErrors = {};
if (!formData.invoice_id) {
newErrors.invoice_id = "La facture est requise";
}
if (!formData.amount || parseFloat(formData.amount) <= 0) {
newErrors.amount = "Le montant doit être supérieur à 0";
}
if (selectedInvoice && parseFloat(formData.amount) > getRemainingBalance()) {
newErrors.amount = "Le montant ne peut pas dépasser le solde restant";
}
if (!formData.payment_method) {
newErrors.payment_method = "Le mode de paiement est requis";
}
if (!formData.transaction_date) {
newErrors.transaction_date = "La date de la transaction est requise";
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!validateForm()) {
return;
}
try {
setSaving(true);
const response = await fetch('/zen/api/admin/transactions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
invoice_id: parseInt(formData.invoice_id),
amount: parseFloat(formData.amount),
payment_method: formData.payment_method,
transaction_date: formData.transaction_date,
notes: formData.notes || null,
status: 'completed',
send_confirmation_email: formData.send_confirmation_email
})
});
const data = await response.json();
if (data.success) {
toast.success("Transaction créée avec succès");
router.push('/admin/invoice/transactions');
} else {
toast.error(data.message || "Échec de la création de la transaction");
}
} catch (error) {
console.error('Error creating transaction:', error);
toast.error("Échec de la création de la transaction");
} finally {
setSaving(false);
}
};
const paymentType = getPaymentType();
return (
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-lg sm:text-xl font-semibold text-neutral-900 dark:text-white">Créer une transaction</h1>
<p className="mt-1 text-xs text-neutral-400">Enregistrer un paiement manuel pour une facture</p>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => router.push('/admin/invoice/transactions')}
>
Retour aux transactions
</Button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-6">
{/* Transaction Information */}
<Card>
<div className="space-y-4">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-white">Informations de la transaction</h2>
<div className="grid grid-cols-1 gap-4">
<Select
label="Facture *"
value={formData.invoice_id}
onChange={handleInvoiceChange}
options={[
{ value: '', label: "Sélectionner une facture" },
...invoices.map(invoice => {
const clientName = invoice.company_name || `${invoice.first_name} ${invoice.last_name}`;
const remaining = parseFloat(invoice.total_amount) - parseFloat(invoice.paid_amount || 0);
const dueDate = formatDateShort(invoice.due_date, 'fr-FR');
return {
value: invoice.id,
label: `#${invoice.invoice_number} - ${clientName} - ${formatCurrency(remaining)} (Échéance : ${dueDate})`
};
})
]}
error={errors.invoice_id}
disabled={loading}
/>
{selectedInvoice && (
<div className="bg-blue-500/10 border border-blue-500/20 text-blue-400 px-4 py-3 rounded-lg">
<div className="grid grid-cols-2 gap-2 text-sm">
<div>
<span className="text-neutral-400">Total facture :</span>
<span className="ml-2 font-semibold">{formatCurrency(selectedInvoice.total_amount)}</span>
</div>
<div>
<span className="text-neutral-400">Montant payé :</span>
<span className="ml-2 font-semibold">{formatCurrency(selectedInvoice.paid_amount || 0)}</span>
</div>
<div>
<span className="text-neutral-400">Solde restant :</span>
<span className="ml-2 font-semibold text-green-400">{formatCurrency(getRemainingBalance())}</span>
</div>
<div>
<span className="text-neutral-400">Date d'échéance :</span>
<span className="ml-2 font-semibold">
{formatDateShort(selectedInvoice.due_date, 'fr-FR')}
</span>
</div>
</div>
</div>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Input
label="Montant *"
type="number"
value={formData.amount}
onChange={(value) => handleInputChange('amount', value)}
min="0"
step="0.01"
placeholder="0.00"
error={errors.amount}
/>
{paymentType && (
<p className={`mt-1 text-sm ${paymentType.color}`}>
{paymentType.label}
</p>
)}
</div>
<Select
label="Mode de paiement *"
value={formData.payment_method}
onChange={(value) => handleInputChange('payment_method', value)}
options={paymentMethodOptions}
error={errors.payment_method}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label="Date de la transaction *"
type="date"
value={formData.transaction_date}
onChange={(value) => handleInputChange('transaction_date', value)}
error={errors.transaction_date}
/>
</div>
<div className="flex items-center pt-4">
<label className="flex items-center cursor-pointer">
<input
type="checkbox"
name="send_confirmation_email"
checked={formData.send_confirmation_email}
onChange={handleCheckboxChange}
className="w-5 h-5 text-blue-600 bg-neutral-100 dark:bg-neutral-800 border-neutral-300 dark:border-neutral-700 rounded focus:ring-2 focus:ring-blue-500"
/>
<span className="ml-2 text-sm text-neutral-700 dark:text-neutral-300">
Envoyer l'email de confirmation de paiement au client
</span>
</label>
</div>
</div>
</Card>
{/* Notes */}
<Card>
<div className="space-y-4">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-white">Notes</h2>
<Textarea
value={formData.notes}
onChange={(value) => handleInputChange('notes', value)}
rows={4}
placeholder="Ajoutez des notes sur cette transaction..."
/>
</div>
</Card>
{/* Actions */}
<div className="flex items-center justify-end gap-3">
<Button
type="button"
variant="secondary"
onClick={() => router.push('/admin/invoice/transactions')}
disabled={saving}
>
Annuler
</Button>
<Button
type="submit"
variant="success"
loading={saving}
disabled={saving}
>
{saving ? "Création..." : "Créer la transaction"}
</Button>
</div>
</form>
</div>
);
};
export default TransactionCreatePage;
@@ -0,0 +1,228 @@
'use client';
import React, { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { PlusSignCircleIcon } from '../../../../shared/Icons.js';
import {
Table,
Button,
StatusBadge,
Card,
Pagination
} from '../../../../shared/components';
import { formatCurrency } from '../../../../shared/utils/currency.js';
import { useToast } from '@hykocx/zen/toast';
import { formatDateShort } from '../../../../shared/lib/dates.js';
/**
* Transactions List Page Component
* Displays list of transactions with pagination and sorting
*/
const TransactionsListPage = ({ user }) => {
const router = useRouter();
const toast = useToast();
const [transactions, setTransactions] = useState([]);
const [loading, setLoading] = useState(true);
// Pagination state
const [pagination, setPagination] = useState({
page: 1,
limit: 20,
total: 0,
totalPages: 0
});
// Sort state
const [sortBy, setSortBy] = useState('created_at');
const [sortOrder, setSortOrder] = useState('desc');
// Table columns configuration
const columns = [
{
key: 'transaction_number',
label: "Transaction n°",
sortable: true,
render: (transaction) => (
<div>
<div className="text-sm font-mono font-semibold text-neutral-900 dark:text-white">{transaction.transaction_number}</div>
<div className="text-xs text-neutral-500 dark:text-gray-400">
{formatDateShort(transaction.created_at, 'fr-FR')}
</div>
</div>
),
skeleton: {
height: 'h-4',
width: '30%',
secondary: { height: 'h-3', width: '25%' }
}
},
{
key: 'invoice',
label: "Facture",
sortable: false,
render: (transaction) => (
<div>
{transaction.invoice_number ? (
<>
<div className="text-sm text-neutral-900 dark:text-white">
Facture n° {transaction.invoice_number}
</div>
<div className="text-xs text-neutral-500 dark:text-gray-400">
{transaction.client_name}
</div>
</>
) : (
<span className="text-sm text-neutral-400 dark:text-gray-500">Aucune facture</span>
)}
</div>
),
skeleton: { height: 'h-4', width: '50%' }
},
{
key: 'amount',
label: "Montant",
sortable: true,
render: (transaction) => (
<div className="text-sm font-semibold text-green-400">
{formatCurrency(transaction.amount)}
</div>
),
skeleton: { height: 'h-4', width: '40%' }
},
{
key: 'payment_method',
label: "Mode de paiement",
sortable: true,
render: (transaction) => (
<div className="text-sm text-neutral-600 dark:text-gray-300">
{transaction.payment_method || '-'}
</div>
),
skeleton: { height: 'h-4', width: '35%' }
},
{
key: 'status',
label: "Statut",
sortable: true,
render: (transaction) => <TransactionStatusBadge status={transaction.status} />,
skeleton: { height: 'h-6', width: '80px', className: 'rounded-full' }
}
];
useEffect(() => {
loadTransactions();
}, [sortBy, sortOrder, pagination.page, pagination.limit]);
const loadTransactions = async () => {
try {
setLoading(true);
const searchParams = new URLSearchParams({
page: pagination.page.toString(),
limit: pagination.limit.toString(),
sortBy,
sortOrder
});
const response = await fetch(`/zen/api/admin/transactions?${searchParams}`, {
credentials: 'include'
});
const data = await response.json();
if (data.success) {
setTransactions(data.transactions || []);
setPagination(prev => ({
...prev,
total: data.total || 0,
totalPages: data.totalPages || 0,
page: data.page || 1
}));
} else {
toast.error(data.error || "Échec du chargement des transactions");
}
} catch (error) {
console.error('Error loading transactions:', error);
toast.error("Échec du chargement des transactions");
} finally {
setLoading(false);
}
};
const handlePageChange = (newPage) => {
setPagination(prev => ({ ...prev, page: newPage }));
};
const handleLimitChange = (newLimit) => {
setPagination(prev => ({
...prev,
limit: newLimit,
page: 1
}));
};
const handleSort = (newSortBy) => {
const newSortOrder = sortBy === newSortBy && sortOrder === 'asc' ? 'desc' : 'asc';
setSortBy(newSortBy);
setSortOrder(newSortOrder);
};
return (
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-lg sm:text-xl font-semibold text-neutral-900 dark:text-white">Transactions</h1>
<p className="mt-1 text-xs text-neutral-400">Historique des paiements</p>
</div>
<Button
onClick={() => router.push('/admin/invoice/transactions/new')}
icon={<PlusSignCircleIcon className="w-4 h-4" />}
>
Créer une transaction
</Button>
</div>
{/* Transactions Table */}
<Card variant="default" padding="none">
<Table
columns={columns}
data={transactions}
loading={loading}
sortBy={sortBy}
sortOrder={sortOrder}
onSort={handleSort}
emptyMessage="Aucune transaction trouvée"
emptyDescription="Les transactions apparaîtront ici une fois les paiements traités"
/>
<Pagination
currentPage={pagination.page}
totalPages={pagination.totalPages}
onPageChange={handlePageChange}
onLimitChange={handleLimitChange}
limit={pagination.limit}
total={pagination.total}
loading={loading}
showPerPage={true}
showStats={true}
/>
</Card>
</div>
);
};
// Transaction Status Badge Component
const TransactionStatusBadge = ({ status }) => {
const statusConfig = {
completed: { label: "Terminé", color: 'success' },
pending: { label: "En attente", color: 'warning' },
failed: { label: "Échoué", color: 'danger' },
refunded: { label: "Remboursé", color: 'default' }
};
const config = statusConfig[status] || statusConfig.pending;
return <StatusBadge variant={config.color}>{config.label}</StatusBadge>;
};
export default TransactionsListPage;
@@ -0,0 +1,8 @@
/**
* Transactions Admin Components
* Part of Invoice Module
*/
export { default as TransactionsListPage } from './TransactionsListPage.js';
export { default as TransactionCreatePage } from './TransactionCreatePage.js';
+377
View File
@@ -0,0 +1,377 @@
/**
* Transactions Module - CRUD Operations
* Create, Read, Update, Delete operations for transactions
* Part of Invoice Module
*/
import { query } from '@hykocx/zen/database';
/**
* Generate transaction number
* Format: TXN-YYYYMMDD-XXXXX
* @returns {Promise<string>}
*/
async function generateTransactionNumber() {
const date = new Date();
const dateStr = date.toISOString().slice(0, 10).replace(/-/g, '');
const prefix = `TXN-${dateStr}-`;
const result = await query(
`SELECT transaction_number FROM zen_transactions
WHERE transaction_number LIKE $1
ORDER BY transaction_number DESC
LIMIT 1`,
[`${prefix}%`]
);
let nextNumber = 1;
if (result.rows.length > 0) {
const lastTransaction = result.rows[0].transaction_number;
const lastNumber = parseInt(lastTransaction.split('-')[2]);
nextNumber = lastNumber + 1;
}
return `${prefix}${nextNumber.toString().padStart(5, '0')}`;
}
/**
* Payment method types
*/
export const PAYMENT_METHODS = {
STRIPE: 'stripe',
CASH: 'cash',
CHECK: 'check',
WIRE: 'wire',
INTERAC: 'interac',
OTHER: 'other'
};
/**
* Transaction status types
*/
export const TRANSACTION_STATUS = {
COMPLETED: 'completed',
PARTIAL: 'partial',
PENDING: 'pending',
FAILED: 'failed',
REFUNDED: 'refunded'
};
/**
* Create a new transaction
* @param {Object} transactionData - Transaction data
* @returns {Promise<Object>} Created transaction
*/
export async function createTransaction(transactionData) {
const {
invoice_id = null,
client_id = null,
amount,
payment_method,
status = TRANSACTION_STATUS.COMPLETED,
stripe_payment_intent_id = null,
stripe_charge_id = null,
notes = null,
transaction_date = new Date(),
} = transactionData;
// Validate required fields
if (!amount || !payment_method) {
throw new Error('Amount and payment method are required');
}
// Validate amount is positive
if (amount <= 0) {
throw new Error('Amount must be positive');
}
// If invoice_id is provided, get client_id from invoice if not provided
let finalClientId = client_id;
if (invoice_id && !client_id) {
const invoiceResult = await query(
`SELECT client_id FROM zen_invoices WHERE id = $1`,
[invoice_id]
);
if (invoiceResult.rows.length > 0) {
finalClientId = invoiceResult.rows[0].client_id;
}
}
// Generate transaction number
const transaction_number = await generateTransactionNumber();
const result = await query(
`INSERT INTO zen_transactions (
transaction_number, invoice_id, client_id, amount, payment_method,
status, stripe_payment_intent_id, stripe_charge_id, notes, transaction_date
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING *`,
[
transaction_number, invoice_id, finalClientId, amount, payment_method,
status, stripe_payment_intent_id, stripe_charge_id, notes, transaction_date
]
);
// If invoice_id is provided, update invoice paid_amount
if (invoice_id) {
const { markInvoiceAsPaid } = await import('../crud.js');
await markInvoiceAsPaid(invoice_id, amount);
}
return result.rows[0];
}
/**
* Get transaction by ID
* @param {number} id - Transaction ID
* @returns {Promise<Object|null>}
*/
export async function getTransactionById(id) {
const result = await query(
`SELECT t.*,
i.invoice_number,
c.first_name as client_first_name,
c.last_name as client_last_name,
c.company_name as client_company_name
FROM zen_transactions t
LEFT JOIN zen_invoices i ON t.invoice_id = i.id
LEFT JOIN zen_clients c ON t.client_id = c.id
WHERE t.id = $1`,
[id]
);
return result.rows[0] || null;
}
/**
* Get transaction by transaction number
* @param {string} transactionNumber - Transaction number
* @returns {Promise<Object|null>}
*/
export async function getTransactionByNumber(transactionNumber) {
const result = await query(
`SELECT t.*,
i.invoice_number,
c.first_name as client_first_name,
c.last_name as client_last_name,
c.company_name as client_company_name
FROM zen_transactions t
LEFT JOIN zen_invoices i ON t.invoice_id = i.id
LEFT JOIN zen_clients c ON t.client_id = c.id
WHERE t.transaction_number = $1`,
[transactionNumber]
);
return result.rows[0] || null;
}
/**
* Get all transactions with pagination and filters
* @param {Object} options - Query options
* @returns {Promise<Object>} Transactions and metadata
*/
export async function getTransactions(options = {}) {
const {
page = 1,
limit = 20,
search = '',
payment_method = null,
status = null,
invoice_id = null,
client_id = null,
start_date = null,
end_date = null,
sortBy = 'transaction_date',
sortOrder = 'DESC'
} = options;
const offset = (page - 1) * limit;
// Build where conditions
const conditions = [];
const params = [];
let paramIndex = 1;
if (search) {
conditions.push(`(
t.transaction_number ILIKE $${paramIndex} OR
i.invoice_number ILIKE $${paramIndex} OR
c.first_name ILIKE $${paramIndex} OR
c.last_name ILIKE $${paramIndex} OR
c.company_name ILIKE $${paramIndex}
)`);
params.push(`%${search}%`);
paramIndex++;
}
if (payment_method) {
conditions.push(`t.payment_method = $${paramIndex}`);
params.push(payment_method);
paramIndex++;
}
if (status) {
conditions.push(`t.status = $${paramIndex}`);
params.push(status);
paramIndex++;
}
if (invoice_id) {
conditions.push(`t.invoice_id = $${paramIndex}`);
params.push(invoice_id);
paramIndex++;
}
if (client_id) {
conditions.push(`t.client_id = $${paramIndex}`);
params.push(client_id);
paramIndex++;
}
if (start_date) {
conditions.push(`t.transaction_date >= $${paramIndex}`);
params.push(start_date);
paramIndex++;
}
if (end_date) {
conditions.push(`t.transaction_date <= $${paramIndex}`);
params.push(end_date);
paramIndex++;
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
// Get total count
const countResult = await query(
`SELECT COUNT(*)
FROM zen_transactions t
LEFT JOIN zen_invoices i ON t.invoice_id = i.id
LEFT JOIN zen_clients c ON t.client_id = c.id
${whereClause}`,
params
);
const total = parseInt(countResult.rows[0].count);
// Get transactions
const transactionsResult = await query(
`SELECT t.*,
i.invoice_number,
c.first_name as client_first_name,
c.last_name as client_last_name,
c.company_name as client_company_name
FROM zen_transactions t
LEFT JOIN zen_invoices i ON t.invoice_id = i.id
LEFT JOIN zen_clients c ON t.client_id = c.id
${whereClause}
ORDER BY t.${sortBy} ${sortOrder}
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
[...params, limit, offset]
);
return {
transactions: transactionsResult.rows,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit)
}
};
}
/**
* Get transactions by invoice ID
* @param {number} invoiceId - Invoice ID
* @returns {Promise<Array>}
*/
export async function getTransactionsByInvoice(invoiceId) {
const result = await query(
`SELECT t.*
FROM zen_transactions t
WHERE t.invoice_id = $1
ORDER BY t.transaction_date DESC`,
[invoiceId]
);
return result.rows;
}
/**
* Get transactions by client ID
* @param {number} clientId - Client ID
* @returns {Promise<Array>}
*/
export async function getTransactionsByClient(clientId) {
const result = await query(
`SELECT t.*, i.invoice_number
FROM zen_transactions t
LEFT JOIN zen_invoices i ON t.invoice_id = i.id
WHERE t.client_id = $1
ORDER BY t.transaction_date DESC`,
[clientId]
);
return result.rows;
}
/**
* Update transaction
* @param {number} id - Transaction ID
* @param {Object} updates - Fields to update
* @returns {Promise<Object>} Updated transaction
*/
export async function updateTransaction(id, updates) {
const allowedFields = [
'amount', 'payment_method', 'status', 'notes', 'transaction_date'
];
const setFields = [];
const values = [];
let paramIndex = 1;
for (const [key, value] of Object.entries(updates)) {
if (allowedFields.includes(key)) {
setFields.push(`${key} = $${paramIndex}`);
values.push(value);
paramIndex++;
}
}
if (setFields.length === 0) {
throw new Error('No valid fields to update');
}
// Add updated_at
setFields.push(`updated_at = CURRENT_TIMESTAMP`);
// Add ID parameter
values.push(id);
const result = await query(
`UPDATE zen_transactions
SET ${setFields.join(', ')}
WHERE id = $${paramIndex}
RETURNING *`,
values
);
return result.rows[0];
}
/**
* Delete transaction
* @param {number} id - Transaction ID
* @returns {Promise<boolean>} Success status
*/
export async function deleteTransaction(id) {
const result = await query(
`DELETE FROM zen_transactions WHERE id = $1`,
[id]
);
return result.rowCount > 0;
}
+82
View File
@@ -0,0 +1,82 @@
/**
* Transactions Module - Database
* Database initialization and tables for transactions
* Part of Invoice Module
*/
import { query } from '@hykocx/zen/database';
/**
* Check if a table exists in the database
* @param {string} tableName - Name of the table to check
* @returns {Promise<boolean>}
*/
async function tableExists(tableName) {
const result = await query(
`SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = $1
)`,
[tableName]
);
return result.rows[0].exists;
}
/**
* Create transactions table
* @returns {Promise<Object>}
*/
export async function createTransactionsTable() {
const tableName = 'zen_transactions';
const exists = await tableExists(tableName);
if (exists) {
console.log(`- Table already exists: ${tableName}`);
return { created: false, tableName };
}
await query(`
CREATE TABLE zen_transactions (
id SERIAL PRIMARY KEY,
transaction_number VARCHAR(50) UNIQUE NOT NULL,
invoice_id INTEGER REFERENCES zen_invoices(id) ON DELETE RESTRICT,
client_id INTEGER REFERENCES zen_clients(id) ON DELETE SET NULL,
amount DECIMAL(10, 2) NOT NULL,
payment_method VARCHAR(50) NOT NULL,
status VARCHAR(50) DEFAULT 'completed',
stripe_payment_intent_id VARCHAR(255),
stripe_charge_id VARCHAR(255),
notes TEXT,
transaction_date TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
)
`);
// Create indexes
await query(`CREATE INDEX idx_zen_transactions_transaction_number ON zen_transactions(transaction_number)`);
await query(`CREATE INDEX idx_zen_transactions_invoice_id ON zen_transactions(invoice_id)`);
await query(`CREATE INDEX idx_zen_transactions_client_id ON zen_transactions(client_id)`);
await query(`CREATE INDEX idx_zen_transactions_payment_method ON zen_transactions(payment_method)`);
await query(`CREATE INDEX idx_zen_transactions_status ON zen_transactions(status)`);
await query(`CREATE INDEX idx_zen_transactions_transaction_date ON zen_transactions(transaction_date)`);
console.log(`✓ Created table: ${tableName}`);
return { created: true, tableName };
}
/**
* Drop transactions table (use with caution!)
* @returns {Promise<void>}
*/
export async function dropTransactionsTable() {
const tableName = 'zen_transactions';
const exists = await tableExists(tableName);
if (exists) {
await query(`DROP TABLE IF EXISTS ${tableName} CASCADE`);
console.log(`✓ Dropped table: ${tableName}`);
}
}