chore: import codes
This commit is contained in:
@@ -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).
|
||||
- **User–client 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 client’s invoices with:
|
||||
|
||||
| Endpoint | Method | Auth | Description |
|
||||
|----------|--------|------|-------------|
|
||||
| `/zen/api/invoices/me` | GET | Session (user) | Returns the current user’s linked client (if any) and that client’s 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 Zen’s 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 client’s 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.
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
]
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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: '$',
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 dû 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 dû :</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>
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user