vision refactor
This commit is contained in:
parent
0f12b2a5ab
commit
4cb4cccf5d
252
TWENTY_CRM_INTEGRATION.md
Normal file
252
TWENTY_CRM_INTEGRATION.md
Normal file
@ -0,0 +1,252 @@
|
||||
# Intégration Twenty CRM dans le Widget Devoirs
|
||||
|
||||
## 📋 Vue d'ensemble
|
||||
|
||||
Le widget "Devoirs" affiche maintenant les tâches en retard provenant de **deux sources** :
|
||||
1. **Leantime** (agilite.slm-lab.net)
|
||||
2. **Twenty CRM** (mediation.slm-lab.net)
|
||||
|
||||
Les tâches sont combinées, filtrées (uniquement celles en retard), triées par date d'échéance, et limitées à 7 tâches.
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Configuration Requise
|
||||
|
||||
### Variables d'Environnement
|
||||
|
||||
Ajoutez les variables suivantes à votre fichier `.env.local` (développement) ou à vos variables d'environnement de production :
|
||||
|
||||
```env
|
||||
# Twenty CRM API Configuration
|
||||
TWENTY_CRM_API_URL=https://mediation.slm-lab.net/graphql
|
||||
TWENTY_CRM_API_KEY=your_api_key_here
|
||||
TWENTY_CRM_URL=https://mediation.slm-lab.net
|
||||
```
|
||||
|
||||
**Où obtenir la clé API :**
|
||||
1. Connectez-vous à Twenty CRM (mediation.slm-lab.net)
|
||||
2. Allez dans **Settings → APIs & Webhooks**
|
||||
3. Cliquez sur **"+ Create key"**
|
||||
4. Donnez un nom à la clé (ex: "NeahStable Widget")
|
||||
5. Copiez la clé (elle ne sera affichée qu'une seule fois)
|
||||
|
||||
---
|
||||
|
||||
## 📁 Fichiers Créés/Modifiés
|
||||
|
||||
### Nouveau Fichier
|
||||
- **`app/api/twenty-crm/tasks/route.ts`** - Endpoint API pour récupérer les tâches Twenty CRM
|
||||
|
||||
### Fichiers Modifiés
|
||||
- **`components/flow.tsx`** - Widget Devoirs modifié pour combiner les deux sources
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Flow de Fonctionnement
|
||||
|
||||
### 1. Récupération des Tâches
|
||||
|
||||
Le widget fait **deux appels API en parallèle** :
|
||||
|
||||
```typescript
|
||||
const [leantimeResponse, twentyCrmResponse] = await Promise.allSettled([
|
||||
fetch('/api/leantime/tasks'),
|
||||
fetch('/api/twenty-crm/tasks'),
|
||||
]);
|
||||
```
|
||||
|
||||
**Avantages :**
|
||||
- ✅ Appels parallèles = plus rapide
|
||||
- ✅ `Promise.allSettled` = si une source échoue, l'autre continue de fonctionner
|
||||
- ✅ Pas de dépendance entre les deux sources
|
||||
|
||||
### 2. Transformation des Tâches Twenty CRM
|
||||
|
||||
Les tâches Twenty CRM sont transformées pour correspondre au format Leantime :
|
||||
|
||||
```typescript
|
||||
{
|
||||
id: `twenty-${task.id}`, // Préfixe pour éviter les conflits
|
||||
headline: task.title,
|
||||
dateToFinish: task.dueAt,
|
||||
projectName: 'Twenty CRM',
|
||||
source: 'twenty-crm', // Identifiant de source
|
||||
url: `${TWENTY_CRM_URL}/object/activity/${task.id}`, // Lien direct
|
||||
// ... autres champs
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Filtrage et Tri
|
||||
|
||||
1. **Filtrage :** Uniquement les tâches avec date d'échéance **avant aujourd'hui** (en retard)
|
||||
2. **Tri :** Par date d'échéance (plus anciennes en premier)
|
||||
3. **Limite :** Maximum 7 tâches affichées
|
||||
|
||||
### 4. Affichage
|
||||
|
||||
- Les tâches Twenty CRM sont identifiées par un badge "(Twenty CRM)"
|
||||
- Le lien pointe vers la page de la tâche dans Twenty CRM
|
||||
- Le format d'affichage est identique pour les deux sources
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Structure de l'API Twenty CRM
|
||||
|
||||
### Endpoint GraphQL
|
||||
|
||||
**URL :** `https://mediation.slm-lab.net/graphql`
|
||||
|
||||
**Méthode :** POST
|
||||
|
||||
**Headers :**
|
||||
```
|
||||
Authorization: Bearer YOUR_API_KEY
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
### Requête GraphQL
|
||||
|
||||
```graphql
|
||||
query GetOverdueTasks {
|
||||
findManyActivities(
|
||||
filter: {
|
||||
type: { eq: Task }
|
||||
completedAt: { is: NULL }
|
||||
dueAt: { lt: "2026-01-15T00:00:00Z" }
|
||||
}
|
||||
orderBy: { dueAt: AscNullsLast }
|
||||
) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
title
|
||||
body
|
||||
dueAt
|
||||
completedAt
|
||||
type
|
||||
assigneeId
|
||||
assignee {
|
||||
id
|
||||
firstName
|
||||
lastName
|
||||
email
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Filtres appliqués :**
|
||||
- `type: { eq: Task }` - Uniquement les tâches (pas les autres activités)
|
||||
- `completedAt: { is: NULL }` - Uniquement les tâches non complétées
|
||||
- `dueAt: { lt: "..." }` - Uniquement les tâches avec date d'échéance avant aujourd'hui
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Dépannage
|
||||
|
||||
### Erreur : "TWENTY_CRM_API_URL is not set"
|
||||
|
||||
**Solution :** Ajoutez `TWENTY_CRM_API_URL` à vos variables d'environnement.
|
||||
|
||||
### Erreur : "TWENTY_CRM_API_KEY is not set"
|
||||
|
||||
**Solution :** Ajoutez `TWENTY_CRM_API_KEY` à vos variables d'environnement.
|
||||
|
||||
### Erreur : "401 Unauthorized"
|
||||
|
||||
**Causes possibles :**
|
||||
- Clé API invalide ou expirée
|
||||
- Clé API mal copiée (espaces, caractères invisibles)
|
||||
- Permissions insuffisantes sur la clé API
|
||||
|
||||
**Solution :**
|
||||
1. Vérifiez que la clé API est correctement copiée
|
||||
2. Régénérez la clé API dans Twenty CRM
|
||||
3. Vérifiez les permissions de la clé API
|
||||
|
||||
### Erreur : "GraphQL errors from Twenty CRM"
|
||||
|
||||
**Causes possibles :**
|
||||
- Structure de la requête GraphQL incorrecte
|
||||
- Version de Twenty CRM incompatible
|
||||
- Schéma GraphQL différent selon le workspace
|
||||
|
||||
**Solution :**
|
||||
1. Vérifiez la documentation de votre version de Twenty CRM
|
||||
2. Testez la requête GraphQL directement dans l'interface GraphQL de Twenty CRM
|
||||
3. Ajustez la requête selon votre schéma
|
||||
|
||||
### Aucune tâche Twenty CRM n'apparaît
|
||||
|
||||
**Vérifications :**
|
||||
1. ✅ Vérifiez qu'il existe des tâches en retard dans Twenty CRM
|
||||
2. ✅ Vérifiez que les tâches ont une date d'échéance (`dueAt`)
|
||||
3. ✅ Vérifiez que les tâches ne sont pas complétées (`completedAt` est NULL)
|
||||
4. ✅ Vérifiez les logs du serveur pour voir les erreurs éventuelles
|
||||
|
||||
---
|
||||
|
||||
## 📊 Logs et Debug
|
||||
|
||||
### Logs Backend
|
||||
|
||||
Tous les logs sont préfixés avec `[TWENTY_CRM_TASKS]` :
|
||||
|
||||
```typescript
|
||||
logger.debug('[TWENTY_CRM_TASKS] Fetching tasks from Twenty CRM', {...});
|
||||
logger.error('[TWENTY_CRM_TASKS] Failed to fetch tasks', {...});
|
||||
```
|
||||
|
||||
### Logs Frontend
|
||||
|
||||
Le widget affiche dans la console :
|
||||
- Nombre de tâches Leantime récupérées
|
||||
- Nombre de tâches Twenty CRM récupérées
|
||||
- Total combiné
|
||||
- Tâches triées avec leur source
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Alternatives si GraphQL ne fonctionne pas
|
||||
|
||||
Si la requête GraphQL ne fonctionne pas avec votre version de Twenty CRM, vous pouvez utiliser l'API REST :
|
||||
|
||||
### Option 1 : API REST (si disponible)
|
||||
|
||||
```typescript
|
||||
const response = await fetch(`${process.env.TWENTY_CRM_API_URL}/api/activities`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${process.env.TWENTY_CRM_API_KEY}`,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Option 2 : Ajuster la requête GraphQL
|
||||
|
||||
La structure exacte peut varier. Consultez la documentation de votre instance Twenty CRM ou utilisez l'explorateur GraphQL intégré.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Checklist de Déploiement
|
||||
|
||||
- [ ] Variables d'environnement configurées :
|
||||
- [ ] `TWENTY_CRM_API_URL`
|
||||
- [ ] `TWENTY_CRM_API_KEY`
|
||||
- [ ] `TWENTY_CRM_URL` (optionnel, pour les liens)
|
||||
- [ ] Clé API créée dans Twenty CRM
|
||||
- [ ] Permissions de la clé API vérifiées
|
||||
- [ ] Test de l'endpoint `/api/twenty-crm/tasks` effectué
|
||||
- [ ] Vérification que les tâches apparaissent dans le widget
|
||||
- [ ] Logs vérifiés pour détecter d'éventuelles erreurs
|
||||
|
||||
---
|
||||
|
||||
## 📝 Notes
|
||||
|
||||
- Les tâches Twenty CRM sont préfixées avec `twenty-` dans leur ID pour éviter les conflits
|
||||
- Le widget continue de fonctionner même si une seule source échoue (grace à `Promise.allSettled`)
|
||||
- Le cache Redis n'est pas encore implémenté pour Twenty CRM (peut être ajouté plus tard)
|
||||
- La requête GraphQL peut nécessiter des ajustements selon votre version de Twenty CRM
|
||||
216
app/api/twenty-crm/tasks/route.ts
Normal file
216
app/api/twenty-crm/tasks/route.ts
Normal file
@ -0,0 +1,216 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth/next";
|
||||
import { authOptions } from "@/app/api/auth/options";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
interface TwentyTask {
|
||||
id: string;
|
||||
title: string;
|
||||
body?: string;
|
||||
dueAt?: string;
|
||||
completedAt?: string;
|
||||
type?: string;
|
||||
assigneeId?: string;
|
||||
assignee?: {
|
||||
id: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
email?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch tasks from Twenty CRM using GraphQL API
|
||||
*/
|
||||
async function fetchTwentyTasks(): Promise<TwentyTask[]> {
|
||||
try {
|
||||
if (!process.env.TWENTY_CRM_API_URL) {
|
||||
logger.error('[TWENTY_CRM_TASKS] TWENTY_CRM_API_URL is not set in environment variables');
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!process.env.TWENTY_CRM_API_KEY) {
|
||||
logger.error('[TWENTY_CRM_TASKS] TWENTY_CRM_API_KEY is not set in environment variables');
|
||||
return [];
|
||||
}
|
||||
|
||||
const apiUrl = process.env.TWENTY_CRM_API_URL.endsWith('/graphql')
|
||||
? process.env.TWENTY_CRM_API_URL
|
||||
: `${process.env.TWENTY_CRM_API_URL}/graphql`;
|
||||
|
||||
// Calculate today's date at midnight for filtering overdue tasks
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const todayISO = today.toISOString();
|
||||
|
||||
// GraphQL query to fetch activities (tasks) that are not completed and due before today
|
||||
// Using findManyActivities which is the standard query for Twenty CRM
|
||||
// Note: The exact filter syntax may vary based on your Twenty CRM version
|
||||
// If this doesn't work, try using REST API or adjust the filter syntax
|
||||
const query = `
|
||||
query GetOverdueTasks {
|
||||
findManyActivities(
|
||||
filter: {
|
||||
type: { eq: Task }
|
||||
completedAt: { is: NULL }
|
||||
dueAt: { lt: "${todayISO}" }
|
||||
}
|
||||
orderBy: { dueAt: AscNullsLast }
|
||||
) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
title
|
||||
body
|
||||
dueAt
|
||||
completedAt
|
||||
type
|
||||
assigneeId
|
||||
assignee {
|
||||
id
|
||||
firstName
|
||||
lastName
|
||||
email
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
logger.debug('[TWENTY_CRM_TASKS] Fetching tasks from Twenty CRM', {
|
||||
apiUrl: apiUrl.replace(/\/graphql$/, ''), // Log without /graphql for security
|
||||
});
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${process.env.TWENTY_CRM_API_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({ query }),
|
||||
});
|
||||
|
||||
const responseText = await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
logger.error('[TWENTY_CRM_TASKS] Failed to fetch tasks from Twenty CRM', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
response: responseText.substring(0, 500), // Log first 500 chars
|
||||
});
|
||||
return [];
|
||||
}
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(responseText);
|
||||
} catch (e) {
|
||||
logger.error('[TWENTY_CRM_TASKS] Failed to parse Twenty CRM response', {
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
response: responseText.substring(0, 500),
|
||||
});
|
||||
return [];
|
||||
}
|
||||
|
||||
// Check for GraphQL errors
|
||||
if (data.errors) {
|
||||
logger.error('[TWENTY_CRM_TASKS] GraphQL errors from Twenty CRM', {
|
||||
errors: data.errors,
|
||||
});
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!data.data?.findManyActivities?.edges) {
|
||||
logger.warn('[TWENTY_CRM_TASKS] Unexpected response format from Twenty CRM', {
|
||||
dataKeys: Object.keys(data.data || {}),
|
||||
});
|
||||
return [];
|
||||
}
|
||||
|
||||
// Transform Twenty CRM tasks to match our Task interface
|
||||
const tasks: TwentyTask[] = data.data.findManyActivities.edges.map((edge: any) => ({
|
||||
id: edge.node.id,
|
||||
title: edge.node.title || 'Untitled Task',
|
||||
body: edge.node.body || null,
|
||||
dueAt: edge.node.dueAt || null,
|
||||
completedAt: edge.node.completedAt || null,
|
||||
type: edge.node.type || 'Task',
|
||||
assigneeId: edge.node.assigneeId || null,
|
||||
assignee: edge.node.assignee ? {
|
||||
id: edge.node.assignee.id,
|
||||
firstName: edge.node.assignee.firstName || null,
|
||||
lastName: edge.node.assignee.lastName || null,
|
||||
email: edge.node.assignee.email || null,
|
||||
} : null,
|
||||
}));
|
||||
|
||||
logger.debug('[TWENTY_CRM_TASKS] Successfully fetched tasks from Twenty CRM', {
|
||||
count: tasks.length,
|
||||
});
|
||||
|
||||
return tasks;
|
||||
} catch (error) {
|
||||
logger.error('[TWENTY_CRM_TASKS] Error fetching tasks from Twenty CRM', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Check for force refresh parameter
|
||||
const url = new URL(request.url);
|
||||
const forceRefresh = url.searchParams.get('refresh') === 'true';
|
||||
|
||||
logger.debug('[TWENTY_CRM_TASKS] Fetching tasks for user', {
|
||||
emailHash: Buffer.from(session.user.email.toLowerCase()).toString('base64').slice(0, 12),
|
||||
forceRefresh,
|
||||
});
|
||||
|
||||
const tasks = await fetchTwentyTasks();
|
||||
|
||||
// Transform to match Leantime task format for consistency
|
||||
const transformedTasks = tasks.map((task) => ({
|
||||
id: `twenty-${task.id}`, // Prefix to avoid conflicts with Leantime IDs
|
||||
headline: task.title,
|
||||
description: task.body || null,
|
||||
dateToFinish: task.dueAt || null,
|
||||
projectName: 'Twenty CRM',
|
||||
projectId: 0,
|
||||
status: task.completedAt ? 5 : 1, // 5 = Done, 1 = New
|
||||
editorId: task.assigneeId || null,
|
||||
editorFirstname: task.assignee?.firstName || null,
|
||||
editorLastname: task.assignee?.lastName || null,
|
||||
authorFirstname: null,
|
||||
authorLastname: null,
|
||||
milestoneHeadline: null,
|
||||
editTo: null,
|
||||
editFrom: null,
|
||||
type: 'twenty-crm',
|
||||
dependingTicketId: null,
|
||||
source: 'twenty-crm', // Add source identifier
|
||||
url: process.env.TWENTY_CRM_URL ? `${process.env.TWENTY_CRM_URL}/object/activity/${task.id}` : null,
|
||||
}));
|
||||
|
||||
logger.debug('[TWENTY_CRM_TASKS] Transformed tasks', {
|
||||
count: transformedTasks.length,
|
||||
});
|
||||
|
||||
return NextResponse.json(transformedTasks);
|
||||
} catch (error) {
|
||||
logger.error('[TWENTY_CRM_TASKS] Error in tasks route', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch tasks" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -100,28 +100,57 @@ export function Duties() {
|
||||
setRefreshing(true);
|
||||
setError(null);
|
||||
try {
|
||||
// Use cache by default, only bypass with ?refresh=true for manual refresh
|
||||
const url = forceRefresh ? '/api/leantime/tasks?refresh=true' : '/api/leantime/tasks';
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch tasks');
|
||||
// Fetch tasks from both Leantime and Twenty CRM in parallel
|
||||
const leantimeUrl = forceRefresh ? '/api/leantime/tasks?refresh=true' : '/api/leantime/tasks';
|
||||
const twentyCrmUrl = forceRefresh ? '/api/twenty-crm/tasks?refresh=true' : '/api/twenty-crm/tasks';
|
||||
|
||||
const [leantimeResponse, twentyCrmResponse] = await Promise.allSettled([
|
||||
fetch(leantimeUrl),
|
||||
fetch(twentyCrmUrl),
|
||||
]);
|
||||
|
||||
// Process Leantime tasks
|
||||
let leantimeTasks: Task[] = [];
|
||||
if (leantimeResponse.status === 'fulfilled' && leantimeResponse.value.ok) {
|
||||
const leantimeData = await leantimeResponse.value.json();
|
||||
if (Array.isArray(leantimeData)) {
|
||||
leantimeTasks = leantimeData;
|
||||
}
|
||||
} else {
|
||||
console.warn('Failed to fetch Leantime tasks:', leantimeResponse);
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
// Process Twenty CRM tasks
|
||||
let twentyCrmTasks: Task[] = [];
|
||||
if (twentyCrmResponse.status === 'fulfilled' && twentyCrmResponse.value.ok) {
|
||||
const twentyCrmData = await twentyCrmResponse.value.json();
|
||||
if (Array.isArray(twentyCrmData)) {
|
||||
twentyCrmTasks = twentyCrmData;
|
||||
}
|
||||
} else {
|
||||
console.warn('Failed to fetch Twenty CRM tasks:', twentyCrmResponse);
|
||||
}
|
||||
|
||||
// Combine tasks from both sources
|
||||
const allTasks = [...leantimeTasks, ...twentyCrmTasks];
|
||||
|
||||
console.log('Raw API response:', data);
|
||||
|
||||
if (!Array.isArray(data)) {
|
||||
console.warn('No tasks found in response', data as unknown);
|
||||
console.log('Combined tasks:', {
|
||||
leantime: leantimeTasks.length,
|
||||
twentyCrm: twentyCrmTasks.length,
|
||||
total: allTasks.length,
|
||||
});
|
||||
|
||||
if (allTasks.length === 0) {
|
||||
setTasks([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Backend already filters out status=5 (Done) and filters by editorId
|
||||
// Backend already filters out status=5 (Done) and filters by editorId for Leantime
|
||||
// Filter to keep only tasks with due date before today (past due)
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0); // Set to start of today for accurate comparison
|
||||
|
||||
const filteredTasks = data.filter((task: Task) => {
|
||||
const filteredTasks = allTasks.filter((task: Task) => {
|
||||
const dueDate = getValidDate(task);
|
||||
if (!dueDate) {
|
||||
return false; // Exclude tasks without a due date
|
||||
@ -162,7 +191,8 @@ export function Duties() {
|
||||
id: t.id,
|
||||
date: t.dateToFinish,
|
||||
status: t.status,
|
||||
type: t.type || 'main'
|
||||
type: t.type || 'main',
|
||||
source: (t as any).source || 'leantime'
|
||||
})));
|
||||
setTasks(sortedTasks.slice(0, 7));
|
||||
} catch (error) {
|
||||
@ -288,7 +318,7 @@ export function Duties() {
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<a
|
||||
href={`https://agilite.slm-lab.net/tickets/showTicket/${task.id}`}
|
||||
href={(task as any).url || `https://agilite.slm-lab.net/tickets/showTicket/${task.id.replace('twenty-', '')}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:text-blue-700 font-medium block text-sm line-clamp-2"
|
||||
@ -298,6 +328,9 @@ export function Duties() {
|
||||
<div className="flex items-center text-gray-500 text-[10px] bg-gray-50 px-1.5 py-0.5 rounded-md">
|
||||
<Folder className="h-2.5 w-2.5 mr-1 opacity-70" />
|
||||
<span className="truncate">{task.projectName}</span>
|
||||
{(task as any).source === 'twenty-crm' && (
|
||||
<span className="ml-1 text-[9px] text-purple-600 font-medium">(Twenty CRM)</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user