create-crm-tmp 1.1.2 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/template/.prettierignore +2 -0
- package/template/README.md +53 -67
- package/template/components.json +22 -0
- package/template/exemple-contacts.csv +54 -0
- package/template/next.config.ts +27 -1
- package/template/package.json +64 -27
- package/template/prisma/schema.prisma +821 -72
- package/template/skills-lock.json +25 -0
- package/template/src/app/(auth)/invite/[token]/page.tsx +21 -24
- package/template/src/app/(auth)/reset-password/complete/page.tsx +12 -21
- package/template/src/app/(auth)/reset-password/page.tsx +12 -8
- package/template/src/app/(auth)/reset-password/verify/page.tsx +12 -8
- package/template/src/app/(auth)/signin/page.tsx +20 -17
- package/template/src/app/(dashboard)/agenda/page.tsx +2231 -2188
- package/template/src/app/(dashboard)/automatisation/[id]/page.tsx +10 -7
- package/template/src/app/(dashboard)/automatisation/_components/workflow-editor.tsx +680 -323
- package/template/src/app/(dashboard)/automatisation/new/page.tsx +11 -8
- package/template/src/app/(dashboard)/automatisation/page.tsx +473 -180
- package/template/src/app/(dashboard)/closing/page.tsx +500 -468
- package/template/src/app/(dashboard)/contacts/[id]/page.tsx +5035 -4126
- package/template/src/app/(dashboard)/contacts/companies/[id]/page.tsx +1703 -0
- package/template/src/app/(dashboard)/contacts/loading.tsx +13 -0
- package/template/src/app/(dashboard)/contacts/page.tsx +3776 -2064
- package/template/src/app/(dashboard)/dashboard/page.tsx +37 -519
- package/template/src/app/(dashboard)/error.tsx +37 -0
- package/template/src/app/(dashboard)/layout.tsx +1 -1
- package/template/src/app/(dashboard)/loading.tsx +5 -0
- package/template/src/app/(dashboard)/settings/loading.tsx +19 -0
- package/template/src/app/(dashboard)/settings/page.tsx +2685 -2489
- package/template/src/app/(dashboard)/templates/page.tsx +500 -300
- package/template/src/app/(dashboard)/users/list/page.tsx +356 -350
- package/template/src/app/(dashboard)/users/page.tsx +279 -310
- package/template/src/app/(dashboard)/users/permissions/page.tsx +104 -99
- package/template/src/app/(dashboard)/users/roles/page.tsx +164 -137
- package/template/src/app/api/audit-logs/route.ts +1 -1
- package/template/src/app/api/auth/google/callback/route.ts +8 -5
- package/template/src/app/api/auth/google/disconnect/route.ts +2 -2
- package/template/src/app/api/companies/[id]/activities/route.ts +131 -0
- package/template/src/app/api/companies/[id]/route.ts +195 -0
- package/template/src/app/api/companies/export/route.ts +206 -0
- package/template/src/app/api/companies/route.ts +166 -0
- package/template/src/app/api/contact-views/[id]/pin/route.ts +69 -0
- package/template/src/app/api/contact-views/[id]/route.ts +197 -0
- package/template/src/app/api/contact-views/route.ts +146 -0
- package/template/src/app/api/contacts/[id]/files/[fileId]/preview/route.ts +77 -0
- package/template/src/app/api/contacts/[id]/files/[fileId]/route.ts +7 -17
- package/template/src/app/api/contacts/[id]/files/route.ts +83 -44
- package/template/src/app/api/contacts/[id]/interactions/route.ts +37 -0
- package/template/src/app/api/contacts/[id]/kyc/route.ts +71 -0
- package/template/src/app/api/contacts/[id]/meet/route.ts +38 -29
- package/template/src/app/api/contacts/[id]/route.ts +111 -20
- package/template/src/app/api/contacts/[id]/send-email/route.ts +6 -0
- package/template/src/app/api/contacts/[id]/workflows/run/route.ts +61 -0
- package/template/src/app/api/contacts/export/route.ts +12 -17
- package/template/src/app/api/contacts/import/route.ts +22 -19
- package/template/src/app/api/contacts/import-preview/route.ts +139 -0
- package/template/src/app/api/contacts/route.ts +202 -49
- package/template/src/app/api/dashboard/stats/route.ts +9 -292
- package/template/src/app/api/integrations/google-sheet/sync/route.ts +203 -185
- package/template/src/app/api/invite/complete/route.ts +20 -23
- package/template/src/app/api/reminders/route.ts +1 -0
- package/template/src/app/api/reset-password/complete/route.ts +11 -13
- package/template/src/app/api/send/route.ts +9 -85
- package/template/src/app/api/settings/closing-reasons/[id]/route.ts +10 -21
- package/template/src/app/api/settings/closing-reasons/route.ts +10 -21
- package/template/src/app/api/settings/company/route.ts +19 -26
- package/template/src/app/api/settings/google-ads/[id]/route.ts +20 -23
- package/template/src/app/api/settings/google-ads/route.ts +20 -23
- package/template/src/app/api/settings/google-sheet/[id]/route.ts +20 -23
- package/template/src/app/api/settings/google-sheet/auto-map/route.ts +23 -32
- package/template/src/app/api/settings/google-sheet/preview/route.ts +104 -0
- package/template/src/app/api/settings/google-sheet/route.ts +20 -23
- package/template/src/app/api/settings/meta-leads/[id]/route.ts +20 -23
- package/template/src/app/api/settings/meta-leads/route.ts +20 -23
- package/template/src/app/api/settings/statuses/[id]/route.ts +33 -23
- package/template/src/app/api/settings/statuses/route.ts +24 -22
- package/template/src/app/api/statuses/route.ts +2 -5
- package/template/src/app/api/tasks/[id]/attendees/route.ts +14 -7
- package/template/src/app/api/tasks/[id]/route.ts +161 -137
- package/template/src/app/api/tasks/meet/route.ts +11 -8
- package/template/src/app/api/tasks/route.ts +155 -95
- package/template/src/app/api/templates/[id]/route.ts +22 -13
- package/template/src/app/api/templates/route.ts +22 -5
- package/template/src/app/api/users/[id]/resend-invite/route.ts +95 -0
- package/template/src/app/api/users/[id]/route.ts +16 -1
- package/template/src/app/api/users/commercials/route.ts +38 -0
- package/template/src/app/api/users/for-agenda/route.ts +1 -2
- package/template/src/app/api/users/route.ts +94 -55
- package/template/src/app/api/webhooks/google-ads/route.ts +20 -1
- package/template/src/app/api/webhooks/meta-leads/route.ts +18 -1
- package/template/src/app/api/workflows/[id]/route.ts +33 -6
- package/template/src/app/api/workflows/process/route.ts +509 -146
- package/template/src/app/api/workflows/route.ts +46 -4
- package/template/src/app/globals.css +210 -101
- package/template/src/app/layout.tsx +19 -8
- package/template/src/app/page.tsx +37 -7
- package/template/src/components/address-autocomplete.tsx +232 -0
- package/template/src/components/contacts/filter-bar.tsx +181 -0
- package/template/src/components/contacts/filter-builder.tsx +589 -0
- package/template/src/components/contacts/save-view-dialog.tsx +160 -0
- package/template/src/components/contacts/views-tab-bar.tsx +440 -0
- package/template/src/components/dashboard/activity-chart.tsx +31 -39
- package/template/src/components/dashboard/dashboard-content.tsx +79 -0
- package/template/src/components/dashboard/stat-card.tsx +40 -42
- package/template/src/components/dashboard/tasks-pie-chart.tsx +34 -37
- package/template/src/components/dashboard/upcoming-tasks-list.tsx +78 -72
- package/template/src/components/date-picker.tsx +396 -0
- package/template/src/components/editor.tsx +27 -13
- package/template/src/components/email-template.tsx +4 -2
- package/template/src/components/global-search.tsx +358 -0
- package/template/src/components/header.tsx +57 -62
- package/template/src/components/invitation-email-template.tsx +4 -2
- package/template/src/components/lazy-editor.tsx +11 -0
- package/template/src/components/meet-cancellation-email-template.tsx +11 -3
- package/template/src/components/meet-confirmation-email-template.tsx +10 -3
- package/template/src/components/meet-update-email-template.tsx +10 -3
- package/template/src/components/page-header.tsx +19 -15
- package/template/src/components/protected-page.tsx +94 -0
- package/template/src/components/reset-password-email-template.tsx +4 -2
- package/template/src/components/sidebar.tsx +92 -94
- package/template/src/components/skeleton.tsx +128 -42
- package/template/src/components/ui/accordion.tsx +64 -0
- package/template/src/components/ui/alert-dialog.tsx +139 -0
- package/template/src/components/ui/button.tsx +60 -0
- package/template/src/components/view-as-banner.tsx +1 -1
- package/template/src/components/view-as-modal.tsx +21 -16
- package/template/src/config/nav-pages.ts +108 -0
- package/template/src/contexts/app-toast-context.tsx +174 -0
- package/template/src/contexts/sidebar-context.tsx +16 -47
- package/template/src/contexts/task-reminder-context.tsx +6 -6
- package/template/src/contexts/view-as-context.tsx +11 -16
- package/template/src/hooks/use-alert.tsx +65 -0
- package/template/src/hooks/use-confirm.tsx +87 -0
- package/template/src/hooks/use-contact-views.ts +140 -0
- package/template/src/hooks/use-contacts.ts +69 -0
- package/template/src/hooks/use-fetch.ts +17 -0
- package/template/src/hooks/use-focus-trap.ts +73 -0
- package/template/src/hooks/use-statuses.ts +22 -0
- package/template/src/lib/address-api.ts +155 -0
- package/template/src/lib/cache.ts +73 -0
- package/template/src/lib/check-permission.ts +12 -177
- package/template/src/lib/contact-interactions.ts +3 -1
- package/template/src/lib/contact-view-filters.ts +341 -0
- package/template/src/lib/dashboard-stats.ts +224 -0
- package/template/src/lib/date-utils.ts +49 -0
- package/template/src/lib/get-auth-user.ts +25 -0
- package/template/src/lib/google-calendar.ts +54 -12
- package/template/src/lib/google-drive.ts +796 -75
- package/template/src/lib/google-fetch.ts +63 -0
- package/template/src/lib/local-storage.ts +34 -0
- package/template/src/lib/permissions.ts +245 -47
- package/template/src/lib/prisma.ts +11 -11
- package/template/src/lib/roles.ts +14 -39
- package/template/src/lib/template-variables.ts +67 -33
- package/template/src/lib/utils.ts +26 -2
- package/template/src/lib/workflow-executor.ts +445 -229
- package/template/src/proxy.ts +34 -73
- package/template/src/types/contact-views.ts +351 -0
- package/template/src/types/yousign.ts +52 -0
- package/template/vercel.json +12 -0
- package/template/WORKFLOWS_CRON.md +0 -185
- package/template/prisma/migrations/20251126144728_init/migration.sql +0 -78
- package/template/prisma/migrations/20251126155204_add_user_roles/migration.sql +0 -5
- package/template/prisma/migrations/20251128095126_add_company_info/migration.sql +0 -19
- package/template/prisma/migrations/20251128123321_add_smtp_config/migration.sql +0 -22
- package/template/prisma/migrations/20251128132303_add_status/migration.sql +0 -23
- package/template/prisma/migrations/20251201102207_add_user_active/migration.sql +0 -75
- package/template/prisma/migrations/20251201105507_add_email_signature/migration.sql +0 -2
- package/template/prisma/migrations/20251201151122_add_tasks/migration.sql +0 -45
- package/template/prisma/migrations/20251202111854_add_task_reminder/migration.sql +0 -2
- package/template/prisma/migrations/20251202135859_add_google_meet_integration/migration.sql +0 -27
- package/template/prisma/migrations/20251203103317_add_meta_lead_integration/migration.sql +0 -20
- package/template/prisma/migrations/20251203104002_add_google_ads_integration/migration.sql +0 -18
- package/template/prisma/migrations/20251203112122_add_google_sheet_integration/migration.sql +0 -32
- package/template/prisma/migrations/20251203153853_allow_multiple_integration_configs/migration.sql +0 -20
- package/template/prisma/migrations/20251205141705_update_user_roles/migration.sql +0 -12
- package/template/prisma/migrations/20251205150000_add_commercial_and_telepro_assignment/migration.sql +0 -21
- package/template/prisma/migrations/20251205160000_add_interaction_logging/migration.sql +0 -11
- package/template/prisma/migrations/20251208090314_add_automatic_interaction_types/migration.sql +0 -12
- package/template/prisma/migrations/20251208094843_mg/migration.sql +0 -14
- package/template/prisma/migrations/20251208100000_add_company_support/migration.sql +0 -14
- package/template/prisma/migrations/20251208110000_add_templates/migration.sql +0 -26
- package/template/prisma/migrations/20251208141304_add_video_conference_task_type/migration.sql +0 -2
- package/template/prisma/migrations/20251209104759_add_internal_note_to_task/migration.sql +0 -2
- package/template/prisma/migrations/20251209134803_add_company_field/migration.sql +0 -2
- package/template/prisma/migrations/20251209150000_rename_company_to_company_name/migration.sql +0 -3
- package/template/prisma/migrations/20251209150016_add_email_tracking/migration.sql +0 -21
- package/template/prisma/migrations/20251209155908_add_notify_contact_to_task/migration.sql +0 -2
- package/template/prisma/migrations/20251210110019_add_appointment_types/migration.sql +0 -10
- package/template/prisma/migrations/20251210113928_add_contact_files/migration.sql +0 -26
- package/template/prisma/migrations/20251212132339_add_custom_roles/migration.sql +0 -24
- package/template/prisma/migrations/20251215104448_add_file_interaction_types/migration.sql +0 -11
- package/template/prisma/migrations/20251215145616_add_closing_reasons/migration.sql +0 -12
- package/template/prisma/migrations/20251216140850_add_log_users/migration.sql +0 -25
- package/template/prisma/migrations/20251216151000_rename_perdu_to_ferme/migration.sql +0 -8
- package/template/prisma/migrations/20251216162318_add_column_mappings_to_google_sheet/migration.sql +0 -2
- package/template/prisma/migrations/20251216185127_add_workflows/migration.sql +0 -80
- package/template/prisma/migrations/20251216192237_add_scheduled_workflow_actions/migration.sql +0 -32
- package/template/prisma/migrations/20251220000000_add_task_interaction_type/migration.sql +0 -4
- package/template/prisma/migrations/20251221000000_add_task_type/migration.sql +0 -3
- package/template/prisma/migrations/20251221000001_add_event_color/migration.sql +0 -23
- package/template/prisma/migrations/20260210114913_add_dashboard_widget/migration.sql +0 -20
- package/template/prisma/migrations/migration_lock.toml +0 -3
- package/template/src/app/(dashboard)/users/layout.tsx +0 -30
- package/template/src/app/api/dashboard/widgets/[id]/route.ts +0 -47
- package/template/src/app/api/dashboard/widgets/route.ts +0 -181
- package/template/src/components/dashboard/add-widget-dialog.tsx +0 -161
- package/template/src/components/dashboard/color-picker.tsx +0 -65
- package/template/src/components/dashboard/contacts-chart.tsx +0 -69
- package/template/src/components/dashboard/interactions-by-type-chart.tsx +0 -121
- package/template/src/components/dashboard/recent-activity.tsx +0 -157
- package/template/src/components/dashboard/sales-analytics-chart.tsx +0 -77
- package/template/src/components/dashboard/status-distribution-chart.tsx +0 -82
- package/template/src/components/dashboard/top-contacts-list.tsx +0 -119
- package/template/src/components/dashboard/widget-wrapper.tsx +0 -39
- package/template/src/contexts/dashboard-theme-context.tsx +0 -58
- package/template/src/lib/dashboard-themes.ts +0 -140
- package/template/src/lib/default-widgets.ts +0 -14
- package/template/src/lib/widget-registry.ts +0 -177
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import {
|
|
5
|
+
AlertDialog,
|
|
6
|
+
AlertDialogAction,
|
|
7
|
+
AlertDialogCancel,
|
|
8
|
+
AlertDialogContent,
|
|
9
|
+
AlertDialogDescription,
|
|
10
|
+
AlertDialogFooter,
|
|
11
|
+
AlertDialogHeader,
|
|
12
|
+
AlertDialogTitle,
|
|
13
|
+
} from '@/components/ui/alert-dialog';
|
|
14
|
+
|
|
15
|
+
interface ConfirmOptions {
|
|
16
|
+
title: string;
|
|
17
|
+
description: string;
|
|
18
|
+
confirmText?: string;
|
|
19
|
+
cancelText?: string;
|
|
20
|
+
variant?: 'default' | 'destructive';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function useConfirm() {
|
|
24
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
25
|
+
const [options, setOptions] = useState<ConfirmOptions>({
|
|
26
|
+
title: '',
|
|
27
|
+
description: '',
|
|
28
|
+
confirmText: 'Confirmer',
|
|
29
|
+
cancelText: 'Annuler',
|
|
30
|
+
variant: 'default',
|
|
31
|
+
});
|
|
32
|
+
const [resolvePromise, setResolvePromise] = useState<((value: boolean) => void) | null>(null);
|
|
33
|
+
|
|
34
|
+
const confirm = (opts: ConfirmOptions): Promise<boolean> => {
|
|
35
|
+
setOptions({
|
|
36
|
+
confirmText: 'Confirmer',
|
|
37
|
+
cancelText: 'Annuler',
|
|
38
|
+
variant: 'default',
|
|
39
|
+
...opts,
|
|
40
|
+
});
|
|
41
|
+
setIsOpen(true);
|
|
42
|
+
|
|
43
|
+
return new Promise<boolean>((resolve) => {
|
|
44
|
+
setResolvePromise(() => resolve);
|
|
45
|
+
});
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const handleConfirm = () => {
|
|
49
|
+
if (resolvePromise) {
|
|
50
|
+
resolvePromise(true);
|
|
51
|
+
}
|
|
52
|
+
setIsOpen(false);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const handleCancel = () => {
|
|
56
|
+
if (resolvePromise) {
|
|
57
|
+
resolvePromise(false);
|
|
58
|
+
}
|
|
59
|
+
setIsOpen(false);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const ConfirmDialog = () => (
|
|
63
|
+
<AlertDialog open={isOpen} onOpenChange={setIsOpen}>
|
|
64
|
+
<AlertDialogContent>
|
|
65
|
+
<AlertDialogHeader>
|
|
66
|
+
<AlertDialogTitle>{options.title}</AlertDialogTitle>
|
|
67
|
+
<AlertDialogDescription>{options.description}</AlertDialogDescription>
|
|
68
|
+
</AlertDialogHeader>
|
|
69
|
+
<AlertDialogFooter>
|
|
70
|
+
<AlertDialogCancel onClick={handleCancel}>{options.cancelText}</AlertDialogCancel>
|
|
71
|
+
<AlertDialogAction
|
|
72
|
+
onClick={handleConfirm}
|
|
73
|
+
className={
|
|
74
|
+
options.variant === 'destructive'
|
|
75
|
+
? 'bg-red-600 hover:bg-red-700 focus:ring-red-600'
|
|
76
|
+
: ''
|
|
77
|
+
}
|
|
78
|
+
>
|
|
79
|
+
{options.confirmText}
|
|
80
|
+
</AlertDialogAction>
|
|
81
|
+
</AlertDialogFooter>
|
|
82
|
+
</AlertDialogContent>
|
|
83
|
+
</AlertDialog>
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
return { confirm, ConfirmDialog };
|
|
87
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import useSWR, { mutate as globalMutate } from 'swr';
|
|
2
|
+
import type {
|
|
3
|
+
ContactViewData,
|
|
4
|
+
ViewFilter,
|
|
5
|
+
ViewColumn,
|
|
6
|
+
ViewSortConfig,
|
|
7
|
+
} from '@/types/contact-views';
|
|
8
|
+
|
|
9
|
+
async function fetcher<T>(url: string): Promise<T> {
|
|
10
|
+
const res = await fetch(url);
|
|
11
|
+
if (!res.ok) throw new Error('Erreur lors du chargement des vues');
|
|
12
|
+
return res.json();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function getViewsKey(entityType: string) {
|
|
16
|
+
return `/api/contact-views?entityType=${entityType}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function useContactViews(entityType: 'contacts' | 'companies' = 'contacts') {
|
|
20
|
+
const viewsKey = getViewsKey(entityType);
|
|
21
|
+
|
|
22
|
+
const { data, error, isLoading, mutate } = useSWR<ContactViewData[]>(viewsKey, fetcher, {
|
|
23
|
+
revalidateOnFocus: false,
|
|
24
|
+
dedupingInterval: 60_000,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const createView = async (view: {
|
|
28
|
+
name: string;
|
|
29
|
+
isPublic?: boolean;
|
|
30
|
+
filters?: ViewFilter[];
|
|
31
|
+
columns?: ViewColumn[] | null;
|
|
32
|
+
sortConfig?: ViewSortConfig | null;
|
|
33
|
+
}): Promise<ContactViewData> => {
|
|
34
|
+
const res = await fetch('/api/contact-views', {
|
|
35
|
+
method: 'POST',
|
|
36
|
+
headers: { 'Content-Type': 'application/json' },
|
|
37
|
+
body: JSON.stringify({ ...view, entityType }),
|
|
38
|
+
});
|
|
39
|
+
if (!res.ok) {
|
|
40
|
+
const err = await res.json();
|
|
41
|
+
throw new Error(err.error || 'Erreur lors de la création');
|
|
42
|
+
}
|
|
43
|
+
const created = await res.json();
|
|
44
|
+
await mutate();
|
|
45
|
+
return created;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const updateView = async (
|
|
49
|
+
id: string,
|
|
50
|
+
updates: {
|
|
51
|
+
name?: string;
|
|
52
|
+
isPublic?: boolean;
|
|
53
|
+
filters?: ViewFilter[];
|
|
54
|
+
columns?: ViewColumn[] | null;
|
|
55
|
+
sortConfig?: ViewSortConfig | null;
|
|
56
|
+
isDefault?: boolean;
|
|
57
|
+
},
|
|
58
|
+
): Promise<ContactViewData> => {
|
|
59
|
+
const res = await fetch(`/api/contact-views/${id}`, {
|
|
60
|
+
method: 'PUT',
|
|
61
|
+
headers: { 'Content-Type': 'application/json' },
|
|
62
|
+
body: JSON.stringify(updates),
|
|
63
|
+
});
|
|
64
|
+
if (!res.ok) {
|
|
65
|
+
const err = await res.json();
|
|
66
|
+
throw new Error(err.error || 'Erreur lors de la modification');
|
|
67
|
+
}
|
|
68
|
+
const updated = await res.json();
|
|
69
|
+
await mutate();
|
|
70
|
+
return updated;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const deleteView = async (id: string): Promise<void> => {
|
|
74
|
+
const res = await fetch(`/api/contact-views/${id}`, { method: 'DELETE' });
|
|
75
|
+
if (!res.ok) {
|
|
76
|
+
const err = await res.json();
|
|
77
|
+
throw new Error(err.error || 'Erreur lors de la suppression');
|
|
78
|
+
}
|
|
79
|
+
await mutate();
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const cloneView = async (view: ContactViewData, newName?: string): Promise<ContactViewData> => {
|
|
83
|
+
return createView({
|
|
84
|
+
name: newName || `${view.name} (copie)`,
|
|
85
|
+
isPublic: false,
|
|
86
|
+
filters: view.filters,
|
|
87
|
+
columns: view.columns,
|
|
88
|
+
sortConfig: view.sortConfig,
|
|
89
|
+
});
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const pinView = async (id: string): Promise<void> => {
|
|
93
|
+
const res = await fetch(`/api/contact-views/${id}/pin`, { method: 'POST' });
|
|
94
|
+
if (!res.ok) {
|
|
95
|
+
const err = await res.json();
|
|
96
|
+
throw new Error(err.error || "Erreur lors de l'épinglage");
|
|
97
|
+
}
|
|
98
|
+
await mutate();
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const unpinView = async (id: string): Promise<void> => {
|
|
102
|
+
const res = await fetch(`/api/contact-views/${id}/pin`, { method: 'DELETE' });
|
|
103
|
+
if (!res.ok) {
|
|
104
|
+
const err = await res.json();
|
|
105
|
+
throw new Error(err.error || 'Erreur lors du désépinglage');
|
|
106
|
+
}
|
|
107
|
+
await mutate();
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const togglePin = async (view: ContactViewData): Promise<void> => {
|
|
111
|
+
if (view.pinOrder != null) {
|
|
112
|
+
await unpinView(view.id);
|
|
113
|
+
} else {
|
|
114
|
+
await pinView(view.id);
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
views: data || [],
|
|
120
|
+
error,
|
|
121
|
+
isLoading,
|
|
122
|
+
mutate,
|
|
123
|
+
createView,
|
|
124
|
+
updateView,
|
|
125
|
+
deleteView,
|
|
126
|
+
cloneView,
|
|
127
|
+
pinView,
|
|
128
|
+
unpinView,
|
|
129
|
+
togglePin,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function invalidateContactViews(entityType?: string) {
|
|
134
|
+
if (entityType) {
|
|
135
|
+
globalMutate(getViewsKey(entityType));
|
|
136
|
+
} else {
|
|
137
|
+
globalMutate(getViewsKey('contacts'));
|
|
138
|
+
globalMutate(getViewsKey('companies'));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import useSWR from 'swr';
|
|
2
|
+
import type { ViewFilter } from '@/types/contact-views';
|
|
3
|
+
|
|
4
|
+
interface ContactsParams {
|
|
5
|
+
search?: string;
|
|
6
|
+
viewId?: string | null;
|
|
7
|
+
filters?: ViewFilter[];
|
|
8
|
+
page?: number;
|
|
9
|
+
limit?: number;
|
|
10
|
+
sortField?: string;
|
|
11
|
+
sortDir?: 'asc' | 'desc';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface ContactsResponse {
|
|
15
|
+
contacts: any[];
|
|
16
|
+
pagination: {
|
|
17
|
+
page: number;
|
|
18
|
+
limit: number;
|
|
19
|
+
total: number;
|
|
20
|
+
totalPages: number;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function buildContactsUrl(params: ContactsParams): string {
|
|
25
|
+
const sp = new URLSearchParams();
|
|
26
|
+
|
|
27
|
+
if (params.search) sp.set('search', params.search);
|
|
28
|
+
if (params.page) sp.set('page', params.page.toString());
|
|
29
|
+
if (params.limit) sp.set('limit', params.limit.toString());
|
|
30
|
+
if (params.sortField) sp.set('sortField', params.sortField);
|
|
31
|
+
if (params.sortDir) sp.set('sortDir', params.sortDir);
|
|
32
|
+
|
|
33
|
+
if (params.viewId) {
|
|
34
|
+
sp.set('viewId', params.viewId);
|
|
35
|
+
} else if (params.filters && params.filters.length > 0) {
|
|
36
|
+
sp.set('filters', JSON.stringify(params.filters));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return `/api/contacts?${sp.toString()}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function fetcher<T>(url: string): Promise<T> {
|
|
43
|
+
const res = await fetch(url);
|
|
44
|
+
if (!res.ok) throw new Error('Erreur lors du chargement des contacts');
|
|
45
|
+
return res.json();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function useContacts(params: ContactsParams) {
|
|
49
|
+
const url = buildContactsUrl(params);
|
|
50
|
+
|
|
51
|
+
const { data, error, isLoading, isValidating, mutate } = useSWR<ContactsResponse>(url, fetcher, {
|
|
52
|
+
revalidateOnFocus: false,
|
|
53
|
+
keepPreviousData: true,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
contacts: data?.contacts || [],
|
|
58
|
+
pagination: data?.pagination || {
|
|
59
|
+
page: 1,
|
|
60
|
+
limit: 25,
|
|
61
|
+
total: 0,
|
|
62
|
+
totalPages: 1,
|
|
63
|
+
},
|
|
64
|
+
error,
|
|
65
|
+
isLoading,
|
|
66
|
+
isValidating,
|
|
67
|
+
mutate,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import useSWR, { type SWRConfiguration } from 'swr';
|
|
2
|
+
|
|
3
|
+
async function fetcher<T>(url: string): Promise<T> {
|
|
4
|
+
const res = await fetch(url);
|
|
5
|
+
if (!res.ok) {
|
|
6
|
+
const error = new Error('Erreur lors du chargement des données');
|
|
7
|
+
throw error;
|
|
8
|
+
}
|
|
9
|
+
return res.json();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function useFetch<T>(key: string | null, config?: SWRConfiguration<T>) {
|
|
13
|
+
return useSWR<T>(key, fetcher, {
|
|
14
|
+
revalidateOnFocus: false,
|
|
15
|
+
...config,
|
|
16
|
+
});
|
|
17
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef } from 'react';
|
|
4
|
+
|
|
5
|
+
const FOCUSABLE_SELECTOR =
|
|
6
|
+
'button, a[href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
|
|
7
|
+
|
|
8
|
+
function getFocusables(container: HTMLElement): HTMLElement[] {
|
|
9
|
+
return Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
|
|
10
|
+
(el) => !el.hasAttribute('disabled') && el.offsetParent !== null
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function useFocusTrap(
|
|
15
|
+
isActive: boolean,
|
|
16
|
+
containerRef: React.RefObject<HTMLElement | null>,
|
|
17
|
+
options?: {
|
|
18
|
+
onClose?: () => void;
|
|
19
|
+
initialFocusRef?: React.RefObject<HTMLElement | null>;
|
|
20
|
+
skipInitialFocus?: boolean;
|
|
21
|
+
}
|
|
22
|
+
) {
|
|
23
|
+
const prevFocusRef = useRef<HTMLElement | null>(null);
|
|
24
|
+
const onCloseRef = useRef(options?.onClose);
|
|
25
|
+
onCloseRef.current = options?.onClose;
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
if (!isActive || !containerRef.current) return;
|
|
29
|
+
|
|
30
|
+
const container = containerRef.current;
|
|
31
|
+
prevFocusRef.current = document.activeElement as HTMLElement | null;
|
|
32
|
+
|
|
33
|
+
const focusables = getFocusables(container);
|
|
34
|
+
const first = options?.initialFocusRef?.current ?? focusables[0];
|
|
35
|
+
const last = focusables[focusables.length - 1];
|
|
36
|
+
|
|
37
|
+
if (!options?.skipInitialFocus && first) first.focus();
|
|
38
|
+
|
|
39
|
+
function handleKeyDown(e: KeyboardEvent) {
|
|
40
|
+
if (e.key === 'Escape') {
|
|
41
|
+
e.preventDefault();
|
|
42
|
+
options?.onClose?.();
|
|
43
|
+
prevFocusRef.current?.focus();
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (e.key !== 'Tab') return;
|
|
47
|
+
|
|
48
|
+
const focusablesNow = getFocusables(container);
|
|
49
|
+
if (focusablesNow.length === 0) return;
|
|
50
|
+
|
|
51
|
+
const firstEl = focusablesNow[0];
|
|
52
|
+
const lastEl = focusablesNow[focusablesNow.length - 1];
|
|
53
|
+
|
|
54
|
+
if (e.shiftKey) {
|
|
55
|
+
if (document.activeElement === firstEl) {
|
|
56
|
+
e.preventDefault();
|
|
57
|
+
lastEl.focus();
|
|
58
|
+
}
|
|
59
|
+
} else {
|
|
60
|
+
if (document.activeElement === lastEl) {
|
|
61
|
+
e.preventDefault();
|
|
62
|
+
firstEl.focus();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
container.addEventListener('keydown', handleKeyDown);
|
|
68
|
+
return () => {
|
|
69
|
+
container.removeEventListener('keydown', handleKeyDown);
|
|
70
|
+
prevFocusRef.current?.focus();
|
|
71
|
+
};
|
|
72
|
+
}, [isActive, options?.onClose]);
|
|
73
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { useFetch } from './use-fetch';
|
|
2
|
+
|
|
3
|
+
export function useStatuses() {
|
|
4
|
+
return useFetch<any[]>('/api/statuses', {
|
|
5
|
+
revalidateOnFocus: false,
|
|
6
|
+
dedupingInterval: 300_000,
|
|
7
|
+
});
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function useClosingReasons() {
|
|
11
|
+
return useFetch<any[]>('/api/closing-reasons', {
|
|
12
|
+
revalidateOnFocus: false,
|
|
13
|
+
dedupingInterval: 300_000,
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function useUsersList() {
|
|
18
|
+
return useFetch<any[]>('/api/users/list', {
|
|
19
|
+
revalidateOnFocus: false,
|
|
20
|
+
dedupingInterval: 300_000,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Service pour interroger l'API Adresse française
|
|
3
|
+
* https://api-adresse.data.gouv.fr
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface AddressFeature {
|
|
7
|
+
type: string;
|
|
8
|
+
geometry: {
|
|
9
|
+
type: string;
|
|
10
|
+
coordinates: number[];
|
|
11
|
+
};
|
|
12
|
+
properties: {
|
|
13
|
+
label: string;
|
|
14
|
+
score: number;
|
|
15
|
+
housenumber?: string;
|
|
16
|
+
id: string;
|
|
17
|
+
banId: string;
|
|
18
|
+
name: string;
|
|
19
|
+
postcode: string;
|
|
20
|
+
citycode: string;
|
|
21
|
+
x: number;
|
|
22
|
+
y: number;
|
|
23
|
+
city: string;
|
|
24
|
+
context: string;
|
|
25
|
+
type: string;
|
|
26
|
+
importance: number;
|
|
27
|
+
street?: string;
|
|
28
|
+
_type: string;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface AddressSearchResult {
|
|
33
|
+
type: string;
|
|
34
|
+
version: string;
|
|
35
|
+
features: AddressFeature[];
|
|
36
|
+
attribution: string;
|
|
37
|
+
licence: string;
|
|
38
|
+
query: string;
|
|
39
|
+
limit: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const API_BASE_URL = 'https://api-adresse.data.gouv.fr';
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Recherche une adresse
|
|
46
|
+
* @param query - Texte de recherche
|
|
47
|
+
* @param limit - Nombre maximum de résultats (défaut: 5)
|
|
48
|
+
*/
|
|
49
|
+
export async function searchAddress(
|
|
50
|
+
query: string,
|
|
51
|
+
limit: number = 5,
|
|
52
|
+
): Promise<AddressSearchResult> {
|
|
53
|
+
if (!query || query.trim().length < 3) {
|
|
54
|
+
return {
|
|
55
|
+
type: 'FeatureCollection',
|
|
56
|
+
version: 'draft',
|
|
57
|
+
features: [],
|
|
58
|
+
attribution: '',
|
|
59
|
+
licence: '',
|
|
60
|
+
query: query,
|
|
61
|
+
limit: 0,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const encodedQuery = encodeURIComponent(query.trim());
|
|
66
|
+
const url = `${API_BASE_URL}/search?q=${encodedQuery}&limit=${limit}`;
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const controller = new AbortController();
|
|
70
|
+
const timeoutId = setTimeout(() => controller.abort(), 15_000);
|
|
71
|
+
const response = await fetch(url, { signal: controller.signal });
|
|
72
|
+
clearTimeout(timeoutId);
|
|
73
|
+
|
|
74
|
+
if (!response.ok) {
|
|
75
|
+
const message =
|
|
76
|
+
response.status === 504 || response.status === 502 || response.status === 503
|
|
77
|
+
? "Le service de recherche d'adresses est temporairement indisponible. Réessayez dans un instant."
|
|
78
|
+
: `Erreur HTTP: ${response.status}`;
|
|
79
|
+
throw new Error(message);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const data: AddressSearchResult = await response.json();
|
|
83
|
+
return data;
|
|
84
|
+
} catch (error) {
|
|
85
|
+
if (error instanceof Error) {
|
|
86
|
+
if (error.name === 'AbortError') {
|
|
87
|
+
const timeoutMessage =
|
|
88
|
+
"La recherche d'adresse a pris trop de temps. Réessayez dans un instant.";
|
|
89
|
+
console.error("Erreur lors de la recherche d'adresse:", timeoutMessage);
|
|
90
|
+
throw new Error(timeoutMessage);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
console.error("Erreur lors de la recherche d'adresse:", error);
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Extrait les composants d'une adresse
|
|
100
|
+
*/
|
|
101
|
+
export function extractAddressComponents(feature: AddressFeature): {
|
|
102
|
+
street: string;
|
|
103
|
+
city: string;
|
|
104
|
+
postalCode: string;
|
|
105
|
+
fullAddress: string;
|
|
106
|
+
citycode: string;
|
|
107
|
+
} {
|
|
108
|
+
const props = feature.properties;
|
|
109
|
+
return {
|
|
110
|
+
street: props.name || '',
|
|
111
|
+
city: props.city || '',
|
|
112
|
+
postalCode: props.postcode || '',
|
|
113
|
+
fullAddress: props.label || '',
|
|
114
|
+
citycode: props.citycode || '',
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Recherche inversée : trouve une adresse à partir de coordonnées GPS
|
|
120
|
+
* @param latitude - Latitude (ex: 48.8566)
|
|
121
|
+
* @param longitude - Longitude (ex: 2.3522)
|
|
122
|
+
*/
|
|
123
|
+
export async function reverseGeocode(
|
|
124
|
+
latitude: number,
|
|
125
|
+
longitude: number,
|
|
126
|
+
): Promise<AddressSearchResult> {
|
|
127
|
+
const url = `${API_BASE_URL}/reverse?lon=${longitude}&lat=${latitude}`;
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
const controller = new AbortController();
|
|
131
|
+
const timeoutId = setTimeout(() => controller.abort(), 15_000);
|
|
132
|
+
const response = await fetch(url, { signal: controller.signal });
|
|
133
|
+
clearTimeout(timeoutId);
|
|
134
|
+
|
|
135
|
+
if (!response.ok) {
|
|
136
|
+
const message =
|
|
137
|
+
response.status === 504 || response.status === 502 || response.status === 503
|
|
138
|
+
? "Le service de recherche d'adresses est temporairement indisponible. Réessayez dans un instant."
|
|
139
|
+
: `Erreur HTTP: ${response.status}`;
|
|
140
|
+
throw new Error(message);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const data: AddressSearchResult = await response.json();
|
|
144
|
+
return data;
|
|
145
|
+
} catch (error) {
|
|
146
|
+
if (error instanceof Error && error.name === 'AbortError') {
|
|
147
|
+
const timeoutMessage =
|
|
148
|
+
"La recherche d'adresse a pris trop de temps. Réessayez dans un instant.";
|
|
149
|
+
console.error('Erreur lors du reverse geocoding:', timeoutMessage);
|
|
150
|
+
throw new Error(timeoutMessage);
|
|
151
|
+
}
|
|
152
|
+
console.error('Erreur lors du reverse geocoding:', error);
|
|
153
|
+
throw error;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { LRUCache } from 'lru-cache';
|
|
2
|
+
import { prisma } from '@/lib/prisma';
|
|
3
|
+
|
|
4
|
+
const cache = new LRUCache<string, any>({
|
|
5
|
+
max: 100,
|
|
6
|
+
ttl: 5 * 60 * 1000,
|
|
7
|
+
allowStale: false,
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
const pendingFetches = new Map<string, Promise<any>>();
|
|
11
|
+
|
|
12
|
+
function getCached<T>(key: string, ttlMs: number, fetcher: () => Promise<T>): Promise<T> {
|
|
13
|
+
const cached = cache.get(key);
|
|
14
|
+
if (cached !== undefined) return Promise.resolve(cached as T);
|
|
15
|
+
|
|
16
|
+
const pending = pendingFetches.get(key);
|
|
17
|
+
if (pending) return pending as Promise<T>;
|
|
18
|
+
|
|
19
|
+
const promise = fetcher()
|
|
20
|
+
.then((data) => {
|
|
21
|
+
cache.set(key, data, { ttl: ttlMs });
|
|
22
|
+
pendingFetches.delete(key);
|
|
23
|
+
return data;
|
|
24
|
+
})
|
|
25
|
+
.catch((err) => {
|
|
26
|
+
pendingFetches.delete(key);
|
|
27
|
+
throw err;
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
pendingFetches.set(key, promise);
|
|
31
|
+
return promise;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function invalidateCache(key: string) {
|
|
35
|
+
cache.delete(key);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function invalidateCachePrefix(prefix: string) {
|
|
39
|
+
for (const key of cache.keys()) {
|
|
40
|
+
if (key.startsWith(prefix)) {
|
|
41
|
+
cache.delete(key);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const FIVE_MINUTES = 5 * 60 * 1000;
|
|
47
|
+
const ONE_HOUR = 60 * 60 * 1000;
|
|
48
|
+
|
|
49
|
+
export function getCachedStatuses() {
|
|
50
|
+
return getCached('statuses', FIVE_MINUTES, () =>
|
|
51
|
+
prisma.status.findMany({ orderBy: { order: 'asc' } }),
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function getCachedCompany() {
|
|
56
|
+
return getCached('company', ONE_HOUR, () =>
|
|
57
|
+
prisma.organization.findUnique({ where: { id: 'company' } }),
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function getCachedStatusByName(name: string) {
|
|
62
|
+
return getCached(`status:${name}`, FIVE_MINUTES, () =>
|
|
63
|
+
prisma.status.findFirst({
|
|
64
|
+
where: { name: { equals: name, mode: 'insensitive' } },
|
|
65
|
+
}),
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function getCachedClosingReasons() {
|
|
70
|
+
return getCached('closing-reasons', FIVE_MINUTES, () =>
|
|
71
|
+
prisma.closingReason.findMany({ orderBy: { name: 'asc' } }),
|
|
72
|
+
);
|
|
73
|
+
}
|