refactor: remove clients, invoice, and nuage module integrations

Strips out built-in clients, invoice, and nuage modules from core
handlers, module initializers, and action registries. This cleans up
hardcoded module dependencies, leaving only the posts module as a
reference implementation for the modular architecture.
This commit is contained in:
2026-04-12 13:18:21 -04:00
parent 024d6e37e6
commit 4983a24325
100 changed files with 4 additions and 21022 deletions
+1 -30
View File
@@ -6,7 +6,7 @@
import { validateSession } from '../../../features/auth/lib/session.js';
import { cookies } from 'next/headers';
import { query, updateById } from '@hykocx/zen/database';
import { getSessionCookieName, getModulesConfig } from '../../../shared/lib/appConfig.js';
import { getSessionCookieName } from '../../../shared/lib/appConfig.js';
import { updateUser } from '../../../features/auth/lib/auth.js';
import { uploadImage, deleteFile, generateUniqueFilename, generateUserFilePath, FILE_TYPE_PRESETS, FILE_SIZE_LIMITS, validateUpload } from '@hykocx/zen/storage';
@@ -100,17 +100,6 @@ export async function handleGetUserById(request, userId) {
const response = { user: result.rows[0] };
// When clients module is active, include the client linked to this user (if any)
const modules = getModulesConfig();
if (modules.clients) {
const clientResult = await query(
`SELECT id, client_number, company_name, first_name, last_name, email
FROM zen_clients WHERE user_id = $1 LIMIT 1`,
[userId]
);
response.linkedClient = clientResult.rows[0] || null;
}
return response;
}
@@ -158,24 +147,6 @@ export async function handleUpdateUserById(request, userId) {
return { success: false, error: 'Not Found', message: 'User not found' };
}
// When clients module is active, update client association (one user = one client)
const modules = getModulesConfig();
if (modules.clients && body.client_id !== undefined) {
const clientId = body.client_id === null || body.client_id === '' ? null : parseInt(body.client_id, 10);
// Unlink all clients currently linked to this user
await query(
'UPDATE zen_clients SET user_id = NULL, updated_at = CURRENT_TIMESTAMP WHERE user_id = $1',
[userId]
);
// Link the selected client to this user if provided
if (clientId != null && !Number.isNaN(clientId)) {
await query(
'UPDATE zen_clients SET user_id = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
[userId, clientId]
);
}
}
const result = await query(
'SELECT id, email, name, role, image, email_verified, created_at FROM zen_auth_users WHERE id = $1',
[userId]
-4
View File
@@ -1,4 +0,0 @@
#################################
# MODULE CLIENTS
ZEN_MODULE_CLIENTS=false
#################################
-37
View File
@@ -1,37 +0,0 @@
# Clients Module Installation
## 1. Configure Environment Variables
Copy all variables from [`.env.example`](.env.example) and add them to your `.env` file.
## 2. Activate the Module
In your `.env` file, set:
```env
ZEN_MODULE_CLIENTS=true
```
## 3. Database Setup
Run the database initialization to create the required tables:
```bash
npx zen-db init
```
This will create the following table:
- `zen_clients` - Stores client information
## 4. Features
### Client Management
- Create, edit, and delete clients
- Store contact information (name, email, phone, address)
- Link clients to user accounts (optional)
- Unique client numbers (auto-generated)
### Used By Other Modules
The clients module is a dependency for:
- **Quote Module**: Assign quotes to clients
- **Invoice Module**: Assign invoices to clients
@@ -1,76 +0,0 @@
'use client';
import React, { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Button } from '../../../shared/components';
import ClientForm from './ClientForm.js';
import { useToast } from '@hykocx/zen/toast';
/**
* Client Create Page Component
* Page for creating a new client
*/
const ClientCreatePage = ({ user }) => {
const router = useRouter();
const toast = useToast();
const [saving, setSaving] = useState(false);
const handleSubmit = async (formData) => {
try {
setSaving(true);
const response = await fetch('/zen/api/admin/clients', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({ client: formData })
});
const data = await response.json();
if (data.success) {
toast.success('Client créé avec succès');
router.push('/admin/clients/list');
} else {
toast.error(data.message || 'Échec de la création du client');
}
} catch (error) {
console.error('Error creating client:', error);
toast.error('Échec de la création du client');
} 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 client</h1>
<p className="mt-1 text-xs text-neutral-400">Remplissez les détails pour créer un nouveau client</p>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => router.push('/admin/clients/list')}
>
Retour aux clients
</Button>
</div>
{/* Form */}
<ClientForm
onSubmit={handleSubmit}
onCancel={() => router.push('/admin/clients/list')}
isEdit={false}
saving={saving}
users={[]}
/>
</div>
);
};
export default ClientCreatePage;
-139
View File
@@ -1,139 +0,0 @@
'use client';
import React, { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Button, Card, Loading } from '../../../shared/components';
import ClientForm from './ClientForm.js';
import { useToast } from '@hykocx/zen/toast';
/**
* Client Edit Page Component
* Page for editing an existing client
*/
const ClientEditPage = ({ clientId, user }) => {
const router = useRouter();
const toast = useToast();
const [client, setClient] = useState(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
useEffect(() => {
loadClient();
}, [clientId]);
const loadClient = async () => {
try {
setLoading(true);
const response = await fetch(`/zen/api/admin/clients?id=${clientId}`, {
credentials: 'include'
});
const data = await response.json();
if (data.success) {
setClient(data.client);
} else {
toast.error(data.error || 'Échec du chargement du client');
}
} catch (error) {
console.error('Error loading client:', error);
toast.error('Échec du chargement du client');
} finally {
setLoading(false);
}
};
const handleSubmit = async (formData) => {
try {
setSaving(true);
const response = await fetch(`/zen/api/admin/clients`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({ id: clientId, client: formData })
});
const data = await response.json();
if (data.success) {
toast.success('Client mis à jour avec succès');
router.push('/admin/clients/list');
} else {
toast.error(data.message || 'Échec de la mise à jour du client');
}
} catch (error) {
console.error('Error updating client:', error);
toast.error('Échec de la mise à jour du client');
} finally {
setSaving(false);
}
};
if (loading) {
return (
<div className="min-h-64 flex items-center justify-center">
<Loading />
</div>
);
}
if (!client) {
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 le client</h1>
<p className="mt-1 text-xs text-neutral-400">Client non trouvé</p>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => router.push('/admin/clients/list')}
>
Retour aux clients
</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">Client non trouvé</p>
<p className="text-sm mt-1">Le client 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 le client</h1>
<p className="mt-1 text-xs text-neutral-400">Client : {client.first_name} {client.last_name}</p>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => router.push('/admin/clients/list')}
>
← Retour aux clients
</Button>
</div>
{/* Form */}
<ClientForm
initialData={client}
onSubmit={handleSubmit}
onCancel={() => router.push('/admin/clients/list')}
isEdit={true}
saving={saving}
users={[]}
/>
</div>
);
};
export default ClientEditPage;
-245
View File
@@ -1,245 +0,0 @@
'use client';
import React, { useState } from 'react';
import { Button, Card, Input, Select, Textarea } from '../../../shared/components';
/**
* Client Form Component
* Form for creating and editing clients
*/
const ClientForm = ({
initialData = null,
users = [],
onSubmit,
onCancel,
isEdit = false,
saving = false
}) => {
const [formData, setFormData] = useState({
user_id: initialData?.user_id || '',
company_name: initialData?.company_name || '',
first_name: initialData?.first_name || '',
last_name: initialData?.last_name || '',
email: initialData?.email || '',
phone: initialData?.phone || '',
address: initialData?.address || '',
city: initialData?.city || '',
province: initialData?.province || '',
postal_code: initialData?.postal_code || '',
country: initialData?.country || 'Canada',
notes: initialData?.notes || '',
});
const [errors, setErrors] = useState({});
const handleInputChange = (field, value) => {
setFormData(prev => ({
...prev,
[field]: value
}));
if (errors[field]) {
setErrors(prev => ({
...prev,
[field]: null
}));
}
};
const validateForm = () => {
const newErrors = {};
if (!formData.first_name.trim()) {
newErrors.first_name = "Le prénom est requis";
}
if (!formData.last_name.trim()) {
newErrors.last_name = "Le nom de famille est requis";
}
if (!formData.email.trim()) {
newErrors.email = "L'email est requis";
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
newErrors.email = "Format d'email invalide";
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = (e) => {
e.preventDefault();
if (!validateForm()) {
return;
}
if (onSubmit) {
onSubmit(formData);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Basic Information */}
<Card>
<div className="space-y-4">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-white">Informations de base</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="md:col-span-2">
<Input
label="Nom de la société"
value={formData.company_name}
onChange={(value) => handleInputChange('company_name', value)}
placeholder="Entrez le nom de la société..."
/>
</div>
<Input
label="Prénom *"
value={formData.first_name}
onChange={(value) => handleInputChange('first_name', value)}
placeholder="Entrez le prénom..."
error={errors.first_name}
/>
<Input
label="Nom de famille *"
value={formData.last_name}
onChange={(value) => handleInputChange('last_name', value)}
placeholder="Entrez le nom de famille..."
error={errors.last_name}
/>
<Input
label="Courriel *"
type="email"
value={formData.email}
onChange={(value) => handleInputChange('email', value)}
placeholder="Entrez le courriel..."
error={errors.email}
/>
<Input
label="Téléphone"
type="tel"
value={formData.phone}
onChange={(value) => handleInputChange('phone', value)}
placeholder="Entrez le numéro de téléphone..."
/>
</div>
</div>
</Card>
{/* Address */}
<Card>
<div className="space-y-4">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-white">Adresse</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="md:col-span-2">
<Input
label="Adresse"
value={formData.address}
onChange={(value) => handleInputChange('address', value)}
placeholder="Entrez l'adresse..."
/>
</div>
<Input
label="Ville"
value={formData.city}
onChange={(value) => handleInputChange('city', value)}
placeholder="Entrez la ville..."
/>
<Input
label="Province / État"
value={formData.province}
onChange={(value) => handleInputChange('province', value)}
placeholder="Entrez la province ou l'état..."
/>
<Input
label="Code postal"
value={formData.postal_code}
onChange={(value) => handleInputChange('postal_code', value)}
placeholder="Entrez le code postal..."
/>
<Input
label="Pays"
value={formData.country}
onChange={(value) => handleInputChange('country', value)}
placeholder="Entrez le pays..."
/>
</div>
</div>
</Card>
{/* User Link */}
{users.length > 0 && (
<Card>
<div className="space-y-4">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-white">Lien compte utilisateur</h2>
<p className="text-sm text-neutral-400">
Vous pouvez associer ce client à un compte utilisateur sur la plateforme
</p>
<Select
label="Compte utilisateur"
value={formData.user_id || ''}
onChange={(value) => handleInputChange('user_id', value)}
options={[
{ value: '', label: 'Aucun compte utilisateur associé' },
...users.map(user => ({
value: user.id,
label: `${user.name} (${user.email})`
}))
]}
/>
</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="Notes supplémentaires sur ce client..."
/>
</div>
</Card>
{/* Actions */}
<div className="flex items-center justify-end gap-3">
<Button
type="button"
variant="secondary"
onClick={onCancel}
disabled={saving}
>
Annuler
</Button>
<Button
type="submit"
variant="success"
loading={saving}
disabled={saving}
>
{saving
? (isEdit ? 'Mise à jour...' : 'Création...')
: (isEdit ? 'Mettre à jour le client' : 'Créer le client')
}
</Button>
</div>
</form>
);
};
export default ClientForm;
@@ -1,277 +0,0 @@
'use client';
import React, { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { PlusSignCircleIcon, PencilEdit01Icon, Delete02Icon } from '../../../shared/Icons.js';
import {
Table,
Button,
Card,
Pagination
} from '../../../shared/components';
import { useToast } from '@hykocx/zen/toast';
/**
* Clients List Page Component
* Displays list of clients with pagination and sorting
*/
const ClientsListPage = ({ user }) => {
const router = useRouter();
const toast = useToast();
const [clients, setClients] = 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: 'client_number',
label: 'N° client',
sortable: true,
render: (client) => (
<div>
<div className="text-sm font-mono font-semibold text-neutral-900 dark:text-white">{client.client_number}</div>
<div className="text-xs text-neutral-500 dark:text-gray-400">
ID: {client.id}
</div>
</div>
),
skeleton: {
height: 'h-4',
width: '30%',
secondary: { height: 'h-3', width: '25%' }
}
},
{
key: 'name',
label: 'Nom / Société',
sortable: true,
render: (client) => (
<div>
<div className="text-sm text-neutral-900 dark:text-white font-medium">
{client.company_name || `${client.first_name} ${client.last_name}`}
</div>
{client.company_name && (
<div className="text-xs text-neutral-500 dark:text-gray-400">
{client.first_name} {client.last_name}
</div>
)}
</div>
),
skeleton: { height: 'h-4', width: '60%' }
},
{
key: 'email',
label: 'Contact',
sortable: true,
render: (client) => (
<div>
<div className="text-sm text-neutral-900 dark:text-white">{client.email}</div>
{client.phone && (
<div className="text-xs text-neutral-500 dark:text-gray-400">{client.phone}</div>
)}
</div>
),
skeleton: { height: 'h-4', width: '50%' }
},
{
key: 'location',
label: 'Lieu',
sortable: false,
render: (client) => (
<div className="text-sm text-neutral-600 dark:text-gray-300">
{client.city && client.province ? (
`${client.city}, ${client.province}`
) : (
<span className="text-neutral-400 dark:text-gray-500">-</span>
)}
</div>
),
skeleton: { height: 'h-4', width: '40%' }
},
{
key: 'created_at',
label: 'Créé',
sortable: true,
render: (client) => (
<div className="text-sm text-neutral-600 dark:text-gray-300">
{new Date(client.created_at).toLocaleDateString('fr-FR')}
</div>
),
skeleton: { height: 'h-4', width: '35%' }
},
{
key: 'actions',
label: 'Actions',
render: (client) => (
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleEditClient(client)}
disabled={deleting}
icon={<PencilEdit01Icon className="w-4 h-4" />}
className="p-2"
/>
<Button
variant="danger"
size="sm"
onClick={() => handleDeleteClient(client)}
disabled={deleting}
icon={<Delete02Icon className="w-4 h-4" />}
className="p-2"
/>
</div>
),
skeleton: { height: 'h-8', width: '80px' }
}
];
useEffect(() => {
loadClients();
}, [sortBy, sortOrder, pagination.page, pagination.limit]);
const loadClients = 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/clients?${searchParams}`, {
credentials: 'include'
});
const data = await response.json();
if (data.success) {
setClients(data.clients || []);
setPagination(prev => ({
...prev,
total: data.total || 0,
totalPages: data.totalPages || 0,
page: data.page || 1
}));
} else {
toast.error(data.error || 'Échec du chargement des clients');
}
} catch (error) {
console.error('Error loading clients:', error);
toast.error('Échec du chargement des clients');
} 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 handleEditClient = (client) => {
router.push(`/admin/clients/edit/${client.id}`);
};
const handleDeleteClient = async (client) => {
const clientName = client.company_name || `${client.first_name} ${client.last_name}`;
if (!confirm(`Êtes-vous sûr de vouloir supprimer le client « ${clientName} » ?`)) {
return;
}
try {
setDeleting(true);
const response = await fetch(`/zen/api/admin/clients?id=${client.id}`, {
method: 'DELETE',
credentials: 'include'
});
const data = await response.json();
if (data.success) {
toast.success('Client supprimé avec succès');
loadClients();
} else {
toast.error(data.error || 'Échec de la suppression du client');
}
} catch (error) {
console.error('Error deleting client:', error);
toast.error('Échec de la suppression du client');
} 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">Clients</h1>
<p className="mt-1 text-xs text-neutral-400">Gérez vos clients</p>
</div>
<Button
onClick={() => router.push('/admin/clients/new')}
icon={<PlusSignCircleIcon className="w-4 h-4" />}
>
Créer un client
</Button>
</div>
{/* Clients Table */}
<Card variant="default" padding="none">
<Table
columns={columns}
data={clients}
loading={loading}
sortBy={sortBy}
sortOrder={sortOrder}
onSort={handleSort}
emptyMessage="Aucun client trouvé"
emptyDescription="Créez votre premier client 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 ClientsListPage;
-8
View File
@@ -1,8 +0,0 @@
/**
* Clients Admin Components
*/
export { default as ClientsListPage } from './ClientsListPage.js';
export { default as ClientCreatePage } from './ClientCreatePage.js';
export { default as ClientEditPage } from './ClientEditPage.js';
export { default as ClientForm } from './ClientForm.js';
-148
View File
@@ -1,148 +0,0 @@
/**
* Clients Module API Routes
* All API endpoints for the clients module
*/
// Client CRUD
import {
createClient,
getClientById,
getClients,
updateClient,
deleteClient,
} from './crud.js';
// ============================================================================
// Client Handlers
// ============================================================================
async function handleGetClients(request) {
try {
const url = new URL(request.url);
const id = url.searchParams.get('id');
if (id) {
const client = await getClientById(parseInt(id));
if (!client) {
return { success: false, error: 'Client not found' };
}
return { success: true, client };
}
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 getClients({ page, limit, search, sortBy, sortOrder });
return {
success: true,
clients: result.clients,
total: result.pagination.total,
totalPages: result.pagination.totalPages,
page: result.pagination.page,
limit: result.pagination.limit
};
} catch (error) {
console.error('Error handling GET clients:', error);
return { success: false, error: 'Failed to fetch clients' };
}
}
async function handleCreateClient(request) {
try {
const body = await request.json();
// Accept both { client: {...} } and direct {...} format
const clientData = body.client || body;
if (!clientData || Object.keys(clientData).length === 0) {
return { success: false, error: 'Client data is required' };
}
const client = await createClient(clientData);
return { success: true, client, message: 'Client created successfully' };
} catch (error) {
console.error('Error creating client:', error);
return { success: false, error: error.message || 'Failed to create client' };
}
}
async function handleUpdateClient(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 { client: {...} } and direct {...} format
const updates = body.client || (({ id, ...rest }) => rest)(body);
if (!id) {
return { success: false, error: 'Client ID is required' };
}
if (!updates || Object.keys(updates).length === 0) {
return { success: false, error: 'Update data is required' };
}
const existingClient = await getClientById(parseInt(id));
if (!existingClient) {
return { success: false, error: 'Client not found' };
}
const client = await updateClient(parseInt(id), updates);
return { success: true, client, message: 'Client updated successfully' };
} catch (error) {
console.error('Error updating client:', error);
return { success: false, error: error.message || 'Failed to update client' };
}
}
async function handleDeleteClient(request) {
try {
const url = new URL(request.url);
const id = url.searchParams.get('id');
if (!id) {
return { success: false, error: 'Client ID is required' };
}
const existingClient = await getClientById(parseInt(id));
if (!existingClient) {
return { success: false, error: 'Client not found' };
}
const deleted = await deleteClient(parseInt(id));
if (!deleted) {
return { success: false, error: 'Failed to delete client' };
}
return { success: true, message: 'Client deleted successfully' };
} catch (error) {
console.error('Error deleting client:', error);
return { success: false, error: 'Failed to delete client' };
}
}
// ============================================================================
// Route Definitions
// ============================================================================
export default {
routes: [
// Clients (admin)
{ path: '/admin/clients', method: 'GET', handler: handleGetClients, auth: 'admin' },
{ path: '/admin/clients', method: 'POST', handler: handleCreateClient, auth: 'admin' },
{ path: '/admin/clients', method: 'PUT', handler: handleUpdateClient, auth: 'admin' },
{ path: '/admin/clients', method: 'DELETE', handler: handleDeleteClient, auth: 'admin' },
]
};
// Export individual handlers for direct use if needed
export {
handleGetClients,
handleCreateClient,
handleUpdateClient,
handleDeleteClient,
};
-269
View File
@@ -1,269 +0,0 @@
/**
* Clients Module - CRUD Operations
* Create, Read, Update, Delete operations for clients
*/
import { query } from '@hykocx/zen/database';
/**
* Generate a unique client number
* Format: 2-digit sequential number (01, 02, 03, etc.)
* @returns {Promise<string>}
*/
async function generateClientNumber() {
const result = await query(
`SELECT client_number FROM zen_clients ORDER BY id DESC LIMIT 1`
);
if (result.rows.length === 0) {
return '01'; // First client
}
const lastNumber = parseInt(result.rows[0].client_number);
const nextNumber = lastNumber + 1;
return nextNumber.toString().padStart(2, '0');
}
/**
* Create a new client
* @param {Object} clientData - Client data
* @returns {Promise<Object>} Created client
*/
export async function createClient(clientData) {
const {
user_id = null,
company_name = null,
first_name,
last_name,
email,
phone = null,
address = null,
city = null,
province = null,
postal_code = null,
country = 'Canada',
notes = null,
} = clientData;
// Validate required fields
if (!first_name || !last_name || !email) {
throw new Error('First name, last name, and email are required');
}
// Convert empty strings to null for foreign key fields
const cleanUserId = user_id === '' ? null : user_id;
// Generate client number
const client_number = await generateClientNumber();
const result = await query(
`INSERT INTO zen_clients (
client_number, user_id, company_name, first_name, last_name, email,
phone, address, city, province, postal_code, country, notes
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
RETURNING *`,
[
client_number, cleanUserId, company_name, first_name, last_name, email,
phone, address, city, province, postal_code, country, notes
]
);
return result.rows[0];
}
/**
* Get client by ID
* @param {number} id - Client ID
* @returns {Promise<Object|null>}
*/
export async function getClientById(id) {
const result = await query(
`SELECT c.*, u.name as user_name, u.email as user_email
FROM zen_clients c
LEFT JOIN zen_auth_users u ON c.user_id = u.id
WHERE c.id = $1`,
[id]
);
return result.rows[0] || null;
}
/**
* Get client by client number
* @param {string} clientNumber - Client number
* @returns {Promise<Object|null>}
*/
export async function getClientByNumber(clientNumber) {
const result = await query(
`SELECT c.*, u.name as user_name, u.email as user_email
FROM zen_clients c
LEFT JOIN zen_auth_users u ON c.user_id = u.id
WHERE c.client_number = $1`,
[clientNumber]
);
return result.rows[0] || null;
}
/**
* Get client by user ID
* @param {string} userId - User ID
* @returns {Promise<Object|null>}
*/
export async function getClientByUserId(userId) {
const result = await query(
`SELECT c.*, u.name as user_name, u.email as user_email
FROM zen_clients c
LEFT JOIN zen_auth_users u ON c.user_id = u.id
WHERE c.user_id = $1`,
[userId]
);
return result.rows[0] || null;
}
/**
* Get all clients with pagination
* @param {Object} options - Query options
* @returns {Promise<Object>} Clients and metadata
*/
export async function getClients(options = {}) {
const {
page = 1,
limit = 20,
search = '',
sortBy = 'created_at',
sortOrder = 'DESC'
} = options;
const offset = (page - 1) * limit;
// Build search condition
let searchCondition = '';
const params = [];
if (search) {
searchCondition = `WHERE (
c.first_name ILIKE $1 OR
c.last_name ILIKE $1 OR
c.email ILIKE $1 OR
c.company_name ILIKE $1 OR
c.client_number ILIKE $1
)`;
params.push(`%${search}%`);
}
// Get total count
const countResult = await query(
`SELECT COUNT(*) FROM zen_clients c ${searchCondition}`,
params
);
const total = parseInt(countResult.rows[0].count);
// Get clients
const clientsResult = await query(
`SELECT c.*, u.name as user_name, u.email as user_email
FROM zen_clients c
LEFT JOIN zen_auth_users u ON c.user_id = u.id
${searchCondition}
ORDER BY c.${sortBy} ${sortOrder}
LIMIT $${params.length + 1} OFFSET $${params.length + 2}`,
[...params, limit, offset]
);
return {
clients: clientsResult.rows,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit)
}
};
}
/**
* Update client
* @param {number} id - Client ID
* @param {Object} updates - Fields to update
* @returns {Promise<Object>} Updated client
*/
export async function updateClient(id, updates) {
const allowedFields = [
'user_id', 'company_name', 'first_name', 'last_name', 'email',
'phone', 'address', 'city', 'province', 'postal_code', 'country', 'notes'
];
const setFields = [];
const values = [];
let paramIndex = 1;
for (const [key, value] of Object.entries(updates)) {
if (allowedFields.includes(key)) {
// Convert empty strings to null for foreign key fields
const cleanValue = (key === 'user_id' && value === '') ? null : value;
setFields.push(`${key} = $${paramIndex}`);
values.push(cleanValue);
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_clients
SET ${setFields.join(', ')}
WHERE id = $${paramIndex}
RETURNING *`,
values
);
return result.rows[0];
}
/**
* Delete client
* @param {number} id - Client ID
* @returns {Promise<boolean>} Success status
*/
export async function deleteClient(id) {
const result = await query(
`DELETE FROM zen_clients WHERE id = $1`,
[id]
);
return result.rowCount > 0;
}
/**
* Link client to user
* @param {number} clientId - Client ID
* @param {string} userId - User ID
* @returns {Promise<Object>} Updated client
*/
export async function linkClientToUser(clientId, userId) {
// Check if user is already linked to another client
const existingLink = await getClientByUserId(userId);
if (existingLink && existingLink.id !== clientId) {
throw new Error('User is already linked to another client');
}
return await updateClient(clientId, { user_id: userId });
}
/**
* Unlink client from user
* @param {number} clientId - Client ID
* @returns {Promise<Object>} Updated client
*/
export async function unlinkClientFromUser(clientId) {
return await updateClient(clientId, { user_id: null });
}
-114
View File
@@ -1,114 +0,0 @@
/**
* Clients Module - Database
* Database initialization and tables for clients
*/
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 clients table
* @returns {Promise<Object>}
*/
export async function createClientsTable() {
const tableName = 'zen_clients';
const exists = await tableExists(tableName);
if (exists) {
console.log(`- Table already exists: ${tableName}`);
return { created: false, tableName };
}
await query(`
CREATE TABLE zen_clients (
id SERIAL PRIMARY KEY,
client_number VARCHAR(10) UNIQUE NOT NULL,
user_id TEXT REFERENCES zen_auth_users(id) ON DELETE SET NULL,
company_name VARCHAR(255),
first_name VARCHAR(100),
last_name VARCHAR(100),
email VARCHAR(255) NOT NULL,
phone VARCHAR(50),
address TEXT,
city VARCHAR(100),
province VARCHAR(100),
postal_code VARCHAR(20),
country VARCHAR(100) DEFAULT 'Canada',
notes TEXT,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
)
`);
// Create index on client_number for fast lookups
await query(`
CREATE INDEX idx_zen_clients_client_number ON zen_clients(client_number)
`);
// Create index on user_id for fast lookups
await query(`
CREATE INDEX idx_zen_clients_user_id ON zen_clients(user_id)
`);
// Create index on email for fast lookups
await query(`
CREATE INDEX idx_zen_clients_email ON zen_clients(email)
`);
console.log(`✓ Created table: ${tableName}`);
return { created: true, tableName };
}
/**
* Drop clients table (use with caution!)
* @returns {Promise<void>}
*/
export async function dropClientsTable() {
const tableName = 'zen_clients';
const exists = await tableExists(tableName);
if (exists) {
await query(`DROP TABLE IF EXISTS ${tableName} CASCADE`);
console.log(`✓ Dropped table: ${tableName}`);
}
}
/**
* Create all client-related tables
* @returns {Promise<Object>}
*/
export async function createTables() {
const created = [];
const skipped = [];
console.log('\n--- Clients Module ---');
const clientsResult = await createClientsTable();
if (clientsResult.created) created.push(clientsResult.tableName);
else skipped.push(clientsResult.tableName);
return { created, skipped };
}
/**
* Drop all client-related tables
* @returns {Promise<void>}
*/
export async function dropTables() {
await dropClientsTable();
}
-28
View File
@@ -1,28 +0,0 @@
/**
* Clients Module Entry Point
* Standalone clients module for use across other modules
*/
// Database
export {
createTables,
dropTables,
createClientsTable,
dropClientsTable,
} from './db.js';
// CRUD Operations
export {
createClient,
getClientById,
getClientByNumber,
getClientByUserId,
getClients,
updateClient,
deleteClient,
linkClientToUser,
unlinkClientFromUser,
} from './crud.js';
// Admin Components
export * from './admin/index.js';
-50
View File
@@ -1,50 +0,0 @@
/**
* Clients Module Configuration
* Standalone clients module for use across other modules
*/
import { lazy } from 'react';
export default {
// Basic module info
name: 'clients',
displayName: 'Clients',
version: '1.0.0',
description: 'Client management module - reusable across other modules',
// Module dependencies
dependencies: [],
// Environment variables this module uses
envVars: [],
// Admin navigation section
navigation: {
id: 'clients',
title: 'Clients',
icon: 'UserGroupIcon',
items: [
{
name: 'Clients',
href: '/admin/clients/list',
icon: 'UserGroupIcon',
},
]
},
// Admin pages (lazy-loaded for client-side rendering)
adminPages: {
'/admin/clients/list': lazy(() => import('./admin/ClientsListPage.js')),
'/admin/clients/new': lazy(() => import('./admin/ClientCreatePage.js')),
'/admin/clients/edit': lazy(() => import('./admin/ClientEditPage.js')),
},
// No public pages for clients module
publicPages: {},
// No public routes
publicRoutes: [],
// No dashboard widgets
dashboardWidgets: [],
};
+1 -7
View File
@@ -8,22 +8,16 @@
// Import createTables functions from each module
// These are bundled together so they're available at runtime
import { createTables as createClientsTables } from './clients/db.js';
import { createTables as createInvoiceTables } from './invoice/db.js';
import { createTables as createPostsTables } from './posts/db.js';
import { createTables as createNuageTables } from './nuage/db.js';
/**
* Module database initializers
* Maps module names to their createTables functions
*
*
* Add new modules here:
*/
const MODULE_DB_INITIALIZERS = {
clients: createClientsTables,
invoice: createInvoiceTables,
posts: createPostsTables,
nuage: createNuageTables,
};
/**
-19
View File
@@ -1,19 +0,0 @@
#################################
# 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=
#################################
@@ -1,142 +0,0 @@
# Invoice module Dashboard (Facturation)
This guide explains how to add a **Facturation** (Billing) section to your client dashboard so that logged-in users whose account is linked to a client can see and open their invoices.
## Prerequisites
- Auth and dashboard set up as in [Client dashboard and user features](../../features/auth/README-dashboard.md).
- Invoice and Clients modules enabled (`ZEN_MODULE_INVOICE=true`, clients are created by the invoice module).
- **Userclient link**: In the admin, a user must be linked to a client (e.g. in User edit, set “Client” to the corresponding client). The link is stored in `zen_clients.user_id`.
## API for “my invoices”
When a user is authenticated and their account is linked to a client, you can load that clients invoices with:
| Endpoint | Method | Auth | Description |
|----------|--------|------|-------------|
| `/zen/api/invoices/me` | GET | Session (user) | Returns the current users linked client (if any) and that clients invoices. |
**Query parameters (optional):**
- `page` Page number (default: 1).
- `limit` Items per page (default: 20).
- `status` Filter by status: `draft`, `sent`, `partial`, `paid`, `overdue`, `cancelled`.
- `sortBy` e.g. `created_at`, `due_date`, `issue_date`.
- `sortOrder` `ASC` or `DESC`.
**Example response (user has a linked client):**
```json
{
"success": true,
"linkedClient": {
"id": 1,
"client_number": "01",
"company_name": null,
"first_name": "Jane",
"last_name": "Doe",
"email": "jane@example.com"
},
"invoices": [
{
"id": 1,
"invoice_number": "01202501",
"token": "abc123...",
"client_id": 1,
"issue_date": "2025-01-15",
"due_date": "2025-02-15",
"status": "sent",
"total_amount": "1500.00",
...
}
],
"total": 1,
"totalPages": 1,
"page": 1,
"limit": 20
}
```
**When the user has no linked client:**
```json
{
"success": true,
"linkedClient": null,
"invoices": [],
"total": 0,
"totalPages": 0,
"page": 1,
"limit": 20
}
```
All requests must send the session cookie (e.g. `fetch(..., { credentials: 'include' })`).
## Adding the Facturation section to the dashboard
### 1. Protected page
Create a dashboard page that requires login and renders the Facturation section:
```js
// app/dashboard/invoices/page.js (Server Component)
import { protect } from '@hykocx/zen/auth';
import { ClientInvoicesSection } from '@hykocx/zen/invoice/dashboard';
export default async function DashboardInvoicesPage() {
await protect({ redirectTo: '/auth/login' });
return (
<div>
<h1>Facturation</h1>
<ClientInvoicesSection
invoicePageBasePath="/zen/invoice"
apiBasePath="/zen/api"
/>
</div>
);
}
```
- **`invoicePageBasePath`** Base path for the public invoice page. Links will be `{invoicePageBasePath}/{token}` (e.g. `/zen/invoice/abc123...`). Use the same base path as where you mount the Zen invoice public pages.
- **`apiBasePath`** Base path for the Zen API (default: `/zen/api`).
Optional props:
- **`title`** Section title (default: `"Facturation"`).
- **`emptyMessage`** Message when the client has no invoices.
- **`noClientMessage`** Message when the user has no linked client.
### 2. Link in the dashboard layout
In your dashboard layout or nav, add a link to the Facturation page:
```js
<Link href="/dashboard/invoices">Facturation</Link>
```
### 3. Public invoice page
Ensure the invoice public page is available at `{invoicePageBasePath}/{token}` (e.g. under `/zen/invoice/...`) so “Voir” and the invoice number open the correct page. This is usually done by mounting Zens module pages (e.g. `PublicPages` from `@hykocx/zen/modules/pages`) on a route like `/zen/invoice/[...slug]`.
## Behaviour summary
- **Logged in, linked to a client** → The section lists that clients invoices with links to view each invoice (by token).
- **Logged in, not linked to a client** → The section shows `noClientMessage` (e.g. “Aucun compte client n'est associé à votre compte…”).
- **Not logged in** → The dashboard page should not be reachable if you use `protect()` and redirect to login.
## Linking a user to a client
In the Zen admin:
1. Open **Users** and edit the user.
2. Set **Client** to the desired client (same person/company as the account).
3. Save.
Only one client can be linked per user. The same client can be linked to only one user.
## Security
- `GET /zen/api/invoices/me` requires a valid session (`auth: 'user'`). No admin role.
- Invoices returned are restricted to the client linked to the current user (`zen_clients.user_id = session.user.id`). No other clients invoices are exposed.
-262
View File
@@ -1,262 +0,0 @@
# Invoice Module
Full billing system with clients, items, categories, transactions, and recurrences. Supports Stripe and Interac payments, automatic interest on overdue invoices, email reminders, and PDF generation.
---
## Features
- **Invoices** with automatic numbering (`CLIENTYEAR##`, e.g. `0120250l`)
- **Public payment page** via secure token (Stripe, Interac, or manual)
- **Statuses**: `draft``sent``partial` / `overdue``paid` / `cancelled`
- **Reusable items** catalogued by categories
- **Transactions** recorded automatically (Stripe webhook) or manually
- **Recurrences**: automatic invoice generation at configurable intervals
- **Interest**: automatic interest on overdue invoices (configurable rate and grace period)
- **Email reminders**: automatic reminders before and after the due date
- **PDF**: invoice and payment receipt
- **Client dashboard**: "my invoices" portal for logged-in users
---
## Installation
### 1. Environment variables
Copy variables from [`.env.example`](.env.example) into your `.env`:
## 2. Stripe Webhook Configuration
**Get your webhook URL:**
- Production: `https://yourdomain.com/zen/api/webhook/stripe`
- Development: `http://localhost:3000/zen/api/webhook/stripe`
**Configure in Stripe Dashboard:**
1. Go to [Stripe Dashboard > Developers > Webhooks](https://dashboard.stripe.com/webhooks)
2. Click "Add endpoint"
3. Enter your webhook URL
4. Select events:
- `checkout.session.completed`
- `payment_intent.succeeded`
- `payment_intent.payment_failed`
5. Click "Add endpoint"
**Add webhook secret to `.env`:**
1. Click on the endpoint in Stripe Dashboard
2. Copy the "Signing secret" (starts with `whsec_`)
3. Add to `.env`: `STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxx`
**Test webhook (optional):**
```bash
stripe listen --forward-to localhost:3000/zen/api/webhook/stripe
```
### 3. Database tables
Tables are created automatically with `npx zen-db init`. For reference, here are the module tables:
| Table | Description |
|---|---|
| `zen_invoices` | Invoices |
| `zen_invoice_items` | Invoice line items |
| `zen_invoice_reminders` | Sent reminder history |
| `zen_invoice_interac` | Interac details per client |
| `zen_transactions` | Received payments |
| `zen_recurrences` | Recurring billing configurations |
| `zen_items` | Reusable item catalogue |
| `zen_categories` | Item categories |
> This module depends on `zen_clients` (**clients** module).
---
## Admin interface
| Page | URL |
|---|---|
| Invoice list | `/admin/invoice/invoices` |
| Create invoice | `/admin/invoice/invoices/new` |
| Edit invoice | `/admin/invoice/invoices/edit` |
| Item list | `/admin/invoice/items` |
| Create item | `/admin/invoice/items/new` |
| Edit item | `/admin/invoice/items/edit` |
| Category list | `/admin/invoice/categories` |
| Create category | `/admin/invoice/categories/new` |
| Edit category | `/admin/invoice/categories/edit` |
| Transactions | `/admin/invoice/transactions` |
| Add transaction | `/admin/invoice/transactions/new` |
| Recurrences | `/admin/invoice/recurrences` |
| Create recurrence | `/admin/invoice/recurrences/new` |
| Edit recurrence | `/admin/invoice/recurrences/edit` |
---
## Public pages (no authentication)
| URL | Description |
|---|---|
| `/invoice/:token` | Invoice payment page |
| `/invoice/:token/pdf` | Invoice PDF viewer |
| `/invoice/:token/receipt` | Payment receipt PDF |
Each invoice gets a unique 64-character hex token on creation. This token is the only access method for the client.
---
## Admin API (authentication required)
### Invoices
| Method | Route | Description |
|---|---|---|
| `GET` | `/zen/api/admin/invoices` | Invoice list (paginated) |
| `GET` | `/zen/api/admin/invoices?id={id}` | Invoice by ID |
| `POST` | `/zen/api/admin/invoices` | Create an invoice |
| `PUT` | `/zen/api/admin/invoices?id={id}` | Update an invoice |
| `DELETE` | `/zen/api/admin/invoices?id={id}` | Delete an invoice (rejected if paid) |
**List parameters:**
| Parameter | Default | Description |
|---|---|---|
| `page` | `1` | Current page |
| `limit` | `20` | Results per page |
| `search` | — | Text search |
| `status` | — | Filter by status |
| `client_id` | — | Filter by client |
| `sortBy` | `created_at` | Sort field |
| `sortOrder` | `DESC` | `ASC` or `DESC` |
**Invoice statuses:**
| Status | Description |
|---|---|
| `draft` | Draft — not sent to client |
| `sent` | Sent — awaiting payment |
| `partial` | Partially paid |
| `overdue` | Overdue (past due date) |
| `paid` | Paid in full |
| `cancelled` | Cancelled |
### Items
| Method | Route | Description |
|---|---|---|
| `GET` | `/zen/api/admin/items` | Item list |
| `GET` | `/zen/api/admin/items?id={id}` | Item by ID |
| `POST` | `/zen/api/admin/items` | Create an item |
| `PUT` | `/zen/api/admin/items?id={id}` | Update an item |
| `DELETE` | `/zen/api/admin/items?id={id}` | Delete an item |
### Categories
| Method | Route | Description |
|---|---|---|
| `GET` | `/zen/api/admin/categories` | Category list |
| `GET` | `/zen/api/admin/categories?id={id}` | Category by ID |
| `POST` | `/zen/api/admin/categories` | Create a category |
| `PUT` | `/zen/api/admin/categories?id={id}` | Update a category |
| `DELETE` | `/zen/api/admin/categories?id={id}` | Delete a category |
### Transactions
| Method | Route | Description |
|---|---|---|
| `GET` | `/zen/api/admin/transactions` | Transaction list |
| `GET` | `/zen/api/admin/transactions?id={id}` | Transaction by ID |
| `GET` | `/zen/api/admin/transactions?invoice_id={id}` | Transactions for an invoice |
| `GET` | `/zen/api/admin/transactions?client_id={id}` | Transactions for a client |
| `POST` | `/zen/api/admin/transactions` | Record a payment |
| `PUT` | `/zen/api/admin/transactions?id={id}` | Update a transaction |
> When creating a transaction, pass `send_confirmation_email: true` in the body to send a confirmation email to the client.
### Recurrences
| Method | Route | Description |
|---|---|---|
| `GET` | `/zen/api/admin/recurrences` | Recurrence list |
| `GET` | `/zen/api/admin/recurrences?id={id}` | Recurrence by ID |
| `POST` | `/zen/api/admin/recurrences` | Create a recurrence |
| `PUT` | `/zen/api/admin/recurrences?id={id}` | Update a recurrence |
| `DELETE` | `/zen/api/admin/recurrences?id={id}` | Delete a recurrence |
---
## Client API (user authentication required)
```
GET /zen/api/invoices/me
```
Returns invoices linked to the current user's client account. Invoices with `draft` status are never exposed.
**Parameters:** `page`, `limit`, `status`, `sortBy`, `sortOrder`
**Response:**
```json
{
"success": true,
"linkedClient": {
"id": 1,
"client_number": "01",
"company_name": "Acme Inc.",
"first_name": "Jean",
"last_name": "Tremblay",
"email": "jean@exemple.com"
},
"invoices": [...],
"total": 5,
"totalPages": 1,
"page": 1,
"limit": 20
}
```
> If the user account is not linked to a client, `linkedClient` is `null` and `invoices` is `[]`.
---
## Interest on overdue invoices
Interest is calculated automatically every 5 minutes (cron) on invoices with statuses `sent`, `partial`, and `overdue` whose due date has passed.
**Formula (simple interest):**
```
daily_rate = monthly_rate / 30.4375
daily_interest = principal × daily_rate
```
- `principal` = `total_amount - paid_amount`
- Calculation starts after the grace period (`ZEN_MODULE_INVOICE_INTEREST_GRACE_DAYS`)
- Interest is cumulative (added to the invoice's `interest_amount` field)
- One update per day per invoice (internal deduplication)
---
## Automatic reminders
The `invoice-reminders` cron runs every 5 minutes and sends emails:
- **Pre-due reminder**: X days before the due date (configured per invoice via `first_reminder_days`)
- **Overdue reminder**: after the due date
- **Admin notification**: email sent to `ZEN_MODULE_INVOICE_OVERDUE_EMAIL` when an invoice becomes overdue
One reminder of each type is sent per day per invoice.
---
## Recurrences
The `invoice-recurrences` cron runs every 5 minutes (between 8am and 5pm) and automatically creates new invoices based on the configured frequency. Generated invoices are created with `draft` status and can be sent manually.
---
## Invoice numbering
Format: `CLIENT_NUMBER + YEAR + SEQUENCE`
Example: client `01`, year `2025`, 3rd invoice → `01202503`
The sequence resets each year per client.
-338
View File
@@ -1,338 +0,0 @@
/**
* 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' };
}
}
@@ -1,613 +0,0 @@
'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;
@@ -1,788 +0,0 @@
'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;
@@ -1,354 +0,0 @@
'use client';
import React, { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { PlusSignCircleIcon, PencilEdit01Icon, Delete02Icon, Link02Icon, CoinsDollarIcon, Recycle03Icon, PackageIcon } from '../../../shared/Icons.js';
import {
Table,
Button,
Badge,
Card,
Pagination
} from '../../../shared/components';
import { useToast } from '@hykocx/zen/toast';
import { parseUTCDate, formatDateForInput, getDaysBetween, isOverdue, getTodayUTC } from '../../../shared/lib/dates.js';
/**
* Invoices List Page Component
* Displays list of invoices with pagination and sorting
*/
const InvoicesListPage = ({ user }) => {
const router = useRouter();
const toast = useToast();
const [invoices, setInvoices] = useState([]);
const [loading, setLoading] = useState(true);
const [deleting, setDeleting] = useState(false);
// Pagination state
const [pagination, setPagination] = useState({
page: 1,
limit: 20,
total: 0,
totalPages: 0
});
// Sort state
const [sortBy, setSortBy] = useState('created_at');
const [sortOrder, setSortOrder] = useState('desc');
// Table columns configuration
const columns = [
{
key: 'invoice_number',
label: "Facture #",
sortable: true,
render: (invoice) => (
<div>
<div className="text-sm font-mono font-semibold text-neutral-900 dark:text-white">{invoice.invoice_number}</div>
<div className="text-xs text-neutral-500 dark:text-gray-400">
Émise : {formatDateForInput(invoice.issue_date)}
</div>
</div>
),
skeleton: {
height: 'h-4',
width: '30%',
secondary: { height: 'h-3', width: '25%' }
}
},
{
key: 'client',
label: "Client",
sortable: false,
render: (invoice) => (
<div>
<div className="text-sm text-neutral-900 dark:text-white">
{invoice.company_name || `${invoice.first_name} ${invoice.last_name}`}
</div>
<div className="text-xs text-neutral-500 dark:text-gray-400">
{invoice.client_email}
</div>
</div>
),
skeleton: { height: 'h-4', width: '60%' }
},
{
key: 'total_amount',
label: "Montant",
sortable: true,
render: (invoice) => {
const principalAmount = parseFloat(invoice.total_amount);
const interestAmount = parseFloat(invoice.interest_amount || 0);
const totalWithInterest = principalAmount + interestAmount;
const hasInterest = interestAmount > 0;
return (
<div>
<div className={`text-sm font-semibold ${invoice.status === 'paid' ? 'text-green-400' : 'text-neutral-900 dark:text-white'}`}>
${totalWithInterest.toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})}
</div>
<div className="text-xs text-neutral-500 dark:text-gray-400">
{invoice.items_count} {invoice.items_count !== 1 ? 'articles' : 'article'}
{hasInterest && ` + Intérêt`}
</div>
</div>
);
},
skeleton: { height: 'h-4', width: '40%' }
},
{
key: 'due_date',
label: "Date d'échéance",
sortable: true,
render: (invoice) => {
const invoiceIsOverdue = isOverdue(invoice.due_date) && invoice.status !== 'paid';
const daysUntilDue = getDaysBetween(getTodayUTC(), invoice.due_date);
return (
<div>
<div className={`text-sm font-mono font-semibold ${invoiceIsOverdue ? 'text-red-400' : 'text-neutral-900 dark:text-white'}`}>
{formatDateForInput(invoice.due_date)}
</div>
{daysUntilDue > 0 && (
<div className="text-xs text-neutral-500 dark:text-gray-400">dans {daysUntilDue} jours</div>
)}
{invoiceIsOverdue && (
<div className="text-xs text-red-400">En retard</div>
)}
</div>
);
},
skeleton: { height: 'h-4', width: '35%' }
},
{
key: 'status',
label: "Statut",
sortable: true,
render: (invoice) => <InvoiceStatusBadge status={invoice.status} />,
skeleton: { height: 'h-6', width: '80px', className: 'rounded-full' }
},
{
key: 'actions',
label: "Actions",
render: (invoice) => {
const isPaid = invoice.status === 'paid' || invoice.status === 'partial';
return (
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleCopyPaymentLink(invoice)}
disabled={deleting}
icon={<Link02Icon className="w-3.5 h-3.5" />}
className=""
title="Copier le lien de paiement"
/>
<Button
variant="ghost"
size="sm"
onClick={() => handleEditInvoice(invoice)}
disabled={deleting}
icon={<PencilEdit01Icon className="w-3.5 h-3.5" />}
className=""
/>
{!isPaid && (
<Button
variant="danger"
size="sm"
onClick={() => handleDeleteInvoice(invoice)}
disabled={deleting}
icon={<Delete02Icon className="w-3.5 h-3.5" />}
className=""
/>
)}
</div>
);
},
skeleton: { height: 'h-8', width: '120px' }
}
];
useEffect(() => {
loadInvoices();
}, [sortBy, sortOrder, pagination.page, pagination.limit]);
const loadInvoices = async () => {
try {
setLoading(true);
const searchParams = new URLSearchParams({
page: pagination.page.toString(),
limit: pagination.limit.toString(),
sortBy,
sortOrder
});
const response = await fetch(`/zen/api/admin/invoices?${searchParams}`, {
credentials: 'include'
});
const data = await response.json();
if (data.success) {
setInvoices(data.invoices || []);
setPagination(prev => ({
...prev,
total: data.total || 0,
totalPages: data.totalPages || 0,
page: data.page || 1
}));
} else {
toast.error(data.error || "Échec du chargement des factures");
}
} catch (error) {
console.error('Error loading invoices:', error);
toast.error("Échec du chargement des factures");
} finally {
setLoading(false);
}
};
const handlePageChange = (newPage) => {
setPagination(prev => ({ ...prev, page: newPage }));
};
const handleLimitChange = (newLimit) => {
setPagination(prev => ({
...prev,
limit: newLimit,
page: 1
}));
};
const handleSort = (newSortBy) => {
const newSortOrder = sortBy === newSortBy && sortOrder === 'asc' ? 'desc' : 'asc';
setSortBy(newSortBy);
setSortOrder(newSortOrder);
};
const handleEditInvoice = (invoice) => {
router.push(`/admin/invoice/invoices/edit/${invoice.id}`);
};
const handleCopyPaymentLink = (invoice) => {
const paymentLink = `${window.location.origin}/zen/invoice/${invoice.token}`;
navigator.clipboard.writeText(paymentLink).then(() => {
toast.success("Lien de paiement copié dans le presse-papiers !");
}).catch(() => {
toast.error("Échec de la copie du lien de paiement");
});
};
const handleDeleteInvoice = async (invoice) => {
if (!confirm(`Êtes-vous sûr de vouloir supprimer la facture « ${invoice.invoice_number} » ?`)) {
return;
}
try {
setDeleting(true);
const response = await fetch(`/zen/api/admin/invoices?id=${invoice.id}`, {
method: 'DELETE',
credentials: 'include'
});
const data = await response.json();
if (data.success) {
toast.success("Facture supprimée avec succès");
loadInvoices();
} else {
toast.error(data.error || "Échec de la suppression de la facture");
}
} catch (error) {
console.error('Error deleting invoice:', error);
toast.error("Échec de la suppression de la facture");
} finally {
setDeleting(false);
}
};
return (
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-lg sm:text-xl font-semibold text-neutral-900 dark:text-white">Factures</h1>
<p className="mt-1 text-xs text-neutral-400">Gérez vos factures</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
onClick={() => router.push('/admin/invoice/invoices/new')}
icon={<PlusSignCircleIcon className="w-4 h-4" />}
>
Créer une facture
</Button>
<Button
variant="secondary"
onClick={() => router.push('/admin/invoice/transactions')}
icon={<CoinsDollarIcon className="w-4 h-4" />}
>
Transactions
</Button>
<Button
variant="secondary"
onClick={() => router.push('/admin/invoice/recurrences')}
icon={<Recycle03Icon className="w-4 h-4" />}
>
Récurrences
</Button>
<Button
variant="secondary"
onClick={() => router.push('/admin/invoice/items')}
icon={<PackageIcon className="w-4 h-4" />}
>
Articles
</Button>
</div>
</div>
{/* Invoices Table */}
<Card variant="default" padding="none">
<Table
columns={columns}
data={invoices}
loading={loading}
sortBy={sortBy}
sortOrder={sortOrder}
onSort={handleSort}
emptyMessage="Aucune facture trouvée"
emptyDescription="Créez votre première facture pour commencer"
/>
<Pagination
currentPage={pagination.page}
totalPages={pagination.totalPages}
onPageChange={handlePageChange}
onLimitChange={handleLimitChange}
limit={pagination.limit}
total={pagination.total}
loading={loading}
showPerPage={true}
showStats={true}
/>
</Card>
</div>
);
};
// Invoice Status Badge Component
const InvoiceStatusBadge = ({ status }) => {
const statusConfig = {
draft: { label: "Brouillon", color: 'default' },
sent: { label: "Envoyée", color: 'warning' },
paid: { label: "Payée", color: 'success' },
overdue: { label: "En retard", color: 'danger' },
cancelled: { label: "Annulée", color: 'default' }
};
const config = statusConfig[status] || statusConfig.draft;
return <Badge variant={config.color}>{config.label}</Badge>;
};
export default InvoicesListPage;
-7
View File
@@ -1,7 +0,0 @@
/**
* Invoice Admin Components
*/
export { default as InvoicesListPage } from './InvoicesListPage.js';
export { default as InvoiceCreatePage } from './InvoiceCreatePage.js';
export { default as InvoiceEditPage } from './InvoiceEditPage.js';
-882
View File
@@ -1,882 +0,0 @@
/**
* 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,
};
@@ -1,266 +0,0 @@
'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;
@@ -1,231 +0,0 @@
'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;
@@ -1,288 +0,0 @@
'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;
@@ -1,9 +0,0 @@
/**
* Categories Admin Components
* Part of Invoice Module
*/
export { default as CategoriesListPage } from './CategoriesListPage.js';
export { default as CategoryCreatePage } from './CategoryCreatePage.js';
export { default as CategoryEditPage } from './CategoryEditPage.js';
-317
View File
@@ -1,317 +0,0 @@
/**
* Categories Module - CRUD Operations
* Create, Read, Update, Delete operations for categories
* Part of Invoice Module
*/
import { query } from '@hykocx/zen/database';
/**
* Create a new category
* @param {Object} categoryData - Category data
* @returns {Promise<Object>} Created category
*/
export async function createCategory(categoryData) {
const {
title,
description = null,
parent_id = null,
is_active = true,
} = categoryData;
// Validate required fields
if (!title) {
throw new Error('Title is required');
}
// Validate parent_id if provided
if (parent_id !== null) {
const parentResult = await query(
`SELECT id FROM zen_items_category WHERE id = $1`,
[parent_id]
);
if (parentResult.rows.length === 0) {
throw new Error('Parent category not found');
}
}
const result = await query(
`INSERT INTO zen_items_category (title, description, parent_id, is_active)
VALUES ($1, $2, $3, $4)
RETURNING *`,
[title, description, parent_id, is_active]
);
return result.rows[0];
}
/**
* Get category by ID
* @param {number} id - Category ID
* @returns {Promise<Object|null>}
*/
export async function getCategoryById(id) {
const result = await query(
`SELECT c.*,
p.title as parent_title
FROM zen_items_category c
LEFT JOIN zen_items_category p ON c.parent_id = p.id
WHERE c.id = $1`,
[id]
);
return result.rows[0] || null;
}
/**
* Get all categories with pagination and filters
* @param {Object} options - Query options
* @returns {Promise<Object>} Categories and metadata
*/
export async function getCategories(options = {}) {
const {
page = 1,
limit = 50,
search = '',
parent_id = null,
is_active = null,
sortBy = 'title',
sortOrder = 'ASC'
} = options;
const offset = (page - 1) * limit;
// Build where conditions
const conditions = [];
const params = [];
let paramIndex = 1;
if (search) {
conditions.push(`(c.title ILIKE $${paramIndex} OR c.description ILIKE $${paramIndex})`);
params.push(`%${search}%`);
paramIndex++;
}
if (parent_id !== null) {
if (parent_id === 'null') {
conditions.push(`c.parent_id IS NULL`);
} else {
conditions.push(`c.parent_id = $${paramIndex}`);
params.push(parent_id);
paramIndex++;
}
}
if (is_active !== null) {
conditions.push(`c.is_active = $${paramIndex}`);
params.push(is_active);
paramIndex++;
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
// Get total count
const countResult = await query(
`SELECT COUNT(*)
FROM zen_items_category c
${whereClause}`,
params
);
const total = parseInt(countResult.rows[0].count);
// Get categories with parent info
const categoriesResult = await query(
`SELECT c.*,
p.title as parent_title,
(SELECT COUNT(*) FROM zen_items_category WHERE parent_id = c.id) as subcategory_count,
(SELECT COUNT(*) FROM zen_items WHERE category_id = c.id) as items_count
FROM zen_items_category c
LEFT JOIN zen_items_category p ON c.parent_id = p.id
${whereClause}
ORDER BY c.${sortBy} ${sortOrder}
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
[...params, limit, offset]
);
return {
categories: categoriesResult.rows,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit)
}
};
}
/**
* Get all active categories (for dropdowns/selects)
* @returns {Promise<Array>}
*/
export async function getActiveCategories() {
const result = await query(
`SELECT c.id, c.title, c.parent_id, p.title as parent_title
FROM zen_items_category c
LEFT JOIN zen_items_category p ON c.parent_id = p.id
WHERE c.is_active = true
ORDER BY c.title ASC`
);
return result.rows;
}
/**
* Get subcategories of a category
* @param {number} parentId - Parent category ID
* @returns {Promise<Array>}
*/
export async function getSubcategories(parentId) {
const result = await query(
`SELECT * FROM zen_items_category
WHERE parent_id = $1 AND is_active = true
ORDER BY title ASC`,
[parentId]
);
return result.rows;
}
/**
* Update category
* @param {number} id - Category ID
* @param {Object} updates - Fields to update
* @returns {Promise<Object>} Updated category
*/
export async function updateCategory(id, updates) {
const allowedFields = [
'title', 'description', 'parent_id', 'is_active'
];
// Validate parent_id if being updated
if (updates.parent_id !== undefined && updates.parent_id !== null) {
// Prevent category from being its own parent
if (updates.parent_id === id) {
throw new Error('Category cannot be its own parent');
}
// Check if parent exists
const parentResult = await query(
`SELECT id FROM zen_items_category WHERE id = $1`,
[updates.parent_id]
);
if (parentResult.rows.length === 0) {
throw new Error('Parent category not found');
}
// Prevent circular references (parent cannot be a child of this category)
const checkCircular = await query(
`WITH RECURSIVE category_tree AS (
SELECT id, parent_id FROM zen_items_category WHERE id = $1
UNION ALL
SELECT c.id, c.parent_id
FROM zen_items_category c
INNER JOIN category_tree ct ON c.id = ct.parent_id
)
SELECT id FROM category_tree WHERE id = $2`,
[updates.parent_id, id]
);
if (checkCircular.rows.length > 0) {
throw new Error('Cannot create circular category reference');
}
}
const setFields = [];
const values = [];
let paramIndex = 1;
for (const [key, value] of Object.entries(updates)) {
if (allowedFields.includes(key)) {
setFields.push(`${key} = $${paramIndex}`);
values.push(value);
paramIndex++;
}
}
if (setFields.length === 0) {
throw new Error('No valid fields to update');
}
// Add updated_at
setFields.push(`updated_at = CURRENT_TIMESTAMP`);
// Add ID parameter
values.push(id);
const result = await query(
`UPDATE zen_items_category
SET ${setFields.join(', ')}
WHERE id = $${paramIndex}
RETURNING *`,
values
);
return result.rows[0];
}
/**
* Delete category (soft delete by setting is_active to false)
* @param {number} id - Category ID
* @returns {Promise<Object>} Updated category
*/
export async function deleteCategory(id) {
// Check if category has items
const itemsResult = await query(
`SELECT COUNT(*) FROM zen_items WHERE category_id = $1`,
[id]
);
const itemsCount = parseInt(itemsResult.rows[0].count);
if (itemsCount > 0) {
throw new Error(`Cannot delete category with ${itemsCount} associated items`);
}
// Check if category has subcategories
const subcategoriesResult = await query(
`SELECT COUNT(*) FROM zen_items_category WHERE parent_id = $1`,
[id]
);
const subcategoriesCount = parseInt(subcategoriesResult.rows[0].count);
if (subcategoriesCount > 0) {
throw new Error(`Cannot delete category with ${subcategoriesCount} subcategories`);
}
return await updateCategory(id, { is_active: false });
}
/**
* Permanently delete category (use with caution!)
* @param {number} id - Category ID
* @returns {Promise<boolean>} Success status
*/
export async function permanentlyDeleteCategory(id) {
// Check if category has items
const itemsResult = await query(
`SELECT COUNT(*) FROM zen_items WHERE category_id = $1`,
[id]
);
const itemsCount = parseInt(itemsResult.rows[0].count);
if (itemsCount > 0) {
throw new Error(`Cannot delete category with ${itemsCount} associated items`);
}
const result = await query(
`DELETE FROM zen_items_category WHERE id = $1`,
[id]
);
return result.rowCount > 0;
}
-78
View File
@@ -1,78 +0,0 @@
/**
* Categories Module - Database
* Database initialization and tables for categories
* Part of Invoice Module
*/
import { query } from '@hykocx/zen/database';
/**
* Check if a table exists in the database
* @param {string} tableName - Name of the table to check
* @returns {Promise<boolean>}
*/
async function tableExists(tableName) {
const result = await query(
`SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = $1
)`,
[tableName]
);
return result.rows[0].exists;
}
/**
* Create categories table
* @returns {Promise<Object>}
*/
export async function createCategoriesTable() {
const tableName = 'zen_items_category';
const exists = await tableExists(tableName);
if (exists) {
console.log(`- Table already exists: ${tableName}`);
return { created: false, tableName };
}
await query(`
CREATE TABLE zen_items_category (
id SERIAL PRIMARY KEY,
title VARCHAR(255) NOT NULL,
description TEXT,
parent_id INTEGER REFERENCES zen_items_category(id) ON DELETE SET NULL,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
)
`);
// Create index on title for fast lookups
await query(`
CREATE INDEX idx_zen_items_category_title ON zen_items_category(title)
`);
// Create index on parent_id for hierarchy queries
await query(`
CREATE INDEX idx_zen_items_category_parent_id ON zen_items_category(parent_id)
`);
console.log(`✓ Created table: ${tableName}`);
return { created: true, tableName };
}
/**
* Drop categories table (use with caution!)
* @returns {Promise<void>}
*/
export async function dropCategoriesTable() {
const tableName = 'zen_items_category';
const exists = await tableExists(tableName);
if (exists) {
await query(`DROP TABLE IF EXISTS ${tableName} CASCADE`);
console.log(`✓ Dropped table: ${tableName}`);
}
}
-44
View File
@@ -1,44 +0,0 @@
/**
* Invoice Module Cron Configuration
* Defines scheduled tasks for the invoice module
*/
import { processInvoiceReminders } from './reminders.js';
import { processAllInvoiceInterest } from './interest.js';
import { processRecurrences } from './recurrences/processor.js';
export default {
jobs: [
{
name: 'invoice-reminders',
description: 'Send invoice reminders for pending and overdue invoices',
// Every 5 minutes between 5 AM and 11 PM
schedule: '*/5 * * * *',
handler: processInvoiceReminders,
timezone: process.env.ZEN_TIMEZONE || 'America/Toronto',
},
{
name: 'invoice-interest',
description: 'Calculate and apply interest to overdue invoices',
// Every 5 minutes (interest calculation is debounced internally)
schedule: '*/5 * * * *',
handler: async () => {
const summary = await processAllInvoiceInterest();
// Only log if something was actually processed
if (summary.updated > 0 || summary.errors > 0) {
console.log('[Invoice Interest] Processed:', summary);
}
return summary;
},
timezone: process.env.ZEN_TIMEZONE || 'America/Toronto',
},
{
name: 'invoice-recurrences',
description: 'Process recurring invoices and create new invoices',
// Every 5 minutes between 5 AM and 5 PM
schedule: '*/5 8-17 * * *',
handler: processRecurrences,
timezone: process.env.ZEN_TIMEZONE || 'America/Toronto',
},
]
};
-721
View File
@@ -1,721 +0,0 @@
/**
* 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;
}
@@ -1,258 +0,0 @@
'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>
);
}
@@ -1,46 +0,0 @@
'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}
/>
))}
</>
);
}
@@ -1,77 +0,0 @@
'use client';
/**
* Invoice Revenue Dashboard Widget
* Displays revenue statistics (month, year, total)
*/
import { StatCard } from '../../../shared/components';
import { CoinsDollarIcon, ChartBarLineIcon } from '../../../shared/Icons.js';
/**
* Format currency value
*/
function formatCurrency(value, symbol = '$') {
return `${value.toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})} ${symbol}`;
}
/**
* Format percentage change
*/
function formatPercentage(change) {
const sign = change > 0 ? '+' : '';
return `${sign}${change.toFixed(2)}%`;
}
export default function RevenueWidget({ stats }) {
const loading = !stats;
const widgets = [
{
title: 'Revenus ce mois',
value: loading ? '-' : formatCurrency(stats.revenueThisMonth, stats.currencySymbol),
change: loading ? '' : formatPercentage(stats.monthChangePercent),
changeType: stats?.monthChangePercent >= 0 ? 'increase' : 'decrease',
icon: CoinsDollarIcon,
color: 'text-green-400',
bgColor: 'bg-green-500/10',
},
{
title: 'Revenus de l\'année en cours',
value: loading ? '-' : formatCurrency(stats.revenueYear, stats.currencySymbol),
change: loading ? '' : formatPercentage(stats.yearChangePercent),
changeType: stats?.yearChangePercent >= 0 ? 'increase' : 'decrease',
icon: ChartBarLineIcon,
color: 'text-blue-400',
bgColor: 'bg-blue-500/10',
},
{
title: 'Revenus totaux',
value: loading ? '-' : formatCurrency(stats.totalRevenue, stats.currencySymbol),
icon: ChartBarLineIcon,
color: 'text-cyan-400',
bgColor: 'bg-cyan-500/10',
},
];
return (
<>
{widgets.map((widget, index) => (
<StatCard
key={`revenue-${index}`}
title={widget.title}
value={widget.value}
change={widget.change}
changeType={widget.changeType}
icon={widget.icon}
color={widget.color}
bgColor={widget.bgColor}
loading={loading}
/>
))}
</>
);
}
-14
View File
@@ -1,14 +0,0 @@
/**
* 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
@@ -1,142 +0,0 @@
/**
* Invoice Dashboard Stats Actions
* Server-side actions for invoice dashboard statistics
*/
'use server';
import { query } from '@hykocx/zen/database';
import { getTodayUTC } from '../../../shared/lib/dates.js';
/**
* Get invoice dashboard statistics
* @returns {Promise<Object>}
*/
export async function getInvoiceDashboardStats() {
const today = getTodayUTC();
const currentYear = today.getFullYear();
const currentMonth = today.getMonth() + 1;
const firstDayOfMonth = new Date(currentYear, currentMonth - 1, 1);
const firstDayOfLastMonth = new Date(currentYear, currentMonth - 2, 1);
const lastDayOfLastMonth = new Date(currentYear, currentMonth - 1, 0);
const firstDayOfYear = new Date(currentYear, 0, 1);
const firstDayOfLastYear = new Date(currentYear - 1, 0, 1);
const lastDayOfLastYear = new Date(currentYear - 1, 11, 31);
try {
// Revenue this month
const revenueThisMonthResult = await query(
`SELECT COALESCE(SUM(amount), 0) as total
FROM zen_transactions
WHERE status = 'completed'
AND transaction_date >= $1
AND transaction_date < $2`,
[firstDayOfMonth, today]
);
const revenueThisMonth = parseFloat(revenueThisMonthResult.rows[0].total) || 0;
// Revenue last month (for comparison)
const revenueLastMonthResult = await query(
`SELECT COALESCE(SUM(amount), 0) as total
FROM zen_transactions
WHERE status = 'completed'
AND transaction_date >= $1
AND transaction_date <= $2`,
[firstDayOfLastMonth, lastDayOfLastMonth]
);
const revenueLastMonth = parseFloat(revenueLastMonthResult.rows[0].total) || 0;
// Calculate month change percentage
let monthChangePercent = 0;
if (revenueLastMonth > 0) {
monthChangePercent = ((revenueThisMonth - revenueLastMonth) / revenueLastMonth) * 100;
} else if (revenueThisMonth > 0) {
monthChangePercent = 100;
}
// Current year revenue
const revenueYearResult = await query(
`SELECT COALESCE(SUM(amount), 0) as total
FROM zen_transactions
WHERE status = 'completed'
AND transaction_date >= $1
AND transaction_date < $2`,
[firstDayOfYear, today]
);
const revenueYear = parseFloat(revenueYearResult.rows[0].total) || 0;
// Revenue last year (for comparison)
const revenueLastYearResult = await query(
`SELECT COALESCE(SUM(amount), 0) as total
FROM zen_transactions
WHERE status = 'completed'
AND transaction_date >= $1
AND transaction_date <= $2`,
[firstDayOfLastYear, lastDayOfLastYear]
);
const revenueLastYear = parseFloat(revenueLastYearResult.rows[0].total) || 0;
// Calculate year change percentage
let yearChangePercent = 0;
if (revenueLastYear > 0) {
yearChangePercent = ((revenueYear - revenueLastYear) / revenueLastYear) * 100;
} else if (revenueYear > 0) {
yearChangePercent = 100;
}
// Pending invoices
const pendingInvoicesResult = await query(
`SELECT COUNT(*) as count FROM zen_invoices WHERE status = 'sent'`
);
const pendingInvoices = parseInt(pendingInvoicesResult.rows[0].count) || 0;
// Overdue invoices
const overdueInvoicesResult = await query(
`SELECT COUNT(*) as count
FROM zen_invoices
WHERE due_date < $1
AND status NOT IN ('paid', 'cancelled')`,
[today]
);
const overdueInvoices = parseInt(overdueInvoicesResult.rows[0].count) || 0;
// Total revenue (all time)
const totalRevenueResult = await query(
`SELECT COALESCE(SUM(amount), 0) as total
FROM zen_transactions
WHERE status = 'completed'`
);
const totalRevenue = parseFloat(totalRevenueResult.rows[0].total) || 0;
return {
success: true,
stats: {
revenueThisMonth,
monthChangePercent,
revenueYear,
yearChangePercent,
pendingInvoices,
overdueInvoices,
totalRevenue,
currencySymbol: process.env.ZEN_CURRENCY_SYMBOL || '$',
}
};
} catch (error) {
console.error('Error getting invoice dashboard stats:', error);
return {
success: false,
error: error.message || 'Failed to get invoice statistics',
stats: {
revenueThisMonth: 0,
monthChangePercent: 0,
revenueYear: 0,
yearChangePercent: 0,
pendingInvoices: 0,
overdueInvoices: 0,
totalRevenue: 0,
currencySymbol: '$',
}
};
}
}
-235
View File
@@ -1,235 +0,0 @@
/**
* 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;
@@ -1,121 +0,0 @@
/**
* 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>
);
};
@@ -1,110 +0,0 @@
/**
* 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>
);
};
@@ -1,116 +0,0 @@
/**
* Invoice Payment Confirmation Email Template
*/
import { Button, Section, Text } from "@react-email/components";
import { BaseLayout } from "@hykocx/zen/email/templates";
import { formatDateForDisplay } from "../../../shared/lib/dates.js";
const LOCALE = 'fr-FR';
export const InvoicePaymentConfirmationEmail = ({
clientName,
invoiceNumber,
paidAmount,
paymentDate,
paymentMethod,
transactionNumber,
companyName,
totalAmount = null,
remainingBalance = null,
isFullPayment = null,
invoiceUrl
}) => {
const appName = companyName || process.env.ZEN_NAME || 'ZEN';
const supportEmail = process.env.ZEN_SUPPORT_EMAIL || 'support@zenya.test';
const formattedPaymentDate = formatDateForDisplay(paymentDate, LOCALE, {
year: 'numeric',
month: 'long',
day: 'numeric'
});
const partialLabel = isFullPayment !== null && !isFullPayment ? 'partiel ' : '';
const hasRemainingBalance = remainingBalance !== null && parseFloat(remainingBalance) > 0;
return (
<BaseLayout
preview={`Merci ! Nous avons bien reçu votre ${partialLabel}paiement.`}
title="Paiement confirmé"
companyName={companyName}
supportSection={true}
supportEmail={supportEmail}
>
<Text className="text-[14px] leading-[24px] text-neutral-600 mt-[4px] mb-[24px]">
Bonjour {clientName}, merci ! Nous avons bien reçu votre {partialLabel}paiement.
</Text>
<Section style={{ border: '1px solid #E5E5E5' }} className="bg-green-50 rounded-[12px] p-[20px] my-[24px]">
<Text className="text-[12px] font-medium text-green-500 m-0 mb-[12px] uppercase tracking-wider">
Détails du paiement
</Text>
<Text className="text-[13px] text-neutral-900 m-0 mb-[6px]">
<span className="text-neutral-500 inline-block w-[180px]">Numéro de facture :</span>
#{invoiceNumber}
</Text>
<Text className="text-[13px] font-medium text-neutral-900 m-0 mb-[6px]">
<span className="text-neutral-500 font-normal inline-block w-[180px]">Montant payé :</span>
<span className="text-green-700">{paidAmount}</span>
</Text>
{totalAmount && (
<Text className="text-[13px] text-neutral-900 m-0 mb-[6px]">
<span className="text-neutral-500 inline-block w-[180px]">Total de la facture :</span>
{totalAmount}
</Text>
)}
{hasRemainingBalance && (
<Text className="text-[13px] font-medium text-neutral-900 m-0 mb-[6px]">
<span className="text-neutral-500 font-normal inline-block w-[180px]">Solde restant :</span>
<span className="text-orange-600">{remainingBalance}</span>
</Text>
)}
{isFullPayment !== null && (
<Text className="text-[13px] text-neutral-900 m-0 mb-[6px]">
<span className="text-neutral-500 inline-block w-[180px]">Statut :</span>
<span className={`font-medium ${isFullPayment ? 'text-green-700' : 'text-orange-600'}`}>
{isFullPayment ? 'Payé en entier' : 'Paiement partiel'}
</span>
</Text>
)}
<Text className="text-[13px] text-neutral-900 m-0 mb-[6px]">
<span className="text-neutral-500 inline-block w-[180px]">Date de paiement :</span>
{formattedPaymentDate}
</Text>
<Text className="text-[13px] text-neutral-900 m-0 mb-[6px]">
<span className="text-neutral-500 inline-block w-[180px]">Mode de paiement :</span>
{paymentMethod}
</Text>
{transactionNumber && (
<Text className="text-[13px] text-neutral-900 m-0">
<span className="text-neutral-500 inline-block w-[180px]">Numéro de transaction :</span>
{transactionNumber}
</Text>
)}
</Section>
{hasRemainingBalance && (
<Text className="text-[13px] leading-[22px] text-orange-600 m-0 mb-[24px]">
Un solde de {remainingBalance} est toujours sur cette facture.
</Text>
)}
<Section className="mt-[4px] mb-[32px]">
<Button
href={invoiceUrl}
className="bg-neutral-900 rounded-[8px] text-white font-medium px-[20px] py-[11px] no-underline text-[13px]"
>
Voir la facture
</Button>
</Section>
<Text className="text-[12px] leading-[20px] text-neutral-400 m-0">
Merci pour votre confiance. Nous apprécions votre {partialLabel}paiement.
</Text>
</BaseLayout>
);
};
@@ -1,100 +0,0 @@
/**
* 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>
);
};
@@ -1,106 +0,0 @@
/**
* Invoice Reminder Email Template
*/
import { Button, Section, Text, Link } from "@react-email/components";
import { BaseLayout } from "@hykocx/zen/email/templates";
import { formatDateForDisplay } from "../../../shared/lib/dates.js";
const LOCALE = 'fr-FR';
export const InvoiceReminderEmail = ({
clientName,
invoiceNumber,
invoiceAmount,
dueDate,
daysUntilDue,
paymentUrl,
companyName,
isFirstReminder = false,
graceDays = 3,
interestRate = 1
}) => {
const appName = companyName || process.env.ZEN_NAME || 'ZEN';
const supportEmail = process.env.ZEN_SUPPORT_EMAIL || 'support@zenya.test';
const showInterestWarning = daysUntilDue === 1;
const formattedDueDate = formatDateForDisplay(dueDate, LOCALE, {
year: 'numeric',
month: 'long',
day: 'numeric'
});
const preview = isFirstReminder
? `Une nouvelle facture a été émise. Paiement attendu avant le ${formattedDueDate}.`
: `Rappel : votre facture est due avant le ${formattedDueDate} (${daysUntilDue} jours).`;
const title = isFirstReminder
? 'Nouvelle facture disponible'
: 'Rappel de paiement';
return (
<BaseLayout
preview={preview}
title={title}
companyName={companyName}
supportSection={true}
supportEmail={supportEmail}
>
<Text className="text-[14px] leading-[24px] text-neutral-600 mt-[4px] mb-[24px]">
Bonjour {clientName},{' '}
{isFirstReminder ? (
<>une nouvelle facture a été émise pour vous. Le paiement est attendu avant le <span className="font-medium text-neutral-900">{formattedDueDate}</span>.</>
) : (
<>votre facture est due avant le <span className="font-medium text-neutral-900">{formattedDueDate}</span>.</>
)}
</Text>
<Section style={{ border: '1px solid #E5E5E5' }} className="bg-neutral-100 rounded-[12px] p-[20px] my-[24px]">
<Text className="text-[12px] font-medium text-neutral-400 m-0 mb-[12px] uppercase tracking-wider">
Détails de la facture
</Text>
<Text className="text-[13px] text-neutral-900 m-0 mb-[6px]">
<span className="text-neutral-500 inline-block w-[150px]">Numéro de facture :</span>
#{invoiceNumber}
</Text>
<Text className="text-[13px] font-medium text-neutral-900 m-0 mb-[6px]">
<span className="text-neutral-500 font-normal inline-block w-[150px]">Montant :</span>
{invoiceAmount}
</Text>
<Text className="text-[13px] text-neutral-900 m-0">
<span className="text-neutral-500 inline-block w-[150px]">Date d'échéance :</span>
{formattedDueDate}
</Text>
</Section>
<Section className="mt-[28px] mb-[32px]">
<Button
href={paymentUrl}
className="bg-neutral-900 rounded-[8px] text-white font-medium px-[20px] py-[11px] no-underline text-[13px]"
>
{isFirstReminder ? 'Voir et payer la facture' : 'Payer la facture'}
</Button>
</Section>
{showInterestWarning && (
<Text className="text-[12px] leading-[20px] text-neutral-500 m-0 mb-[8px]">
Des intérêts de {interestRate}% par mois ({interestRate * 12}% par an) seront appliqués sur tout solde impayé au-delà de {graceDays} jour{graceDays > 1 ? 's' : ''} de retard.
</Text>
)}
<Text className="text-[12px] leading-[20px] text-neutral-400 m-0">
{isFirstReminder
? "Si vous avez des questions concernant cette facture, n'hésitez pas à nous contacter."
: "Si vous avez déjà effectué le paiement, ignorez ce rappel."}
</Text>
<Text className="text-[12px] leading-[20px] text-neutral-400 m-0 mt-[8px]">
Lien :{' '}
<Link href={paymentUrl} className="text-neutral-400 underline break-all">
{paymentUrl}
</Link>
</Text>
</BaseLayout>
);
};
-9
View File
@@ -1,9 +0,0 @@
/**
* Invoice Email Templates
*/
export { InvoiceReminderEmail } from './InvoiceReminderEmail';
export { InvoiceOverdueEmail } from './InvoiceOverdueEmail';
export { InvoicePaymentConfirmationEmail } from './InvoicePaymentConfirmationEmail';
export { InvoiceReceiptEmail } from './InvoiceReceiptEmail';
export { AdminOverdueNotificationEmail } from './AdminOverdueNotificationEmail';
-173
View File
@@ -1,173 +0,0 @@
/**
* Invoice Module Entry Point
* Includes Clients, Items, and Transactions functionality
*/
// Invoice DB
export {
createTables,
dropTables,
// Backward compatibility aliases
createInvoiceTables,
dropInvoiceTables,
} from './db.js';
// Clients - Re-export from clients module for backward compatibility
export {
createClientsTable,
dropClientsTable,
createClient,
getClientById,
getClientByNumber,
getClientByUserId,
getClients,
updateClient,
deleteClient,
linkClientToUser,
unlinkClientFromUser,
} from '../clients/index.js';
// Invoice CRUD
export {
createInvoice,
getInvoiceById,
getInvoiceByToken,
getInvoiceByNumber,
getInvoices,
updateInvoice,
markInvoiceAsPaid,
addInvoiceReminder,
deleteInvoice,
getInteracCredentialsByClientId,
createInteracCredentials,
getOrCreateInteracCredentials,
} from './crud.js';
// Invoice Email & Reminders
export {
processInvoiceReminders,
sendPaymentConfirmation,
sendReceiptEmail,
} from './reminders.js';
// Stripe utilities - use @hykocx/zen/stripe directly
// Invoice-specific Stripe functions are in api.js and actions.js
// Cron jobs - defined in cron.config.js, managed by @hykocx/zen/cron
// Invoice Interest
export {
getInterestConfig,
calculateInvoiceInterest,
processAllInvoiceInterest,
getTotalAmountOwed,
getInvoiceAmountBreakdown,
} from './interest.js';
// Items DB
export {
createItemsTable,
dropItemsTable,
} from './items/db.js';
// Items CRUD
export {
createItem,
getItemById,
getItemBySku,
getItems,
getActiveItems,
getItemsByCategory,
updateItem,
deleteItem,
permanentlyDeleteItem,
} from './items/crud.js';
// Categories DB
export {
createCategoriesTable,
dropCategoriesTable,
} from './categories/db.js';
// Categories CRUD
export {
createCategory,
getCategoryById,
getCategories,
getActiveCategories,
getSubcategories,
updateCategory,
deleteCategory,
permanentlyDeleteCategory,
} from './categories/crud.js';
// Transactions DB
export {
createTransactionsTable,
dropTransactionsTable,
} from './transactions/db.js';
// Transactions CRUD
export {
createTransaction,
getTransactionById,
getTransactionByNumber,
getTransactions,
getTransactionsByInvoice,
getTransactionsByClient,
updateTransaction,
deleteTransaction,
PAYMENT_METHODS,
TRANSACTION_STATUS,
} from './transactions/crud.js';
// Recurrences DB
export {
createRecurrencesTable,
dropRecurrencesTable,
} from './recurrences/db.js';
// Recurrences CRUD
export {
createRecurrence,
getRecurrenceById,
getRecurrences,
getActiveRecurrences,
updateRecurrence,
deleteRecurrence,
deactivateRecurrence,
calculateNextDueDate,
calculateFirstDueDate,
updateRecurrenceAfterInvoiceCreation,
} from './recurrences/crud.js';
// Recurrences Processor
export {
processRecurrences,
shouldCreateInvoice,
} from './recurrences/processor.js';
// Export email templates
export * from './email/index.js';
// Export metadata generators
export {
generateInvoicePaymentMetadata,
generateInvoicePDFMetadata,
generateReceiptPDFMetadata,
getInvoiceStatus,
} from './metadata.js';
// Export admin components (invoice, items, categories, transactions, recurrences)
// Note: Client admin components are now in the clients module
export * from './admin/index.js';
export * from './items/admin/index.js';
export * from './categories/admin/index.js';
export * from './transactions/admin/index.js';
export * from './recurrences/admin/index.js';
// NOTE: Public pages are NOT exported here to avoid conflicts with server actions
// Import them from '@hykocx/zen/modules/pages' instead
// NOTE: Server actions are NOT exported here to avoid conflicts with client components
// Import them from '@hykocx/zen/modules/invoice/actions' instead
-278
View File
@@ -1,278 +0,0 @@
/**
* 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,
};
}
@@ -1,255 +0,0 @@
'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;
@@ -1,312 +0,0 @@
'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;
@@ -1,267 +0,0 @@
'use client';
import React, { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { PlusSignCircleIcon, PencilEdit01Icon, Delete02Icon } from '../../../../shared/Icons.js';
import {
Table,
Button,
StatusBadge,
Card,
Pagination
} from '../../../../shared/components';
import { formatCurrency } from '../../../../shared/utils/currency.js';
import { useToast } from '@hykocx/zen/toast';
/**
* Items List Page Component
* Displays list of items with pagination and sorting
*/
const ItemsListPage = ({ user }) => {
const router = useRouter();
const toast = useToast();
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(true);
const [deleting, setDeleting] = useState(false);
// Pagination state
const [pagination, setPagination] = useState({
page: 1,
limit: 20,
total: 0,
totalPages: 0
});
// Sort state
const [sortBy, setSortBy] = useState('created_at');
const [sortOrder, setSortOrder] = useState('desc');
// Table columns configuration
const columns = [
{
key: 'name',
label: "Nom",
sortable: true,
render: (item) => (
<div>
<div className="text-sm font-semibold text-neutral-900 dark:text-white">{item.name}</div>
{item.sku && (
<div className="text-xs text-neutral-500 dark:text-gray-400 font-mono">SKU : {item.sku}</div>
)}
</div>
),
skeleton: {
height: 'h-4',
width: '40%',
secondary: { height: 'h-3', width: '30%' }
}
},
{
key: 'description',
label: "Description",
sortable: false,
render: (item) => (
<div className="text-sm text-neutral-600 dark:text-gray-300 max-w-xs truncate">
{item.description || <span className="text-neutral-400 dark:text-gray-500">-</span>}
</div>
),
skeleton: { height: 'h-4', width: '60%' }
},
{
key: 'category_id',
label: "Catégorie",
sortable: true,
render: (item) => (
item.category_title ? (
<StatusBadge variant="info">{item.category_title}</StatusBadge>
) : (
<span className="text-sm text-neutral-400 dark:text-gray-500">-</span>
)
),
skeleton: { height: 'h-6', width: '80px', className: 'rounded-full' }
},
{
key: 'unit_price',
label: "Prix unitaire",
sortable: true,
render: (item) => (
<div className="text-sm font-semibold text-green-400">
{formatCurrency(item.unit_price)}
</div>
),
skeleton: { height: 'h-4', width: '35%' }
},
{
key: 'is_active',
label: "Statut",
sortable: true,
render: (item) => (
<StatusBadge variant={item.is_active ? 'success' : 'default'}>
{item.is_active ? "Actif" : "Inactif"}
</StatusBadge>
),
skeleton: { height: 'h-6', width: '70px', className: 'rounded-full' }
},
{
key: 'actions',
label: "Actions",
render: (item) => (
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleEditItem(item)}
disabled={deleting}
icon={<PencilEdit01Icon className="w-4 h-4" />}
className="p-2"
/>
<Button
variant="danger"
size="sm"
onClick={() => handleDeleteItem(item)}
disabled={deleting}
icon={<Delete02Icon className="w-4 h-4" />}
className="p-2"
/>
</div>
),
skeleton: { height: 'h-8', width: '80px' }
}
];
useEffect(() => {
loadItems();
}, [sortBy, sortOrder, pagination.page, pagination.limit]);
const loadItems = async () => {
try {
setLoading(true);
const searchParams = new URLSearchParams({
page: pagination.page.toString(),
limit: pagination.limit.toString(),
sortBy,
sortOrder
});
const response = await fetch(`/zen/api/admin/items?${searchParams}`, {
credentials: 'include'
});
const data = await response.json();
if (data.success) {
setItems(data.items || []);
setPagination(prev => ({
...prev,
total: data.total || 0,
totalPages: data.totalPages || 0,
page: data.page || 1
}));
} else {
toast.error(data.error || "Échec du chargement des articles");
}
} catch (error) {
console.error('Error loading items:', error);
toast.error("Échec du chargement des articles");
} finally {
setLoading(false);
}
};
const handlePageChange = (newPage) => {
setPagination(prev => ({ ...prev, page: newPage }));
};
const handleLimitChange = (newLimit) => {
setPagination(prev => ({
...prev,
limit: newLimit,
page: 1
}));
};
const handleSort = (newSortBy) => {
const newSortOrder = sortBy === newSortBy && sortOrder === 'asc' ? 'desc' : 'asc';
setSortBy(newSortBy);
setSortOrder(newSortOrder);
};
const handleEditItem = (item) => {
router.push(`/admin/invoice/items/edit/${item.id}`);
};
const handleDeleteItem = async (item) => {
if (!confirm(`Êtes-vous sûr de vouloir supprimer l'article "${item.name}" ?`)) {
return;
}
try {
setDeleting(true);
const response = await fetch(`/zen/api/admin/items?id=${item.id}`, {
method: 'DELETE',
credentials: 'include'
});
const data = await response.json();
if (data.success) {
toast.success("Article supprimé avec succès");
loadItems();
} else {
toast.error(data.error || "Échec de la suppression de l'article");
}
} catch (error) {
console.error('Error deleting item:', error);
toast.error("Échec de la suppression de l'article");
} finally {
setDeleting(false);
}
};
return (
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-lg sm:text-xl font-semibold text-neutral-900 dark:text-white">Articles</h1>
<p className="mt-1 text-xs text-neutral-400">Gérez votre catalogue d'articles</p>
</div>
<Button
onClick={() => router.push('/admin/invoice/items/new')}
icon={<PlusSignCircleIcon className="w-4 h-4" />}
>
Créer un article
</Button>
</div>
{/* Items Table */}
<Card variant="default" padding="none">
<Table
columns={columns}
data={items}
loading={loading}
sortBy={sortBy}
sortOrder={sortOrder}
onSort={handleSort}
emptyMessage="Aucun article trouvé"
emptyDescription="Créez votre premier article pour commencer"
/>
<Pagination
currentPage={pagination.page}
totalPages={pagination.totalPages}
onPageChange={handlePageChange}
onLimitChange={handleLimitChange}
limit={pagination.limit}
total={pagination.total}
loading={loading}
showPerPage={true}
showStats={true}
/>
</Card>
</div>
);
};
export default ItemsListPage;
-9
View File
@@ -1,9 +0,0 @@
/**
* Items Admin Components
* Part of Invoice Module
*/
export { default as ItemsListPage } from './ItemsListPage.js';
export { default as ItemCreatePage } from './ItemCreatePage.js';
export { default as ItemEditPage } from './ItemEditPage.js';
-248
View File
@@ -1,248 +0,0 @@
/**
* Items Module - CRUD Operations
* Create, Read, Update, Delete operations for items
* Part of Invoice Module
*/
import { query } from '@hykocx/zen/database';
/**
* Create a new item
* @param {Object} itemData - Item data
* @returns {Promise<Object>} Created item
*/
export async function createItem(itemData) {
const {
name,
description = null,
unit_price,
sku = null,
category_id = null,
is_active = true,
} = itemData;
// Validate required fields
if (!name || unit_price === undefined || unit_price === null) {
throw new Error('Name and unit price are required');
}
// Validate price is positive
if (unit_price < 0) {
throw new Error('Unit price must be positive');
}
const result = await query(
`INSERT INTO zen_items (name, description, unit_price, sku, category_id, is_active)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *`,
[name, description, unit_price, sku, category_id, is_active]
);
return result.rows[0];
}
/**
* Get item by ID
* @param {number} id - Item ID
* @returns {Promise<Object|null>}
*/
export async function getItemById(id) {
const result = await query(
`SELECT * FROM zen_items WHERE id = $1`,
[id]
);
return result.rows[0] || null;
}
/**
* Get item by SKU
* @param {string} sku - Item SKU
* @returns {Promise<Object|null>}
*/
export async function getItemBySku(sku) {
const result = await query(
`SELECT * FROM zen_items WHERE sku = $1`,
[sku]
);
return result.rows[0] || null;
}
/**
* Get all items with pagination and filters
* @param {Object} options - Query options
* @returns {Promise<Object>} Items and metadata
*/
export async function getItems(options = {}) {
const {
page = 1,
limit = 50,
search = '',
category_id = null,
is_active = null,
sortBy = 'name',
sortOrder = 'ASC'
} = options;
const offset = (page - 1) * limit;
// Build where conditions
const conditions = [];
const params = [];
let paramIndex = 1;
if (search) {
conditions.push(`(i.name ILIKE $${paramIndex} OR i.description ILIKE $${paramIndex} OR i.sku ILIKE $${paramIndex})`);
params.push(`%${search}%`);
paramIndex++;
}
if (category_id) {
conditions.push(`i.category_id = $${paramIndex}`);
params.push(category_id);
paramIndex++;
}
if (is_active !== null) {
conditions.push(`i.is_active = $${paramIndex}`);
params.push(is_active);
paramIndex++;
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
// Get total count
const countResult = await query(
`SELECT COUNT(*) FROM zen_items i ${whereClause}`,
params
);
const total = parseInt(countResult.rows[0].count);
// Get items with category info
const itemsResult = await query(
`SELECT i.*, c.title as category_title, c.parent_id, p.title as parent_category_title
FROM zen_items i
LEFT JOIN zen_items_category c ON i.category_id = c.id
LEFT JOIN zen_items_category p ON c.parent_id = p.id
${whereClause}
ORDER BY i.${sortBy} ${sortOrder}
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
[...params, limit, offset]
);
return {
items: itemsResult.rows,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit)
}
};
}
/**
* Get all active items (for dropdowns/selects)
* @returns {Promise<Array>}
*/
export async function getActiveItems() {
const result = await query(
`SELECT i.id, i.name, i.description, i.unit_price, i.sku, i.category_id,
c.title as category_title, c.parent_id, p.title as parent_category_title
FROM zen_items i
LEFT JOIN zen_items_category c ON i.category_id = c.id
LEFT JOIN zen_items_category p ON c.parent_id = p.id
WHERE i.is_active = true
ORDER BY i.name ASC`
);
return result.rows;
}
/**
* Get items by category
* @param {number} categoryId - Category ID
* @returns {Promise<Array>}
*/
export async function getItemsByCategory(categoryId) {
const result = await query(
`SELECT i.*, c.title as category_title, c.parent_id, p.title as parent_category_title
FROM zen_items i
LEFT JOIN zen_items_category c ON i.category_id = c.id
LEFT JOIN zen_items_category p ON c.parent_id = p.id
WHERE i.category_id = $1 AND i.is_active = true
ORDER BY i.name ASC`,
[categoryId]
);
return result.rows;
}
/**
* Update item
* @param {number} id - Item ID
* @param {Object} updates - Fields to update
* @returns {Promise<Object>} Updated item
*/
export async function updateItem(id, updates) {
const allowedFields = [
'name', 'description', 'unit_price', 'sku', 'category_id', 'is_active'
];
const setFields = [];
const values = [];
let paramIndex = 1;
for (const [key, value] of Object.entries(updates)) {
if (allowedFields.includes(key)) {
setFields.push(`${key} = $${paramIndex}`);
values.push(value);
paramIndex++;
}
}
if (setFields.length === 0) {
throw new Error('No valid fields to update');
}
// Add updated_at
setFields.push(`updated_at = CURRENT_TIMESTAMP`);
// Add ID parameter
values.push(id);
const result = await query(
`UPDATE zen_items
SET ${setFields.join(', ')}
WHERE id = $${paramIndex}
RETURNING *`,
values
);
return result.rows[0];
}
/**
* Delete item (soft delete by setting is_active to false)
* @param {number} id - Item ID
* @returns {Promise<Object>} Updated item
*/
export async function deleteItem(id) {
return await updateItem(id, { is_active: false });
}
/**
* Permanently delete item (use with caution!)
* @param {number} id - Item ID
* @returns {Promise<boolean>} Success status
*/
export async function permanentlyDeleteItem(id) {
const result = await query(
`DELETE FROM zen_items WHERE id = $1`,
[id]
);
return result.rowCount > 0;
}
-85
View File
@@ -1,85 +0,0 @@
/**
* Items Module - Database
* Database initialization and tables for items
* Part of Invoice Module
*/
import { query } from '@hykocx/zen/database';
/**
* Check if a table exists in the database
* @param {string} tableName - Name of the table to check
* @returns {Promise<boolean>}
*/
async function tableExists(tableName) {
const result = await query(
`SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = $1
)`,
[tableName]
);
return result.rows[0].exists;
}
/**
* Create items table
* @returns {Promise<Object>}
*/
export async function createItemsTable() {
const tableName = 'zen_items';
const exists = await tableExists(tableName);
if (exists) {
console.log(`- Table already exists: ${tableName}`);
return { created: false, tableName };
}
await query(`
CREATE TABLE zen_items (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT,
unit_price DECIMAL(10, 2) NOT NULL,
sku VARCHAR(100),
category_id INTEGER REFERENCES zen_items_category(id) ON DELETE SET NULL,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
)
`);
// Create index on name for fast lookups
await query(`
CREATE INDEX idx_zen_items_name ON zen_items(name)
`);
// Create index on sku for fast lookups
await query(`
CREATE INDEX idx_zen_items_sku ON zen_items(sku)
`);
// Create index on category_id
await query(`
CREATE INDEX idx_zen_items_category_id ON zen_items(category_id)
`);
console.log(`✓ Created table: ${tableName}`);
return { created: true, tableName };
}
/**
* Drop items table (use with caution!)
* @returns {Promise<void>}
*/
export async function dropItemsTable() {
const tableName = 'zen_items';
const exists = await tableExists(tableName);
if (exists) {
await query(`DROP TABLE IF EXISTS ${tableName} CASCADE`);
console.log(`✓ Dropped table: ${tableName}`);
}
}
-233
View File
@@ -1,233 +0,0 @@
/**
* Invoice Metadata Utilities
* Functions to generate dynamic metadata for invoice pages
*/
import { generateMetadata, generateRobots } from '../../shared/lib/metadata/index.js';
import { getInvoiceByToken } from './crud.js';
import { formatCurrency } from '../../shared/utils/currency.js';
import { getAppName } from '../../shared/lib/appConfig.js';
/**
* Generate metadata for invoice payment page
* This function should be used in Next.js page.js or layout.js files
*
* Example usage in Next.js:
* ```
* export async function generateMetadata({ params }) {
* return await generateInvoicePaymentMetadata(params.token);
* }
* ```
*
* @param {string} token - Invoice token
* @param {Object} options - Generation options
* @returns {Promise<Object>} Next.js metadata object
*/
export async function generateInvoicePaymentMetadata(token, options = {}) {
try {
// Fetch invoice data
const invoice = await getInvoiceByToken(token);
if (!invoice) {
return generateMetadata({
title: 'Facture non trouvée',
description: "La facture que vous recherchez n'a pas pu être trouvée.",
robots: generateRobots({ index: false, follow: false }),
}, options);
}
const isPaid = invoice.status === 'paid';
const remainingAmount = parseFloat(invoice.total_amount) - parseFloat(invoice.paid_amount || 0);
const totalAmount = parseFloat(invoice.total_amount);
const appName = options.appName || getAppName();
// Generate title based on invoice status (using existing translation keys)
let title;
if (isPaid) {
title = 'Facture payée';
} else if (remainingAmount > 0) {
title = 'Facture à payer';
} else {
title = 'Facture';
}
// Generate description
const formattedAmount = formatCurrency(totalAmount);
let description;
if (isPaid) {
description = `Facture # ${invoice.invoice_number} - ${formattedAmount} - Payée`;
} else {
description = `Facture # ${invoice.invoice_number} - ${formattedAmount}`;
}
return generateMetadata({
title,
description,
openGraph: {
title,
description,
type: 'website',
},
robots: generateRobots({
index: false, // Don't index invoice payment pages
follow: false
}),
}, { ...options, appName });
} catch (error) {
console.error('Error generating invoice metadata:', error);
return generateMetadata({
title: 'Facture',
description: 'Consultez et payez votre facture en ligne.',
robots: generateRobots({ index: false, follow: false }),
}, options);
}
}
/**
* Generate metadata for invoice PDF viewer page
*
* Example usage in Next.js:
* ```
* export async function generateMetadata({ params }) {
* return await generateInvoicePDFMetadata(params.token);
* }
* ```
*
* @param {string} token - Invoice token
* @param {Object} options - Generation options
* @returns {Promise<Object>} Next.js metadata object
*/
export async function generateInvoicePDFMetadata(token, options = {}) {
try {
// Fetch invoice data
const invoice = await getInvoiceByToken(token);
if (!invoice) {
return generateMetadata({
title: 'Facture non trouvée',
description: "La facture que vous recherchez n'a pas pu être trouvée.",
robots: generateRobots({ index: false, follow: false }),
}, options);
}
const appName = options.appName || getAppName();
return generateMetadata({
title: `Facture # ${invoice.invoice_number}`,
description: `Facture # ${invoice.invoice_number} - ${formatCurrency(invoice.total_amount)}`,
robots: generateRobots({
index: false, // Don't index invoice PDFs
follow: false
}),
}, { ...options, appName });
} catch (error) {
console.error('Error generating invoice PDF metadata:', error);
return generateMetadata({
title: 'Facture',
description: 'Consultez et payez votre facture en ligne.',
robots: generateRobots({ index: false, follow: false }),
}, options);
}
}
/**
* Generate metadata for receipt PDF viewer page
*
* Example usage in Next.js:
* ```
* export async function generateMetadata({ params }) {
* return await generateReceiptPDFMetadata(params.token);
* }
* ```
*
* @param {string} token - Invoice token
* @param {Object} options - Generation options
* @returns {Promise<Object>} Next.js metadata object
*/
export async function generateReceiptPDFMetadata(token, options = {}) {
try {
// Fetch invoice data
const invoice = await getInvoiceByToken(token);
if (!invoice) {
return generateMetadata({
title: 'Reçu non trouvé',
description: "Le reçu que vous recherchez n'a pas pu être trouvé.",
robots: generateRobots({ index: false, follow: false }),
}, options);
}
const appName = options.appName || getAppName();
// Check if invoice is paid
if (invoice.status !== 'paid') {
return generateMetadata({
title: 'Reçu non disponible',
description: "Le reçu n'est disponible que pour les factures payées.",
robots: generateRobots({ index: false, follow: false }),
}, { ...options, appName });
}
return generateMetadata({
title: `Reçu # ${invoice.invoice_number}`,
description: `Reçu # ${invoice.invoice_number} - ${formatCurrency(invoice.total_amount)}`,
robots: generateRobots({
index: false, // Don't index receipts
follow: false
}),
}, { ...options, appName });
} catch (error) {
console.error('Error generating receipt metadata:', error);
return generateMetadata({
title: 'Reçu',
description: 'Consultez le reçu de paiement.',
robots: generateRobots({ index: false, follow: false }),
}, options);
}
}
/**
* Get invoice status for quick checks
* @param {string} token - Invoice token
* @returns {Promise<Object|null>} Invoice status object or null if not found
*/
export async function getInvoiceStatus(token) {
try {
const invoice = await getInvoiceByToken(token);
if (!invoice) {
return null;
}
const isPaid = invoice.status === 'paid';
const remainingAmount = parseFloat(invoice.total_amount) - parseFloat(invoice.paid_amount || 0);
return {
invoiceNumber: invoice.invoice_number,
totalAmount: parseFloat(invoice.total_amount),
paidAmount: parseFloat(invoice.paid_amount || 0),
remainingAmount,
status: invoice.status,
isPaid,
dueDate: invoice.due_date,
isOverdue: new Date(invoice.due_date) < new Date() && !isPaid,
};
} catch (error) {
console.error('Error fetching invoice status:', error);
return null;
}
}
/**
* Metadata configuration for module registration
* This object maps route types to their metadata generator functions
*/
export default {
// Route type -> metadata generator function
payment: generateInvoicePaymentMetadata,
pdf: generateInvoicePDFMetadata,
receipt: generateReceiptPDFMetadata,
};
-112
View File
@@ -1,112 +0,0 @@
/**
* 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')),
],
};
@@ -1,106 +0,0 @@
'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;
@@ -1,273 +0,0 @@
'use client';
import React, { useState, useEffect } from 'react';
import PaymentPage from './PaymentPage.js';
import InvoicePDFViewerPage from './InvoicePDFViewerPage.js';
import ReceiptPDFViewerPage from './ReceiptPDFViewerPage.js';
import ThemeToggle from './ThemeToggle.js';
import { Loading } from '../../../shared/components';
/**
* Invoice Public Pages Router
* Handles routing for public invoice pages
* Routes:
* - /zen/invoice/{token}/ - View invoice and payment details
* - /zen/invoice/{token}/pdf - View invoice PDF
* - /zen/invoice/{token}/receipt - View receipt PDF (paid invoices only)
*/
const InvoicePublicPages = ({
path = [],
getInvoiceByTokenAction,
createStripeCheckoutSessionAction,
generateInvoicePDFAction,
generateReceiptPDFAction,
getInteracCredentialsAction,
stripeEnabled = false,
interacEnabled = false,
interacEmail = null,
publicLogoWhite = '',
publicLogoBlack = '',
publicDashboardUrl = '',
}) => {
const [invoice, setInvoice] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [paymentStatus, setPaymentStatus] = useState(null);
const [interacCredentials, setInteracCredentials] = useState(null);
// Path structure: ['invoice', token, ...additional]
const token = path[1];
const action = path[2]; // Could be 'pdf', 'receipt', or other actions
useEffect(() => {
if (token) {
// Only load invoice if not viewing PDF or receipt
if (action !== 'pdf' && action !== 'receipt') {
loadInvoice();
// Check for payment status in URL
if (typeof window !== 'undefined') {
const urlParams = new URLSearchParams(window.location.search);
const payment = urlParams.get('payment');
if (payment === 'success') {
setPaymentStatus('success');
} else if (payment === 'cancelled') {
setPaymentStatus('cancelled');
}
}
} else {
// For PDF/receipt viewer, we need to load the invoice to get the ID
loadInvoice();
}
} else {
setLoading(false);
setError("Lien de facture invalide");
}
}, [token, action]);
const loadInvoice = async () => {
try {
setLoading(true);
setError(null);
if (!getInvoiceByTokenAction) {
throw new Error('getInvoiceByTokenAction is required');
}
const result = await getInvoiceByTokenAction(token);
if (result.success && result.invoice) {
setInvoice(result.invoice);
// Load Interac credentials if enabled
if (interacEnabled && getInteracCredentialsAction && token) {
try {
const credentialsResult = await getInteracCredentialsAction(token);
if (credentialsResult.success && credentialsResult.credentials) {
setInteracCredentials(credentialsResult.credentials);
}
} catch (credErr) {
console.warn('Failed to load Interac credentials:', credErr);
// Don't fail the whole page if credentials fail to load
}
}
} else {
setError(result.error || "Facture non trouvée");
}
} catch (err) {
console.error('Error loading invoice:', err);
setError("Échec du chargement de la facture");
} finally {
setLoading(false);
}
};
const handlePaymentSubmit = async (paymentMethod) => {
try {
if (paymentMethod === 'stripe' && stripeEnabled) {
if (!createStripeCheckoutSessionAction) {
throw new Error('Stripe is not configured');
}
const result = await createStripeCheckoutSessionAction(invoice.id, token);
if (result.success && result.url) {
// Redirect to Stripe Checkout
window.location.href = result.url;
} else {
throw new Error(result.error || 'Failed to create checkout session');
}
} else if (paymentMethod === 'interac' && interacEnabled) {
// For Interac, instructions are already displayed on the page
// Just show a confirmation message
alert('Please follow the Interac e-Transfer instructions displayed below to complete your payment.');
} else {
// For other payment methods, you might want to show instructions
// or redirect to a payment instructions page
alert('Please contact us for payment instructions.');
}
} catch (err) {
console.error('Payment error:', err);
alert(err.message || 'Payment processing failed');
}
};
if (loading) {
return (
<div className="min-h-64 flex items-center justify-center bg-white dark:bg-black">
<Loading />
</div>
);
}
// Show PDF viewer
if (action === 'pdf') {
if (loading) {
return (
<div className="min-h-64 flex items-center justify-center bg-white dark:bg-black">
<Loading />
</div>
);
}
if (error || !invoice) {
return (
<>
<div className="min-h-screen flex items-center justify-center p-4 bg-white dark:bg-black">
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-lg p-8 max-w-md w-full text-center shadow-sm dark:shadow-none">
<h1 className="text-2xl font-semibold text-neutral-900 dark:text-white mb-4">Facture non trouvée</h1>
<p className="text-neutral-600 dark:text-neutral-400">
{error || "La facture que vous recherchez n'existe pas ou a été supprimée."}
</p>
</div>
</div>
<ThemeToggle />
</>
);
}
return (
<>
<InvoicePDFViewerPage
invoiceId={invoice.id}
token={token}
generateInvoicePDFAction={generateInvoicePDFAction}
/>
<ThemeToggle />
</>
);
}
// Show Receipt viewer
if (action === 'receipt') {
if (loading) {
return (
<div className="min-h-64 flex items-center justify-center bg-white dark:bg-black">
<Loading />
</div>
);
}
if (error || !invoice) {
return (
<>
<div className="min-h-screen flex items-center justify-center p-4 bg-white dark:bg-black">
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-lg p-8 max-w-md w-full text-center shadow-sm dark:shadow-none">
<h1 className="text-2xl font-semibold text-neutral-900 dark:text-white mb-4">Facture non trouvée</h1>
<p className="text-neutral-600 dark:text-neutral-400">
{error || "La facture que vous recherchez n'existe pas ou a été supprimée."}
</p>
</div>
</div>
<ThemeToggle />
</>
);
}
// Check if invoice is paid
if (invoice.status !== 'paid') {
return (
<>
<div className="min-h-screen flex items-center justify-center p-4 bg-white dark:bg-black">
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-lg p-8 max-w-md w-full text-center shadow-sm dark:shadow-none">
<h1 className="text-2xl font-semibold text-neutral-900 dark:text-white mb-4">Reçu non disponible</h1>
<p className="text-neutral-600 dark:text-neutral-400">
Le reçu ne peut être généré que pour les factures payées.
</p>
</div>
</div>
<ThemeToggle />
</>
);
}
return (
<>
<ReceiptPDFViewerPage
invoiceId={invoice.id}
token={token}
generateReceiptPDFAction={generateReceiptPDFAction}
/>
<ThemeToggle />
</>
);
}
// Show payment page
if (error || !invoice) {
return (
<>
<div className="min-h-screen flex items-center justify-center p-4 bg-white dark:bg-black">
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-lg p-8 max-w-md w-full text-center shadow-sm dark:shadow-none">
<h1 className="text-2xl font-semibold text-neutral-900 dark:text-white mb-4">Facture non trouvée</h1>
<p className="text-neutral-600 dark:text-neutral-400">
{error || "La facture que vous recherchez n'existe pas ou a été supprimée."}
</p>
</div>
</div>
<ThemeToggle />
</>
);
}
return (
<>
<PaymentPage
invoice={invoice}
onPaymentSubmit={handlePaymentSubmit}
stripeEnabled={stripeEnabled}
interacEnabled={interacEnabled}
interacEmail={interacEmail}
interacCredentials={interacCredentials}
paymentStatus={paymentStatus}
token={token}
publicLogoWhite={publicLogoWhite}
publicLogoBlack={publicLogoBlack}
publicDashboardUrl={publicDashboardUrl}
/>
<ThemeToggle />
</>
);
};
export default InvoicePublicPages;
-410
View File
@@ -1,410 +0,0 @@
'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;
@@ -1,106 +0,0 @@
'use client';
import React, { useState, useEffect } from 'react';
import { Loading } from '../../../shared/components';
/**
* Receipt PDF Viewer Page
* Displays receipt PDF in an iframe for viewing and downloading
*/
const ReceiptPDFViewerPage = ({
invoiceId,
token,
generateReceiptPDFAction,
}) => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [pdfUrl, setPdfUrl] = useState(null);
const [filename, setFilename] = useState('receipt.pdf');
useEffect(() => {
loadPDF();
// Cleanup: revoke object URL when component unmounts
return () => {
if (pdfUrl) {
URL.revokeObjectURL(pdfUrl);
}
};
}, [invoiceId, token]);
const loadPDF = async () => {
try {
setLoading(true);
setError(null);
if (!generateReceiptPDFAction) {
throw new Error('Receipt PDF generation action is not configured');
}
const result = await generateReceiptPDFAction(invoiceId, token);
if (!result.success) {
throw new Error(result.error || 'Failed to generate receipt PDF');
}
// Convert base64 to blob
const binaryString = atob(result.pdf);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
// Create File object instead of Blob to preserve filename
const fileName = result.filename || 'receipt.pdf';
const file = new File([bytes], fileName, { type: 'application/pdf' });
// Create object URL
const url = URL.createObjectURL(file);
setPdfUrl(url);
setFilename(fileName);
} catch (err) {
console.error('Error loading receipt PDF:', err);
setError(err.message || 'Échec du chargement du reçu PDF');
} finally {
setLoading(false);
}
};
if (loading) {
return (
<div className="min-h-64 flex items-center justify-center bg-white dark:bg-black">
<Loading />
</div>
);
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center p-4 bg-white dark:bg-black">
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-lg p-8 max-w-md w-full text-center shadow-sm dark:shadow-none">
<h1 className="text-2xl font-semibold text-neutral-900 dark:text-white mb-4">Erreur</h1>
<p className="text-neutral-600 dark:text-neutral-400 mb-6">{error}</p>
<button
onClick={loadPDF}
className="px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors"
>
Réessayer
</button>
</div>
</div>
);
}
return (
<div className="h-screen w-screen bg-white dark:bg-black">
<iframe
src={pdfUrl}
className="w-full h-full border-0"
title={filename}
/>
</div>
);
};
export default ReceiptPDFViewerPage;
-46
View File
@@ -1,46 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
function SunIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" width={20} height={20} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" />
</svg>
);
}
function MoonIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" width={20} height={20} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
);
}
export default function ThemeToggle() {
const [dark, setDark] = useState(false);
useEffect(() => {
setDark(document.documentElement.classList.contains('dark'));
}, []);
function toggle() {
const next = !dark;
setDark(next);
document.documentElement.classList.toggle('dark', next);
localStorage.setItem('theme', next ? 'dark' : 'light');
}
return (
<button
type="button"
onClick={toggle}
aria-label="Toggle theme"
className="fixed cursor-pointer bottom-5 right-5 z-50 flex h-10 w-10 items-center justify-center rounded-full border border-neutral-200 bg-neutral-50 text-neutral-600 transition-colors hover:bg-neutral-100 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300 dark:hover:bg-neutral-800"
>
{dark ? <SunIcon /> : <MoonIcon />}
</button>
);
}
-10
View File
@@ -1,10 +0,0 @@
'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';
@@ -1,334 +0,0 @@
/**
* 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;
@@ -1,392 +0,0 @@
/**
* Receipt PDF Template
* React-PDF template for generating receipt PDFs
*/
import React from 'react';
import {
Document,
Page,
Text,
View,
StyleSheet,
Image,
} from '@react-pdf/renderer';
import { formatDateForDisplay } from '../../../shared/lib/dates.js';
const LOCALE = 'fr-FR';
// Create styles - Simple black and white design
const styles = StyleSheet.create({
page: {
padding: 30,
fontSize: 10,
fontFamily: 'Helvetica',
color: '#000',
},
// Header section
receiptTitle: {
fontSize: 16,
fontWeight: 'bold',
marginBottom: 10,
},
paidBadge: {
fontSize: 14,
fontWeight: 'bold',
marginBottom: 10,
color: '#059669', // Green color for PAID
},
datesSection: {
marginBottom: 25,
fontSize: 9,
},
dateItem: {
marginBottom: 3,
},
dateLabel: {
fontWeight: 'normal',
},
// Transaction section
transactionSection: {
marginBottom: 25,
padding: 10,
backgroundColor: '#f3f4f6',
borderRadius: 4,
},
transactionTitle: {
fontSize: 10,
fontWeight: 'bold',
marginBottom: 6,
},
transactionItem: {
fontSize: 9,
marginBottom: 3,
},
// Company and Client Info
infoSection: {
flexDirection: 'row',
marginBottom: 25,
},
companySection: {
width: '50%',
paddingRight: 15,
},
clientSection: {
width: '50%',
paddingLeft: 15,
},
sectionTitle: {
fontSize: 10,
fontWeight: 'bold',
marginBottom: 6,
},
infoText: {
fontSize: 9,
marginBottom: 2,
lineHeight: 1.3,
},
// Price notice
priceNotice: {
fontSize: 15,
fontWeight: 'bold',
marginBottom: 20,
textAlign: 'left',
color: '#059669', // Green for paid amount
},
// Table
table: {
marginTop: 10,
marginBottom: 20,
},
tableHeader: {
flexDirection: 'row',
paddingBottom: 10,
borderBottomWidth: 1,
borderBottomColor: '#E5E5E5',
fontSize: 9,
fontWeight: 'bold',
},
tableRow: {
flexDirection: 'row',
paddingVertical: 10,
borderBottomWidth: 1,
borderBottomColor: '#E5E5E5',
fontSize: 9,
},
tableColDescription: {
width: '50%',
},
tableColQuantity: {
width: '15%',
textAlign: 'center',
},
tableColPrice: {
width: '17.5%',
textAlign: 'right',
},
tableColTotal: {
width: '17.5%',
textAlign: 'right',
},
itemDescription: {
fontSize: 8,
marginTop: 2,
lineHeight: 1.2,
},
// Totals
totalsSection: {
marginTop: 15,
marginBottom: 30,
alignItems: 'flex-end',
},
totalRow: {
flexDirection: 'row',
paddingVertical: 5,
width: '35%',
fontSize: 10,
},
totalLabel: {
width: '60%',
textAlign: 'right',
paddingRight: 15,
},
totalValue: {
width: '40%',
textAlign: 'right',
},
// Notes section
notesSection: {
marginTop: 20,
marginBottom: 20,
},
notesTitle: {
fontSize: 10,
fontWeight: 'bold',
marginBottom: 6,
},
notesText: {
fontSize: 9,
lineHeight: 1.4,
},
logoSection: {
position: 'absolute',
top: 30,
right: 30,
alignItems: 'flex-end',
},
logoImage: {
maxWidth: 120,
maxHeight: 40,
objectFit: 'contain',
},
});
/**
* Format currency
*/
const formatCurrency = (amount) => {
const currency = process.env.ZEN_CURRENCY_SYMBOL || '$';
return `${currency}${parseFloat(amount).toFixed(2)}`;
};
/**
* Format date (using UTC date utilities)
*/
const formatDate = (dateString) => {
return formatDateForDisplay(dateString, LOCALE, {
year: 'numeric',
month: 'long',
day: 'numeric',
});
};
const PAYMENT_METHOD_LABELS = {
stripe: 'Carte de crédit (Stripe)',
cash: 'Comptant',
check: 'Chèque',
wire: 'Virement bancaire',
interac: 'Virement Interac',
other: 'Autre',
};
/**
* Format payment method for display
*/
const formatPaymentMethod = (method) => {
return PAYMENT_METHOD_LABELS[method] || method;
};
/**
* Receipt PDF Document Component
*/
const ReceiptPDFTemplate = ({ invoice, transaction, companyInfo = {} }) => {
if (!invoice) {
return null;
}
const clientName = invoice.company_name || `${invoice.first_name} ${invoice.last_name}`;
const interestAmount = parseFloat(invoice.interest_amount || 0);
const hasInterest = interestAmount > 0;
const totalWithInterest = parseFloat(invoice.total_amount) + interestAmount;
// Default company info
const company = {
name: companyInfo.name || process.env.ZEN_MODULE_INVOICE_COMPANY_NAME || 'Your Company',
address: companyInfo.address || process.env.ZEN_MODULE_INVOICE_COMPANY_ADDRESS || '',
city: companyInfo.city || process.env.ZEN_MODULE_INVOICE_COMPANY_CITY || '',
province: companyInfo.province || process.env.ZEN_MODULE_INVOICE_COMPANY_PROVINCE || '',
postalCode: companyInfo.postalCode || process.env.ZEN_MODULE_INVOICE_COMPANY_POSTAL_CODE || '',
country: companyInfo.country || process.env.ZEN_MODULE_INVOICE_COMPANY_COUNTRY || '',
phone: companyInfo.phone || process.env.ZEN_MODULE_INVOICE_COMPANY_PHONE || '',
email: companyInfo.email || process.env.ZEN_MODULE_INVOICE_COMPANY_EMAIL || '',
};
const logoBlackUrl = companyInfo.publicLogoBlack || '';
return (
<Document>
<Page size="LETTER" style={styles.page}>
{/* Logo top right */}
{logoBlackUrl && (
<View style={styles.logoSection}>
<Image style={styles.logoImage} src={logoBlackUrl} />
</View>
)}
{/* Header: Receipt Number and Status */}
<View>
<Text style={styles.receiptTitle}>REÇU #{invoice.invoice_number}</Text>
<Text style={styles.paidBadge}>PAYÉ</Text>
<View style={styles.datesSection}>
<Text style={styles.dateItem}>
<Text style={styles.dateLabel}>Date de facture : </Text>
<Text style={{ fontWeight: 'bold' }}>{formatDate(invoice.issue_date)}</Text>
</Text>
{transaction && (
<Text style={styles.dateItem}>
<Text style={styles.dateLabel}>Date de paiement : </Text>
<Text style={{ fontWeight: 'bold' }}>{formatDate(transaction.transaction_date)}</Text>
</Text>
)}
</View>
</View>
{/* Transaction Information */}
{transaction && (
<View style={styles.transactionSection}>
<Text style={styles.transactionTitle}>Informations de paiement</Text>
<Text style={styles.transactionItem}>
<Text style={styles.dateLabel}>Numéro de transaction : </Text>
<Text style={{ fontWeight: 'bold' }}>{transaction.transaction_number}</Text>
</Text>
<Text style={styles.transactionItem}>
<Text style={styles.dateLabel}>Mode de paiement : </Text>
<Text style={{ fontWeight: 'bold' }}>{formatPaymentMethod(transaction.payment_method)}</Text>
</Text>
<Text style={styles.transactionItem}>
<Text style={styles.dateLabel}>Montant payé : </Text>
<Text style={{ fontWeight: 'bold' }}>{formatCurrency(transaction.amount)}</Text>
</Text>
</View>
)}
{/* Company and Client Info - Side by Side */}
<View style={styles.infoSection}>
{/* Company Info */}
<View style={styles.companySection}>
<Text style={styles.sectionTitle}>{company.name}</Text>
{company.address && <Text style={styles.infoText}>{company.address}</Text>}
{(company.city || company.province || company.postalCode) && (
<Text style={styles.infoText}>
{[company.city, company.province, company.postalCode]
.filter(Boolean)
.join(', ')}
</Text>
)}
{company.country && <Text style={styles.infoText}>{company.country}</Text>}
{company.phone && <Text style={styles.infoText}>{company.phone}</Text>}
{company.email && <Text style={styles.infoText}>{company.email}</Text>}
</View>
{/* Client Info */}
<View style={styles.clientSection}>
<Text style={styles.sectionTitle}>Payé par :</Text>
<Text style={styles.infoText}>{clientName}</Text>
{invoice.address && <Text style={styles.infoText}>{invoice.address}</Text>}
{(invoice.city || invoice.province || invoice.postal_code) && (
<Text style={styles.infoText}>
{[invoice.city, invoice.province, invoice.postal_code]
.filter(Boolean)
.join(', ')}
</Text>
)}
{invoice.country && <Text style={styles.infoText}>{invoice.country}</Text>}
{invoice.client_email && <Text style={styles.infoText}>{invoice.client_email}</Text>}
</View>
</View>
{/* Amount paid notice */}
<Text style={styles.priceNotice}>
{formatCurrency(transaction ? transaction.amount : invoice.total_amount)} payé en entier
</Text>
{/* Items Table */}
<View style={styles.table}>
<View style={styles.tableHeader}>
<Text style={styles.tableColDescription}>Description</Text>
<Text style={styles.tableColQuantity}>Qté</Text>
<Text style={styles.tableColPrice}>Prix unitaire</Text>
<Text style={styles.tableColTotal}>Total</Text>
</View>
{invoice.items && invoice.items.map((item, index) => (
<View key={index} style={styles.tableRow}>
<View style={styles.tableColDescription}>
<Text style={{ fontWeight: 'bold' }}>{item.name}</Text>
{item.description && (
<Text style={styles.itemDescription}>
{item.description}
</Text>
)}
</View>
<Text style={styles.tableColQuantity}>{item.quantity}</Text>
<Text style={styles.tableColPrice}>{formatCurrency(item.unit_price)}</Text>
<Text style={styles.tableColTotal}>{formatCurrency(item.total)}</Text>
</View>
))}
</View>
{/* Totals - Simple Subtotal and Total */}
<View style={styles.totalsSection}>
<View style={styles.totalRow}>
<Text style={styles.totalLabel}>Sous-total</Text>
<Text style={styles.totalValue}>{formatCurrency(invoice.subtotal)}</Text>
</View>
{hasInterest && (
<View style={styles.totalRow}>
<Text style={styles.totalLabel}>Intérêts</Text>
<Text style={styles.totalValue}>{formatCurrency(interestAmount)}</Text>
</View>
)}
<View style={[styles.totalRow, { fontWeight: 'bold', fontSize: 12 }]}>
<Text style={styles.totalLabel}>Total payé</Text>
<Text style={styles.totalValue}>{formatCurrency(totalWithInterest)}</Text>
</View>
</View>
{/* Notes Section */}
{invoice.notes && (
<View style={styles.notesSection}>
<Text style={styles.notesTitle}>Notes</Text>
<Text style={styles.notesText}>{invoice.notes}</Text>
</View>
)}
</Page>
</Document>
);
};
export default ReceiptPDFTemplate;
-102
View File
@@ -1,102 +0,0 @@
/**
* 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`;
}
@@ -1,691 +0,0 @@
'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;
@@ -1,765 +0,0 @@
'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;
@@ -1,360 +0,0 @@
'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;
@@ -1,9 +0,0 @@
/**
* Recurrence Admin Components
* Part of Invoice Module
*/
export { default as RecurrencesListPage } from './RecurrencesListPage.js';
export { default as RecurrenceCreatePage } from './RecurrenceCreatePage.js';
export { default as RecurrenceEditPage } from './RecurrenceEditPage.js';
-369
View File
@@ -1,369 +0,0 @@
/**
* Recurrences Module - CRUD Operations
* Create, Read, Update, Delete operations for invoice recurrences
* Part of Invoice Module
*/
import { query } from '@hykocx/zen/database';
import {
parseUTCDate,
formatDateForInput,
getTodayUTC,
addDays
} from '../../../shared/lib/dates.js';
/**
* Calculate next due date based on recurrence settings
* @param {Object} recurrence - Recurrence object with frequency settings
* @param {Date|string} fromDate - Starting date (defaults to today)
* @returns {string} Next due date in YYYY-MM-DD format
*/
export function calculateNextDueDate(recurrence, fromDate = null) {
const { frequency_type, frequency_value, recurrence_day } = recurrence;
let baseDate = fromDate ? parseUTCDate(fromDate) : getTodayUTC();
// If there's a last invoice date, start from there
if (recurrence.last_invoice_date && !fromDate) {
baseDate = parseUTCDate(recurrence.last_invoice_date);
}
let nextDate = new Date(baseDate);
if (frequency_type === 'days') {
// Add X days using UTC methods
nextDate.setUTCDate(nextDate.getUTCDate() + parseInt(frequency_value));
} else if (frequency_type === 'months') {
// Add X months and set to recurrence_day using UTC methods
nextDate.setUTCMonth(nextDate.getUTCMonth() + parseInt(frequency_value));
nextDate.setUTCDate(Math.min(parseInt(recurrence_day), 28));
} else if (frequency_type === 'years') {
// Add X years and set to recurrence_day using UTC methods
nextDate.setUTCFullYear(nextDate.getUTCFullYear() + parseInt(frequency_value));
nextDate.setUTCDate(Math.min(parseInt(recurrence_day), 28));
}
return formatDateForInput(nextDate);
}
/**
* Calculate first due date for a new recurrence
* @param {Object} recurrence - Recurrence settings
* @returns {string} First due date in YYYY-MM-DD format
*/
export function calculateFirstDueDate(recurrence) {
const { frequency_type, recurrence_day, first_occurrence_date } = recurrence;
// If a custom first occurrence date is specified, use it
if (first_occurrence_date) {
return formatDateForInput(parseUTCDate(first_occurrence_date));
}
const today = getTodayUTC();
if (frequency_type === 'days') {
// For days-based recurrence, due date is today + frequency_value days
const dueDate = new Date(today);
dueDate.setUTCDate(dueDate.getUTCDate() + parseInt(recurrence.frequency_value));
return formatDateForInput(dueDate);
}
// For months/years, find the next occurrence of recurrence_day
const targetDay = parseInt(recurrence_day);
const currentDay = today.getUTCDate();
let firstDueDate = new Date(today);
firstDueDate.setUTCDate(targetDay);
// If the target day has already passed this month, move to next period
if (currentDay >= targetDay) {
if (frequency_type === 'months') {
firstDueDate.setUTCMonth(firstDueDate.getUTCMonth() + 1);
} else if (frequency_type === 'years') {
firstDueDate.setUTCFullYear(firstDueDate.getUTCFullYear() + 1);
}
}
return formatDateForInput(firstDueDate);
}
/**
* Create a new recurrence
* @param {Object} recurrenceData - Recurrence data
* @returns {Promise<Object>} Created recurrence
*/
export async function createRecurrence(recurrenceData) {
const {
client_id,
frequency_type,
frequency_value,
recurrence_day,
first_reminder_days = 30,
first_occurrence_date = null,
items = [],
notes = null,
is_active = true,
} = recurrenceData;
// Validate required fields
if (!client_id || !frequency_type || !frequency_value || !recurrence_day) {
throw new Error('Client, frequency type, frequency value, and recurrence day are required');
}
// Validate frequency_type
if (!['days', 'months', 'years'].includes(frequency_type)) {
throw new Error('Frequency type must be days, months, or years');
}
// Validate frequency_value
if (frequency_value < 1) {
throw new Error('Frequency value must be at least 1');
}
// Validate recurrence_day
if (recurrence_day < 1 || recurrence_day > 28) {
throw new Error('Recurrence day must be between 1 and 28');
}
// Validate items
if (!Array.isArray(items) || items.length === 0) {
throw new Error('At least one item is required');
}
// Calculate next_due_date for new recurrence
const next_due_date = calculateFirstDueDate({
frequency_type,
frequency_value,
recurrence_day,
first_occurrence_date
});
const result = await query(
`INSERT INTO zen_invoice_recurrences
(client_id, frequency_type, frequency_value, recurrence_day,
first_reminder_days, first_occurrence_date, items, notes, is_active, next_due_date)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING *`,
[client_id, frequency_type, frequency_value, recurrence_day,
first_reminder_days, first_occurrence_date, JSON.stringify(items), notes, is_active, next_due_date]
);
return result.rows[0];
}
/**
* Get recurrence by ID
* @param {number} id - Recurrence ID
* @returns {Promise<Object|null>}
*/
export async function getRecurrenceById(id) {
const result = await query(
`SELECT r.*,
c.company_name, c.first_name, c.last_name, c.email as client_email,
i.invoice_number as last_invoice_number
FROM zen_invoice_recurrences r
LEFT JOIN zen_clients c ON r.client_id = c.id
LEFT JOIN zen_invoices i ON r.last_invoice_id = i.id
WHERE r.id = $1`,
[id]
);
return result.rows[0] || null;
}
/**
* Get all recurrences with pagination and filters
* @param {Object} options - Query options
* @returns {Promise<Object>} Recurrences and metadata
*/
export async function getRecurrences(options = {}) {
const {
page = 1,
limit = 50,
search = '',
client_id = null,
is_active = null,
sortBy = 'id',
sortOrder = 'DESC'
} = options;
const offset = (page - 1) * limit;
// Build where conditions
const conditions = [];
const params = [];
let paramIndex = 1;
if (search) {
conditions.push(`(c.company_name ILIKE $${paramIndex} OR c.first_name ILIKE $${paramIndex} OR c.last_name ILIKE $${paramIndex} OR c.email ILIKE $${paramIndex})`);
params.push(`%${search}%`);
paramIndex++;
}
if (client_id) {
conditions.push(`r.client_id = $${paramIndex}`);
params.push(client_id);
paramIndex++;
}
if (is_active !== null) {
conditions.push(`r.is_active = $${paramIndex}`);
params.push(is_active);
paramIndex++;
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
// Get total count (need to join with clients for search)
const countResult = await query(
`SELECT COUNT(*)
FROM zen_invoice_recurrences r
LEFT JOIN zen_clients c ON r.client_id = c.id
${whereClause}`,
params
);
const total = parseInt(countResult.rows[0].count);
// Get recurrences with client info
const recurrencesResult = await query(
`SELECT r.*,
c.company_name, c.first_name, c.last_name, c.email as client_email,
i.invoice_number as last_invoice_number
FROM zen_invoice_recurrences r
LEFT JOIN zen_clients c ON r.client_id = c.id
LEFT JOIN zen_invoices i ON r.last_invoice_id = i.id
${whereClause}
ORDER BY r.${sortBy} ${sortOrder}
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
[...params, limit, offset]
);
return {
recurrences: recurrencesResult.rows,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit)
}
};
}
/**
* Get all active recurrences (for cron processing)
* @returns {Promise<Array>}
*/
export async function getActiveRecurrences() {
const result = await query(
`SELECT r.*,
c.company_name, c.first_name, c.last_name, c.email as client_email,
c.client_number
FROM zen_invoice_recurrences r
LEFT JOIN zen_clients c ON r.client_id = c.id
WHERE r.is_active = true
ORDER BY r.next_due_date ASC`
);
return result.rows;
}
/**
* Update recurrence
* @param {number} id - Recurrence ID
* @param {Object} updates - Fields to update
* @returns {Promise<Object>} Updated recurrence
*/
export async function updateRecurrence(id, updates) {
const allowedFields = [
'client_id', 'frequency_type', 'frequency_value', 'recurrence_day',
'first_reminder_days', 'first_occurrence_date', 'items', 'notes', 'is_active',
'last_invoice_id', 'last_invoice_date', 'next_due_date'
];
const setFields = [];
const values = [];
let paramIndex = 1;
for (const [key, value] of Object.entries(updates)) {
if (allowedFields.includes(key)) {
// Handle JSONB fields
if (key === 'items' && typeof value === 'object') {
setFields.push(`${key} = $${paramIndex}`);
values.push(JSON.stringify(value));
} else {
setFields.push(`${key} = $${paramIndex}`);
values.push(value);
}
paramIndex++;
}
}
if (setFields.length === 0) {
throw new Error('No valid fields to update');
}
// Add updated_at
setFields.push(`updated_at = CURRENT_TIMESTAMP`);
// Add ID parameter
values.push(id);
const result = await query(
`UPDATE zen_invoice_recurrences
SET ${setFields.join(', ')}
WHERE id = $${paramIndex}
RETURNING *`,
values
);
return result.rows[0];
}
/**
* Update recurrence after invoice creation
* @param {number} id - Recurrence ID
* @param {number} invoiceId - Created invoice ID
* @param {string} invoiceDate - Invoice due date
* @returns {Promise<Object>} Updated recurrence
*/
export async function updateRecurrenceAfterInvoiceCreation(id, invoiceId, invoiceDate) {
// Get the recurrence to calculate next due date
const recurrence = await getRecurrenceById(id);
if (!recurrence) {
throw new Error('Recurrence not found');
}
// Calculate next due date from the invoice date
const next_due_date = calculateNextDueDate(recurrence, invoiceDate);
return await updateRecurrence(id, {
last_invoice_id: invoiceId,
last_invoice_date: invoiceDate,
next_due_date
});
}
/**
* Soft delete recurrence (set is_active to false)
* @param {number} id - Recurrence ID
* @returns {Promise<Object>} Updated recurrence
*/
export async function deactivateRecurrence(id) {
return await updateRecurrence(id, { is_active: false });
}
/**
* Delete recurrence permanently
* @param {number} id - Recurrence ID
* @returns {Promise<boolean>} Success status
*/
export async function deleteRecurrence(id) {
const result = await query(
`DELETE FROM zen_invoice_recurrences WHERE id = $1`,
[id]
);
return result.rowCount > 0;
}
-98
View File
@@ -1,98 +0,0 @@
/**
* 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}`);
}
}
@@ -1,119 +0,0 @@
/**
* Recurrence Processor
* Processes active recurrences and creates invoices automatically
* Part of Invoice Module
*/
import { getActiveRecurrences, updateRecurrenceAfterInvoiceCreation } from './crud.js';
import { createInvoice } from '../crud.js';
import { parseUTCDate, formatDateForInput, getTodayUTC } from '../../../shared/lib/dates.js';
/**
* Check if an invoice should be created for a recurrence
* @param {Object} recurrence - Recurrence object
* @param {Date} today - Current date
* @returns {boolean} Whether to create invoice
*/
export function shouldCreateInvoice(recurrence, today = null) {
if (!recurrence.next_due_date) {
return false;
}
const nextDueDate = parseUTCDate(recurrence.next_due_date);
const reminderDays = parseInt(recurrence.first_reminder_days) || 0;
// Calculate when the invoice should be created (due date - reminder days)
const creationDate = new Date(nextDueDate);
creationDate.setUTCDate(creationDate.getUTCDate() - reminderDays);
// Create invoice if today is on or after the creation date
// and before or on the due date
const todayDate = today || getTodayUTC();
const todayStr = formatDateForInput(todayDate);
const creationDateStr = formatDateForInput(creationDate);
const nextDueDateStr = formatDateForInput(nextDueDate);
return todayStr >= creationDateStr && todayStr <= nextDueDateStr;
}
/**
* Create invoice from recurrence
* @param {Object} recurrence - Recurrence object
* @returns {Promise<Object>} Created invoice
*/
async function createInvoiceFromRecurrence(recurrence) {
const invoiceData = {
client_id: recurrence.client_id,
due_date: recurrence.next_due_date,
first_reminder_days: recurrence.first_reminder_days,
items: recurrence.items || [],
notes: recurrence.notes,
status: 'sent', // Auto-created invoices are marked as sent
};
// Create the invoice
const invoice = await createInvoice(invoiceData);
return invoice;
}
/**
* Process all active recurrences and create invoices as needed
* @returns {Promise<Object>} Summary of processing
*/
export async function processRecurrences() {
const summary = {
processed: 0,
created: 0,
skipped: 0,
errors: []
};
try {
// Get all active recurrences
const recurrences = await getActiveRecurrences();
console.log(`[Recurrence Processor] Found ${recurrences.length} active recurrences`);
const today = getTodayUTC();
for (const recurrence of recurrences) {
summary.processed++;
try {
// Check if invoice should be created
if (shouldCreateInvoice(recurrence, today)) {
console.log(`[Recurrence Processor] Creating invoice for recurrence #${recurrence.id}`);
// Create the invoice
const invoice = await createInvoiceFromRecurrence(recurrence);
// Update recurrence with last invoice info
await updateRecurrenceAfterInvoiceCreation(
recurrence.id,
invoice.id,
invoice.due_date
);
summary.created++;
console.log(`[Recurrence Processor] Created invoice ${invoice.invoice_number} for recurrence #${recurrence.id}`);
} else {
summary.skipped++;
console.log(`[Recurrence Processor] Skipped recurrence #${recurrence.id} - not due yet`);
}
} catch (error) {
console.error(`[Recurrence Processor] Error processing recurrence #${recurrence.id}:`, error);
summary.errors.push({
recurrence_id: recurrence.id,
error: error.message
});
}
}
return summary;
} catch (error) {
console.error('[Recurrence Processor] Fatal error:', error);
throw error;
}
}
-510
View File
@@ -1,510 +0,0 @@
/**
* 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;
}
}
@@ -1,371 +0,0 @@
'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;
@@ -1,228 +0,0 @@
'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;
@@ -1,8 +0,0 @@
/**
* Transactions Admin Components
* Part of Invoice Module
*/
export { default as TransactionsListPage } from './TransactionsListPage.js';
export { default as TransactionCreatePage } from './TransactionCreatePage.js';
-377
View File
@@ -1,377 +0,0 @@
/**
* Transactions Module - CRUD Operations
* Create, Read, Update, Delete operations for transactions
* Part of Invoice Module
*/
import { query } from '@hykocx/zen/database';
/**
* Generate transaction number
* Format: TXN-YYYYMMDD-XXXXX
* @returns {Promise<string>}
*/
async function generateTransactionNumber() {
const date = new Date();
const dateStr = date.toISOString().slice(0, 10).replace(/-/g, '');
const prefix = `TXN-${dateStr}-`;
const result = await query(
`SELECT transaction_number FROM zen_transactions
WHERE transaction_number LIKE $1
ORDER BY transaction_number DESC
LIMIT 1`,
[`${prefix}%`]
);
let nextNumber = 1;
if (result.rows.length > 0) {
const lastTransaction = result.rows[0].transaction_number;
const lastNumber = parseInt(lastTransaction.split('-')[2]);
nextNumber = lastNumber + 1;
}
return `${prefix}${nextNumber.toString().padStart(5, '0')}`;
}
/**
* Payment method types
*/
export const PAYMENT_METHODS = {
STRIPE: 'stripe',
CASH: 'cash',
CHECK: 'check',
WIRE: 'wire',
INTERAC: 'interac',
OTHER: 'other'
};
/**
* Transaction status types
*/
export const TRANSACTION_STATUS = {
COMPLETED: 'completed',
PARTIAL: 'partial',
PENDING: 'pending',
FAILED: 'failed',
REFUNDED: 'refunded'
};
/**
* Create a new transaction
* @param {Object} transactionData - Transaction data
* @returns {Promise<Object>} Created transaction
*/
export async function createTransaction(transactionData) {
const {
invoice_id = null,
client_id = null,
amount,
payment_method,
status = TRANSACTION_STATUS.COMPLETED,
stripe_payment_intent_id = null,
stripe_charge_id = null,
notes = null,
transaction_date = new Date(),
} = transactionData;
// Validate required fields
if (!amount || !payment_method) {
throw new Error('Amount and payment method are required');
}
// Validate amount is positive
if (amount <= 0) {
throw new Error('Amount must be positive');
}
// If invoice_id is provided, get client_id from invoice if not provided
let finalClientId = client_id;
if (invoice_id && !client_id) {
const invoiceResult = await query(
`SELECT client_id FROM zen_invoices WHERE id = $1`,
[invoice_id]
);
if (invoiceResult.rows.length > 0) {
finalClientId = invoiceResult.rows[0].client_id;
}
}
// Generate transaction number
const transaction_number = await generateTransactionNumber();
const result = await query(
`INSERT INTO zen_transactions (
transaction_number, invoice_id, client_id, amount, payment_method,
status, stripe_payment_intent_id, stripe_charge_id, notes, transaction_date
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING *`,
[
transaction_number, invoice_id, finalClientId, amount, payment_method,
status, stripe_payment_intent_id, stripe_charge_id, notes, transaction_date
]
);
// If invoice_id is provided, update invoice paid_amount
if (invoice_id) {
const { markInvoiceAsPaid } = await import('../crud.js');
await markInvoiceAsPaid(invoice_id, amount);
}
return result.rows[0];
}
/**
* Get transaction by ID
* @param {number} id - Transaction ID
* @returns {Promise<Object|null>}
*/
export async function getTransactionById(id) {
const result = await query(
`SELECT t.*,
i.invoice_number,
c.first_name as client_first_name,
c.last_name as client_last_name,
c.company_name as client_company_name
FROM zen_transactions t
LEFT JOIN zen_invoices i ON t.invoice_id = i.id
LEFT JOIN zen_clients c ON t.client_id = c.id
WHERE t.id = $1`,
[id]
);
return result.rows[0] || null;
}
/**
* Get transaction by transaction number
* @param {string} transactionNumber - Transaction number
* @returns {Promise<Object|null>}
*/
export async function getTransactionByNumber(transactionNumber) {
const result = await query(
`SELECT t.*,
i.invoice_number,
c.first_name as client_first_name,
c.last_name as client_last_name,
c.company_name as client_company_name
FROM zen_transactions t
LEFT JOIN zen_invoices i ON t.invoice_id = i.id
LEFT JOIN zen_clients c ON t.client_id = c.id
WHERE t.transaction_number = $1`,
[transactionNumber]
);
return result.rows[0] || null;
}
/**
* Get all transactions with pagination and filters
* @param {Object} options - Query options
* @returns {Promise<Object>} Transactions and metadata
*/
export async function getTransactions(options = {}) {
const {
page = 1,
limit = 20,
search = '',
payment_method = null,
status = null,
invoice_id = null,
client_id = null,
start_date = null,
end_date = null,
sortBy = 'transaction_date',
sortOrder = 'DESC'
} = options;
const offset = (page - 1) * limit;
// Build where conditions
const conditions = [];
const params = [];
let paramIndex = 1;
if (search) {
conditions.push(`(
t.transaction_number ILIKE $${paramIndex} OR
i.invoice_number ILIKE $${paramIndex} OR
c.first_name ILIKE $${paramIndex} OR
c.last_name ILIKE $${paramIndex} OR
c.company_name ILIKE $${paramIndex}
)`);
params.push(`%${search}%`);
paramIndex++;
}
if (payment_method) {
conditions.push(`t.payment_method = $${paramIndex}`);
params.push(payment_method);
paramIndex++;
}
if (status) {
conditions.push(`t.status = $${paramIndex}`);
params.push(status);
paramIndex++;
}
if (invoice_id) {
conditions.push(`t.invoice_id = $${paramIndex}`);
params.push(invoice_id);
paramIndex++;
}
if (client_id) {
conditions.push(`t.client_id = $${paramIndex}`);
params.push(client_id);
paramIndex++;
}
if (start_date) {
conditions.push(`t.transaction_date >= $${paramIndex}`);
params.push(start_date);
paramIndex++;
}
if (end_date) {
conditions.push(`t.transaction_date <= $${paramIndex}`);
params.push(end_date);
paramIndex++;
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
// Get total count
const countResult = await query(
`SELECT COUNT(*)
FROM zen_transactions t
LEFT JOIN zen_invoices i ON t.invoice_id = i.id
LEFT JOIN zen_clients c ON t.client_id = c.id
${whereClause}`,
params
);
const total = parseInt(countResult.rows[0].count);
// Get transactions
const transactionsResult = await query(
`SELECT t.*,
i.invoice_number,
c.first_name as client_first_name,
c.last_name as client_last_name,
c.company_name as client_company_name
FROM zen_transactions t
LEFT JOIN zen_invoices i ON t.invoice_id = i.id
LEFT JOIN zen_clients c ON t.client_id = c.id
${whereClause}
ORDER BY t.${sortBy} ${sortOrder}
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
[...params, limit, offset]
);
return {
transactions: transactionsResult.rows,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit)
}
};
}
/**
* Get transactions by invoice ID
* @param {number} invoiceId - Invoice ID
* @returns {Promise<Array>}
*/
export async function getTransactionsByInvoice(invoiceId) {
const result = await query(
`SELECT t.*
FROM zen_transactions t
WHERE t.invoice_id = $1
ORDER BY t.transaction_date DESC`,
[invoiceId]
);
return result.rows;
}
/**
* Get transactions by client ID
* @param {number} clientId - Client ID
* @returns {Promise<Array>}
*/
export async function getTransactionsByClient(clientId) {
const result = await query(
`SELECT t.*, i.invoice_number
FROM zen_transactions t
LEFT JOIN zen_invoices i ON t.invoice_id = i.id
WHERE t.client_id = $1
ORDER BY t.transaction_date DESC`,
[clientId]
);
return result.rows;
}
/**
* Update transaction
* @param {number} id - Transaction ID
* @param {Object} updates - Fields to update
* @returns {Promise<Object>} Updated transaction
*/
export async function updateTransaction(id, updates) {
const allowedFields = [
'amount', 'payment_method', 'status', 'notes', 'transaction_date'
];
const setFields = [];
const values = [];
let paramIndex = 1;
for (const [key, value] of Object.entries(updates)) {
if (allowedFields.includes(key)) {
setFields.push(`${key} = $${paramIndex}`);
values.push(value);
paramIndex++;
}
}
if (setFields.length === 0) {
throw new Error('No valid fields to update');
}
// Add updated_at
setFields.push(`updated_at = CURRENT_TIMESTAMP`);
// Add ID parameter
values.push(id);
const result = await query(
`UPDATE zen_transactions
SET ${setFields.join(', ')}
WHERE id = $${paramIndex}
RETURNING *`,
values
);
return result.rows[0];
}
/**
* Delete transaction
* @param {number} id - Transaction ID
* @returns {Promise<boolean>} Success status
*/
export async function deleteTransaction(id) {
const result = await query(
`DELETE FROM zen_transactions WHERE id = $1`,
[id]
);
return result.rowCount > 0;
}
-82
View File
@@ -1,82 +0,0 @@
/**
* 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}`);
}
}
+1 -47
View File
@@ -17,59 +17,13 @@
* ```
*/
// Import module actions (use relative paths for build compatibility)
import {
isStripeEnabled,
isInteracEnabled,
getInteracEmail,
getPublicPageConfig,
getInvoiceByTokenAction,
createStripeCheckoutSessionAction,
generateInvoicePDFAction,
getInteracCredentialsAction,
generateReceiptPDFAction,
} from './invoice/actions.js';
import {
getShareByTokenAction,
verifySharePasswordAction,
getSharedContentsAction,
getShareFileDownloadUrlAction,
uploadToShareAction,
getPublicPageConfig as getNuagePublicPageConfig,
} from './nuage/actions.js';
// Import dashboard stats actions
import { getInvoiceDashboardStats } from './invoice/dashboard/statsActions.js';
// Register module actions (for public pages)
export const MODULE_ACTIONS = {
invoice: {
isStripeEnabled,
isInteracEnabled,
getInteracEmail,
getPublicPageConfig,
getInvoiceByTokenAction,
createStripeCheckoutSessionAction,
generateInvoicePDFAction,
getInteracCredentialsAction,
generateReceiptPDFAction,
},
posts: {},
nuage: {
getPublicPageConfig: getNuagePublicPageConfig,
getShareByTokenAction,
verifySharePasswordAction,
getSharedContentsAction,
getShareFileDownloadUrlAction,
uploadToShareAction,
},
};
// Register dashboard stats actions (for admin dashboard)
export const MODULE_DASHBOARD_ACTIONS = {
invoice: getInvoiceDashboardStats,
};
export const MODULE_DASHBOARD_ACTIONS = {};
/**
* Get actions for a specific module
+1 -8
View File
@@ -14,15 +14,8 @@
* ```
*/
// Import module metadata (use relative paths for build compatibility)
import * as invoiceMetadata from './invoice/metadata.js';
import * as nuageMetadata from './nuage/metadata.js';
// Register module metadata
export const MODULE_METADATA = {
invoice: invoiceMetadata,
nuage: nuageMetadata,
};
export const MODULE_METADATA = {};
/**
* Get metadata generators for a specific module
-6
View File
@@ -8,17 +8,11 @@
*/
// Import module configs
import clientsConfig from './clients/module.config.js';
import invoiceConfig from './invoice/module.config.js';
import postsConfig from './posts/module.config.js';
import nuageConfig from './nuage/module.config.js';
// Register module configs
const MODULE_CONFIGS = {
clients: clientsConfig,
invoice: invoiceConfig,
posts: postsConfig,
nuage: nuageConfig,
};
/**
-3
View File
@@ -12,10 +12,7 @@
* 5. modules/init.js → Import createTables for database CLI
*/
export const AVAILABLE_MODULES = [
'clients',
'invoice',
'posts',
'nuage',
];
export function getAvailableModules() {
-4
View File
@@ -1,4 +0,0 @@
#################################
# MODULE NUAGE
ZEN_MODULE_NUAGE=false
#################################
-192
View File
@@ -1,192 +0,0 @@
# Nuage module Dashboard (Mes fichiers)
This guide explains how to add a **Mes fichiers** (My files) section to your client dashboard so that logged-in users can browse, download, and upload files/folders that have been shared with them by an admin.
## Prerequisites
- Auth and dashboard set up as in [Client dashboard and user features](../../features/auth/README-dashboard.md).
- Nuage module enabled (`ZEN_MODULE_NUAGE=true`).
- **Usershare link**: In the admin Nuage explorer, an admin must have created a share targeting a specific user (by their email/account). The share is stored in `zen_nuage_shares` with `user_id` set to the user.
## API for "my shares"
When a user is authenticated, you can load all active file/folder shares assigned to them with:
| Endpoint | Method | Auth | Description |
|----------|--------|------|-------------|
| `/zen/api/nuage/me` | GET | Session (user) | Returns all active shares assigned to the current user. |
| `/zen/api/nuage/shared` | GET | Session (user) | Returns the contents of a shared folder (subfolders + files). |
| `/zen/api/nuage/download` | GET | Session (user) | Returns a signed download URL for a file inside a share. |
| `/zen/api/nuage/upload` | POST | Session (user) | Uploads a file into a shared folder (collaborator permission only). |
### GET `/zen/api/nuage/me`
**Example response (user has active shares):**
```json
{
"success": true,
"shares": [
{
"id": 1,
"token": "abc123...",
"target_type": "folder",
"target_id": "42",
"target_name": "Contrats 2025",
"permission": "reader",
"expires_at": null
},
{
"id": 2,
"token": "def456...",
"target_type": "file",
"target_id": "17",
"target_name": "Devis-2025-01.pdf",
"permission": "collaborator",
"expires_at": "2025-12-31T23:59:59.000Z"
}
]
}
```
**When the user has no active shares:**
```json
{
"success": true,
"shares": []
}
```
### GET `/zen/api/nuage/shared`
**Query parameters:**
- `shareId` *(required)* The share `token` returned by `/nuage/me`.
- `folder` *(optional)* ID of a subfolder to navigate into.
**Example response:**
```json
{
"success": true,
"folders": [
{ "id": "10", "name": "Sous-dossier", "created_at": "2025-03-01T10:00:00.000Z" }
],
"files": [
{
"id": "55",
"display_name": "rapport.pdf",
"mime_type": "application/pdf",
"size": 204800
}
],
"breadcrumb": [
{ "id": "10", "name": "Sous-dossier" }
],
"permission": "reader"
}
```
### GET `/zen/api/nuage/download`
**Query parameters:**
- `fileId` *(required)* ID of the file to download.
- `shareId` *(required)* The share `token`.
Returns a signed URL to download the file directly from storage.
### POST `/zen/api/nuage/upload`
Only available for shares with `permission: "collaborator"` targeting a folder.
**Form data fields:**
- `file` *(required)* The file to upload.
- `shareId` *(required)* The share `token`.
- `folderId` *(optional)* ID of a subfolder to upload into (must be within the share scope).
All requests must send the session cookie (e.g. `fetch(..., { credentials: 'include' })`).
## Adding the Mes fichiers section to the dashboard
### 1. Protected page
Create a dashboard page that requires login and renders the Nuage section:
```js
// app/dashboard/fichiers/page.js (Server Component)
import { protect } from '@hykocx/zen/auth';
import ClientNuageSection from '@hykocx/zen/nuage/dashboard';
export default async function DashboardFichiersPage() {
await protect({ redirectTo: '/auth/login' });
return (
<div>
<h1>Mes fichiers</h1>
<ClientNuageSection
apiBasePath="/zen/api"
nuagePageBasePath="/zen/nuage"
/>
</div>
);
}
```
**Props:**
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `apiBasePath` | `string` | `"/zen/api"` | Base path for the Zen API. |
| `nuagePageBasePath` | `string` | `"/zen/nuage"` | Base path for the Nuage public pages (currently unused, reserved for future links). |
| `emptyMessage` | `string` | `"Aucun document partagé pour le moment."` | Message shown when the user has no active shares. |
| `newTab` | `boolean` | `false` | Open file download links in a new browser tab. |
### 2. Link in the dashboard layout
In your dashboard layout or nav, add a link to the Mes fichiers page:
```js
<Link href="/dashboard/fichiers">Mes fichiers</Link>
```
## Behaviour summary
- **Logged in, has active shares** → The section lists all files/folders shared with the user. Clicking a share opens a file browser with navigation, download, and (if `collaborator`) upload.
- **Logged in, no active shares** → The section shows `emptyMessage`.
- **Expired share** → The share is displayed as greyed out and cannot be opened.
- **Not logged in** → The dashboard page should not be reachable if you use `protect()` and redirect to login.
- **File preview** → Clicking a viewable file (image, PDF, video, audio, plain text) opens a full-screen viewer. Non-viewable files trigger a direct download.
- **Sorting** → The file list can be sorted by name, date, or size (ascending/descending).
- **Upload feedback** → When uploading as a collaborator, a per-file progress queue is displayed with a checkmark once each file completes.
## Share permissions
There are two permission levels for a share:
| Permission | Browse | Download | Upload |
|------------|--------|----------|--------|
| `reader` | Yes | Yes | No |
| `collaborator` | Yes | Yes | Yes (folder shares only) |
The collaborator upload zone appears automatically when the share is a folder with `permission: "collaborator"`. Files can be dragged and dropped or selected via the file picker. An optional upload size limit (`upload_limit_bytes`) can be set by the admin on the share.
## Creating a share (admin side)
In the Zen admin Nuage explorer:
1. Navigate to the file or folder you want to share.
2. Click the **Share** button (or the share icon).
3. Search for the user by email and select them.
4. Set the permission (`reader` or `collaborator`).
5. Optionally set an expiry date and an upload size limit.
6. Save. The user will immediately see the share in their dashboard under `GET /zen/api/nuage/me`.
## Security
- All four client-facing endpoints (`/nuage/me`, `/nuage/shared`, `/nuage/download`, `/nuage/upload`) require a valid session (`auth: 'user'`). No admin role needed.
- A user can only access shares explicitly assigned to them (`zen_nuage_shares.user_id = session.user.id`).
- File download and folder navigation are scoped to the share target — it is not possible to access files outside the shared scope (IDOR protection enforced server-side).
- Upload is only allowed when `permission = 'collaborator'` and `target_type = 'folder'`.
-154
View File
@@ -1,154 +0,0 @@
# Nuage Module
Integrated file manager with Cloudflare R2 storage, admin file explorer, and document sharing. Supports anonymous shares (public link) and user shares (client dashboard), with permission control, password protection, expiry, and upload limits.
---
## Features
- **File explorer**: folder navigation (recursive tree), upload, rename, move, delete
- **R2 storage**: all files stored in Cloudflare R2 via the Zen storage layer
- **Anonymous shares**: unique public link per token — accessible without an account
- **User shares**: share linked to an account (`reader` or `collaborator`) visible in the client dashboard
- **Password protection**: optional per anonymous share
- **Expiry**: configurable expiry date per share
- **Upload limit**: byte quota per shared folder
- **Permissions**: `reader` (browse + download) or `collaborator` (+ upload)
- **Preview**: images, PDFs, videos, audio, plain text — directly in the browser
- **Share email**: notification sent to the user on a new share
- **Client dashboard**: ready-to-integrate `ClientNuageSection` component
---
## Installation
### 1. Environment variables
Copy variables from [`.env.example`](.env.example) into your `.env`:
> This module uses Zen storage (`ZEN_STORAGE_*`) — make sure your Cloudflare R2 storage variables are configured in your main `.env`.
### 2. Database tables
Tables are created automatically with `npx zen-db init`. For reference, here are the module tables:
| Table | Description |
|---|---|
| `zen_nuage_folders` | Folder tree (recursive via `parent_id`) |
| `zen_nuage_files` | Stored files (metadata + R2 key) |
| `zen_nuage_shares` | Shares (anonymous or user-linked) |
> This module depends on `zen_auth_users` (**auth** module) for user shares.
---
## Admin interface
| Page | URL |
|---|---|
| File explorer | `/admin/nuage/explorateur` |
| Share list | `/admin/nuage/partages` |
---
## Public page (no authentication)
| URL | Description |
|---|---|
| `/zen/nuage/partage/:token` | Access an anonymous share |
Each share has a unique token. If the share is password-protected, a password entry page appears before accessing the content.
---
## Admin API (admin authentication required)
### Explorer
| Method | Route | Description |
|---|---|---|
| `GET` | `/zen/api/admin/nuage?folder={id}` | Folder contents (root if omitted) |
### Folders
| Method | Route | Description |
|---|---|---|
| `POST` | `/zen/api/admin/nuage/folders` | Create a folder |
| `PUT` | `/zen/api/admin/nuage/folders` | Rename or move a folder |
| `DELETE` | `/zen/api/admin/nuage/folders?id={id}` | Delete an empty folder |
| `DELETE` | `/zen/api/admin/nuage/folders?id={id}&force=true` | Delete a non-empty folder |
### Files
| Method | Route | Description |
|---|---|---|
| `POST` | `/zen/api/admin/nuage/files` | Upload a file (max 500 MB) |
| `PUT` | `/zen/api/admin/nuage/files` | Rename or move a file |
| `DELETE` | `/zen/api/admin/nuage/files?id={id}` | Delete a file |
| `GET` | `/zen/api/admin/nuage/files/download?id={id}` | Download a file |
### Shares
| Method | Route | Description |
|---|---|---|
| `GET` | `/zen/api/admin/nuage/shares` | List all active shares |
| `GET` | `/zen/api/admin/nuage/shares?targetId={id}&targetType={type}` | Shares for a file or folder |
| `POST` | `/zen/api/admin/nuage/shares` | Create a share |
| `PATCH` | `/zen/api/admin/nuage/shares` | Update a share |
| `DELETE` | `/zen/api/admin/nuage/shares?id={id}` | Revoke a share |
| `POST` | `/zen/api/admin/nuage/shares/email` | Send a notification email |
**Create share parameters (`POST /zen/api/admin/nuage/shares`):**
| Field | Required | Description |
|---|---|---|
| `targetId` | Yes | UUID of the target file or folder |
| `targetType` | Yes | `"file"` or `"folder"` |
| `shareType` | Yes | `"anonymous"` (public link) or `"user"` (account-linked) |
| `permission` | Yes | `"reader"` or `"collaborator"` |
| `userId` | No | User ID (if `shareType: "user"`) |
| `password` | No | Protection password |
| `expiresAt` | No | Expiry date (ISO 8601) |
| `uploadLimitBytes` | No | Storage limit for the shared folder (in bytes) |
### User search
| Method | Route | Description |
|---|---|---|
| `GET` | `/zen/api/admin/nuage/users?q={query}` | Search a user by email or name |
---
## Client API (user authentication required)
| Method | Route | Description |
|---|---|---|
| `GET` | `/zen/api/nuage/me` | List active shares for the current user |
| `GET` | `/zen/api/nuage/shared?shareId={token}&folder={id}` | Contents of a shared folder |
| `GET` | `/zen/api/nuage/download?fileId={id}&shareId={token}` | Download a file (secure proxy) |
| `POST` | `/zen/api/nuage/upload` | Upload a file into a `collaborator` share |
## Public API (no authentication)
| Method | Route | Description |
|---|---|---|
| `GET` | `/zen/api/nuage/share/download?token={token}&fileId={id}` | Download a file via anonymous link |
> Anonymous download verifies the password via a signed cookie (set when the password is validated on the public side).
---
## Share permissions
| Permission | Browse | Download | Upload |
|---|---|---|---|
| `reader` | Yes | Yes | No |
| `collaborator` | Yes | Yes | Yes (folders only) |
Collaborator upload supports drag-and-drop and file picker. An upload limit (`upload_limit_bytes`) can be set by the admin on the share.
---
## Client dashboard (My files)
To add a "My files" section to the client dashboard, see [GUIDE-client-dashboard.md](./GUIDE-client-dashboard.md).
-274
View File
@@ -1,274 +0,0 @@
/**
* Nuage Module — Server Actions
* Public-facing server actions for anonymous share pages
*/
'use server';
import { cookies } from 'next/headers';
import {
getShareByToken,
isShareValid,
verifySharePassword,
getSharedFolderContents,
getSharedBreadcrumb,
getFileById,
getFolderUploadedSize,
uploadNuageFile,
isFileInShare,
isFolderInShare,
getPasswordCookieName,
signPasswordToken,
} from './crud.js';
const MAX_FILE_SIZE_BYTES = 500 * 1024 * 1024; // 500 MB hard cap
// ─── Brute-force rate limiter for share password attempts ────────────────────
// Keyed by share token. Resets after WINDOW_MS of inactivity.
const _passwordAttempts = new Map();
const PASSWORD_MAX_ATTEMPTS = 10;
const PASSWORD_WINDOW_MS = 15 * 60 * 1000; // 15 minutes
function checkPasswordRateLimit(token) {
const now = Date.now();
const entry = _passwordAttempts.get(token);
if (entry && now < entry.resetAt) {
if (entry.count >= PASSWORD_MAX_ATTEMPTS) {
return false; // locked out
}
entry.count += 1;
} else {
_passwordAttempts.set(token, { count: 1, resetAt: now + PASSWORD_WINDOW_MS });
}
return true;
}
function clearPasswordRateLimit(token) {
_passwordAttempts.delete(token);
}
// ─── Internal helpers ────────────────────────────────────────────────────────
async function isPasswordVerified(shareToken) {
const cookieStore = await cookies();
const cookieValue = cookieStore.get(getPasswordCookieName(shareToken))?.value;
return !!cookieValue && cookieValue === signPasswordToken(shareToken);
}
async function requirePasswordIfProtected(share) {
if (!share.password_hash) return true;
return isPasswordVerified(share.token);
}
/**
* Get public page config (logos) from environment variables.
*/
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 || '',
};
}
/**
* Retrieve and validate a share by token.
* Returns the share metadata (without password hash) or an error.
*/
export async function getShareByTokenAction(token) {
try {
const share = await getShareByToken(token);
if (!share) {
return { success: false, error: 'INVALID', message: 'Ce lien n\'est pas valide' };
}
if (!share.is_active) {
return { success: false, error: 'REVOKED', message: 'Ce lien a été révoqué' };
}
if (share.expires_at && new Date(share.expires_at) < new Date()) {
return { success: false, error: 'EXPIRED', message: 'Ce lien est expiré' };
}
const { password_hash, ...safeShare } = share;
return {
success: true,
share: safeShare,
requiresPassword: !!password_hash,
};
} catch (error) {
console.error('getShareByTokenAction error:', error);
return { success: false, error: 'ERROR', message: 'Erreur lors de la validation du lien' };
}
}
/**
* Verify the password for a password-protected share.
*/
export async function verifySharePasswordAction(token, password) {
try {
const share = await getShareByToken(token);
if (!share || !isShareValid(share)) {
return { success: false, error: 'Lien invalide' };
}
if (!checkPasswordRateLimit(token)) {
return { success: false, error: 'Trop de tentatives. Réessayez dans 15 minutes.' };
}
const valid = await verifySharePassword(share, password);
if (!valid) {
return { success: false, error: 'Mot de passe incorrect' };
}
clearPasswordRateLimit(token);
// Set an httpOnly cookie so subsequent server actions can verify the password
// was validated without relying on client-side state
const cookieStore = await cookies();
cookieStore.set(getPasswordCookieName(token), signPasswordToken(token), {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24, // 24 hours
path: '/',
});
return { success: true };
} catch (error) {
console.error('verifySharePasswordAction error:', error);
return { success: false, error: 'Erreur lors de la vérification du mot de passe' };
}
}
/**
* Get the contents of a shared folder (or file) for the public page.
* folderId is only used when navigating inside a shared folder.
*/
export async function getSharedContentsAction(token, folderId = null) {
try {
const share = await getShareByToken(token);
if (!share || !isShareValid(share)) {
return { success: false, error: 'Lien invalide ou expiré' };
}
// Enforce password protection server-side
const passwordOk = await requirePasswordIfProtected(share);
if (!passwordOk) {
return { success: false, error: 'Mot de passe requis' };
}
const contents = await getSharedFolderContents(share, folderId);
const breadcrumb = await getSharedBreadcrumb(share, folderId);
return {
success: true,
...contents,
breadcrumb,
permission: share.permission,
targetType: share.target_type,
uploadLimitBytes: share.upload_limit_bytes,
};
} catch (error) {
console.error('getSharedContentsAction error:', error);
return { success: false, error: error.message };
}
}
/**
* Get a proxy download URL for a file within a valid share.
* Returns the internal proxy endpoint URL — never a direct storage link.
*/
export async function getShareFileDownloadUrlAction(token, fileId) {
try {
const share = await getShareByToken(token);
if (!share || !isShareValid(share)) {
return { success: false, error: 'Lien invalide ou expiré' };
}
// Enforce password protection server-side
const passwordOk = await requirePasswordIfProtected(share);
if (!passwordOk) {
return { success: false, error: 'Mot de passe requis' };
}
// Ensure the requested file belongs to this share (prevent IDOR)
const inScope = await isFileInShare(fileId, share);
if (!inScope) return { success: false, error: 'Accès refusé' };
const file = await getFileById(fileId);
if (!file) return { success: false, error: 'Fichier introuvable' };
const url = `/zen/api/nuage/share/download?token=${encodeURIComponent(token)}&fileId=${encodeURIComponent(fileId)}`;
return { success: true, url, filename: file.display_name, mimeType: file.mime_type };
} catch (error) {
console.error('getShareFileDownloadUrlAction error:', error);
return { success: false, error: error.message };
}
}
/**
* Upload a file as a collaborator via an anonymous share link.
* Accepts a FormData object from the client.
*/
export async function uploadToShareAction(token, formData) {
try {
const share = await getShareByToken(token);
if (!share || !isShareValid(share)) {
return { success: false, error: 'Lien invalide ou expiré' };
}
// Enforce password protection server-side
const passwordOk = await requirePasswordIfProtected(share);
if (!passwordOk) {
return { success: false, error: 'Mot de passe requis' };
}
if (share.permission !== 'collaborator') {
return { success: false, error: 'Permission insuffisante pour l\'upload' };
}
if (share.target_type !== 'folder') {
return { success: false, error: 'L\'upload n\'est possible que sur un dossier partagé' };
}
const file = formData.get('file');
const folderId = formData.get('folderId') || share.target_id;
// Ensure the target folder is within the share scope (prevent IDOR)
if (folderId !== share.target_id) {
const inScope = await isFolderInShare(folderId, share);
if (!inScope) return { success: false, error: 'Accès refusé' };
}
if (!file || typeof file === 'string') {
return { success: false, error: 'Aucun fichier fourni' };
}
if (file.size > MAX_FILE_SIZE_BYTES) {
return { success: false, error: 'Fichier trop volumineux (max 500 Mo)' };
}
// Enforce upload limit before buffering to avoid loading oversized files into memory
if (share.upload_limit_bytes) {
const currentSize = await getFolderUploadedSize(folderId);
if (currentSize + file.size > share.upload_limit_bytes) {
return { success: false, error: 'La limite de stockage du dossier partagé est atteinte' };
}
}
const buffer = Buffer.from(await file.arrayBuffer());
const nuageFile = await uploadNuageFile(
folderId,
buffer,
file.name,
file.type || 'application/octet-stream',
buffer.length
);
return { success: true, file: nuageFile };
} catch (error) {
console.error('uploadToShareAction error:', error);
return { success: false, error: error.message };
}
}
-796
View File
@@ -1,796 +0,0 @@
'use client';
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import {
Folder01Icon,
CloudUploadIcon,
Delete02Icon,
PencilEdit01Icon,
Link02Icon,
EyeIcon,
PlusSignCircleIcon,
ArrowUp01Icon,
Tick02Icon,
Alert01Icon,
} from '../../../shared/Icons.js';
import { Button, Card, Loading, Input, Modal, Breadcrumb } from '../../../shared/components';
import NuageFileTable, { getFileIcon, getFileColor } from '../components/NuageFileTable.js';
import FileViewerModal, { isViewable } from '../components/FileViewerModal.js';
import SharePanel from '../components/SharePanel.js';
import { useToast } from '@hykocx/zen/toast';
// ─── Main ExplorerPage ────────────────────────────────────────────────────────
const ExplorerPage = ({ user }) => {
const router = useRouter();
const toast = useToast();
const fileInputRef = useRef(null);
const dropRef = useRef(null);
const [folderId, setFolderId] = useState(null);
const [contents, setContents] = useState({ folders: [], files: [] });
const [breadcrumb, setBreadcrumb] = useState([]);
const [currentFolder, setCurrentFolder] = useState(null);
const [loading, setLoading] = useState(true);
const [viewMode, setViewMode] = useState('list');
const [sortBy, setSortBy] = useState('name');
const [sortOrder, setSortOrder] = useState('asc');
// Upload state
const [uploading, setUploading] = useState(false);
const [dragOver, setDragOver] = useState(false);
const [dragOverFolder, setDragOverFolder] = useState(null);
const [uploadQueue, setUploadQueue] = useState([]);
// New folder modal
const [showNewFolder, setShowNewFolder] = useState(false);
const [newFolderName, setNewFolderName] = useState('');
const [creatingFolder, setCreatingFolder] = useState(false);
// Rename modal
const [renameTarget, setRenameTarget] = useState(null);
const [renameName, setRenameName] = useState('');
const [renaming, setRenaming] = useState(false);
// Delete confirmation
const [deleteTarget, setDeleteTarget] = useState(null);
const [deleting, setDeleting] = useState(false);
// Move modal
const [moveTarget, setMoveTarget] = useState(null);
const [allFolders, setAllFolders] = useState([]);
const [moving, setMoving] = useState(false);
// Share panel
const [shareTarget, setShareTarget] = useState(null);
// File viewer
const [viewerFile, setViewerFile] = useState(null); // { file, url }
// Context menu
const [contextMenu, setContextMenu] = useState(null); // { item, x, y }
// Sync folderId with URL
useEffect(() => {
if (typeof window !== 'undefined') {
const params = new URLSearchParams(window.location.search);
const id = params.get('folder') || null;
setFolderId(id);
}
}, []);
// Handle browser back/forward
useEffect(() => {
if (typeof window === 'undefined') return;
const onPop = () => {
const params = new URLSearchParams(window.location.search);
const id = params.get('folder') || null;
setFolderId(id);
};
window.addEventListener('popstate', onPop);
return () => window.removeEventListener('popstate', onPop);
}, []);
const navigateTo = useCallback((id) => {
setFolderId(id);
const url = id ? `/admin/nuage/explorateur?folder=${id}` : '/admin/nuage/explorateur';
router.push(url);
}, [router]);
useEffect(() => {
loadContents();
}, [folderId]);
const loadContents = async () => {
setLoading(true);
try {
const params = folderId ? `?folder=${folderId}` : '';
const res = await fetch(`/zen/api/admin/nuage${params}`, { credentials: 'include' });
const data = await res.json();
if (data.success) {
setContents({ folders: data.folders || [], files: data.files || [] });
setBreadcrumb(data.breadcrumb || []);
setCurrentFolder(data.currentFolder || null);
} else {
toast.error(data.error || 'Erreur lors du chargement');
}
} catch (e) {
console.error(e);
toast.error('Erreur réseau');
} finally {
setLoading(false);
}
};
// ─── Sort helpers ───────────────────────────────────────────────────────────
const sorted = (items, isFolder) => {
return [...items].sort((a, b) => {
let valA, valB;
if (sortBy === 'name') {
valA = (isFolder ? a.name : a.display_name)?.toLowerCase();
valB = (isFolder ? b.name : b.display_name)?.toLowerCase();
} else if (sortBy === 'date') {
valA = a.created_at;
valB = b.created_at;
} else if (sortBy === 'size' && !isFolder) {
valA = a.size;
valB = b.size;
} else {
return 0;
}
if (valA < valB) return sortOrder === 'asc' ? -1 : 1;
if (valA > valB) return sortOrder === 'asc' ? 1 : -1;
return 0;
});
};
const toggleSort = (field) => {
if (sortBy === field) setSortOrder(o => o === 'asc' ? 'desc' : 'asc');
else { setSortBy(field); setSortOrder('asc'); }
};
// ─── Folder actions ─────────────────────────────────────────────────────────
const handleCreateFolder = async () => {
if (!newFolderName.trim()) return;
setCreatingFolder(true);
try {
const res = await fetch('/zen/api/admin/nuage/folders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ name: newFolderName, parentId: folderId }),
});
const data = await res.json();
if (data.success) {
toast.success('Dossier créé');
setShowNewFolder(false);
setNewFolderName('');
loadContents();
} else {
toast.error(data.error || 'Erreur');
}
} catch (e) {
toast.error('Erreur réseau');
} finally {
setCreatingFolder(false);
}
};
const handleDeleteConfirm = async () => {
if (!deleteTarget) return;
setDeleting(true);
try {
const isFolder = deleteTarget.type === 'folder';
const path = isFolder
? `/zen/api/admin/nuage/folders?id=${deleteTarget.id}&force=true`
: `/zen/api/admin/nuage/files?id=${deleteTarget.id}`;
const res = await fetch(path, { method: 'DELETE', credentials: 'include' });
const data = await res.json();
if (data.success) {
toast.success(isFolder ? 'Dossier supprimé' : 'Fichier supprimé');
setDeleteTarget(null);
loadContents();
} else {
toast.error(data.error || 'Erreur');
}
} catch (e) {
toast.error('Erreur réseau');
} finally {
setDeleting(false);
}
};
const handleRenameConfirm = async () => {
if (!renameTarget || !renameName.trim()) return;
setRenaming(true);
try {
const isFolder = renameTarget.type === 'folder';
const body = isFolder
? { id: renameTarget.id, action: 'rename', name: renameName }
: { id: renameTarget.id, action: 'rename', displayName: renameName };
const path = isFolder ? '/zen/api/admin/nuage/folders' : '/zen/api/admin/nuage/files';
const res = await fetch(path, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(body),
});
const data = await res.json();
if (data.success) {
toast.success('Renommé avec succès');
setRenameTarget(null);
setRenameName('');
loadContents();
} else {
toast.error(data.error || 'Erreur');
}
} catch (e) {
toast.error('Erreur réseau');
} finally {
setRenaming(false);
}
};
const handleMoveConfirm = async (targetFolderId) => {
if (!moveTarget) return;
setMoving(true);
try {
const isFolder = moveTarget.type === 'folder';
const body = isFolder
? { id: moveTarget.id, action: 'move', newParentId: targetFolderId }
: { id: moveTarget.id, action: 'move', newFolderId: targetFolderId };
const path = isFolder ? '/zen/api/admin/nuage/folders' : '/zen/api/admin/nuage/files';
const res = await fetch(path, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(body),
});
const data = await res.json();
if (data.success) {
toast.success('Déplacé avec succès');
setMoveTarget(null);
loadContents();
} else {
toast.error(data.error || 'Erreur');
}
} catch (e) {
toast.error('Erreur réseau');
} finally {
setMoving(false);
}
};
// ─── File upload ─────────────────────────────────────────────────────────────
const uploadFiles = async (files, targetFolderId = undefined) => {
const destinationId = targetFolderId !== undefined ? targetFolderId : folderId;
const fileList = Array.from(files);
setUploading(true);
setUploadQueue(fileList.map(f => ({ name: f.name, done: false })));
let success = 0;
for (let i = 0; i < fileList.length; i++) {
const file = fileList[i];
const formData = new FormData();
formData.append('file', file);
if (destinationId) formData.append('folderId', destinationId);
try {
const res = await fetch('/zen/api/admin/nuage/files', {
method: 'POST',
credentials: 'include',
body: formData,
});
const data = await res.json();
if (data.success) {
success++;
setUploadQueue(q => q.map((f, idx) => idx === i ? { ...f, done: true } : f));
} else {
toast.error(`${file.name}: ${data.error || 'Erreur'}`);
}
} catch (e) {
toast.error(`${file.name}: Erreur réseau`);
}
}
setUploading(false);
setUploadQueue([]);
if (success > 0) {
toast.success(`${success} fichier${success > 1 ? 's' : ''} téléversé${success > 1 ? 's' : ''}`);
loadContents();
}
};
const handleFileInput = (e) => {
if (e.target.files?.length) uploadFiles(e.target.files);
e.target.value = '';
};
const handleDrop = (e) => {
e.preventDefault();
setDragOver(false);
setDragOverFolder(null);
if (e.dataTransfer.files?.length) uploadFiles(e.dataTransfer.files);
};
// ─── Download ─────────────────────────────────────────────────────────────────
const handleDownload = async (fileId, filename) => {
try {
const res = await fetch(`/zen/api/admin/nuage/files/download?id=${fileId}`, { credentials: 'include' });
if (!res.ok) { toast.error('Erreur lors du téléchargement'); return; }
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename || 'fichier';
a.click();
URL.revokeObjectURL(url);
} catch (e) {
toast.error('Erreur réseau');
}
};
// ─── File viewer ──────────────────────────────────────────────────────────────
const handleOpenViewer = async (file) => {
try {
const res = await fetch(`/zen/api/admin/nuage/files/download?id=${file.id}`, { credentials: 'include' });
if (!res.ok) { toast.error('Erreur lors de l\'ouverture'); return; }
const blob = await res.blob();
const url = URL.createObjectURL(blob);
setViewerFile({ file, url });
} catch (e) {
toast.error('Erreur réseau');
}
};
const handleCloseViewer = () => {
if (viewerFile?.url?.startsWith('blob:')) URL.revokeObjectURL(viewerFile.url);
setViewerFile(null);
};
// ─── Context menu ─────────────────────────────────────────────────────────────
const openContextMenu = (e, item) => {
e.preventDefault();
e.stopPropagation();
setContextMenu({ item, x: e.clientX, y: e.clientY });
};
useEffect(() => {
const handler = () => setContextMenu(null);
window.addEventListener('click', handler);
return () => window.removeEventListener('click', handler);
}, []);
const contextMenuActions = (item) => [
...(item.type === 'file' && isViewable(item.mime_type)
? [{ label: 'Aperçu', icon: EyeIcon, action: () => handleOpenViewer(item) }]
: []),
{ label: 'Renommer', icon: PencilEdit01Icon, action: () => { setRenameTarget(item); setRenameName(item.name || item.display_name); } },
{ label: 'Déplacer', icon: ArrowUp01Icon, action: async () => {
const res = await fetch('/zen/api/admin/nuage?folder=', { credentials: 'include' });
const data = await res.json();
setAllFolders(data.folders || []);
setMoveTarget(item);
}},
{ label: 'Partager', icon: Link02Icon, action: () => setShareTarget(item) },
...(item.type === 'file' ? [{ label: 'Télécharger', icon: CloudUploadIcon, action: () => handleDownload(item.id, item.name) }] : []),
{ label: 'Supprimer', icon: Delete02Icon, danger: true, action: () => setDeleteTarget(item) },
];
// ─── Folder drop helpers ──────────────────────────────────────────────────────
const getRowProps = (item) => {
if (item._type !== 'folder') return {};
const isHovered = dragOverFolder === item.id;
return {
className: isHovered
? 'bg-blue-50 dark:bg-blue-900/20 outline outline-2 -outline-offset-2 outline-blue-400'
: '',
onDragOver: (e) => {
e.preventDefault();
e.stopPropagation();
setDragOver(false);
setDragOverFolder(item.id);
},
onDragLeave: (e) => {
if (!e.currentTarget.contains(e.relatedTarget)) {
setDragOverFolder(null);
}
},
onDrop: (e) => {
e.preventDefault();
e.stopPropagation();
setDragOverFolder(null);
setDragOver(false);
if (e.dataTransfer.files?.length) uploadFiles(e.dataTransfer.files, item.id);
},
};
};
// ─── Render ───────────────────────────────────────────────────────────────────
const canCreateSubfolder = !currentFolder || currentFolder.depth < 10;
const allFoldersSorted = sorted(contents.folders, true);
const allFilesSorted = sorted(contents.files, false);
const allItemsSorted = [
...allFoldersSorted.map(f => ({ ...f, _type: 'folder' })),
...allFilesSorted.map(f => ({ ...f, _type: 'file' })),
];
return (
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8 h-full">
{/* Header */}
<input type="file" ref={fileInputRef} multiple className="hidden" onChange={handleFileInput} />
<div className="flex items-center justify-between">
<div>
<h1 className="text-lg sm:text-xl font-semibold text-neutral-900 dark:text-white">Nuage</h1>
<p className="mt-1 text-xs text-neutral-400">Gestionnaire de fichiers</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
onClick={() => fileInputRef.current?.click()}
loading={uploading}
icon={<CloudUploadIcon className="w-4 h-4" />}
>
Téléverser
</Button>
{canCreateSubfolder && (
<Button
variant="secondary"
onClick={() => setShowNewFolder(true)}
icon={<PlusSignCircleIcon className="w-4 h-4" />}
>
Nouveau dossier
</Button>
)}
<Button
variant="secondary"
disabled={!folderId || !currentFolder}
onClick={() => {
if (currentFolder) {
setShareTarget({ id: currentFolder.id, type: 'folder', name: currentFolder.name });
}
}}
icon={<Link02Icon className="w-4 h-4" />}
>
Partages
</Button>
<Button
variant="secondary"
onClick={() => setViewMode(v => v === 'list' ? 'grid' : 'list')}
>
{viewMode === 'list' ? '⊞ Grille' : '☰ Liste'}
</Button>
</div>
</div>
{/* Breadcrumb + Explorer */}
<div className="flex flex-col gap-3">
<Breadcrumb
items={[
{
key: 'root',
label: 'Fichiers',
onClick: () => navigateTo(null),
active: breadcrumb.length === 0,
},
...(breadcrumb.length > 3
? [
{
key: 'ellipsis',
label: '···',
onClick: () => navigateTo(breadcrumb[0].id),
active: false,
},
...breadcrumb.slice(-2).map((folder, i) => ({
key: folder.id,
label: folder.name,
onClick: () => navigateTo(folder.id),
active: i === 1,
})),
]
: breadcrumb.map((folder, i) => ({
key: folder.id,
label: folder.name,
onClick: () => navigateTo(folder.id),
active: i === breadcrumb.length - 1,
}))
),
]}
/>
{/* Upload queue indicator */}
{uploading && uploadQueue.length > 0 && (
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-3">
<p className="text-sm font-medium text-blue-700 dark:text-blue-300 mb-2">Téléversement en cours</p>
<div className="space-y-1">
{uploadQueue.map((f, i) => (
<div key={i} className="flex items-center gap-2 text-xs text-blue-600 dark:text-blue-400">
{f.done ? <Tick02Icon className="w-3 h-3 text-green-500" /> : <Loading />}
<span>{f.name}</span>
</div>
))}
</div>
</div>
)}
{/* Explorer area */}
<Card variant="default" padding="none">
<div
ref={dropRef}
className={`min-h-64 ${dragOver && !dragOverFolder ? 'ring-2 ring-blue-500 ring-inset bg-blue-50 dark:bg-blue-900/10' : ''}`}
onDragOver={e => { e.preventDefault(); if (!dragOverFolder) setDragOver(true); }}
onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget)) { setDragOver(false); setDragOverFolder(null); } }}
onDrop={handleDrop}
>
{viewMode === 'list' ? (
/* List view */
<NuageFileTable
items={allItemsSorted}
loading={loading}
sortBy={sortBy}
sortOrder={sortOrder}
onSort={toggleSort}
onRowClick={(item) => {
if (item._type === 'folder') navigateTo(item.id);
else if (isViewable(item.mime_type)) handleOpenViewer({ ...item, type: 'file', name: item.display_name });
}}
getRowProps={getRowProps}
renderActions={(item) => (
<Button
variant="ghost"
size="sm"
onClick={e => { e.stopPropagation(); openContextMenu(e, item._type === 'folder'
? { ...item, type: 'folder', name: item.name }
: { ...item, type: 'file', name: item.display_name }
); }}
className='-m-2'
>
···
</Button>
)}
showShareBadge
emptyMessage="Ce dossier est vide"
emptyDescription='Glissez des fichiers ici ou cliquez sur « Téléverser »'
size="sm"
/>
) : loading ? (
<div className="flex items-center justify-center h-64">
<Loading />
</div>
) : allFoldersSorted.length === 0 && allFilesSorted.length === 0 ? (
<div className="flex flex-col items-center justify-center h-64 gap-3 text-neutral-400">
<CloudUploadIcon className="w-12 h-12 opacity-30" />
<p className="text-sm">Ce dossier est vide</p>
<p className="text-xs">Glissez des fichiers ici ou cliquez sur « Téléverser »</p>
</div>
) : (
/* Grid view */
<div className="p-4 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-3">
{allFoldersSorted.map(folder => (
<div
key={folder.id}
className={`relative group flex flex-col items-center gap-2 p-3 rounded-xl border cursor-pointer transition-colors ${
dragOverFolder === folder.id
? 'border-blue-400 bg-blue-50 dark:bg-blue-900/20 ring-2 ring-blue-400'
: 'border-neutral-200 dark:border-neutral-700 hover:border-blue-300 dark:hover:border-blue-700'
}`}
onClick={() => navigateTo(folder.id)}
onDragOver={e => { e.preventDefault(); e.stopPropagation(); setDragOver(false); setDragOverFolder(folder.id); }}
onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget)) setDragOverFolder(null); }}
onDrop={e => { e.preventDefault(); e.stopPropagation(); setDragOverFolder(null); if (e.dataTransfer.files?.length) uploadFiles(e.dataTransfer.files, folder.id); }}
>
<Folder01Icon className={`w-10 h-10 ${dragOverFolder === folder.id ? 'text-blue-500' : 'text-yellow-500'}`} />
<span className="text-xs text-center text-neutral-900 dark:text-white font-medium line-clamp-2 break-all">{folder.name}</span>
{dragOverFolder === folder.id && (
<span className="text-[10px] text-blue-500 font-medium">Déposer ici</span>
)}
{folder.has_active_share && dragOverFolder !== folder.id && (
<div className="absolute top-1 right-1 w-2 h-2 bg-blue-500 rounded-full" />
)}
<Button
variant="ghost"
size="sm"
onClick={e => openContextMenu(e, { ...folder, type: 'folder', name: folder.name })}
className="absolute top-1 right-1 opacity-0 group-hover:opacity-100"
>
···
</Button>
</div>
))}
{allFilesSorted.map(file => {
const FileIcon = getFileIcon(file.mime_type);
const fileColor = getFileColor(file.mime_type);
const canPreview = isViewable(file.mime_type);
return (
<div
key={file.id}
className={`relative group flex flex-col items-center gap-2 p-3 rounded-xl border border-neutral-200 dark:border-neutral-700 hover:border-blue-300 dark:hover:border-blue-700 ${canPreview ? 'cursor-pointer' : ''}`}
onClick={() => canPreview && handleOpenViewer({ ...file, type: 'file', name: file.display_name })}
>
<FileIcon className={`w-10 h-10 ${fileColor}`} />
<span className="text-xs text-center text-neutral-900 dark:text-white line-clamp-2 break-all">{file.display_name}</span>
{file.has_active_share && (
<div className="absolute top-1 left-1 w-2 h-2 bg-blue-500 rounded-full" />
)}
<Button
variant="ghost"
size="sm"
onClick={e => openContextMenu(e, { ...file, type: 'file', name: file.display_name })}
className="absolute top-1 right-1 opacity-0 group-hover:opacity-100"
>
···
</Button>
</div>
);
})}
</div>
)}
</div>
</Card>
</div>
{/* Context menu */}
{contextMenu && (
<div
className="fixed z-50 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg shadow-lg py-1 min-w-[160px]"
style={{ top: contextMenu.y, left: contextMenu.x }}
onClick={e => e.stopPropagation()}
>
{contextMenuActions(contextMenu.item).map((action, i) => (
<button
key={i}
onClick={() => { action.action(); setContextMenu(null); }}
className={`w-full flex items-center gap-2 px-3 py-2 text-sm text-left hover:bg-neutral-50 dark:hover:bg-neutral-700 ${action.danger ? 'text-red-600' : 'text-neutral-700 dark:text-neutral-200'}`}
>
<action.icon className="w-4 h-4" />
{action.label}
</button>
))}
</div>
)}
{/* Share panel (side panel) */}
{shareTarget && (
<div className="fixed inset-0 z-40 flex justify-end">
<div className="absolute inset-0 bg-black/20" onClick={() => setShareTarget(null)} />
<div className="relative z-50 w-[400px] max-w-[95vw] bg-white dark:bg-neutral-950 h-full shadow-2xl flex flex-col overflow-hidden border-l border-neutral-100 dark:border-neutral-800">
<SharePanel target={shareTarget} onClose={() => setShareTarget(null)} />
</div>
</div>
)}
{/* New folder modal */}
{showNewFolder && (
<Modal
onClose={() => { setShowNewFolder(false); setNewFolderName(''); }}
title="Nouveau dossier"
size="sm"
footer={
<div className="flex gap-2 justify-end">
<Button variant="secondary" onClick={() => { setShowNewFolder(false); setNewFolderName(''); }}>
Annuler
</Button>
<Button onClick={handleCreateFolder} loading={creatingFolder} disabled={!newFolderName.trim()}>
Créer
</Button>
</div>
}
>
<Input
autoFocus
value={newFolderName}
onChange={v => setNewFolderName(v)}
placeholder="Nom du dossier"
onKeyDown={e => e.key === 'Enter' && handleCreateFolder()}
/>
</Modal>
)}
{/* Rename modal */}
{renameTarget && (
<Modal
onClose={() => { setRenameTarget(null); setRenameName(''); }}
title="Renommer"
size="sm"
footer={
<div className="flex gap-2 justify-end">
<Button variant="secondary" onClick={() => { setRenameTarget(null); setRenameName(''); }}>
Annuler
</Button>
<Button onClick={handleRenameConfirm} loading={renaming} disabled={!renameName.trim()}>
Renommer
</Button>
</div>
}
>
<Input
autoFocus
value={renameName}
onChange={v => setRenameName(v)}
onKeyDown={e => e.key === 'Enter' && handleRenameConfirm()}
/>
</Modal>
)}
{/* Delete confirmation modal */}
{deleteTarget && (
<Modal
onClose={() => setDeleteTarget(null)}
title="Confirmer la suppression"
size="sm"
footer={
<div className="flex gap-2 justify-end">
<Button variant="secondary" onClick={() => setDeleteTarget(null)}>Annuler</Button>
<Button variant="danger" onClick={handleDeleteConfirm} loading={deleting}>
Supprimer
</Button>
</div>
}
>
<div className="flex items-start gap-3">
<Alert01Icon className="w-6 h-6 text-red-500 flex-shrink-0 mt-0.5" />
<p className="text-sm text-neutral-500">
Voulez-vous vraiment supprimer{' '}
<span className="font-medium text-neutral-700 dark:text-neutral-200">«&nbsp;{deleteTarget.name}&nbsp;»</span>
{deleteTarget.type === 'folder' && ' et tout son contenu'} ? Cette action est irréversible.
</p>
</div>
</Modal>
)}
{/* File viewer modal */}
{viewerFile && (
<FileViewerModal
file={viewerFile.file}
url={viewerFile.url}
onClose={handleCloseViewer}
/>
)}
{/* Move modal */}
{moveTarget && (
<Modal
onClose={() => setMoveTarget(null)}
title="Déplacer vers…"
size="sm"
footer={
<Button variant="secondary" className="w-full" onClick={() => setMoveTarget(null)}>Annuler</Button>
}
>
<div className="max-h-64 overflow-y-auto space-y-1 border border-neutral-200 dark:border-neutral-700 rounded-lg p-2">
<button
onClick={() => handleMoveConfirm(null)}
className="w-full flex items-center gap-2 px-3 py-2 text-sm rounded hover:bg-neutral-50 dark:hover:bg-neutral-700 text-neutral-700 dark:text-neutral-200"
>
<Folder01Icon className="w-4 h-4 text-yellow-500 flex-shrink-0" /> Racine (Nuage)
</button>
{allFolders
.filter(f => f.id !== moveTarget.id)
.map(f => (
<button
key={f.id}
onClick={() => handleMoveConfirm(f.id)}
className="w-full flex items-center gap-2 px-3 py-2 text-sm rounded hover:bg-neutral-50 dark:hover:bg-neutral-700 text-neutral-700 dark:text-neutral-200"
>
<Folder01Icon className="w-4 h-4 text-yellow-500 flex-shrink-0" /> {f.name}
</button>
))}
</div>
</Modal>
)}
</div>
);
};
export default ExplorerPage;
-254
View File
@@ -1,254 +0,0 @@
'use client';
import React, { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import {
Link02Icon,
Delete02Icon,
UserCircle02Icon,
Folder01Icon,
FileSecurityIcon,
Copy01Icon,
} from '../../../shared/Icons.js';
import { Card, Badge, Button, Table, FilterTabs } from '../../../shared/components';
import { useToast } from '@hykocx/zen/toast';
function formatDate(dateStr) {
if (!dateStr) return '—';
return new Date(dateStr).toLocaleDateString('fr-CA', {
year: 'numeric', month: 'short', day: 'numeric',
});
}
function isExpired(share) {
if (!share.expires_at) return false;
return new Date(share.expires_at) < new Date();
}
const SharesPage = ({ user }) => {
const router = useRouter();
const toast = useToast();
const [shares, setShares] = useState([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState('all'); // 'all' | 'user' | 'anonymous'
const [revoking, setRevoking] = useState(null);
useEffect(() => {
loadShares();
}, []);
const loadShares = async () => {
setLoading(true);
try {
const res = await fetch('/zen/api/admin/nuage/shares', { credentials: 'include' });
const data = await res.json();
if (data.success) {
setShares(data.shares);
} else {
toast.error(data.error || 'Erreur lors du chargement');
}
} catch (e) {
console.error(e);
toast.error('Erreur réseau');
} finally {
setLoading(false);
}
};
const handleRevoke = async (id) => {
setRevoking(id);
try {
const res = await fetch(`/zen/api/admin/nuage/shares?id=${id}`, {
method: 'DELETE',
credentials: 'include',
});
const data = await res.json();
if (data.success) {
toast.success('Partage révoqué');
loadShares();
} else {
toast.error(data.error || 'Erreur');
}
} catch (e) {
toast.error('Erreur réseau');
} finally {
setRevoking(null);
}
};
const copyLink = (token) => {
const url = `${window.location.origin}/zen/nuage/partage/${token}`;
navigator.clipboard.writeText(url).then(() => toast.success('Lien copié !'));
};
const filtered = shares.filter(s => {
if (filter === 'all') return true;
return s.share_type === filter;
});
const getShareStatus = (share) => {
if (!share.is_active) return { label: 'Révoqué', variant: 'default' };
if (isExpired(share)) return { label: 'Expiré', variant: 'danger' };
return { label: 'Actif', variant: 'success' };
};
const columns = [
{
key: 'type',
label: 'Type',
render: (share) => (
<div className="flex items-center gap-2">
{share.share_type === 'user' ? (
<UserCircle02Icon className="w-4 h-4 text-blue-500 flex-shrink-0" />
) : (
<Link02Icon className="w-4 h-4 text-purple-500 flex-shrink-0" />
)}
<span className="text-sm text-neutral-700 dark:text-neutral-200">
{share.share_type === 'user' ? 'Utilisateur' : 'Lien anonyme'}
</span>
</div>
),
skeleton: { height: 'h-4', width: '60%' },
},
{
key: 'recipient',
label: 'Destinataire',
render: (share) => (
<div className="text-sm">
{share.share_type === 'user' && share.name ? (
<>
<div className="font-medium text-neutral-900 dark:text-white">
{share.name}
</div>
<div className="text-xs text-neutral-400">{share.email}</div>
</>
) : (
<span className="text-neutral-500"></span>
)}
</div>
),
skeleton: { height: 'h-4', width: '70%' },
},
{
key: 'target',
label: 'Élément partagé',
render: (share) => (
<div className="flex items-center gap-2 text-sm">
{share.target_type === 'folder' ? (
<Folder01Icon className="w-4 h-4 text-yellow-500 flex-shrink-0" />
) : (
<FileSecurityIcon className="w-4 h-4 text-blue-400 flex-shrink-0" />
)}
<span className="text-neutral-700 dark:text-neutral-200 truncate max-w-[180px]">
{share.target_name || share.target_id}
</span>
</div>
),
skeleton: { height: 'h-4', width: '50%' },
},
{
key: 'permission',
label: 'Permission',
render: (share) => (
<Badge variant={share.permission === 'reader' ? 'default' : 'info'}>
{share.permission === 'reader' ? 'Lecteur' : 'Collaborateur'}
</Badge>
),
skeleton: { height: 'h-4', width: '40%' },
},
{
key: 'expires_at',
label: 'Expiration',
render: (share) => (
<span className="text-sm text-neutral-500">
{share.expires_at ? formatDate(share.expires_at) : 'Aucune'}
</span>
),
skeleton: { height: 'h-4', width: '40%' },
},
{
key: 'status',
label: 'Statut',
render: (share) => {
const status = getShareStatus(share);
return <Badge variant={status.variant}>{status.label}</Badge>;
},
skeleton: { height: 'h-4', width: '30%' },
},
{
key: 'actions',
label: '',
render: (share) => (
<div className="flex items-center gap-1 justify-end">
{share.share_type === 'anonymous' && share.is_active && !isExpired(share) && (
<Button
variant="ghost"
size="sm"
onClick={() => copyLink(share.token)}
title="Copier le lien"
icon={<Copy01Icon className="w-4 h-4" />}
/>
)}
{share.is_active && (
<Button
variant="danger"
size="sm"
onClick={() => handleRevoke(share.id)}
loading={revoking === share.id}
title="Révoquer"
icon={<Delete02Icon className="w-4 h-4" />}
/>
)}
</div>
),
skeleton: { height: 'h-4', width: '20%' },
},
];
const activeCount = shares.filter(s => s.is_active && !isExpired(s)).length;
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">Partages</h1>
<p className="mt-1 text-xs text-neutral-400">Gérez les liens et accès partagés</p>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => router.push('/admin/nuage/explorateur')}
icon={<Folder01Icon className="w-4 h-4" />}
>
Explorateur
</Button>
</div>
{/* Filter tabs + Table */}
<div className="flex flex-col gap-3">
<FilterTabs
tabs={[
{ key: 'all', label: 'Tous' },
{ key: 'user', label: 'Utilisateurs' },
{ key: 'anonymous', label: 'Liens anonymes' },
]}
value={filter}
onChange={setFilter}
/>
<Card padding="none">
{!loading && filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center h-40 gap-2 text-neutral-400">
<Link02Icon className="w-10 h-10 opacity-30" />
<p className="text-sm">Aucun partage trouvé</p>
</div>
) : (
<Table columns={columns} data={filtered} loading={loading} />
)}
</Card>
</div>
</div>
);
};
export default SharesPage;
-5
View File
@@ -1,5 +0,0 @@
/**
* Nuage Admin Components
*/
export { default as ExplorerPage } from './ExplorerPage.js';
export { default as SharesPage } from './SharesPage.js';
-679
View File
@@ -1,679 +0,0 @@
/**
* Nuage Module API Routes
* All API endpoints for the Nuage file manager
*/
import { validateSession } from '../../features/auth/lib/session.js';
import { cookies } from 'next/headers';
import { getSessionCookieName } from '../../shared/lib/appConfig.js';
import {
getFolderContents,
getFolderBreadcrumb,
getFolderById,
createFolder,
renameFolder,
moveFolder,
deleteFolder,
getFolderItemCount,
getFileById,
uploadNuageFile,
renameFile,
moveFile,
deleteNuageFile,
proxyNuageFile,
getFolderUploadedSize,
getShareByToken,
getSharesForTarget,
getSharesForUser,
getAllActiveShares,
createShare,
updateShare,
revokeShare,
isShareValid,
searchUsers,
getSharedFolderContents,
getSharedBreadcrumb,
isFileInShare,
isFolderInShare,
getPasswordCookieName,
signPasswordToken,
} from './crud.js';
import { sendEmail } from '@hykocx/zen/email';
import { render } from '@react-email/components';
import { NuageShareEmail } from './email/index.js';
const COOKIE_NAME = getSessionCookieName();
const MAX_FILE_SIZE_BYTES = 500 * 1024 * 1024; // 500 MB hard cap
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
// ─── Auth helpers ────────────────────────────────────────────────────────────
async function getAdminSession() {
const cookieStore = await cookies();
const sessionToken = cookieStore.get(COOKIE_NAME)?.value;
if (!sessionToken) return null;
const session = await validateSession(sessionToken);
if (!session?.user?.id || session.user.role !== 'admin') return null;
return session;
}
async function getUserSession() {
const cookieStore = await cookies();
const sessionToken = cookieStore.get(COOKIE_NAME)?.value;
if (!sessionToken) return null;
const session = await validateSession(sessionToken);
if (!session?.user?.id) return null;
return session;
}
// ─── Explorer: Folder Contents ───────────────────────────────────────────────
async function handleGetContents(request) {
try {
const session = await getAdminSession();
if (!session) return { success: false, error: 'Unauthorized' };
const url = new URL(request.url);
const folderId = url.searchParams.get('folder') || null;
const contents = await getFolderContents(folderId);
const breadcrumb = await getFolderBreadcrumb(folderId);
const currentFolder = folderId ? await getFolderById(folderId) : null;
return { success: true, ...contents, breadcrumb, currentFolder };
} catch (error) {
console.error('handleGetContents error:', error);
return { success: false, error: error.message };
}
}
// ─── Folders ─────────────────────────────────────────────────────────────────
async function handleCreateFolder(request) {
try {
const session = await getAdminSession();
if (!session) return { success: false, error: 'Unauthorized' };
const body = await request.json();
const { name, parentId } = body;
if (!name?.trim()) return { success: false, error: 'Le nom du dossier est requis' };
const folder = await createFolder(name, parentId || null);
return { success: true, folder };
} catch (error) {
console.error('handleCreateFolder error:', error);
return { success: false, error: error.message };
}
}
async function handleUpdateFolder(request) {
try {
const session = await getAdminSession();
if (!session) return { success: false, error: 'Unauthorized' };
const body = await request.json();
const { id, action, name, newParentId } = body;
if (!id) return { success: false, error: 'ID du dossier requis' };
let folder;
if (action === 'rename') {
if (!name?.trim()) return { success: false, error: 'Le nom est requis' };
folder = await renameFolder(id, name);
} else if (action === 'move') {
folder = await moveFolder(id, newParentId || null);
} else {
return { success: false, error: 'Action inconnue' };
}
return { success: true, folder };
} catch (error) {
console.error('handleUpdateFolder error:', error);
return { success: false, error: error.message };
}
}
async function handleDeleteFolder(request) {
try {
const session = await getAdminSession();
if (!session) return { success: false, error: 'Unauthorized' };
const url = new URL(request.url);
const id = url.searchParams.get('id');
if (!id) return { success: false, error: 'ID du dossier requis' };
const count = await getFolderItemCount(id);
const forceDelete = url.searchParams.get('force') === 'true';
if (count > 0 && !forceDelete) {
return { success: false, error: 'FOLDER_NOT_EMPTY', itemCount: count };
}
await deleteFolder(id);
return { success: true };
} catch (error) {
console.error('handleDeleteFolder error:', error);
return { success: false, error: error.message };
}
}
// ─── Files ───────────────────────────────────────────────────────────────────
async function handleUploadFile(request) {
try {
const session = await getAdminSession();
if (!session) return { success: false, error: 'Unauthorized' };
const formData = await request.formData();
const file = formData.get('file');
const folderId = formData.get('folderId') || null;
if (!file || typeof file === 'string') {
return { success: false, error: 'Aucun fichier fourni' };
}
if (file.size > MAX_FILE_SIZE_BYTES) {
return { success: false, error: 'Fichier trop volumineux (max 500 Mo)' };
}
const buffer = Buffer.from(await file.arrayBuffer());
const nuageFile = await uploadNuageFile(
folderId,
buffer,
file.name,
file.type || 'application/octet-stream',
buffer.length
);
return { success: true, file: nuageFile };
} catch (error) {
console.error('handleUploadFile error:', error);
return { success: false, error: error.message };
}
}
async function handleUpdateFile(request) {
try {
const session = await getAdminSession();
if (!session) return { success: false, error: 'Unauthorized' };
const body = await request.json();
const { id, action, displayName, newFolderId } = body;
if (!id) return { success: false, error: 'ID du fichier requis' };
let file;
if (action === 'rename') {
if (!displayName?.trim()) return { success: false, error: 'Le nom est requis' };
file = await renameFile(id, displayName);
} else if (action === 'move') {
file = await moveFile(id, newFolderId || null);
} else {
return { success: false, error: 'Action inconnue' };
}
return { success: true, file };
} catch (error) {
console.error('handleUpdateFile error:', error);
return { success: false, error: error.message };
}
}
async function handleDeleteFile(request) {
try {
const session = await getAdminSession();
if (!session) return { success: false, error: 'Unauthorized' };
const url = new URL(request.url);
const id = url.searchParams.get('id');
if (!id) return { success: false, error: 'ID du fichier requis' };
await deleteNuageFile(id);
return { success: true };
} catch (error) {
console.error('handleDeleteFile error:', error);
return { success: false, error: error.message };
}
}
async function handleGetDownloadUrl(request) {
try {
const session = await getAdminSession();
if (!session) return { success: false, error: 'Unauthorized' };
const url = new URL(request.url);
const id = url.searchParams.get('id');
if (!id) return { success: false, error: 'ID du fichier requis' };
return await proxyNuageFile(id);
} catch (error) {
console.error('handleGetDownloadUrl error:', error);
return { success: false, error: error.message };
}
}
// ─── Shares ──────────────────────────────────────────────────────────────────
async function handleGetShares(request) {
try {
const session = await getAdminSession();
if (!session) return { success: false, error: 'Unauthorized' };
const url = new URL(request.url);
const targetId = url.searchParams.get('targetId');
const targetType = url.searchParams.get('targetType');
if (targetId && targetType) {
const shares = await getSharesForTarget(targetId, targetType);
return { success: true, shares };
}
const shares = await getAllActiveShares();
return { success: true, shares };
} catch (error) {
console.error('handleGetShares error:', error);
return { success: false, error: error.message };
}
}
async function handleCreateShare(request) {
try {
const session = await getAdminSession();
if (!session) return { success: false, error: 'Unauthorized' };
const body = await request.json();
const {
targetId, targetType, shareType, userId, permission,
password, expiresAt, uploadLimitBytes,
} = body;
if (!targetId || !targetType || !shareType || !permission) {
return { success: false, error: 'Paramètres manquants' };
}
const share = await createShare({
targetId, targetType, shareType,
userId: userId || null,
permission,
password: password || null,
expiresAt: expiresAt || null,
uploadLimitBytes: uploadLimitBytes || null,
});
return { success: true, share };
} catch (error) {
console.error('handleCreateShare error:', error);
return { success: false, error: error.message };
}
}
async function handleUpdateShare(request) {
try {
const session = await getAdminSession();
if (!session) return { success: false, error: 'Unauthorized' };
const body = await request.json();
const {
id, permission, expiresAt, uploadLimitBytes,
password, clearPassword, userId, shareType, isActive,
} = body;
if (!id) return { success: false, error: 'ID du partage requis' };
const share = await updateShare(id, {
permission,
expiresAt,
uploadLimitBytes,
password: password || null,
clearPassword: clearPassword || false,
userId,
shareType,
isActive,
});
return { success: true, share };
} catch (error) {
console.error('handleUpdateShare error:', error);
return { success: false, error: error.message };
}
}
async function handleRevokeShare(request) {
try {
const session = await getAdminSession();
if (!session) return { success: false, error: 'Unauthorized' };
const url = new URL(request.url);
const id = url.searchParams.get('id');
if (!id) return { success: false, error: 'ID du partage requis' };
const share = await revokeShare(id);
return { success: true, share };
} catch (error) {
console.error('handleRevokeShare error:', error);
return { success: false, error: error.message };
}
}
async function handleSendShareEmail(request) {
try {
const session = await getAdminSession();
if (!session) return { success: false, error: 'Unauthorized' };
const body = await request.json();
const { shareId, toEmail, toName, customMessage, shareUrl, targetName, targetType, shareType, expiresAt } = body;
if (!toEmail) return { success: false, error: 'Adresse courriel requise' };
if (!EMAIL_REGEX.test(toEmail)) return { success: false, error: 'Adresse courriel invalide' };
const appName = process.env.ZEN_NAME || 'Nuage';
const html = await render(
NuageShareEmail({
recipientName: toName || toEmail,
shareUrl,
targetName,
targetType: targetType || 'folder',
shareType,
expiresAt: expiresAt || null,
customMessage: customMessage || null,
companyName: appName,
})
);
await sendEmail({
to: toEmail,
subject: `Vous avez reçu un nouveau partage !`,
html,
});
return { success: true };
} catch (error) {
console.error('handleSendShareEmail error:', error);
return { success: false, error: error.message };
}
}
// ─── User search ─────────────────────────────────────────────────────────────
async function handleSearchUsers(request) {
try {
const session = await getAdminSession();
if (!session) return { success: false, error: 'Unauthorized' };
const url = new URL(request.url);
const q = url.searchParams.get('q') || '';
if (q.length < 2) return { success: true, users: [] };
const users = await searchUsers(q, 10);
return { success: true, users };
} catch (error) {
console.error('handleSearchUsers error:', error);
return { success: false, error: error.message };
}
}
// ─── Client-facing: My Shares ────────────────────────────────────────────────
async function handleGetMyShares(request) {
try {
const session = await getUserSession();
if (!session) return { success: false, error: 'Unauthorized' };
const shares = await getSharesForUser(session.user.id);
// Enrich with target names
const enriched = await Promise.all(
shares.map(async (share) => {
let targetName = '';
if (share.target_type === 'file') {
const file = await getFileById(share.target_id);
targetName = file?.display_name || 'Fichier inconnu';
} else {
const folder = await getFolderById(share.target_id);
targetName = folder?.name || 'Dossier inconnu';
}
return { ...share, target_name: targetName };
})
);
return { success: true, shares: enriched };
} catch (error) {
console.error('handleGetMyShares error:', error);
return { success: false, error: error.message };
}
}
// ─── Client-facing: Shared folder contents ───────────────────────────────────
async function handleGetSharedContents(request) {
try {
const session = await getUserSession();
if (!session) return { success: false, error: 'Unauthorized' };
const url = new URL(request.url);
const shareId = url.searchParams.get('shareId');
const folderId = url.searchParams.get('folder') || null;
if (!shareId) return { success: false, error: 'shareId requis' };
const share = await getShareByToken(shareId);
if (!share || !isShareValid(share) || share.user_id !== session.user.id) {
return { success: false, error: 'Accès refusé' };
}
const contents = await getSharedFolderContents(share, folderId);
const breadcrumb = await getSharedBreadcrumb(share, folderId);
return { success: true, ...contents, breadcrumb, permission: share.permission };
} catch (error) {
console.error('handleGetSharedContents error:', error);
return { success: false, error: error.message };
}
}
// ─── Public share: File proxy download ───────────────────────────────────────
async function handleShareFileProxy(request) {
try {
const url = new URL(request.url);
const token = url.searchParams.get('token');
const fileId = url.searchParams.get('fileId');
if (!token || !fileId) return { error: 'Bad Request', message: 'Paramètres manquants' };
const share = await getShareByToken(token);
if (!share || !isShareValid(share)) {
return { error: 'Unauthorized', message: 'Lien invalide ou expiré' };
}
// Enforce password protection server-side
if (share.password_hash) {
const cookieStore = await cookies();
const cookieValue = cookieStore.get(getPasswordCookieName(token))?.value;
if (!cookieValue || cookieValue !== signPasswordToken(token)) {
return { error: 'Unauthorized', message: 'Mot de passe requis' };
}
}
const file = await getFileById(fileId);
if (!file) return { error: 'Not Found', message: 'Fichier introuvable' };
// Ensure the requested file belongs to this share (prevent IDOR)
const inScope = await isFileInShare(fileId, share);
if (!inScope) return { error: 'Forbidden', message: 'Accès refusé' };
const { getFile } = await import('@hykocx/zen/storage');
const result = await getFile(file.r2_key);
if (!result.success) return { error: 'Not Found', message: 'Fichier introuvable' };
return {
success: true,
file: {
body: result.data.body,
contentType: file.mime_type || result.data.contentType,
contentLength: result.data.contentLength,
filename: file.display_name,
}
};
} catch (error) {
console.error('handleShareFileProxy error:', error);
return { error: 'Internal Server Error', message: error.message };
}
}
// ─── Client-facing: Download ─────────────────────────────────────────────────
async function handleClientDownload(request) {
try {
const session = await getUserSession();
if (!session) return { success: false, error: 'Unauthorized' };
const url = new URL(request.url);
const fileId = url.searchParams.get('fileId');
const shareId = url.searchParams.get('shareId');
if (!fileId || !shareId) return { success: false, error: 'Paramètres manquants' };
// Validate the share belongs to the user
const share = await getShareByToken(shareId);
if (!share || !isShareValid(share) || share.user_id !== session.user.id) {
return { success: false, error: 'Accès refusé' };
}
// Ensure the requested file belongs to this share (prevent IDOR)
const inScope = await isFileInShare(fileId, share);
if (!inScope) return { success: false, error: 'Accès refusé' };
const inline = url.searchParams.get('inline') === 'true';
return await proxyNuageFile(fileId, { inline });
} catch (error) {
console.error('handleClientDownload error:', error);
return { success: false, error: error.message };
}
}
// ─── Client-facing: Upload (collaborator) ────────────────────────────────────
async function handleClientUpload(request) {
try {
const session = await getUserSession();
if (!session) return { success: false, error: 'Unauthorized' };
const formData = await request.formData();
const file = formData.get('file');
const shareId = formData.get('shareId');
const folderId = formData.get('folderId') || null;
if (!shareId) return { success: false, error: 'shareId requis' };
const share = await getShareByToken(shareId);
if (!share || !isShareValid(share) || share.user_id !== session.user.id) {
return { success: false, error: 'Accès refusé' };
}
if (share.permission !== 'collaborator') {
return { success: false, error: 'Permission insuffisante' };
}
if (share.target_type !== 'folder') {
return { success: false, error: 'L\'upload n\'est possible que sur un dossier partagé' };
}
if (!file || typeof file === 'string') {
return { success: false, error: 'Aucun fichier fourni' };
}
// Ensure the target folder is within the share scope (prevent IDOR)
if (folderId && folderId !== share.target_id) {
const inScope = await isFolderInShare(folderId, share);
if (!inScope) return { success: false, error: 'Accès refusé' };
}
if (file.size > MAX_FILE_SIZE_BYTES) {
return { success: false, error: 'Fichier trop volumineux (max 500 Mo)' };
}
// Check upload limit before buffering to avoid loading oversized files into memory
if (share.upload_limit_bytes) {
const fileSize = file.size;
const targetFolder = folderId || share.target_id;
const currentSize = await getFolderUploadedSize(targetFolder);
if (currentSize + fileSize > share.upload_limit_bytes) {
return { success: false, error: 'Limite de stockage du dossier partagé atteinte' };
}
}
const buffer = Buffer.from(await file.arrayBuffer());
const targetFolderId = folderId || share.target_id;
const nuageFile = await uploadNuageFile(
targetFolderId,
buffer,
file.name,
file.type || 'application/octet-stream',
buffer.length
);
return { success: true, file: nuageFile };
} catch (error) {
console.error('handleClientUpload error:', error);
return { success: false, error: error.message };
}
}
// ─── Route Definitions ───────────────────────────────────────────────────────
export default {
routes: [
// Admin: Explorer
{ path: '/admin/nuage', method: 'GET', handler: handleGetContents, auth: 'admin' },
// Admin: Folders
{ path: '/admin/nuage/folders', method: 'POST', handler: handleCreateFolder, auth: 'admin' },
{ path: '/admin/nuage/folders', method: 'PUT', handler: handleUpdateFolder, auth: 'admin' },
{ path: '/admin/nuage/folders', method: 'DELETE', handler: handleDeleteFolder, auth: 'admin' },
// Admin: Files
{ path: '/admin/nuage/files', method: 'POST', handler: handleUploadFile, auth: 'admin' },
{ path: '/admin/nuage/files', method: 'PUT', handler: handleUpdateFile, auth: 'admin' },
{ path: '/admin/nuage/files', method: 'DELETE', handler: handleDeleteFile, auth: 'admin' },
{ path: '/admin/nuage/files/download', method: 'GET', handler: handleGetDownloadUrl, auth: 'admin' },
// Admin: Shares
{ path: '/admin/nuage/shares', method: 'GET', handler: handleGetShares, auth: 'admin' },
{ path: '/admin/nuage/shares', method: 'POST', handler: handleCreateShare, auth: 'admin' },
{ path: '/admin/nuage/shares', method: 'PATCH', handler: handleUpdateShare, auth: 'admin' },
{ path: '/admin/nuage/shares', method: 'DELETE', handler: handleRevokeShare, auth: 'admin' },
{ path: '/admin/nuage/shares/email', method: 'POST', handler: handleSendShareEmail, auth: 'admin' },
// Admin: User search
{ path: '/admin/nuage/users', method: 'GET', handler: handleSearchUsers, auth: 'admin' },
// Public share proxy download (no auth required)
{ path: '/nuage/share/download', method: 'GET', handler: handleShareFileProxy, auth: 'none' },
// Client-facing (authenticated user)
{ path: '/nuage/me', method: 'GET', handler: handleGetMyShares, auth: 'user' },
{ path: '/nuage/shared', method: 'GET', handler: handleGetSharedContents, auth: 'user' },
{ path: '/nuage/download', method: 'GET', handler: handleClientDownload, auth: 'user' },
{ path: '/nuage/upload', method: 'POST', handler: handleClientUpload, auth: 'user' },
],
};
export {
handleGetContents,
handleCreateFolder,
handleUpdateFolder,
handleDeleteFolder,
handleUploadFile,
handleUpdateFile,
handleDeleteFile,
handleGetDownloadUrl,
handleGetShares,
handleCreateShare,
handleUpdateShare,
handleRevokeShare,
handleSendShareEmail,
handleSearchUsers,
handleGetMyShares,
handleGetSharedContents,
handleClientDownload,
handleClientUpload,
handleShareFileProxy,
};
@@ -1,85 +0,0 @@
'use client';
import React, { useEffect } from 'react';
import { Cancel01Icon } from '../../../shared/Icons.js';
// ─── Viewable file helpers ────────────────────────────────────────────────────
const VIEWABLE_TYPES = [
'image/',
'application/pdf',
'video/',
'audio/',
'text/plain',
];
export function isViewable(mimeType) {
if (!mimeType) return false;
return VIEWABLE_TYPES.some(t => mimeType.startsWith(t));
}
function getViewerType(mimeType) {
if (!mimeType) return null;
if (mimeType.startsWith('image/')) return 'image';
if (mimeType === 'application/pdf') return 'pdf';
if (mimeType.startsWith('video/')) return 'video';
if (mimeType.startsWith('audio/')) return 'audio';
if (mimeType === 'text/plain') return 'text';
return null;
}
// ─── File viewer modal ────────────────────────────────────────────────────────
export default function FileViewerModal({ file, url, onClose }) {
const viewerType = getViewerType(file.mime_type);
const fileName = file.display_name || file.name;
useEffect(() => {
const handler = (e) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [onClose]);
return (
<div className="fixed inset-0 z-50 flex flex-col bg-neutral-950">
{/* Header */}
<div className="flex items-center justify-between px-3 py-1.5 bg-neutral-900 border-b border-neutral-800 flex-shrink-0">
<div className="w-8" />
<span className="text-xs font-medium text-neutral-300 truncate px-4">{fileName}</span>
<button
onClick={onClose}
className="cursor-pointer flex-shrink-0 text-neutral-400 hover:text-white transition-colors rounded-lg p-1 hover:bg-neutral-700"
aria-label="Fermer"
>
<Cancel01Icon className="w-4 h-4" />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-auto flex items-center justify-center min-h-0">
{viewerType === 'image' && (
<img
src={url}
alt={fileName}
className="max-w-full max-h-full object-contain"
/>
)}
{(viewerType === 'pdf' || viewerType === 'text') && (
<iframe
src={url}
title={fileName}
className="w-full h-full border-0"
/>
)}
{viewerType === 'video' && (
<video src={url} controls className="max-w-full max-h-full" />
)}
{viewerType === 'audio' && (
<div className="p-8 w-full max-w-xl">
<audio src={url} controls className="w-full" />
</div>
)}
</div>
</div>
);
}
@@ -1,219 +0,0 @@
'use client';
import React from 'react';
import {
Folder01Icon,
File02Icon,
Image01Icon,
PlaySquareIcon,
Pdf02Icon,
Mp302Icon,
Ppt02Icon,
Gif02Icon,
Rar02Icon,
Raw02Icon,
Svg02Icon,
Tiff02Icon,
Txt02Icon,
Typescript03Icon,
Wav02Icon,
Xls02Icon,
Xml02Icon,
Zip02Icon,
Csv02Icon,
Doc02Icon,
} from '../../../shared/Icons.js';
import { Badge, Table } from '../../../shared/components';
// ─── File type helpers ────────────────────────────────────────────────────────
export function getFileIcon(mimeType) {
if (!mimeType) return File02Icon;
if (mimeType === 'image/gif') return Gif02Icon;
if (mimeType === 'image/svg+xml') return Svg02Icon;
if (mimeType === 'image/tiff') return Tiff02Icon;
if (mimeType.startsWith('image/')) return Image01Icon;
if (mimeType.startsWith('video/')) return PlaySquareIcon;
if (mimeType === 'application/pdf') return Pdf02Icon;
if (mimeType === 'audio/mpeg' || mimeType === 'audio/mp3') return Mp302Icon;
if (mimeType === 'audio/wav' || mimeType === 'audio/wave') return Wav02Icon;
if (mimeType === 'application/vnd.ms-powerpoint' || mimeType === 'application/vnd.openxmlformats-officedocument.presentationml.presentation') return Ppt02Icon;
if (mimeType === 'application/vnd.rar' || mimeType === 'application/x-rar-compressed') return Rar02Icon;
if (mimeType === 'image/x-raw' || mimeType === 'image/x-canon-cr2' || mimeType === 'image/x-nikon-nef') return Raw02Icon;
if (mimeType === 'text/plain') return Txt02Icon;
if (mimeType === 'application/typescript' || mimeType === 'text/typescript') return Typescript03Icon;
if (mimeType === 'application/vnd.ms-excel' || mimeType === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') return Xls02Icon;
if (mimeType === 'application/xml' || mimeType === 'text/xml') return Xml02Icon;
if (mimeType === 'application/zip' || mimeType === 'application/x-zip-compressed') return Zip02Icon;
if (mimeType === 'text/csv') return Csv02Icon;
if (mimeType === 'application/msword' || mimeType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') return Doc02Icon;
return File02Icon;
}
export function getFileColor(mimeType) {
if (!mimeType) return 'text-neutral-500';
if (mimeType.startsWith('image/')) return 'text-teal-700';
if (mimeType.startsWith('video/')) return 'text-red-800';
if (mimeType === 'application/pdf') return 'text-red-800';
if (mimeType === 'audio/mpeg' || mimeType === 'audio/mp3' || mimeType === 'audio/wav' || mimeType === 'audio/wave') return 'text-indigo-700';
if (mimeType === 'application/vnd.ms-powerpoint' || mimeType === 'application/vnd.openxmlformats-officedocument.presentationml.presentation') return 'text-orange-700';
if (mimeType === 'application/vnd.rar' || mimeType === 'application/x-rar-compressed') return 'text-amber-800';
if (mimeType === 'text/plain') return 'text-neutral-500';
if (mimeType === 'application/typescript' || mimeType === 'text/typescript') return 'text-blue-700';
if (mimeType === 'application/vnd.ms-excel' || mimeType === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') return 'text-green-700';
if (mimeType === 'application/xml' || mimeType === 'text/xml') return 'text-orange-800';
if (mimeType === 'application/zip' || mimeType === 'application/x-zip-compressed') return 'text-amber-700';
if (mimeType === 'text/csv') return 'text-emerald-700';
if (mimeType === 'application/msword' || mimeType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') return 'text-blue-800';
return 'text-neutral-500';
}
export function formatBytes(bytes) {
if (!bytes || bytes === 0) return '0 o';
const units = ['o', 'Ko', 'Mo', 'Go'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
}
export function formatDate(dateStr) {
if (!dateStr) return '';
return new Date(dateStr).toLocaleDateString('fr-CA', {
year: 'numeric', month: 'short', day: 'numeric',
});
}
// ─── NuageFileTable ───────────────────────────────────────────────────────────
/**
* Unified file listing table for the Nuage module.
*
* Each item must have a `_type` field: 'folder' | 'file' | 'share'.
*
* Props:
* items — array of rows (folders, files, or shares with `_type`)
* loading — show skeleton rows
* sortBy — active sort key
* sortOrder — 'asc' | 'desc'
* onSort — (key) => void — called when a sortable header is clicked
* onRowClick — (item) => void
* renderActions — (item) => ReactNode — content for the actions column
* showShareBadge — show a "Partagé" badge on items with has_active_share
* emptyMessage — primary empty state text
* emptyDescription — secondary empty state text
* size — Table size prop (default 'sm')
* className — extra class on the Table
* getRowProps — (item) => object — extra props per row
*/
export default function NuageFileTable({
items = [],
loading = false,
sortBy,
sortOrder,
onSort,
onRowClick,
renderActions,
showShareBadge = false,
emptyMessage = 'Ce dossier est vide',
emptyDescription,
size = 'sm',
className,
getRowProps,
}) {
const sortable = !!onSort;
const columns = [
{
key: 'name',
label: 'Nom',
sortable,
render: (item) => {
if (item._type === 'share') {
const isFolder = item.target_type === 'folder';
const isExpired = item.expires_at && new Date(item.expires_at) < new Date();
const Icon = isFolder ? Folder01Icon : File02Icon;
return (
<div className={`flex items-center gap-3 ${isExpired ? 'opacity-50' : ''}`}>
<Icon className={`w-5 h-5 flex-shrink-0 ${isFolder ? 'text-yellow-500' : 'text-blue-500'}`} />
<span className="font-medium text-sm text-neutral-900 dark:text-white">
{item.target_name || (isFolder ? 'Dossier' : 'Fichier')}
</span>
</div>
);
}
if (item._type === 'folder') {
return (
<div className="flex items-center gap-3">
<Folder01Icon className="w-5 h-5 text-yellow-500 flex-shrink-0" />
<span className="font-medium text-sm text-neutral-900 dark:text-white">{item.name}</span>
{showShareBadge && item.has_active_share && (
<Badge variant="info" className="text-xs">Partagé</Badge>
)}
</div>
);
}
// file
const FileIcon = getFileIcon(item.mime_type);
const fileColor = getFileColor(item.mime_type);
return (
<div className="flex items-center gap-3">
<FileIcon className={`w-5 h-5 flex-shrink-0 ${fileColor}`} />
<span className="text-sm text-neutral-900 dark:text-white">{item.display_name}</span>
{showShareBadge && item.has_active_share && (
<Badge variant="info" className="text-xs">Partagé</Badge>
)}
</div>
);
},
skeleton: { height: 'h-4', width: '60%' },
},
{
key: 'date',
label: 'Date',
sortable,
render: (item) => {
if (item._type === 'share') {
return item.expires_at ? (
<span className="text-xs text-neutral-400">Expire {formatDate(item.expires_at)}</span>
) : null;
}
return <span className="text-neutral-500 text-xs">{formatDate(item.created_at)}</span>;
},
skeleton: { height: 'h-4', width: '40%' },
},
{
key: 'size',
label: 'Taille',
sortable,
render: (item) => {
if (item._type !== 'file') return <span className="text-neutral-400 text-xs"></span>;
return <span className="text-neutral-400 text-xs">{formatBytes(item.size)}</span>;
},
skeleton: { height: 'h-4', width: '30%' },
},
...(renderActions ? [{
key: 'actions',
label: '',
render: (item) => renderActions(item),
skeleton: { height: 'h-8', width: '40px' },
}] : []),
];
return (
<Table
columns={columns}
data={items}
loading={loading}
sortBy={sortBy}
sortOrder={sortOrder}
onSort={onSort}
onRowClick={onRowClick}
getRowProps={getRowProps}
emptyMessage={emptyMessage}
emptyDescription={emptyDescription}
size={size}
className={className}
/>
);
}
-776
View File
@@ -1,776 +0,0 @@
'use client';
import React, { useState, useEffect, useRef } from 'react';
import {
Cancel01Icon,
Link02Icon,
Copy01Icon,
Delete02Icon,
UserCircle02Icon,
} from '../../../shared/Icons.js';
import { Button, Badge, Loading, Input, Modal, Textarea } from '../../../shared/components';
import { formatBytes, formatDate } from './NuageFileTable.js';
import { useToast } from '@hykocx/zen/toast';
// ─── User search input ────────────────────────────────────────────────────────
function UserSearchInput({ selectedUser, onSelect, onClear, query, onQueryChange, results, searching }) {
return (
<div className="relative">
<label className="block text-[11px] font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider mb-1.5">Utilisateur</label>
{selectedUser ? (
<div className="flex items-center justify-between px-3 py-2 border border-neutral-200 dark:border-neutral-700 rounded-xl bg-neutral-50 dark:bg-neutral-800/60">
<div className="min-w-0">
<p className="text-xs font-medium text-neutral-900 dark:text-white truncate">{selectedUser.name}</p>
<p className="text-[11px] text-neutral-400 truncate">{selectedUser.email}</p>
</div>
<button
onClick={onClear}
className="ml-2 flex-shrink-0 text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200 transition-colors"
>
<Cancel01Icon className="w-3.5 h-3.5" />
</button>
</div>
) : (
<>
<Input
value={query}
onChange={onQueryChange}
placeholder="Nom ou courriel…"
/>
{results.length > 0 && (
<div className="absolute z-10 w-full mt-1 bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700 rounded-xl shadow-lg max-h-36 overflow-y-auto">
{results.map(u => (
<button
key={u.id}
onClick={() => onSelect(u)}
className="w-full text-left px-3 py-2 hover:bg-neutral-50 dark:hover:bg-neutral-800 transition-colors first:rounded-t-xl last:rounded-b-xl"
>
<p className="text-xs font-medium text-neutral-900 dark:text-white">{u.name}</p>
<p className="text-[11px] text-neutral-400">{u.email}</p>
</button>
))}
</div>
)}
{searching && <p className="text-[11px] text-neutral-400 mt-1">Recherche</p>}
</>
)}
</div>
);
}
// ─── Share panel ──────────────────────────────────────────────────────────────
export default function SharePanel({ target, onClose }) {
const toast = useToast();
const [shares, setShares] = useState([]);
const [loading, setLoading] = useState(true);
const [tab, setTab] = useState('list'); // 'list' | 'create'
// Create share form
const [shareType, setShareType] = useState('anonymous'); // 'user' | 'anonymous'
const [permission, setPermission] = useState('reader');
const [password, setPassword] = useState('');
const [expiresPreset, setExpiresPreset] = useState('');
const [uploadLimit, setUploadLimit] = useState('');
const [creating, setCreating] = useState(false);
const [createdShare, setCreatedShare] = useState(null);
// User search (create)
const [userQuery, setUserQuery] = useState('');
const [userResults, setUserResults] = useState([]);
const [selectedUser, setSelectedUser] = useState(null);
const [searchingUsers, setSearchingUsers] = useState(false);
const searchTimeout = useRef(null);
// Email modal
const [showEmailModal, setShowEmailModal] = useState(false);
const [emailAddress, setEmailAddress] = useState('');
const [emailMessage, setEmailMessage] = useState('');
const [sendingEmail, setSendingEmail] = useState(false);
const [emailShareTarget, setEmailShareTarget] = useState(null);
// Edit modal
const [editingShare, setEditingShare] = useState(null);
const [editShareType, setEditShareType] = useState('anonymous');
const [editPermission, setEditPermission] = useState('reader');
const [editExpiresPreset, setEditExpiresPreset] = useState('keep');
const [editUploadLimit, setEditUploadLimit] = useState('');
const [editPassword, setEditPassword] = useState('');
const [editClearPassword, setEditClearPassword] = useState(false);
const [editSelectedUser, setEditSelectedUser] = useState(null);
const [editUserQuery, setEditUserQuery] = useState('');
const [editUserResults, setEditUserResults] = useState([]);
const [editSearchingUsers, setEditSearchingUsers] = useState(false);
const [updating, setUpdating] = useState(false);
const editSearchTimeout = useRef(null);
useEffect(() => {
loadShares();
}, [target]);
const loadShares = async () => {
setLoading(true);
try {
const res = await fetch(
`/zen/api/admin/nuage/shares?targetId=${target.id}&targetType=${target.type}`,
{ credentials: 'include' }
);
const data = await res.json();
if (data.success) setShares(data.shares);
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
};
const makeUserSearchHandler = (setQuery, setSelected, setResults, setSearching, timeoutRef) => (q) => {
setQuery(q);
setSelected(null);
clearTimeout(timeoutRef.current);
if (q.length < 2) { setResults([]); return; }
timeoutRef.current = setTimeout(async () => {
setSearching(true);
try {
const res = await fetch(`/zen/api/admin/nuage/users?q=${encodeURIComponent(q)}`, { credentials: 'include' });
const data = await res.json();
if (data.success) setResults(data.users);
} catch (e) {
console.error(e);
} finally {
setSearching(false);
}
}, 300);
};
const handleUserSearch = makeUserSearchHandler(setUserQuery, setSelectedUser, setUserResults, setSearchingUsers, searchTimeout);
const handleEditUserSearch = makeUserSearchHandler(setEditUserQuery, setEditSelectedUser, setEditUserResults, setEditSearchingUsers, editSearchTimeout);
const getExpiresAt = (preset) => {
if (!preset || preset === 'keep') return preset === 'keep' ? undefined : null;
const days = { '24h': 1, '7d': 7, '30d': 30, '90d': 90 }[preset];
if (!days) return null;
const d = new Date();
d.setDate(d.getDate() + days);
return d.toISOString();
};
const handleCreate = async () => {
if (shareType === 'user' && !selectedUser) {
toast.error('Veuillez sélectionner un utilisateur');
return;
}
setCreating(true);
try {
const res = await fetch('/zen/api/admin/nuage/shares', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
targetId: target.id,
targetType: target.type,
shareType,
userId: shareType === 'user' ? selectedUser?.id : null,
permission,
password: shareType === 'anonymous' && password ? password : null,
expiresAt: getExpiresAt(expiresPreset),
uploadLimitBytes: permission === 'collaborator' && target.type === 'folder' && uploadLimit
? parseInt(uploadLimit) * 1024 * 1024
: null,
}),
});
const data = await res.json();
if (data.success) {
toast.success('Partage créé');
setCreatedShare(data.share);
loadShares();
setTab('list');
setPassword('');
setExpiresPreset('');
setUploadLimit('');
setSelectedUser(null);
setUserQuery('');
} else {
toast.error(data.error || 'Erreur lors de la création du partage');
}
} catch (e) {
toast.error('Erreur réseau');
} finally {
setCreating(false);
}
};
const openEditModal = (share) => {
setEditingShare(share);
setEditShareType(share.share_type);
setEditPermission(share.permission);
setEditExpiresPreset('keep');
setEditUploadLimit(share.upload_limit_bytes ? String(Math.round(share.upload_limit_bytes / (1024 * 1024))) : '');
setEditPassword('');
setEditClearPassword(false);
setEditSelectedUser(share.share_type === 'user' ? { id: share.user_id, name: share.name, email: share.email } : null);
setEditUserQuery('');
setEditUserResults([]);
};
const handleUpdate = async () => {
if (editShareType === 'user' && !editSelectedUser) {
toast.error('Veuillez sélectionner un utilisateur');
return;
}
setUpdating(true);
try {
const body = { id: editingShare.id };
body.permission = editPermission;
body.shareType = editShareType;
body.userId = editShareType === 'user' ? (editSelectedUser?.id || null) : null;
const expiresAt = getExpiresAt(editExpiresPreset);
if (expiresAt !== undefined) body.expiresAt = expiresAt;
if (editPermission === 'collaborator' && target.type === 'folder') {
body.uploadLimitBytes = editUploadLimit ? parseInt(editUploadLimit) * 1024 * 1024 : null;
} else {
body.uploadLimitBytes = null;
}
if (editShareType === 'anonymous') {
if (editClearPassword) {
body.clearPassword = true;
} else if (editPassword) {
body.password = editPassword;
}
} else {
body.clearPassword = true;
}
const res = await fetch('/zen/api/admin/nuage/shares', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(body),
});
const data = await res.json();
if (data.success) {
toast.success('Partage modifié');
setEditingShare(null);
loadShares();
} else {
toast.error(data.error || 'Erreur lors de la modification');
}
} catch (e) {
toast.error('Erreur réseau');
} finally {
setUpdating(false);
}
};
const handleRevoke = async (shareId) => {
try {
const res = await fetch(`/zen/api/admin/nuage/shares?id=${shareId}`, {
method: 'DELETE',
credentials: 'include',
});
const data = await res.json();
if (data.success) {
toast.success('Partage révoqué');
loadShares();
} else {
toast.error(data.error || 'Erreur');
}
} catch (e) {
toast.error('Erreur réseau');
}
};
const getSharePublicUrl = (token) => {
const base = typeof window !== 'undefined' ? window.location.origin : '';
return `${base}/zen/nuage/partage/${token}`;
};
const copyToClipboard = (text) => {
navigator.clipboard.writeText(text).then(() => toast.success('Lien copié !'));
};
const openEmailModal = (share) => {
setEmailShareTarget(share);
setEmailAddress(share.share_type === 'user' ? (share.email || '') : '');
setEmailMessage('');
setShowEmailModal(true);
};
const handleSendEmail = async () => {
if (!emailAddress) { toast.error('Adresse courriel requise'); return; }
setSendingEmail(true);
try {
const shareUrl = getSharePublicUrl(emailShareTarget.token);
const res = await fetch('/zen/api/admin/nuage/shares/email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
shareId: emailShareTarget.id,
toEmail: emailAddress,
toName: emailShareTarget.share_type === 'user' ? (emailShareTarget.name || '') : '',
customMessage: emailMessage || null,
shareUrl,
targetName: target.name,
targetType: target.type,
shareType: emailShareTarget.share_type,
expiresAt: emailShareTarget.expires_at || null,
}),
});
const data = await res.json();
if (data.success) {
toast.success('Courriel envoyé');
setShowEmailModal(false);
} else {
toast.error(data.error || 'Erreur lors de l\'envoi');
}
} catch (e) {
toast.error('Erreur réseau');
} finally {
setSendingEmail(false);
}
};
const isShareExpired = (share) => {
if (!share.expires_at) return false;
return new Date(share.expires_at) < new Date();
};
const activeTabClass = 'border-b-2 border-neutral-900 dark:border-white text-neutral-900 dark:text-white font-medium';
const inactiveTabClass = 'text-neutral-400 dark:text-neutral-500 hover:text-neutral-600 dark:hover:text-neutral-300';
const activeChipClass = 'bg-neutral-900 dark:bg-white text-white dark:text-neutral-900 border-neutral-900 dark:border-white';
const inactiveChipClass = 'bg-transparent border-neutral-200 dark:border-neutral-700 text-neutral-500 dark:text-neutral-400 hover:border-neutral-400 dark:hover:border-neutral-500';
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-neutral-100 dark:border-neutral-800">
<div className="min-w-0 flex-1">
<h3 className="text-lg sm:text-xl font-semibold text-neutral-900 dark:text-white">Partager</h3>
<p className="mt-1 text-xs text-neutral-400">{target.name}</p>
</div>
<button
onClick={onClose}
className="ml-2 flex-shrink-0 p-1.5 rounded-lg text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200 hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
>
<Cancel01Icon className="w-4 h-4" />
</button>
</div>
{/* Tabs */}
<div className="flex border-b border-neutral-100 dark:border-neutral-800 px-4">
<button
onClick={() => setTab('list')}
className={`py-2.5 text-xs mr-4 transition-colors ${tab === 'list' ? activeTabClass : inactiveTabClass}`}
>
Actifs {shares.length > 0 && <span className="ml-1 text-neutral-400">({shares.length})</span>}
</button>
<button
onClick={() => setTab('create')}
className={`py-2.5 text-xs transition-colors ${tab === 'create' ? activeTabClass : inactiveTabClass}`}
>
+ Nouveau
</button>
</div>
<div className="flex-1 overflow-y-auto">
{/* ── List tab ── */}
{tab === 'list' && (
<div className="p-3 space-y-2">
{loading ? (
<div className="py-8 flex justify-center"><Loading /></div>
) : shares.length === 0 ? (
<div className="py-10 text-center">
<Link02Icon className="w-6 h-6 text-neutral-300 dark:text-neutral-600 mx-auto mb-2" />
<p className="text-xs text-neutral-400">Aucun partage actif</p>
</div>
) : (
shares.map(share => {
const expired = isShareExpired(share);
const url = getSharePublicUrl(share.token);
return (
<div key={share.id} className="border border-neutral-100 dark:border-neutral-800 rounded-xl p-3 space-y-2.5 bg-neutral-50/50 dark:bg-neutral-900/50">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<div className="w-6 h-6 rounded-lg bg-neutral-100 dark:bg-neutral-800 flex items-center justify-center flex-shrink-0">
{share.share_type === 'user' ? (
<UserCircle02Icon className="w-3.5 h-3.5 text-neutral-500" />
) : (
<Link02Icon className="w-3.5 h-3.5 text-neutral-500" />
)}
</div>
<div className="min-w-0">
<p className="text-xs font-medium text-neutral-900 dark:text-white truncate">
{share.share_type === 'user'
? share.name || share.email || 'Utilisateur'
: 'Lien anonyme'}
</p>
<p className="text-[11px] text-neutral-400 mt-0.5">
{share.permission === 'reader' ? 'Lecteur' : 'Collaborateur'}
{share.expires_at && ` · ${formatDate(share.expires_at)}`}
{share.upload_limit_bytes && ` · ${formatBytes(share.upload_limit_bytes)}`}
</p>
</div>
</div>
<Badge size="sm" variant={!share.is_active ? 'default' : expired ? 'danger' : 'success'}>
{!share.is_active ? 'Révoqué' : expired ? 'Expiré' : 'Actif'}
</Badge>
</div>
<div className="flex items-center gap-1 pt-0.5 border-t border-neutral-100 dark:border-neutral-800">
{share.is_active && !expired && (
<>
<button
onClick={() => copyToClipboard(url)}
className="flex items-center gap-1 px-2 py-1 text-[11px] font-medium text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-lg transition-colors"
>
<Copy01Icon className="w-3 h-3" /> Copier
</button>
<button
onClick={() => openEmailModal(share)}
className="flex items-center gap-1 px-2 py-1 text-[11px] font-medium text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-lg transition-colors"
>
Envoyer
</button>
</>
)}
<button
onClick={() => openEditModal(share)}
className="flex items-center gap-1 px-2 py-1 text-[11px] font-medium text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-lg transition-colors"
>
Modifier
</button>
{share.is_active && !expired && (
<button
onClick={() => handleRevoke(share.id)}
className="flex items-center gap-1 px-2 py-1 text-[11px] font-medium text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-500/10 rounded-lg transition-colors ml-auto"
>
<Delete02Icon className="w-3 h-3" /> Révoquer
</button>
)}
</div>
</div>
);
})
)}
</div>
)}
{/* ── Create tab ── */}
{tab === 'create' && (
<div className="p-3 space-y-3">
{/* Share type */}
<div>
<label className="block text-[11px] font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider mb-1.5">Type</label>
<div className="flex gap-1.5">
<button
onClick={() => setShareType('anonymous')}
className={`flex-1 py-1.5 text-xs rounded-xl border font-medium transition-all ${shareType === 'anonymous' ? activeChipClass : inactiveChipClass}`}
>
Lien
</button>
<button
onClick={() => setShareType('user')}
className={`flex-1 py-1.5 text-xs rounded-xl border font-medium transition-all ${shareType === 'user' ? activeChipClass : inactiveChipClass}`}
>
Utilisateur
</button>
</div>
</div>
{/* User search */}
{shareType === 'user' && (
<UserSearchInput
selectedUser={selectedUser}
onSelect={(u) => { setSelectedUser(u); setUserQuery(''); setUserResults([]); }}
onClear={() => { setSelectedUser(null); setUserQuery(''); }}
query={userQuery}
onQueryChange={handleUserSearch}
results={userResults}
searching={searchingUsers}
/>
)}
{/* Permission */}
<div>
<label className="block text-[11px] font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider mb-1.5">Permission</label>
<div className="flex gap-1.5">
<button
onClick={() => setPermission('reader')}
className={`flex-1 py-1.5 text-xs rounded-xl border font-medium transition-all ${permission === 'reader' ? activeChipClass : inactiveChipClass}`}
>
Lecteur
</button>
<button
onClick={() => setPermission('collaborator')}
className={`flex-1 py-1.5 text-xs rounded-xl border font-medium transition-all ${permission === 'collaborator' ? activeChipClass : inactiveChipClass}`}
>
Collaborateur
</button>
</div>
</div>
{/* Upload limit (collaborator + folder only) */}
{permission === 'collaborator' && target.type === 'folder' && (
<div>
<Input
type="number"
label="Limite (Mo, optionnel)"
min="1"
value={uploadLimit}
onChange={v => setUploadLimit(v)}
placeholder="Illimité"
description="Quota de stockage total pour ce partage"
/>
</div>
)}
{/* Password (anonymous only) */}
{shareType === 'anonymous' && (
<div>
<Input
type="password"
label="Mot de passe (optionnel)"
value={password}
onChange={v => setPassword(v)}
placeholder="Aucun"
/>
</div>
)}
{/* Expiration */}
<div>
<label className="block text-[11px] font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider mb-1.5">Expiration</label>
<div className="flex flex-wrap gap-1.5">
{['', '24h', '7d', '30d', '90d'].map(preset => (
<button
key={preset}
onClick={() => setExpiresPreset(preset)}
className={`px-2.5 py-1 text-xs rounded-xl border font-medium transition-all ${expiresPreset === preset ? activeChipClass : inactiveChipClass}`}
>
{preset === '' ? 'Aucune' : preset === '24h' ? '24 h' : preset === '7d' ? '7 j' : preset === '30d' ? '30 j' : '90 j'}
</button>
))}
</div>
</div>
<Button onClick={handleCreate} loading={creating} className="w-full mt-1">
{shareType === 'anonymous' ? 'Générer le lien' : 'Créer le partage'}
</Button>
</div>
)}
</div>
{/* Email modal */}
{showEmailModal && (
<Modal
onClose={() => setShowEmailModal(false)}
title="Envoyer par courriel"
size="sm"
footer={
<div className="flex gap-2 justify-end">
<Button variant="secondary" onClick={() => setShowEmailModal(false)}>Annuler</Button>
<Button onClick={handleSendEmail} loading={sendingEmail}>Envoyer</Button>
</div>
}
>
<div className="space-y-4">
<Input
type="email"
label="Destinataire"
value={emailAddress}
onChange={v => setEmailAddress(v)}
placeholder="courriel@exemple.com"
/>
<Textarea
label="Message personnalisé (optionnel)"
value={emailMessage}
onChange={v => setEmailMessage(v)}
rows={3}
placeholder="Votre message…"
/>
</div>
</Modal>
)}
{/* Edit share modal */}
{editingShare && (
<Modal
onClose={() => setEditingShare(null)}
title="Modifier le partage"
size="sm"
footer={
<div className="flex gap-2 justify-end">
<Button variant="secondary" onClick={() => setEditingShare(null)}>Annuler</Button>
<Button onClick={handleUpdate} loading={updating}>Enregistrer</Button>
</div>
}
>
<div className="space-y-4">
{/* Type */}
<div>
<label className="block text-[11px] font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider mb-1.5">Type</label>
<div className="flex gap-1.5">
<button
onClick={() => setEditShareType('anonymous')}
className={`flex-1 py-1.5 text-xs rounded-xl border font-medium transition-all ${editShareType === 'anonymous' ? activeChipClass : inactiveChipClass}`}
>
Lien
</button>
<button
onClick={() => setEditShareType('user')}
className={`flex-1 py-1.5 text-xs rounded-xl border font-medium transition-all ${editShareType === 'user' ? activeChipClass : inactiveChipClass}`}
>
Utilisateur
</button>
</div>
</div>
{/* User search */}
{editShareType === 'user' && (
<UserSearchInput
selectedUser={editSelectedUser}
onSelect={(u) => { setEditSelectedUser(u); setEditUserQuery(''); setEditUserResults([]); }}
onClear={() => { setEditSelectedUser(null); setEditUserQuery(''); }}
query={editUserQuery}
onQueryChange={handleEditUserSearch}
results={editUserResults}
searching={editSearchingUsers}
/>
)}
{/* Permission */}
<div>
<label className="block text-[11px] font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider mb-1.5">Permission</label>
<div className="flex gap-1.5">
<button
onClick={() => setEditPermission('reader')}
className={`flex-1 py-1.5 text-xs rounded-xl border font-medium transition-all ${editPermission === 'reader' ? activeChipClass : inactiveChipClass}`}
>
Lecteur
</button>
<button
onClick={() => setEditPermission('collaborator')}
className={`flex-1 py-1.5 text-xs rounded-xl border font-medium transition-all ${editPermission === 'collaborator' ? activeChipClass : inactiveChipClass}`}
>
Collaborateur
</button>
</div>
</div>
{/* Upload limit (collaborator + folder only) */}
{editPermission === 'collaborator' && target.type === 'folder' && (
<Input
type="number"
label="Limite (Mo, optionnel)"
min="1"
value={editUploadLimit}
onChange={v => setEditUploadLimit(v)}
placeholder="Illimité"
description="Quota de stockage total pour ce partage"
/>
)}
{/* Password (anonymous only) */}
{editShareType === 'anonymous' && (
<div className="space-y-2">
<label className="block text-[11px] font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Mot de passe</label>
{editingShare.password_hash && !editClearPassword && (
<div className="flex items-center justify-between px-3 py-2 border border-neutral-200 dark:border-neutral-700 rounded-xl bg-neutral-50 dark:bg-neutral-800/60">
<p className="text-xs text-neutral-500 dark:text-neutral-400">Mot de passe défini</p>
<button
onClick={() => setEditClearPassword(true)}
className="text-[11px] font-medium text-red-500 hover:text-red-600 transition-colors"
>
Supprimer
</button>
</div>
)}
{editClearPassword && (
<div className="flex items-center justify-between px-3 py-2 border border-red-200 dark:border-red-800 rounded-xl bg-red-50 dark:bg-red-500/10">
<p className="text-xs text-red-600 dark:text-red-400">Mot de passe supprimé</p>
<button
onClick={() => setEditClearPassword(false)}
className="text-[11px] font-medium text-neutral-500 hover:text-neutral-700 transition-colors"
>
Annuler
</button>
</div>
)}
{!editClearPassword && (
<Input
type="password"
value={editPassword}
onChange={v => setEditPassword(v)}
placeholder={editingShare.password_hash ? 'Nouveau mot de passe…' : 'Ajouter un mot de passe…'}
/>
)}
</div>
)}
{/* Expiration */}
<div>
<label className="block text-[11px] font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider mb-1.5">
Expiration
{editingShare.expires_at && editExpiresPreset === 'keep' && (
<span className="ml-1 font-normal normal-case text-neutral-400">
(actuellement : {formatDate(editingShare.expires_at)})
</span>
)}
</label>
<div className="flex flex-wrap gap-1.5">
{['keep', '', '24h', '7d', '30d', '90d'].map(preset => (
<button
key={preset === '' ? 'none' : preset}
onClick={() => setEditExpiresPreset(preset)}
className={`px-2.5 py-1 text-xs rounded-xl border font-medium transition-all ${editExpiresPreset === preset ? activeChipClass : inactiveChipClass}`}
>
{preset === 'keep' ? 'Conserver' : preset === '' ? 'Aucune' : preset === '24h' ? '24 h' : preset === '7d' ? '7 j' : preset === '30d' ? '30 j' : '90 j'}
</button>
))}
</div>
</div>
{/* Reactivate revoked share */}
{!editingShare.is_active && (
<div className="flex items-center justify-between px-3 py-2.5 border border-neutral-200 dark:border-neutral-700 rounded-xl bg-neutral-50 dark:bg-neutral-800/60">
<div>
<p className="text-xs font-medium text-neutral-900 dark:text-white">Réactiver le partage</p>
<p className="text-[11px] text-neutral-400 mt-0.5">Ce partage est actuellement révoqué</p>
</div>
<Button size="sm" variant="secondary" onClick={async () => {
setUpdating(true);
try {
const res = await fetch('/zen/api/admin/nuage/shares', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ id: editingShare.id, isActive: true }),
});
const data = await res.json();
if (data.success) {
toast.success('Partage réactivé');
setEditingShare(null);
loadShares();
} else {
toast.error(data.error || 'Erreur');
}
} catch (e) {
toast.error('Erreur réseau');
} finally {
setUpdating(false);
}
}}>
Réactiver
</Button>
</div>
)}
</div>
</Modal>
)}
</div>
);
}
-461
View File
@@ -1,461 +0,0 @@
/**
* Nuage Module — CRUD
* Database operations for folders, files, and shares + R2 integration
*/
import { query } from '@hykocx/zen/database';
import { uploadFile, deleteFile, proxyFile } from '@hykocx/zen/storage';
import crypto from 'crypto';
// ─── Helpers ────────────────────────────────────────────────────────────────
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
function sanitizeFilename(name) {
const sanitized = name
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-zA-Z0-9._-]/g, '_')
.replace(/_+/g, '_')
.substring(0, 200);
return sanitized || 'fichier';
}
function generateNuageFilePath(fileId, filename) {
return `nuage/files/${fileId}/${sanitizeFilename(filename)}`;
}
// ─── Folders ────────────────────────────────────────────────────────────────
export async function getFolderById(id) {
if (!id || !UUID_REGEX.test(id)) return null;
const result = await query('SELECT * FROM zen_nuage_folders WHERE id = $1', [id]);
return result.rows[0] || null;
}
export async function getFolderContents(folderId = null) {
const param = folderId || null;
const folderResult = await query(
`SELECT f.*,
(SELECT COUNT(*) > 0 FROM zen_nuage_shares s WHERE s.target_id = f.id AND s.target_type = 'folder' AND s.is_active = true AND (s.expires_at IS NULL OR s.expires_at > NOW())) AS has_active_share
FROM zen_nuage_folders f
WHERE f.parent_id ${param ? '= $1' : 'IS NULL'}
ORDER BY f.name ASC`,
param ? [param] : []
);
const fileResult = await query(
`SELECT f.*,
(SELECT COUNT(*) > 0 FROM zen_nuage_shares s WHERE s.target_id = f.id AND s.target_type = 'file' AND s.is_active = true AND (s.expires_at IS NULL OR s.expires_at > NOW())) AS has_active_share
FROM zen_nuage_files f
WHERE f.folder_id ${param ? '= $1' : 'IS NULL'}
ORDER BY f.display_name ASC`,
param ? [param] : []
);
return {
folders: folderResult.rows,
files: fileResult.rows,
};
}
export async function getFolderBreadcrumb(folderId) {
if (!folderId) return [];
const breadcrumb = [];
let currentId = folderId;
while (currentId) {
const result = await query('SELECT * FROM zen_nuage_folders WHERE id = $1', [currentId]);
const folder = result.rows[0];
if (!folder) break;
breadcrumb.unshift(folder);
currentId = folder.parent_id;
}
return breadcrumb;
}
export async function createFolder(name, parentId = null) {
let depth = 0;
if (parentId) {
const parent = await getFolderById(parentId);
if (!parent) throw new Error('Dossier parent introuvable');
depth = parent.depth + 1;
if (depth > 10) throw new Error('Profondeur maximale de 10 niveaux atteinte');
}
const result = await query(
`INSERT INTO zen_nuage_folders (name, parent_id, depth) VALUES ($1, $2, $3) RETURNING *`,
[name.trim(), parentId, depth]
);
return result.rows[0];
}
export async function renameFolder(id, name) {
const result = await query(
`UPDATE zen_nuage_folders SET name = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2 RETURNING *`,
[name.trim(), id]
);
return result.rows[0] || null;
}
export async function moveFolder(id, newParentId) {
let depth = 0;
if (newParentId) {
const parent = await getFolderById(newParentId);
if (!parent) throw new Error('Dossier parent introuvable');
depth = parent.depth + 1;
if (depth > 10) throw new Error('Profondeur maximale de 10 niveaux atteinte');
}
const result = await query(
`UPDATE zen_nuage_folders SET parent_id = $1, depth = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $3 RETURNING *`,
[newParentId, depth, id]
);
return result.rows[0] || null;
}
export async function deleteFolder(id) {
// Collect all r2_keys recursively before deleting
const r2Keys = await collectFolderR2Keys(id);
for (const key of r2Keys) {
await deleteFile(key).catch(() => {});
}
await query('DELETE FROM zen_nuage_folders WHERE id = $1', [id]);
return { success: true };
}
async function collectFolderR2Keys(folderId) {
const keys = [];
const filesResult = await query('SELECT r2_key FROM zen_nuage_files WHERE folder_id = $1', [folderId]);
for (const row of filesResult.rows) keys.push(row.r2_key);
const subFolders = await query('SELECT id FROM zen_nuage_folders WHERE parent_id = $1', [folderId]);
for (const sub of subFolders.rows) {
const subKeys = await collectFolderR2Keys(sub.id);
keys.push(...subKeys);
}
return keys;
}
export async function getFolderItemCount(folderId) {
const folders = await query('SELECT COUNT(*) FROM zen_nuage_folders WHERE parent_id = $1', [folderId]);
const files = await query('SELECT COUNT(*) FROM zen_nuage_files WHERE folder_id = $1', [folderId]);
return parseInt(folders.rows[0].count) + parseInt(files.rows[0].count);
}
// ─── Files ──────────────────────────────────────────────────────────────────
export async function getFileById(id) {
if (!id || !UUID_REGEX.test(id)) return null;
const result = await query('SELECT * FROM zen_nuage_files WHERE id = $1', [id]);
return result.rows[0] || null;
}
export async function uploadNuageFile(folderId, buffer, originalName, mimeType, size) {
const fileId = crypto.randomUUID();
const r2Key = generateNuageFilePath(fileId, originalName);
const uploadResult = await uploadFile({ key: r2Key, body: buffer, contentType: mimeType });
if (!uploadResult.success) throw new Error(uploadResult.error || 'Échec du téléversement vers R2');
const result = await query(
`INSERT INTO zen_nuage_files (id, folder_id, original_name, display_name, mime_type, size, r2_key)
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`,
[fileId, folderId || null, originalName, originalName, mimeType, size, r2Key]
);
return result.rows[0];
}
export async function renameFile(id, displayName) {
const result = await query(
`UPDATE zen_nuage_files SET display_name = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2 RETURNING *`,
[displayName.trim(), id]
);
return result.rows[0] || null;
}
export async function moveFile(id, newFolderId) {
const result = await query(
`UPDATE zen_nuage_files SET folder_id = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2 RETURNING *`,
[newFolderId || null, id]
);
return result.rows[0] || null;
}
export async function deleteNuageFile(id) {
const file = await getFileById(id);
if (!file) throw new Error('Fichier introuvable');
await deleteFile(file.r2_key).catch(() => {});
await query('DELETE FROM zen_nuage_files WHERE id = $1', [id]);
return { success: true };
}
export async function proxyNuageFile(id, { inline = false } = {}) {
const file = await getFileById(id);
if (!file) throw new Error('Fichier introuvable');
return proxyFile(file.r2_key, { filename: inline ? undefined : file.display_name });
}
export async function getFolderUploadedSize(folderId) {
const result = await query(
`SELECT COALESCE(SUM(size), 0) AS total FROM zen_nuage_files WHERE folder_id = $1`,
[folderId]
);
return parseInt(result.rows[0].total);
}
// ─── Shares ─────────────────────────────────────────────────────────────────
export async function getShareByToken(token) {
const result = await query('SELECT * FROM zen_nuage_shares WHERE token = $1', [token]);
return result.rows[0] || null;
}
export async function getShareInfoForMetadata(token) {
const result = await query(
`SELECT s.*,
CASE
WHEN s.target_type = 'file' THEN (SELECT display_name FROM zen_nuage_files WHERE id = s.target_id)
WHEN s.target_type = 'folder' THEN (SELECT name FROM zen_nuage_folders WHERE id = s.target_id)
END AS target_name
FROM zen_nuage_shares s
WHERE s.token = $1`,
[token]
);
return result.rows[0] || null;
}
export async function getSharesForTarget(targetId, targetType) {
const result = await query(
`SELECT s.*, u.name, u.email
FROM zen_nuage_shares s
LEFT JOIN zen_auth_users u ON u.id = s.user_id
WHERE s.target_id = $1 AND s.target_type = $2
ORDER BY s.created_at DESC`,
[targetId, targetType]
);
return result.rows;
}
export async function getSharesForUser(userId) {
const result = await query(
`SELECT s.*
FROM zen_nuage_shares s
WHERE s.user_id = $1
AND s.is_active = true
AND (s.expires_at IS NULL OR s.expires_at > CURRENT_TIMESTAMP)
ORDER BY s.created_at DESC`,
[userId]
);
return result.rows;
}
export async function getAllActiveShares() {
const result = await query(
`SELECT s.*, u.name, u.email,
CASE
WHEN s.target_type = 'file' THEN (SELECT display_name FROM zen_nuage_files WHERE id = s.target_id)
WHEN s.target_type = 'folder' THEN (SELECT name FROM zen_nuage_folders WHERE id = s.target_id)
END AS target_name
FROM zen_nuage_shares s
LEFT JOIN zen_auth_users u ON u.id = s.user_id
ORDER BY s.created_at DESC`
);
return result.rows;
}
export async function createShare({
targetId,
targetType,
shareType,
userId = null,
permission,
password = null,
expiresAt = null,
uploadLimitBytes = null,
}) {
const token = crypto.randomBytes(32).toString('hex');
let passwordHash = null;
if (password) {
const salt = crypto.randomBytes(16).toString('hex');
passwordHash = await new Promise((resolve, reject) => {
crypto.scrypt(password, salt, 64, (err, key) => {
if (err) reject(err);
else resolve(`${salt}:${key.toString('hex')}`);
});
});
}
const result = await query(
`INSERT INTO zen_nuage_shares
(token, target_type, target_id, share_type, user_id, permission, password_hash, expires_at, upload_limit_bytes)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING *`,
[token, targetType, targetId, shareType, userId, permission, passwordHash, expiresAt, uploadLimitBytes]
);
return result.rows[0];
}
export async function revokeShare(id) {
const result = await query(
`UPDATE zen_nuage_shares SET is_active = false WHERE id = $1 RETURNING *`,
[id]
);
return result.rows[0] || null;
}
export async function updateShare(id, {
permission,
expiresAt,
uploadLimitBytes,
password,
clearPassword,
userId,
shareType,
isActive,
} = {}) {
let passwordHash = undefined;
if (clearPassword) {
passwordHash = null;
} else if (password) {
const salt = crypto.randomBytes(16).toString('hex');
passwordHash = await new Promise((resolve, reject) => {
crypto.scrypt(password, salt, 64, (err, key) => {
if (err) reject(err);
else resolve(`${salt}:${key.toString('hex')}`);
});
});
}
const sets = [];
const values = [];
let idx = 1;
if (permission !== undefined) { sets.push(`permission = $${idx++}`); values.push(permission); }
if (expiresAt !== undefined) { sets.push(`expires_at = $${idx++}`); values.push(expiresAt); }
if (uploadLimitBytes !== undefined){ sets.push(`upload_limit_bytes = $${idx++}`); values.push(uploadLimitBytes); }
if (passwordHash !== undefined) { sets.push(`password_hash = $${idx++}`); values.push(passwordHash); }
if (userId !== undefined) { sets.push(`user_id = $${idx++}`); values.push(userId); }
if (shareType !== undefined) { sets.push(`share_type = $${idx++}`); values.push(shareType); }
if (isActive !== undefined) { sets.push(`is_active = $${idx++}`); values.push(isActive); }
if (sets.length === 0) throw new Error('Aucun champ à modifier');
values.push(id);
const result = await query(
`UPDATE zen_nuage_shares SET ${sets.join(', ')} WHERE id = $${idx} RETURNING *`,
values
);
return result.rows[0] || null;
}
export function isShareValid(share) {
if (!share || !share.is_active) return false;
if (share.expires_at && new Date(share.expires_at) < new Date()) return false;
return true;
}
export async function verifySharePassword(share, password) {
if (!share.password_hash) return true;
const [salt, storedKey] = share.password_hash.split(':');
return new Promise((resolve, reject) => {
crypto.scrypt(password, salt, 64, (err, derivedKey) => {
if (err) reject(err);
else {
const storedBuf = Buffer.from(storedKey, 'hex');
resolve(
storedBuf.length === derivedKey.length &&
crypto.timingSafeEqual(storedBuf, derivedKey)
);
}
});
});
}
// ─── User search (for share panel) ──────────────────────────────────────────
export async function searchUsers(q, limit = 10) {
const like = `%${q}%`;
const result = await query(
`SELECT id, name, email
FROM zen_auth_users
WHERE (name ILIKE $1 OR email ILIKE $1)
ORDER BY name
LIMIT $2`,
[like, limit]
);
return result.rows;
}
// ─── Shared content access (for public pages and client section) ─────────────
export async function getSharedFolderContents(share, folderId = null) {
if (share.target_type === 'file') {
const file = await getFileById(share.target_id);
return { folders: [], files: file ? [file] : [] };
}
// For folder shares: start at the shared root or a sub-folder within it
const targetId = folderId || share.target_id;
// Verify the requested folder is within the share scope
if (folderId && folderId !== share.target_id) {
const breadcrumb = await getFolderBreadcrumb(folderId);
const inScope = breadcrumb.some(f => f.id === share.target_id);
if (!inScope) throw new Error('Dossier hors de la portée du partage');
}
return getFolderContents(targetId);
}
export async function getSharedBreadcrumb(share, folderId = null) {
if (!folderId || folderId === share.target_id) return [];
const full = await getFolderBreadcrumb(folderId);
const rootIndex = full.findIndex(f => f.id === share.target_id);
return rootIndex >= 0 ? full.slice(rootIndex + 1) : [];
}
// ─── Share scope validators ──────────────────────────────────────────────────
/**
* Checks whether a file belongs to the scope of a share.
* For file shares: the file must be the shared file itself.
* For folder shares: the file must live inside the shared folder tree.
*/
export async function isFileInShare(fileId, share) {
const file = await getFileById(fileId);
if (!file) return false;
if (share.target_type === 'file') {
return file.id === share.target_id;
}
// Folder share: file must be in the shared folder or a descendant
if (!file.folder_id) return false;
if (file.folder_id === share.target_id) return true;
const breadcrumb = await getFolderBreadcrumb(file.folder_id);
return breadcrumb.some(f => f.id === share.target_id);
}
/**
* Checks whether a folder belongs to the scope of a share.
* Only meaningful for folder shares; the folder must be the root or a descendant.
*/
export async function isFolderInShare(folderId, share) {
if (share.target_type !== 'folder') return false;
if (folderId === share.target_id) return true;
const breadcrumb = await getFolderBreadcrumb(folderId);
return breadcrumb.some(f => f.id === share.target_id);
}
// ─── Password cookie helpers (server-side enforcement) ───────────────────────
export function getPasswordCookieName(token) {
return `zen_nuage_pw_${token}`;
}
export function signPasswordToken(token) {
const secret = process.env.ZEN_SESSION_SECRET;
if (!secret) throw new Error('ZEN_SESSION_SECRET environment variable is not set');
return crypto.createHmac('sha256', secret).update(token).digest('hex');
}
@@ -1,424 +0,0 @@
'use client';
/**
* Client Nuage Section
* Unified file explorer for the client portal.
* Shares are displayed as folders/files at the root level.
* Clicking a folder share navigates into it like a real file explorer.
*
* Props:
* apiBasePath — base URL for the Zen API (default: '/zen/api')
* emptyMessage — message shown when there are no active shares
* newTab — open file download links in a new tab
*/
import React, { useState, useEffect, useRef } from 'react';
import {
CloudUploadIcon,
Tick02Icon,
} from '../../../shared/Icons.js';
import { Loading, Button, Card, Breadcrumb } from '../../../shared/components';
import NuageFileTable from '../components/NuageFileTable.js';
// ─── Main explorer ─────────────────────────────────────────────────────────────
export default function ClientNuageSection({
apiBasePath = '/zen/api',
emptyMessage = 'Aucun document partagé pour le moment.',
newTab = false,
}) {
const fileInputRef = useRef(null);
const isInitialMount = useRef(true);
const skipHistoryPush = useRef(false);
// Navigation
const [activeShare, setActiveShare] = useState(null);
const [folderId, setFolderId] = useState(null);
// Data
const [shares, setShares] = useState([]);
const [contents, setContents] = useState({ folders: [], files: [] });
const [breadcrumb, setBreadcrumb] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// Upload
const [uploading, setUploading] = useState(false);
const [uploadQueue, setUploadQueue] = useState([]);
const [uploadError, setUploadError] = useState(null);
// Sort (only used inside a share)
const [sortBy, setSortBy] = useState('name');
const [sortOrder, setSortOrder] = useState('asc');
// ─── Data fetching ──────────────────────────────────────────────────────────
useEffect(() => {
if (!activeShare) {
fetchShares();
} else {
fetchContents();
}
}, [activeShare, folderId]);
// Sync navigation state to URL history
useEffect(() => {
if (typeof window === 'undefined') return;
if (isInitialMount.current) {
isInitialMount.current = false;
return;
}
if (skipHistoryPush.current) {
skipHistoryPush.current = false;
return;
}
const url = new URL(window.location.href);
if (activeShare) {
url.searchParams.set('share', activeShare.token);
if (folderId) url.searchParams.set('folder', folderId);
else url.searchParams.delete('folder');
} else {
url.searchParams.delete('share');
url.searchParams.delete('folder');
}
window.history.pushState(
{ shareToken: activeShare?.token ?? null, folderId: folderId ?? null },
'',
url.toString()
);
}, [activeShare, folderId]);
// Handle browser back/forward
useEffect(() => {
if (typeof window === 'undefined') return;
const onPop = (event) => {
skipHistoryPush.current = true;
const state = event.state;
if (!state?.shareToken) {
setActiveShare(null);
setFolderId(null);
setBreadcrumb([]);
setContents({ folders: [], files: [] });
} else {
const share = shares.find(s => s.token === state.shareToken);
setActiveShare(share || null);
setFolderId(state.folderId || null);
}
};
window.addEventListener('popstate', onPop);
return () => window.removeEventListener('popstate', onPop);
}, [shares]);
const fetchShares = async () => {
setLoading(true);
setError(null);
try {
const base = typeof window !== 'undefined' ? window.location.origin : '';
const res = await fetch(`${base}${apiBasePath}/nuage/me`, { credentials: 'include' });
const data = await res.json();
if (!res.ok || !data.success) throw new Error(data.error || 'Erreur lors du chargement');
const allShares = data.shares || [];
setShares(allShares);
// Restore navigation from URL on initial load
const urlParams = new URLSearchParams(window.location.search);
const shareTokenFromUrl = urlParams.get('share');
if (shareTokenFromUrl) {
const share = allShares.find(s => s.token === shareTokenFromUrl);
if (share) {
skipHistoryPush.current = true;
setActiveShare(share);
setFolderId(urlParams.get('folder') || null);
}
}
} catch (e) {
setError(e.message);
} finally {
setLoading(false);
}
};
const fetchContents = async () => {
setLoading(true);
setError(null);
try {
const base = typeof window !== 'undefined' ? window.location.origin : '';
const params = new URLSearchParams({ shareId: activeShare.token });
if (folderId) params.set('folder', folderId);
const res = await fetch(`${base}${apiBasePath}/nuage/shared?${params}`, { credentials: 'include' });
const data = await res.json();
if (!res.ok || !data.success) throw new Error(data.error || 'Erreur');
setContents({ folders: data.folders || [], files: data.files || [] });
setBreadcrumb(data.breadcrumb || []);
} catch (e) {
setError(e.message);
} finally {
setLoading(false);
}
};
// ─── Navigation ─────────────────────────────────────────────────────────────
const goToRoot = () => {
setActiveShare(null);
setFolderId(null);
setBreadcrumb([]);
setContents({ folders: [], files: [] });
setSortBy('name');
setSortOrder('asc');
};
const goToShareRoot = () => {
setFolderId(null);
};
const openShare = (share) => {
const isExpired = share.expires_at && new Date(share.expires_at) < new Date();
if (isExpired) return;
setActiveShare(share);
setFolderId(null);
setBreadcrumb([]);
};
// ─── Sort helpers ────────────────────────────────────────────────────────────
const sorted = (items, isFolder) => {
return [...items].sort((a, b) => {
let valA, valB;
if (sortBy === 'name') {
valA = (isFolder ? a.name : a.display_name)?.toLowerCase();
valB = (isFolder ? b.name : b.display_name)?.toLowerCase();
} else if (sortBy === 'date') {
valA = a.created_at;
valB = b.created_at;
} else if (sortBy === 'size' && !isFolder) {
valA = a.size;
valB = b.size;
} else {
return 0;
}
if (valA < valB) return sortOrder === 'asc' ? -1 : 1;
if (valA > valB) return sortOrder === 'asc' ? 1 : -1;
return 0;
});
};
const toggleSort = (field) => {
if (sortBy === field) setSortOrder(o => o === 'asc' ? 'desc' : 'asc');
else { setSortBy(field); setSortOrder('asc'); }
};
// ─── Download ────────────────────────────────────────────────────────────────
const getProxyUrl = (fileId) => {
const params = new URLSearchParams({ fileId, shareId: activeShare.token });
const base = typeof window !== 'undefined' ? window.location.origin : '';
return `${base}${apiBasePath}/nuage/download?${params}`;
};
const handleDownload = (file) => {
const a = document.createElement('a');
a.href = getProxyUrl(file.id);
a.download = file.display_name;
if (newTab) a.target = '_blank';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
};
// ─── Upload ──────────────────────────────────────────────────────────────────
const uploadFiles = async (files) => {
const fileList = Array.from(files);
setUploading(true);
setUploadError(null);
setUploadQueue(fileList.map(f => ({ name: f.name, done: false })));
const base = typeof window !== 'undefined' ? window.location.origin : '';
for (let i = 0; i < fileList.length; i++) {
const file = fileList[i];
const formData = new FormData();
formData.append('file', file);
formData.append('shareId', activeShare.token);
if (folderId) formData.append('folderId', folderId);
try {
const res = await fetch(`${base}${apiBasePath}/nuage/upload`, {
method: 'POST',
credentials: 'include',
body: formData,
});
const data = await res.json();
if (data.success) {
setUploadQueue(q => q.map((f, idx) => idx === i ? { ...f, done: true } : f));
} else {
setUploadError(data.error || 'Erreur lors du téléversement');
}
} catch (e) {
setUploadError('Erreur réseau');
}
}
setUploading(false);
setUploadQueue([]);
fetchContents();
};
// ─── Table data ──────────────────────────────────────────────────────────────
let tableData = [];
let tableSortBy;
let tableSortOrder;
let tableOnSort;
if (!activeShare) {
// Root: shares as rows (expired shares hidden)
tableData = shares
.filter(share => !share.expires_at || new Date(share.expires_at) >= new Date())
.map(share => ({ ...share, _type: 'share' }));
} else {
// Inside a share: folders + files
tableData = [
...sorted(contents.folders, true).map(f => ({ ...f, _type: 'folder' })),
...sorted(contents.files, false).map(f => ({ ...f, _type: 'file' })),
];
tableSortBy = sortBy;
tableSortOrder = sortOrder;
tableOnSort = toggleSort;
}
// ─── Breadcrumb ──────────────────────────────────────────────────────────────
const breadcrumbItems = [
{
key: 'root',
label: 'Mes fichiers',
onClick: goToRoot,
active: !activeShare,
},
...(activeShare
? [
{
key: 'share',
label: activeShare.target_name || 'Partage',
onClick: goToShareRoot,
active: breadcrumb.length === 0,
},
...(breadcrumb.length > 3
? [
{ key: 'ellipsis', label: '···', onClick: () => setFolderId(breadcrumb[0].id), active: false },
...breadcrumb.slice(-2).map((f, i) => ({
key: f.id,
label: f.name,
onClick: () => setFolderId(f.id),
active: i === 1,
})),
]
: breadcrumb.map((f, i) => ({
key: f.id,
label: f.name,
onClick: () => setFolderId(f.id),
active: i === breadcrumb.length - 1,
}))
),
]
: []),
];
// ─── Render ───────────────────────────────────────────────────────────────────
const showUploadZone = activeShare &&
activeShare.permission === 'collaborator' &&
activeShare.target_type === 'folder';
return (
<div className="flex flex-col gap-3">
<input
type="file"
ref={fileInputRef}
multiple
className="hidden"
onChange={e => { if (e.target.files?.length) uploadFiles(e.target.files); e.target.value = ''; }}
/>
{/* Breadcrumb */}
<Breadcrumb items={breadcrumbItems} />
{/* Upload zone (collaborator folder shares only) */}
{showUploadZone && (
<div
className="border-2 border-dashed border-neutral-200 dark:border-neutral-700 rounded-xl p-5 text-center hover:border-blue-300 transition-colors"
onDragOver={e => e.preventDefault()}
onDrop={e => { e.preventDefault(); uploadFiles(e.dataTransfer.files); }}
>
<CloudUploadIcon className="w-6 h-6 mx-auto text-neutral-400 mb-2" />
<p className="text-sm text-neutral-500 mb-3">
{uploading ? 'Téléversement…' : 'Glissez des fichiers ici'}
</p>
<Button
size="sm"
onClick={() => fileInputRef.current?.click()}
loading={uploading}
icon={<CloudUploadIcon className="w-4 h-4" />}
>
Choisir des fichiers
</Button>
{uploadError && <p className="text-xs text-red-500 mt-2">{uploadError}</p>}
</div>
)}
{/* Upload queue */}
{uploading && uploadQueue.length > 0 && (
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-3">
<p className="text-sm font-medium text-blue-700 dark:text-blue-300 mb-2">Téléversement en cours</p>
<div className="space-y-1">
{uploadQueue.map((f, i) => (
<div key={i} className="flex items-center gap-2 text-xs text-blue-600 dark:text-blue-400">
{f.done ? <Tick02Icon className="w-3 h-3 text-green-500" /> : <Loading />}
<span>{f.name}</span>
</div>
))}
</div>
</div>
)}
{/* Explorer table */}
<Card variant="default" padding="none">
{error ? (
<div className="h-32 flex items-center justify-center text-red-500 text-sm">{error}</div>
) : (
<NuageFileTable
items={tableData}
loading={loading}
sortBy={tableSortBy}
sortOrder={tableSortOrder}
onSort={tableOnSort}
onRowClick={(item) => {
if (item._type === 'share') {
openShare(item);
} else if (item._type === 'folder') {
setFolderId(item.id);
}
}}
renderActions={(item) => {
if (item._type !== 'file') return null;
return (
<Button
variant="secondary"
size="sm"
onClick={e => { e.stopPropagation(); handleDownload(item); }}
className='-m-2'
>
Télécharger
</Button>
);
}}
emptyMessage={!activeShare ? emptyMessage : 'Ce dossier est vide'}
size="sm"
className='min-h-64'
/>
)}
</Card>
</div>
);
}
-10
View File
@@ -1,10 +0,0 @@
/**
* Nuage Dashboard Module
* Exports client-facing section for the user portal
*
* Stats actions are kept separate to avoid 'use server' conflicts:
* import via modules.actions.js or directly from statsActions.js
*/
export { default as ClientNuageSection } from './ClientNuageSection.js';
export { default } from './ClientNuageSection.js';
-109
View File
@@ -1,109 +0,0 @@
/**
* Nuage Module — Database
* Creates tables for folders, files, and shares
*/
import { query } from '@hykocx/zen/database';
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 Nuage tables
* @returns {Promise<{ created: string[], skipped: string[] }>}
*/
export async function createTables() {
const created = [];
const skipped = [];
// zen_nuage_folders
if (!(await tableExists('zen_nuage_folders'))) {
await query(`
CREATE TABLE zen_nuage_folders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
parent_id UUID REFERENCES zen_nuage_folders(id) ON DELETE CASCADE,
depth INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
)
`);
await query(`CREATE INDEX ON zen_nuage_folders(parent_id)`);
console.log(' ✓ Created zen_nuage_folders');
created.push('zen_nuage_folders');
} else {
console.log(' - Skipped zen_nuage_folders (already exists)');
skipped.push('zen_nuage_folders');
}
// zen_nuage_files
if (!(await tableExists('zen_nuage_files'))) {
await query(`
CREATE TABLE zen_nuage_files (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
folder_id UUID REFERENCES zen_nuage_folders(id) ON DELETE CASCADE,
original_name VARCHAR(255) NOT NULL,
display_name VARCHAR(255) NOT NULL,
mime_type VARCHAR(255) NOT NULL,
size BIGINT NOT NULL DEFAULT 0,
r2_key TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
)
`);
await query(`CREATE INDEX ON zen_nuage_files(folder_id)`);
console.log(' ✓ Created zen_nuage_files');
created.push('zen_nuage_files');
} else {
console.log(' - Skipped zen_nuage_files (already exists)');
skipped.push('zen_nuage_files');
}
// zen_nuage_shares
// References zen_auth_users (CMS auth users table)
if (!(await tableExists('zen_nuage_shares'))) {
await query(`
CREATE TABLE zen_nuage_shares (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
token VARCHAR(255) UNIQUE NOT NULL,
target_type VARCHAR(10) NOT NULL CHECK (target_type IN ('file', 'folder')),
target_id UUID NOT NULL,
share_type VARCHAR(10) NOT NULL CHECK (share_type IN ('user', 'anonymous')),
user_id TEXT REFERENCES zen_auth_users(id) ON DELETE CASCADE,
permission VARCHAR(15) NOT NULL CHECK (permission IN ('reader', 'collaborator')),
password_hash TEXT,
expires_at TIMESTAMPTZ,
upload_limit_bytes BIGINT,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
)
`);
await query(`CREATE INDEX ON zen_nuage_shares(token)`);
await query(`CREATE INDEX ON zen_nuage_shares(target_id, target_type, is_active)`);
await query(`CREATE INDEX ON zen_nuage_shares(user_id, is_active)`);
console.log(' ✓ Created zen_nuage_shares');
created.push('zen_nuage_shares');
} else {
console.log(' - Skipped zen_nuage_shares (already exists)');
skipped.push('zen_nuage_shares');
}
return { created, skipped };
}
/**
* Drop all Nuage tables (in dependency order)
*/
export async function dropTables() {
await query('DROP TABLE IF EXISTS zen_nuage_shares CASCADE');
await query('DROP TABLE IF EXISTS zen_nuage_files CASCADE');
await query('DROP TABLE IF EXISTS zen_nuage_folders CASCADE');
}
-105
View File
@@ -1,105 +0,0 @@
/**
* Nuage Share Notification Email
* Sent when sharing a file or folder with a user or via anonymous link
*/
import { Button, Section, Text, Link } from '@react-email/components';
import { BaseLayout } from '@hykocx/zen/email/templates';
/**
* @param {object} props
* @param {string} props.recipientName — Name or email of the recipient
* @param {string} props.shareUrl — Full URL to access the share
* @param {string} props.targetName — Name of the shared file or folder
* @param {'file'|'folder'} props.targetType
* @param {'user'|'anonymous'} props.shareType
* @param {string|null} props.expiresAt — ISO date string of expiration, or null
* @param {string|null} props.customMessage — Optional message from the sender
* @param {string} props.companyName
*/
export const NuageShareEmail = ({
recipientName,
shareUrl,
targetName,
targetType = 'folder',
shareType = 'anonymous',
expiresAt = null,
customMessage = null,
companyName,
}) => {
const appName = companyName || process.env.ZEN_NAME || 'Nuage';
const supportEmail = process.env.ZEN_SUPPORT_EMAIL || 'support@exemple.com';
const isFile = targetType === 'file';
const formattedExpiry = expiresAt
? new Date(expiresAt).toLocaleDateString('fr-CA', { year: 'numeric', month: 'long', day: 'numeric' })
: null;
const preview = `Le ${isFile ? 'fichier' : 'dossier'} « ${targetName} » a été partagé avec vous.${formattedExpiry ? ` Ce partage reste accessible jusqu'au ${formattedExpiry}.` : ''}`;
return (
<BaseLayout
preview={preview}
title="Nouveau partage"
companyName={appName}
supportSection={true}
supportEmail={supportEmail}
>
<Text className="text-[14px] leading-[24px] text-neutral-600 mt-[4px] mb-[24px]">
Bonjour {recipientName},{' '}
{isFile ? 'un fichier a été partagé avec vous.' : 'un dossier a été partagé avec vous.'}
{formattedExpiry && <>{' '}Ce partage expire le <span className="font-medium text-neutral-900">{formattedExpiry}</span>.</>}
</Text>
{customMessage && (
<Section style={{ border: '1px solid #E5E5E5' }} className="bg-neutral-100 rounded-[8px] p-4 my-[24px]">
<Text className="text-[14px] leading-[22px] text-neutral-500 m-0 italic">
{customMessage}
</Text>
</Section>
)}
<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 partage
</Text>
<Text className="text-[13px] text-neutral-900 m-0 mb-[6px]">
<span className="text-neutral-500 inline-block w-[150px]">Type :</span>
{isFile ? 'Fichier' : 'Dossier'}
</Text>
<Text className="text-[13px] text-neutral-900 m-0 mb-[6px]">
<span className="text-neutral-500 inline-block w-[150px]">Nom :</span>
{targetName}
</Text>
{formattedExpiry && (
<Text className="text-[13px] text-neutral-900 m-0 mb-[6px]">
<span className="text-neutral-500 inline-block w-[150px]">Expire le :</span>
{formattedExpiry}
</Text>
)}
</Section>
<Section className="mt-[28px] mb-[40px]">
<Button
href={shareUrl}
className="bg-neutral-900 rounded-[8px] text-white font-medium px-[20px] py-[11px] no-underline text-[13px]"
>
{isFile ? 'Accéder au fichier' : 'Accéder au dossier'}
</Button>
</Section>
<Text className="text-[12px] leading-[20px] text-neutral-400 m-0">
Lien :{' '}
<Link href={shareUrl} className="text-neutral-400 underline break-all">
{shareUrl}
</Link>
</Text>
<Text className="text-[12px] leading-[20px] text-neutral-400 m-0 mt-[8px]">
{shareType === 'anonymous'
? 'Ce lien est accessible à toute personne qui le possède.'
: 'Ce partage est destiné à votre compte uniquement.'}
{' '}Si vous n'attendiez pas ce message, ignorez-le.
</Text>
</BaseLayout>
);
};
-4
View File
@@ -1,4 +0,0 @@
/**
* Nuage Email Templates
*/
export { NuageShareEmail } from './NuageShareEmail.jsx';
-69
View File
@@ -1,69 +0,0 @@
/**
* Nuage Metadata Utilities
* Functions to generate dynamic metadata for nuage share pages
*/
import { generateMetadata, generateRobots } from '../../shared/lib/metadata/index.js';
import { getShareInfoForMetadata } from './crud.js';
import { getAppName } from '../../shared/lib/appConfig.js';
/**
* Generate metadata for a public share page.
*
* @param {string} token - Share token
* @param {Object} options - Generation options
* @returns {Promise<Object>} Next.js metadata object
*/
export async function generateShareMetadata(token, options = {}) {
try {
const share = await getShareInfoForMetadata(token);
if (!share || !share.is_active) {
return generateMetadata({
title: 'Lien invalide',
description: 'Ce lien de partage n\'est plus valide.',
robots: generateRobots({ index: false, follow: false }),
}, options);
}
if (share.expires_at && new Date(share.expires_at) < new Date()) {
return generateMetadata({
title: 'Lien expiré',
description: 'Ce lien de partage a expiré.',
robots: generateRobots({ index: false, follow: false }),
}, options);
}
const appName = options.appName || getAppName();
const isFolder = share.target_type === 'folder';
const typeLabel = isFolder ? 'Dossier partagé' : 'Fichier partagé';
const targetName = share.target_name || typeLabel;
return generateMetadata({
title: targetName !== typeLabel ? `${typeLabel}${targetName}` : typeLabel,
description: `Accédez au ${isFolder ? 'dossier' : 'fichier'} partagé « ${targetName} ».`,
openGraph: {
title: targetName !== typeLabel ? `${typeLabel}${targetName}` : typeLabel,
description: `Accédez au ${isFolder ? 'dossier' : 'fichier'} partagé « ${targetName} ».`,
type: 'website',
},
robots: generateRobots({ index: false, follow: false }),
}, { ...options, appName });
} catch (error) {
console.error('Error generating nuage share metadata:', error);
return generateMetadata({
title: 'Fichier partagé',
description: 'Accédez au contenu partagé.',
robots: generateRobots({ index: false, follow: false }),
}, options);
}
}
/**
* Metadata configuration for module registration.
* Maps route types to their metadata generator functions.
*/
export default {
share: generateShareMetadata,
};
-44
View File
@@ -1,44 +0,0 @@
/**
* Nuage Module Configuration
* File manager with Cloudflare R2 storage and share system
*
* 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 {
name: 'nuage',
displayName: 'Nuage',
version: '1.0.0',
description: 'Gestionnaire de fichiers intégré avec partage de documents via Cloudflare R2',
dependencies: [],
envVars: [],
navigation: {
id: 'nuage',
title: 'Nuage',
icon: 'CloudIcon',
items: [
{ name: 'Explorateur', href: '/admin/nuage/explorateur', icon: 'Folder01Icon' },
{ name: 'Partages', href: '/admin/nuage/partages', icon: 'Link02Icon' },
],
},
adminPages: {
'/admin/nuage/explorateur': lazy(() => import('./admin/ExplorerPage.js')),
'/admin/nuage/partages': lazy(() => import('./admin/SharesPage.js')),
},
publicPages: {
default: lazy(() => import('./pages/NuagePublicPages.js')),
},
publicRoutes: [
{ pattern: 'partage/:token', description: 'Accès à un partage anonyme' },
],
};
-427
View File
@@ -1,427 +0,0 @@
'use client';
import React, { useState, useEffect, useRef } from 'react';
import {
CloudUploadIcon,
Alert01Icon,
} from '../../../shared/Icons.js';
import { Loading, Input, Button, Card, Breadcrumb } from '../../../shared/components';
import NuageFileTable, { formatBytes } from '../components/NuageFileTable.js';
// ─── Password Gate ────────────────────────────────────────────────────────────
function PasswordGate({ onSubmit, error }) {
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
if (!password.trim()) return;
setLoading(true);
await onSubmit(password);
setLoading(false);
};
return (
<div className="min-h-screen flex items-center justify-center bg-neutral-50 dark:bg-neutral-950 p-4">
<div className="w-full max-w-sm">
<div className="text-center mb-8">
<div className="w-14 h-14 bg-blue-100 dark:bg-blue-900/30 rounded-2xl flex items-center justify-center mx-auto mb-4">
<CloudUploadIcon className="w-7 h-7 text-blue-600 dark:text-blue-400" />
</div>
<h1 className="text-xl font-semibold text-neutral-900 dark:text-white">Accès protégé</h1>
<p className="text-sm text-neutral-500 mt-2">Ce lien est protégé par un mot de passe.</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<Input
type="password"
autoFocus
value={password}
onChange={v => setPassword(v)}
placeholder="Mot de passe"
error={error}
/>
<Button
type="submit"
loading={loading}
disabled={!password.trim()}
className="w-full"
>
Accéder
</Button>
</form>
</div>
</div>
);
}
// ─── Error Page ───────────────────────────────────────────────────────────────
function ErrorPage({ message }) {
return (
<div className="min-h-screen flex items-center justify-center bg-neutral-50 dark:bg-neutral-950 p-4">
<div className="text-center">
<div className="w-14 h-14 bg-red-100 dark:bg-red-900/30 rounded-2xl flex items-center justify-center mx-auto mb-4">
<Alert01Icon className="w-7 h-7 text-red-500" />
</div>
<h1 className="text-xl font-semibold text-neutral-900 dark:text-white mb-2">Lien invalide</h1>
<p className="text-sm text-neutral-500">{message || 'Ce lien n\'est plus valide.'}</p>
</div>
</div>
);
}
// ─── Content View ─────────────────────────────────────────────────────────────
function ContentView({
token,
permission,
targetType,
uploadLimitBytes,
getSharedContentsAction,
uploadToShareAction,
publicLogoWhite,
publicLogoBlack,
publicDashboardUrl,
}) {
const fileInputRef = useRef(null);
const skipHistoryPush = useRef(false);
const [folderId, setFolderId] = useState(null);
const [contents, setContents] = useState({ folders: [], files: [] });
const [breadcrumb, setBreadcrumb] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState(null);
const [dragOver, setDragOver] = useState(false);
useEffect(() => {
loadContents();
}, [folderId]);
// Sync folder navigation to URL history
useEffect(() => {
if (typeof window === 'undefined') return;
if (skipHistoryPush.current) {
skipHistoryPush.current = false;
return;
}
const url = new URL(window.location.href);
if (folderId) url.searchParams.set('folder', folderId);
else url.searchParams.delete('folder');
window.history.pushState({ folderId: folderId ?? null }, '', url.toString());
}, [folderId]);
// Handle browser back/forward
useEffect(() => {
if (typeof window === 'undefined') return;
const onPop = (event) => {
skipHistoryPush.current = true;
const id = event.state?.folderId ?? null;
setFolderId(id);
};
window.addEventListener('popstate', onPop);
return () => window.removeEventListener('popstate', onPop);
}, []);
const loadContents = async () => {
setLoading(true);
setError(null);
try {
const result = await getSharedContentsAction(token, folderId);
if (result.success) {
setContents({ folders: result.folders || [], files: result.files || [] });
setBreadcrumb(result.breadcrumb || []);
} else {
setError(result.error || 'Erreur lors du chargement');
}
} catch (e) {
setError('Erreur lors du chargement du contenu');
} finally {
setLoading(false);
}
};
const handleDownload = (fileId, filename) => {
const a = document.createElement('a');
a.href = `/zen/api/nuage/share/download?token=${encodeURIComponent(token)}&fileId=${encodeURIComponent(fileId)}`;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
};
const uploadFiles = async (files) => {
setUploading(true);
setUploadError(null);
for (const file of Array.from(files)) {
const formData = new FormData();
formData.append('file', file);
if (folderId) formData.append('folderId', folderId);
const result = await uploadToShareAction(token, formData);
if (!result.success) {
setUploadError(result.error || 'Erreur lors du téléversement');
}
}
setUploading(false);
loadContents();
};
const totalItems = contents.folders.length + contents.files.length;
const currentFolderName = breadcrumb.length > 0
? breadcrumb[breadcrumb.length - 1].name
: targetType === 'file' ? 'Fichier partagé' : 'Dossier partagé';
const subtext = loading
? 'Chargement…'
: error
? 'Erreur lors du chargement'
: totalItems === 0
? permission === 'collaborator' ? 'Aucun fichier · Vous pouvez déposer des fichiers' : 'Aucun fichier'
: `${totalItems} élément${totalItems > 1 ? 's' : ''} · ${permission === 'reader' ? 'Lecture seule' : 'Collaborateur'}`;
return (
<div className="min-h-screen bg-neutral-100 dark:bg-black">
{/* Header */}
<div className="px-4 py-3 border-b bg-white dark:bg-neutral-800/30 border-neutral-200 dark:border-neutral-700/30">
<div className="w-full relative flex items-center justify-center">
{/* Logo — far left */}
<div className="absolute left-0 flex items-center shrink-0">
{(publicLogoWhite || publicLogoBlack) ? (() => {
const logoHref = publicDashboardUrl && publicDashboardUrl.trim() !== '' ? publicDashboardUrl.trim() : null;
const logoImgs = (
<>
{publicLogoBlack && (
<img src={publicLogoBlack} alt="Logo" className="max-h-7 dark:hidden" />
)}
{publicLogoWhite && (
<img src={publicLogoWhite} alt="Logo" className="max-h-7 hidden dark:block" />
)}
</>
);
return logoHref ? (
<a href={logoHref} className="focus:outline-none focus:ring-2 focus:ring-neutral-400 dark:focus:ring-neutral-500 rounded">
{logoImgs}
</a>
) : logoImgs;
})() : (
<div className="w-8 h-8 bg-blue-100 dark:bg-blue-900/30 rounded-xl flex items-center justify-center">
<CloudUploadIcon className="w-4 h-4 text-blue-600 dark:text-blue-400" />
</div>
)}
</div>
{/* Title + Subtext — centered */}
<div className="text-center">
<h1 className="text-neutral-900 dark:text-white font-semibold text-sm leading-tight">
{currentFolderName}
</h1>
<p className="text-xs text-neutral-500 dark:text-neutral-400 leading-tight">
{subtext}
</p>
</div>
</div>
</div>
<div className="max-w-3xl mx-auto px-4 py-6 space-y-4">
{/* Breadcrumb */}
{breadcrumb.length > 0 && (
<Breadcrumb
items={[
{ key: 'home', label: 'Accueil', onClick: () => setFolderId(null) },
...breadcrumb.map((folder, i) => ({
key: folder.id,
label: folder.name,
onClick: i === breadcrumb.length - 1 ? undefined : () => setFolderId(folder.id),
active: i === breadcrumb.length - 1,
})),
]}
/>
)}
{/* Upload area for collaborators */}
{permission === 'collaborator' && (
<div
className={`border-2 border-dashed rounded-xl p-6 text-center transition-colors ${dragOver ? 'border-blue-400 bg-blue-50 dark:bg-blue-900/10' : 'border-neutral-200 dark:border-neutral-700 hover:border-blue-300'}`}
onDragOver={e => { e.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onDrop={e => { e.preventDefault(); setDragOver(false); uploadFiles(e.dataTransfer.files); }}
>
<input
type="file"
ref={fileInputRef}
multiple
className="hidden"
onChange={e => { if (e.target.files?.length) uploadFiles(e.target.files); e.target.value = ''; }}
/>
<CloudUploadIcon className="w-8 h-8 mx-auto text-neutral-400 mb-3" />
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-2">
{uploading ? 'Téléversement en cours…' : 'Glissez vos fichiers ici'}
</p>
{uploadLimitBytes && (
<p className="text-xs text-neutral-400 mb-3">Limite totale du dossier : {formatBytes(uploadLimitBytes)}</p>
)}
<Button
onClick={() => fileInputRef.current?.click()}
loading={uploading}
size="sm"
>
Choisir des fichiers
</Button>
{uploadError && (
<p className="text-sm text-red-500 mt-2">{uploadError}</p>
)}
</div>
)}
{/* Content */}
<Card variant="default" padding="none">
{error ? (
<div className="flex items-center justify-center h-40 text-red-500 text-sm">{error}</div>
) : (
<NuageFileTable
items={[
...contents.folders.map(f => ({ ...f, _type: 'folder' })),
...contents.files.map(f => ({ ...f, _type: 'file' })),
]}
loading={loading}
onRowClick={(item) => {
if (item._type === 'folder') setFolderId(item.id);
}}
renderActions={(item) => {
if (item._type !== 'file') return null;
return (
<Button
variant="secondary"
size="sm"
onClick={e => { e.stopPropagation(); handleDownload(item.id, item.display_name); }}
className='-m-2'
>
Télécharger
</Button>
);
}}
emptyMessage="Ce dossier est vide"
size="sm"
className="min-h-40"
/>
)}
</Card>
</div>
</div>
);
}
// ─── Main router ──────────────────────────────────────────────────────────────
/**
* Nuage Public Pages Router
* Routes:
* - /zen/nuage/partage/{token} — Anonymous share access
*/
const NuagePublicPages = ({
path = [],
getShareByTokenAction,
verifySharePasswordAction,
getSharedContentsAction,
uploadToShareAction,
publicLogoWhite = '',
publicLogoBlack = '',
publicDashboardUrl = '',
}) => {
const token = path[2];
const [status, setStatus] = useState('loading'); // 'loading' | 'password' | 'content' | 'error'
const [shareInfo, setShareInfo] = useState(null);
const [errorMessage, setErrorMessage] = useState('');
const [passwordError, setPasswordError] = useState('');
const [passwordVerified, setPasswordVerified] = useState(false);
useEffect(() => {
if (!token) {
setStatus('error');
setErrorMessage('Lien invalide');
return;
}
if (!getShareByTokenAction) {
setStatus('error');
setErrorMessage('Configuration manquante');
return;
}
loadShare();
}, [token]);
const loadShare = async () => {
try {
const result = await getShareByTokenAction(token);
if (!result.success) {
setStatus('error');
setErrorMessage(result.message || 'Ce lien n\'est plus valide');
return;
}
setShareInfo(result.share);
if (result.requiresPassword && !passwordVerified) {
setStatus('password');
} else {
setStatus('content');
}
} catch (e) {
setStatus('error');
setErrorMessage('Erreur lors du chargement');
}
};
const handlePasswordSubmit = async (password) => {
setPasswordError('');
try {
const result = await verifySharePasswordAction(token, password);
if (result.success) {
setPasswordVerified(true);
setStatus('content');
} else {
setPasswordError(result.error || 'Mot de passe incorrect');
}
} catch (e) {
setPasswordError('Erreur lors de la vérification');
}
};
if (status === 'loading') {
return (
<div className="min-h-screen flex items-center justify-center bg-neutral-50 dark:bg-neutral-950">
<Loading />
</div>
);
}
if (status === 'error') {
return <ErrorPage message={errorMessage} />;
}
if (status === 'password') {
return <PasswordGate onSubmit={handlePasswordSubmit} error={passwordError} />;
}
if (status === 'content' && shareInfo) {
return (
<ContentView
token={token}
permission={shareInfo.permission}
targetType={shareInfo.target_type}
uploadLimitBytes={shareInfo.upload_limit_bytes}
getSharedContentsAction={getSharedContentsAction}
uploadToShareAction={uploadToShareAction}
publicLogoWhite={publicLogoWhite}
publicLogoBlack={publicLogoBlack}
publicDashboardUrl={publicDashboardUrl}
/>
);
}
return null;
};
export default NuagePublicPages;
-4
View File
@@ -1,4 +0,0 @@
/**
* Nuage Public Pages
*/
export { default as NuagePublicPages } from './NuagePublicPages.js';