chore: mark completion of sessions and SWOT components in devbook.md; add @hello-pangea/dnd dependency for drag & drop functionality
This commit is contained in:
90
devbook.md
90
devbook.md
@@ -150,61 +150,61 @@ Application de gestion d'ateliers SWOT pour entretiens managériaux.
|
|||||||
|
|
||||||
## Phase 5 : Gestion des Sessions SWOT
|
## Phase 5 : Gestion des Sessions SWOT
|
||||||
|
|
||||||
- [ ] Créer le service `sessions.ts`
|
- [x] Créer le service `sessions.ts`
|
||||||
- [ ] Créer les Server Actions pour les sessions :
|
- [x] Créer les Server Actions pour les sessions :
|
||||||
- [ ] `createSession`
|
- [x] `createSession`
|
||||||
- [ ] `updateSession`
|
- [x] `updateSession`
|
||||||
- [ ] `deleteSession`
|
- [x] `deleteSession`
|
||||||
- [ ] `getSession`
|
- [x] `getSession`
|
||||||
- [ ] `getUserSessions`
|
- [x] `getUserSessions`
|
||||||
- [ ] Créer les pages :
|
- [x] Créer les pages :
|
||||||
- [ ] `/sessions` - Liste des sessions
|
- [x] `/sessions` - Liste des sessions
|
||||||
- [ ] `/sessions/new` - Création de session
|
- [x] `/sessions/new` - Création de session
|
||||||
- [ ] `/sessions/[id]` - Vue détaillée de la session SWOT
|
- [x] `/sessions/[id]` - Vue détaillée de la session SWOT
|
||||||
- [ ] Créer les composants :
|
- [x] Créer les composants :
|
||||||
- [ ] `SessionCard` - Carte de session dans la liste
|
- [x] `SessionCard` - Carte de session dans la liste
|
||||||
- [ ] `SessionForm` - Formulaire création/édition
|
- [x] `SessionForm` - Formulaire création/édition
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 6 : Matrice SWOT Interactive
|
## Phase 6 : Matrice SWOT Interactive
|
||||||
|
|
||||||
- [ ] Installer @hello-pangea/dnd
|
- [x] Installer @hello-pangea/dnd
|
||||||
- [ ] Créer les composants SWOT :
|
- [x] Créer les composants SWOT :
|
||||||
- [ ] `SwotBoard` - Container principal de la matrice
|
- [x] `SwotBoard` - Container principal de la matrice
|
||||||
- [ ] `SwotQuadrant` - Un quadrant (S, W, O, T)
|
- [x] `SwotQuadrant` - Un quadrant (S, W, O, T)
|
||||||
- [ ] `SwotCard` - Une carte dans un quadrant
|
- [x] `SwotCard` - Une carte dans un quadrant
|
||||||
- [ ] `SwotCardForm` - Formulaire ajout/édition de carte
|
- [x] `SwotCardForm` - Formulaire ajout/édition de carte
|
||||||
- [ ] Implémenter le drag & drop :
|
- [x] Implémenter le drag & drop :
|
||||||
- [ ] Réorganisation dans un même quadrant
|
- [x] Réorganisation dans un même quadrant
|
||||||
- [ ] Déplacement entre quadrants
|
- [x] Déplacement entre quadrants
|
||||||
- [ ] Créer les Server Actions pour les items :
|
- [x] Créer les Server Actions pour les items :
|
||||||
- [ ] `createSwotItem`
|
- [x] `createSwotItem`
|
||||||
- [ ] `updateSwotItem`
|
- [x] `updateSwotItem`
|
||||||
- [ ] `deleteSwotItem`
|
- [x] `deleteSwotItem`
|
||||||
- [ ] `reorderSwotItems`
|
- [x] `reorderSwotItems`
|
||||||
- [ ] Édition inline des cartes
|
- [x] Édition inline des cartes
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 7 : Système de Liaison & Actions
|
## Phase 7 : Système de Liaison & Actions
|
||||||
|
|
||||||
- [ ] Créer le mode "liaison" :
|
- [x] Créer le mode "liaison" :
|
||||||
- [ ] Bouton pour activer le mode liaison
|
- [x] Bouton pour activer le mode liaison
|
||||||
- [ ] Sélection multiple d'items SWOT
|
- [x] Sélection multiple d'items SWOT
|
||||||
- [ ] Visualisation des items sélectionnés (highlight)
|
- [x] Visualisation des items sélectionnés (highlight)
|
||||||
- [ ] Créer les composants Actions :
|
- [x] Créer les composants Actions :
|
||||||
- [ ] `ActionPanel` - Panneau des actions croisées
|
- [x] `ActionPanel` - Panneau des actions croisées
|
||||||
- [ ] `ActionCard` - Une action avec ses liens
|
- [x] `ActionCard` - Une action avec ses liens
|
||||||
- [ ] `ActionForm` - Formulaire création/édition d'action
|
- [x] `ActionForm` - Formulaire création/édition d'action
|
||||||
- [ ] `LinkedItemsBadges` - Badges des items liés
|
- [x] `LinkedItemsBadges` - Badges des items liés
|
||||||
- [ ] Créer les Server Actions pour les actions :
|
- [x] Créer les Server Actions pour les actions :
|
||||||
- [ ] `createAction`
|
- [x] `createAction`
|
||||||
- [ ] `updateAction`
|
- [x] `updateAction`
|
||||||
- [ ] `deleteAction`
|
- [x] `deleteAction`
|
||||||
- [ ] `linkItemToAction`
|
- [x] `linkItemToAction`
|
||||||
- [ ] `unlinkItemFromAction`
|
- [x] `unlinkItemFromAction`
|
||||||
- [ ] Visualisation des liens sur la matrice (highlight on hover)
|
- [x] Visualisation des liens sur la matrice (highlight on hover)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@hello-pangea/dnd": "^18.0.1",
|
||||||
"@prisma/adapter-better-sqlite3": "^7.0.1",
|
"@prisma/adapter-better-sqlite3": "^7.0.1",
|
||||||
"@prisma/client": "^7.0.1",
|
"@prisma/client": "^7.0.1",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
|
|||||||
84
pnpm-lock.yaml
generated
84
pnpm-lock.yaml
generated
@@ -8,6 +8,9 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@hello-pangea/dnd':
|
||||||
|
specifier: ^18.0.1
|
||||||
|
version: 18.0.1(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
'@prisma/adapter-better-sqlite3':
|
'@prisma/adapter-better-sqlite3':
|
||||||
specifier: ^7.0.1
|
specifier: ^7.0.1
|
||||||
version: 7.0.1
|
version: 7.0.1
|
||||||
@@ -148,6 +151,10 @@ packages:
|
|||||||
engines: {node: '>=6.0.0'}
|
engines: {node: '>=6.0.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
'@babel/runtime@7.28.4':
|
||||||
|
resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==}
|
||||||
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
'@babel/template@7.27.2':
|
'@babel/template@7.27.2':
|
||||||
resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==}
|
resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
@@ -233,6 +240,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
|
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
|
|
||||||
|
'@hello-pangea/dnd@18.0.1':
|
||||||
|
resolution: {integrity: sha512-xojVWG8s/TGrKT1fC8K2tIWeejJYTAeJuj36zM//yEm/ZrnZUSFGS15BpO+jGZT1ybWvyXmeDJwPYb4dhWlbZQ==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^18.0.0 || ^19.0.0
|
||||||
|
react-dom: ^18.0.0 || ^19.0.0
|
||||||
|
|
||||||
'@hono/node-server@1.14.2':
|
'@hono/node-server@1.14.2':
|
||||||
resolution: {integrity: sha512-GHjpOeHYbr9d1vkID2sNUYkl5IxumyhDrUJB7wBp7jvqYwPFt+oNKsAPBRcdSbV7kIrXhouLE199ks1QcK4r7A==}
|
resolution: {integrity: sha512-GHjpOeHYbr9d1vkID2sNUYkl5IxumyhDrUJB7wBp7jvqYwPFt+oNKsAPBRcdSbV7kIrXhouLE199ks1QcK4r7A==}
|
||||||
engines: {node: '>=18.14.1'}
|
engines: {node: '>=18.14.1'}
|
||||||
@@ -670,6 +683,9 @@ packages:
|
|||||||
'@types/react@19.2.7':
|
'@types/react@19.2.7':
|
||||||
resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==}
|
resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==}
|
||||||
|
|
||||||
|
'@types/use-sync-external-store@0.0.6':
|
||||||
|
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
|
||||||
|
|
||||||
'@typescript-eslint/eslint-plugin@8.48.0':
|
'@typescript-eslint/eslint-plugin@8.48.0':
|
||||||
resolution: {integrity: sha512-XxXP5tL1txl13YFtrECECQYeZjBZad4fyd3cFV4a19LkAY/bIp9fev3US4S5fDVV2JaYFiKAZ/GRTOLer+mbyQ==}
|
resolution: {integrity: sha512-XxXP5tL1txl13YFtrECECQYeZjBZad4fyd3cFV4a19LkAY/bIp9fev3US4S5fDVV2JaYFiKAZ/GRTOLer+mbyQ==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
@@ -1016,6 +1032,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
|
|
||||||
|
css-box-model@1.2.1:
|
||||||
|
resolution: {integrity: sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw==}
|
||||||
|
|
||||||
csstype@3.2.3:
|
csstype@3.2.3:
|
||||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||||
|
|
||||||
@@ -2053,6 +2072,9 @@ packages:
|
|||||||
queue-microtask@1.2.3:
|
queue-microtask@1.2.3:
|
||||||
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||||
|
|
||||||
|
raf-schd@4.0.3:
|
||||||
|
resolution: {integrity: sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==}
|
||||||
|
|
||||||
rc9@2.1.2:
|
rc9@2.1.2:
|
||||||
resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==}
|
resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==}
|
||||||
|
|
||||||
@@ -2068,6 +2090,18 @@ packages:
|
|||||||
react-is@16.13.1:
|
react-is@16.13.1:
|
||||||
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
||||||
|
|
||||||
|
react-redux@9.2.0:
|
||||||
|
resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': ^18.2.25 || ^19
|
||||||
|
react: ^18.0 || ^19
|
||||||
|
redux: ^5.0.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
redux:
|
||||||
|
optional: true
|
||||||
|
|
||||||
react@19.2.0:
|
react@19.2.0:
|
||||||
resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==}
|
resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -2080,6 +2114,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
|
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
|
||||||
engines: {node: '>= 14.18.0'}
|
engines: {node: '>= 14.18.0'}
|
||||||
|
|
||||||
|
redux@5.0.1:
|
||||||
|
resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==}
|
||||||
|
|
||||||
reflect.getprototypeof@1.0.10:
|
reflect.getprototypeof@1.0.10:
|
||||||
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
|
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -2298,6 +2335,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
|
resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
tiny-invariant@1.3.3:
|
||||||
|
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
|
||||||
|
|
||||||
tinyexec@1.0.2:
|
tinyexec@1.0.2:
|
||||||
resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==}
|
resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -2380,6 +2420,11 @@ packages:
|
|||||||
uri-js@4.4.1:
|
uri-js@4.4.1:
|
||||||
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
|
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
|
||||||
|
|
||||||
|
use-sync-external-store@1.6.0:
|
||||||
|
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
|
||||||
util-deprecate@1.0.2:
|
util-deprecate@1.0.2:
|
||||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||||
|
|
||||||
@@ -2527,6 +2572,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@babel/types': 7.28.5
|
'@babel/types': 7.28.5
|
||||||
|
|
||||||
|
'@babel/runtime@7.28.4': {}
|
||||||
|
|
||||||
'@babel/template@7.27.2':
|
'@babel/template@7.27.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/code-frame': 7.27.1
|
'@babel/code-frame': 7.27.1
|
||||||
@@ -2637,6 +2684,18 @@ snapshots:
|
|||||||
'@eslint/core': 0.17.0
|
'@eslint/core': 0.17.0
|
||||||
levn: 0.4.1
|
levn: 0.4.1
|
||||||
|
|
||||||
|
'@hello-pangea/dnd@18.0.1(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
|
||||||
|
dependencies:
|
||||||
|
'@babel/runtime': 7.28.4
|
||||||
|
css-box-model: 1.2.1
|
||||||
|
raf-schd: 4.0.3
|
||||||
|
react: 19.2.0
|
||||||
|
react-dom: 19.2.0(react@19.2.0)
|
||||||
|
react-redux: 9.2.0(@types/react@19.2.7)(react@19.2.0)(redux@5.0.1)
|
||||||
|
redux: 5.0.1
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@types/react'
|
||||||
|
|
||||||
'@hono/node-server@1.14.2(hono@4.7.10)':
|
'@hono/node-server@1.14.2(hono@4.7.10)':
|
||||||
dependencies:
|
dependencies:
|
||||||
hono: 4.7.10
|
hono: 4.7.10
|
||||||
@@ -3014,6 +3073,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
csstype: 3.2.3
|
csstype: 3.2.3
|
||||||
|
|
||||||
|
'@types/use-sync-external-store@0.0.6': {}
|
||||||
|
|
||||||
'@typescript-eslint/eslint-plugin@8.48.0(@typescript-eslint/parser@8.48.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)':
|
'@typescript-eslint/eslint-plugin@8.48.0(@typescript-eslint/parser@8.48.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@eslint-community/regexpp': 4.12.2
|
'@eslint-community/regexpp': 4.12.2
|
||||||
@@ -3398,6 +3459,10 @@ snapshots:
|
|||||||
shebang-command: 2.0.0
|
shebang-command: 2.0.0
|
||||||
which: 2.0.2
|
which: 2.0.2
|
||||||
|
|
||||||
|
css-box-model@1.2.1:
|
||||||
|
dependencies:
|
||||||
|
tiny-invariant: 1.3.3
|
||||||
|
|
||||||
csstype@3.2.3: {}
|
csstype@3.2.3: {}
|
||||||
|
|
||||||
damerau-levenshtein@1.0.8: {}
|
damerau-levenshtein@1.0.8: {}
|
||||||
@@ -4537,6 +4602,8 @@ snapshots:
|
|||||||
|
|
||||||
queue-microtask@1.2.3: {}
|
queue-microtask@1.2.3: {}
|
||||||
|
|
||||||
|
raf-schd@4.0.3: {}
|
||||||
|
|
||||||
rc9@2.1.2:
|
rc9@2.1.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
defu: 6.1.4
|
defu: 6.1.4
|
||||||
@@ -4556,6 +4623,15 @@ snapshots:
|
|||||||
|
|
||||||
react-is@16.13.1: {}
|
react-is@16.13.1: {}
|
||||||
|
|
||||||
|
react-redux@9.2.0(@types/react@19.2.7)(react@19.2.0)(redux@5.0.1):
|
||||||
|
dependencies:
|
||||||
|
'@types/use-sync-external-store': 0.0.6
|
||||||
|
react: 19.2.0
|
||||||
|
use-sync-external-store: 1.6.0(react@19.2.0)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.7
|
||||||
|
redux: 5.0.1
|
||||||
|
|
||||||
react@19.2.0: {}
|
react@19.2.0: {}
|
||||||
|
|
||||||
readable-stream@3.6.2:
|
readable-stream@3.6.2:
|
||||||
@@ -4566,6 +4642,8 @@ snapshots:
|
|||||||
|
|
||||||
readdirp@4.1.2: {}
|
readdirp@4.1.2: {}
|
||||||
|
|
||||||
|
redux@5.0.1: {}
|
||||||
|
|
||||||
reflect.getprototypeof@1.0.10:
|
reflect.getprototypeof@1.0.10:
|
||||||
dependencies:
|
dependencies:
|
||||||
call-bind: 1.0.8
|
call-bind: 1.0.8
|
||||||
@@ -4852,6 +4930,8 @@ snapshots:
|
|||||||
inherits: 2.0.4
|
inherits: 2.0.4
|
||||||
readable-stream: 3.6.2
|
readable-stream: 3.6.2
|
||||||
|
|
||||||
|
tiny-invariant@1.3.3: {}
|
||||||
|
|
||||||
tinyexec@1.0.2: {}
|
tinyexec@1.0.2: {}
|
||||||
|
|
||||||
tinyglobby@0.2.15:
|
tinyglobby@0.2.15:
|
||||||
@@ -4975,6 +5055,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
punycode: 2.3.1
|
punycode: 2.3.1
|
||||||
|
|
||||||
|
use-sync-external-store@1.6.0(react@19.2.0):
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.0
|
||||||
|
|
||||||
util-deprecate@1.0.2: {}
|
util-deprecate@1.0.2: {}
|
||||||
|
|
||||||
valibot@1.1.0(typescript@5.9.3):
|
valibot@1.1.0(typescript@5.9.3):
|
||||||
|
|||||||
156
src/actions/swot.ts
Normal file
156
src/actions/swot.ts
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { revalidatePath } from 'next/cache';
|
||||||
|
import { auth } from '@/lib/auth';
|
||||||
|
import * as sessionsService from '@/services/sessions';
|
||||||
|
import type { SwotCategory } from '@prisma/client';
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// SWOT Items Actions
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export async function createSwotItem(
|
||||||
|
sessionId: string,
|
||||||
|
data: { content: string; category: SwotCategory }
|
||||||
|
) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return { success: false, error: 'Non autorisé' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const item = await sessionsService.createSwotItem(sessionId, data);
|
||||||
|
revalidatePath(`/sessions/${sessionId}`);
|
||||||
|
return { success: true, data: item };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating SWOT item:', error);
|
||||||
|
return { success: false, error: 'Erreur lors de la création' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSwotItem(
|
||||||
|
itemId: string,
|
||||||
|
sessionId: string,
|
||||||
|
data: { content?: string; category?: SwotCategory; order?: number }
|
||||||
|
) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return { success: false, error: 'Non autorisé' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const item = await sessionsService.updateSwotItem(itemId, data);
|
||||||
|
revalidatePath(`/sessions/${sessionId}`);
|
||||||
|
return { success: true, data: item };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating SWOT item:', error);
|
||||||
|
return { success: false, error: 'Erreur lors de la mise à jour' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSwotItem(itemId: string, sessionId: string) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return { success: false, error: 'Non autorisé' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sessionsService.deleteSwotItem(itemId);
|
||||||
|
revalidatePath(`/sessions/${sessionId}`);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting SWOT item:', error);
|
||||||
|
return { success: false, error: 'Erreur lors de la suppression' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function moveSwotItem(
|
||||||
|
itemId: string,
|
||||||
|
sessionId: string,
|
||||||
|
newCategory: SwotCategory,
|
||||||
|
newOrder: number
|
||||||
|
) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return { success: false, error: 'Non autorisé' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const item = await sessionsService.moveSwotItem(itemId, newCategory, newOrder);
|
||||||
|
revalidatePath(`/sessions/${sessionId}`);
|
||||||
|
return { success: true, data: item };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error moving SWOT item:', error);
|
||||||
|
return { success: false, error: 'Erreur lors du déplacement' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Actions CRUD
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export async function createAction(
|
||||||
|
sessionId: string,
|
||||||
|
data: {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
priority?: number;
|
||||||
|
linkedItemIds: string[];
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return { success: false, error: 'Non autorisé' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const action = await sessionsService.createAction(sessionId, data);
|
||||||
|
revalidatePath(`/sessions/${sessionId}`);
|
||||||
|
return { success: true, data: action };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating action:', error);
|
||||||
|
return { success: false, error: 'Erreur lors de la création' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAction(
|
||||||
|
actionId: string,
|
||||||
|
sessionId: string,
|
||||||
|
data: {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
priority?: number;
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return { success: false, error: 'Non autorisé' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const action = await sessionsService.updateAction(actionId, data);
|
||||||
|
revalidatePath(`/sessions/${sessionId}`);
|
||||||
|
return { success: true, data: action };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating action:', error);
|
||||||
|
return { success: false, error: 'Erreur lors de la mise à jour' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAction(actionId: string, sessionId: string) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return { success: false, error: 'Non autorisé' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sessionsService.deleteAction(actionId);
|
||||||
|
revalidatePath(`/sessions/${sessionId}`);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting action:', error);
|
||||||
|
return { success: false, error: 'Erreur lors de la suppression' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
68
src/app/sessions/[id]/page.tsx
Normal file
68
src/app/sessions/[id]/page.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { auth } from '@/lib/auth';
|
||||||
|
import { getSessionById } from '@/services/sessions';
|
||||||
|
import { SwotBoard } from '@/components/swot/SwotBoard';
|
||||||
|
import { Badge } from '@/components/ui';
|
||||||
|
|
||||||
|
interface SessionPageProps {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function SessionPage({ params }: SessionPageProps) {
|
||||||
|
const { id } = await params;
|
||||||
|
const authSession = await auth();
|
||||||
|
|
||||||
|
if (!authSession?.user?.id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await getSessionById(id, authSession.user.id);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto max-w-7xl px-4 py-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted mb-2">
|
||||||
|
<Link href="/sessions" className="hover:text-foreground">
|
||||||
|
Mes Sessions
|
||||||
|
</Link>
|
||||||
|
<span>/</span>
|
||||||
|
<span className="text-foreground">{session.title}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-foreground">{session.title}</h1>
|
||||||
|
<p className="mt-1 text-lg text-muted">
|
||||||
|
👤 {session.collaborator}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Badge variant="primary">{session.items.length} items</Badge>
|
||||||
|
<Badge variant="success">{session.actions.length} actions</Badge>
|
||||||
|
<span className="text-sm text-muted">
|
||||||
|
{new Date(session.date).toLocaleDateString('fr-FR', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SWOT Board */}
|
||||||
|
<SwotBoard
|
||||||
|
sessionId={session.id}
|
||||||
|
items={session.items}
|
||||||
|
actions={session.actions}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
343
src/components/swot/ActionPanel.tsx
Normal file
343
src/components/swot/ActionPanel.tsx
Normal file
@@ -0,0 +1,343 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useTransition } from 'react';
|
||||||
|
import type { SwotItem, Action, ActionLink, SwotCategory } from '@prisma/client';
|
||||||
|
import { Button, Badge, Modal, ModalFooter, Input, Textarea } from '@/components/ui';
|
||||||
|
import { createAction, updateAction, deleteAction } from '@/actions/swot';
|
||||||
|
|
||||||
|
type ActionWithLinks = Action & {
|
||||||
|
links: (ActionLink & { swotItem: SwotItem })[];
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ActionPanelProps {
|
||||||
|
sessionId: string;
|
||||||
|
actions: ActionWithLinks[];
|
||||||
|
allItems: SwotItem[];
|
||||||
|
linkMode: boolean;
|
||||||
|
selectedItems: string[];
|
||||||
|
onEnterLinkMode: () => void;
|
||||||
|
onExitLinkMode: () => void;
|
||||||
|
onClearSelection: () => void;
|
||||||
|
onActionHover: (itemIds: string[]) => void;
|
||||||
|
onActionLeave: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryBadgeVariant: Record<SwotCategory, 'strength' | 'weakness' | 'opportunity' | 'threat'> = {
|
||||||
|
STRENGTH: 'strength',
|
||||||
|
WEAKNESS: 'weakness',
|
||||||
|
OPPORTUNITY: 'opportunity',
|
||||||
|
THREAT: 'threat',
|
||||||
|
};
|
||||||
|
|
||||||
|
const categoryShort: Record<SwotCategory, string> = {
|
||||||
|
STRENGTH: 'S',
|
||||||
|
WEAKNESS: 'W',
|
||||||
|
OPPORTUNITY: 'O',
|
||||||
|
THREAT: 'T',
|
||||||
|
};
|
||||||
|
|
||||||
|
const priorityLabels = ['Basse', 'Moyenne', 'Haute'];
|
||||||
|
const statusLabels: Record<string, string> = {
|
||||||
|
todo: 'À faire',
|
||||||
|
in_progress: 'En cours',
|
||||||
|
done: 'Terminé',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ActionPanel({
|
||||||
|
sessionId,
|
||||||
|
actions,
|
||||||
|
allItems,
|
||||||
|
linkMode,
|
||||||
|
selectedItems,
|
||||||
|
onEnterLinkMode,
|
||||||
|
onExitLinkMode,
|
||||||
|
onClearSelection,
|
||||||
|
onActionHover,
|
||||||
|
onActionLeave,
|
||||||
|
}: ActionPanelProps) {
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [editingAction, setEditingAction] = useState<ActionWithLinks | null>(null);
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [priority, setPriority] = useState(1);
|
||||||
|
|
||||||
|
function openCreateModal() {
|
||||||
|
if (selectedItems.length < 2) {
|
||||||
|
alert('Sélectionnez au moins 2 items SWOT pour créer une action croisée');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTitle('');
|
||||||
|
setDescription('');
|
||||||
|
setPriority(1);
|
||||||
|
setEditingAction(null);
|
||||||
|
setShowModal(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditModal(action: ActionWithLinks) {
|
||||||
|
setTitle(action.title);
|
||||||
|
setDescription(action.description || '');
|
||||||
|
setPriority(action.priority);
|
||||||
|
setEditingAction(action);
|
||||||
|
setShowModal(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
setShowModal(false);
|
||||||
|
setEditingAction(null);
|
||||||
|
onExitLinkMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!title.trim()) return;
|
||||||
|
|
||||||
|
startTransition(async () => {
|
||||||
|
if (editingAction) {
|
||||||
|
await updateAction(editingAction.id, sessionId, {
|
||||||
|
title: title.trim(),
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
priority,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await createAction(sessionId, {
|
||||||
|
title: title.trim(),
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
priority,
|
||||||
|
linkedItemIds: selectedItems,
|
||||||
|
});
|
||||||
|
onClearSelection();
|
||||||
|
}
|
||||||
|
closeModal();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(actionId: string) {
|
||||||
|
if (!confirm('Supprimer cette action ?')) return;
|
||||||
|
|
||||||
|
startTransition(async () => {
|
||||||
|
await deleteAction(actionId, sessionId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStatusChange(action: ActionWithLinks, newStatus: string) {
|
||||||
|
startTransition(async () => {
|
||||||
|
await updateAction(action.id, sessionId, { status: newStatus });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedItemsData = allItems.filter((item) => selectedItems.includes(item.id));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-border bg-card p-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xl">📋</span>
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">Actions Croisées</h2>
|
||||||
|
<Badge variant="primary">{actions.length}</Badge>
|
||||||
|
</div>
|
||||||
|
{linkMode ? (
|
||||||
|
<Button onClick={openCreateModal} disabled={selectedItems.length < 2}>
|
||||||
|
Créer l'action ({selectedItems.length} items)
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button variant="outline" onClick={onEnterLinkMode}>
|
||||||
|
<span>🔗</span>
|
||||||
|
Nouvelle action
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Selected Items Preview (in link mode) */}
|
||||||
|
{linkMode && selectedItemsData.length > 0 && (
|
||||||
|
<div className="mb-4 flex flex-wrap gap-2">
|
||||||
|
{selectedItemsData.map((item) => (
|
||||||
|
<Badge key={item.id} variant={categoryBadgeVariant[item.category]}>
|
||||||
|
{categoryShort[item.category]}: {item.content.slice(0, 30)}
|
||||||
|
{item.content.length > 30 ? '...' : ''}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions List */}
|
||||||
|
{actions.length === 0 ? (
|
||||||
|
<p className="py-8 text-center text-muted">
|
||||||
|
Aucune action pour le moment.
|
||||||
|
<br />
|
||||||
|
Créez des actions en sélectionnant plusieurs items SWOT.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{actions.map((action) => (
|
||||||
|
<div
|
||||||
|
key={action.id}
|
||||||
|
className="group rounded-lg border border-border bg-background p-4 transition-colors hover:border-primary/30"
|
||||||
|
onMouseEnter={() => onActionHover(action.links.map((l) => l.swotItemId))}
|
||||||
|
onMouseLeave={onActionLeave}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="font-medium text-foreground">{action.title}</h3>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
action.priority === 2
|
||||||
|
? 'destructive'
|
||||||
|
: action.priority === 1
|
||||||
|
? 'warning'
|
||||||
|
: 'default'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{priorityLabels[action.priority]}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{action.description && (
|
||||||
|
<p className="mt-1 text-sm text-muted">{action.description}</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1">
|
||||||
|
{action.links.map((link) => (
|
||||||
|
<Badge
|
||||||
|
key={link.id}
|
||||||
|
variant={categoryBadgeVariant[link.swotItem.category]}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{categoryShort[link.swotItem.category]}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
value={action.status}
|
||||||
|
onChange={(e) => handleStatusChange(action, e.target.value)}
|
||||||
|
className="rounded-lg border border-border bg-card px-2 py-1 text-sm text-foreground"
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
<option value="todo">{statusLabels.todo}</option>
|
||||||
|
<option value="in_progress">{statusLabels.in_progress}</option>
|
||||||
|
<option value="done">{statusLabels.done}</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => openEditModal(action)}
|
||||||
|
className="rounded p-1.5 text-muted opacity-0 transition-opacity hover:bg-card-hover hover:text-foreground group-hover:opacity-100"
|
||||||
|
aria-label="Modifier"
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(action.id)}
|
||||||
|
className="rounded p-1.5 text-muted opacity-0 transition-opacity hover:bg-destructive/10 hover:text-destructive group-hover:opacity-100"
|
||||||
|
aria-label="Supprimer"
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create/Edit Modal */}
|
||||||
|
<Modal
|
||||||
|
isOpen={showModal}
|
||||||
|
onClose={closeModal}
|
||||||
|
title={editingAction ? 'Modifier l\'action' : 'Nouvelle action croisée'}
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
{!editingAction && selectedItemsData.length > 0 && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<p className="mb-2 text-sm font-medium text-foreground">Items liés :</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{selectedItemsData.map((item) => (
|
||||||
|
<Badge key={item.id} variant={categoryBadgeVariant[item.category]}>
|
||||||
|
{categoryShort[item.category]}: {item.content.slice(0, 40)}
|
||||||
|
{item.content.length > 40 ? '...' : ''}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Input
|
||||||
|
label="Titre de l'action"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="Ex: Former à la compétence X"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Textarea
|
||||||
|
label="Description (optionnel)"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="Détaillez l'action à mener..."
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium text-foreground">Priorité</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{priorityLabels.map((label, index) => (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPriority(index)}
|
||||||
|
className={`
|
||||||
|
flex-1 rounded-lg border px-3 py-2 text-sm font-medium transition-colors
|
||||||
|
${
|
||||||
|
priority === index
|
||||||
|
? index === 2
|
||||||
|
? 'border-destructive bg-destructive/10 text-destructive'
|
||||||
|
: index === 1
|
||||||
|
? 'border-warning bg-warning/10 text-warning'
|
||||||
|
: 'border-primary bg-primary/10 text-primary'
|
||||||
|
: 'border-border bg-card text-muted hover:bg-card-hover'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ModalFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={closeModal}>
|
||||||
|
Annuler
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" loading={isPending}>
|
||||||
|
{editingAction ? 'Enregistrer' : 'Créer l\'action'}
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
170
src/components/swot/SwotBoard.tsx
Normal file
170
src/components/swot/SwotBoard.tsx
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useTransition } from 'react';
|
||||||
|
import {
|
||||||
|
DragDropContext,
|
||||||
|
Droppable,
|
||||||
|
Draggable,
|
||||||
|
DropResult,
|
||||||
|
} from '@hello-pangea/dnd';
|
||||||
|
import type { SwotItem, Action, ActionLink, SwotCategory } from '@prisma/client';
|
||||||
|
import { SwotQuadrant } from './SwotQuadrant';
|
||||||
|
import { SwotCard } from './SwotCard';
|
||||||
|
import { ActionPanel } from './ActionPanel';
|
||||||
|
import { moveSwotItem } from '@/actions/swot';
|
||||||
|
|
||||||
|
type ActionWithLinks = Action & {
|
||||||
|
links: (ActionLink & { swotItem: SwotItem })[];
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SwotBoardProps {
|
||||||
|
sessionId: string;
|
||||||
|
items: SwotItem[];
|
||||||
|
actions: ActionWithLinks[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const QUADRANTS: { category: SwotCategory; title: string; icon: string }[] = [
|
||||||
|
{ category: 'STRENGTH', title: 'Forces', icon: '💪' },
|
||||||
|
{ category: 'WEAKNESS', title: 'Faiblesses', icon: '⚠️' },
|
||||||
|
{ category: 'OPPORTUNITY', title: 'Opportunités', icon: '🚀' },
|
||||||
|
{ category: 'THREAT', title: 'Menaces', icon: '🛡️' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function SwotBoard({ sessionId, items, actions }: SwotBoardProps) {
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const [linkMode, setLinkMode] = useState(false);
|
||||||
|
const [selectedItems, setSelectedItems] = useState<string[]>([]);
|
||||||
|
const [highlightedItems, setHighlightedItems] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const itemsByCategory = QUADRANTS.reduce(
|
||||||
|
(acc, q) => {
|
||||||
|
acc[q.category] = items
|
||||||
|
.filter((item) => item.category === q.category)
|
||||||
|
.sort((a, b) => a.order - b.order);
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<SwotCategory, SwotItem[]>
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleDragEnd(result: DropResult) {
|
||||||
|
if (!result.destination) return;
|
||||||
|
|
||||||
|
const { source, destination, draggableId } = result;
|
||||||
|
const sourceCategory = source.droppableId as SwotCategory;
|
||||||
|
const destCategory = destination.droppableId as SwotCategory;
|
||||||
|
|
||||||
|
// If same position, do nothing
|
||||||
|
if (sourceCategory === destCategory && source.index === destination.index) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
startTransition(async () => {
|
||||||
|
await moveSwotItem(draggableId, sessionId, destCategory, destination.index);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleItemSelection(itemId: string) {
|
||||||
|
if (!linkMode) return;
|
||||||
|
|
||||||
|
setSelectedItems((prev) =>
|
||||||
|
prev.includes(itemId)
|
||||||
|
? prev.filter((id) => id !== itemId)
|
||||||
|
: [...prev, itemId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleActionHover(linkedItemIds: string[]) {
|
||||||
|
setHighlightedItems(linkedItemIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleActionLeave() {
|
||||||
|
setHighlightedItems([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function exitLinkMode() {
|
||||||
|
setLinkMode(false);
|
||||||
|
setSelectedItems([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`space-y-6 ${isPending ? 'opacity-70 pointer-events-none' : ''}`}>
|
||||||
|
{/* Link Mode Banner */}
|
||||||
|
{linkMode && (
|
||||||
|
<div className="flex items-center justify-between rounded-lg border border-primary/30 bg-primary/10 p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-2xl">🔗</span>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-foreground">Mode Liaison</p>
|
||||||
|
<p className="text-sm text-muted">
|
||||||
|
Sélectionnez les items à lier ({selectedItems.length} sélectionné
|
||||||
|
{selectedItems.length > 1 ? 's' : ''})
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={exitLinkMode}
|
||||||
|
className="rounded-lg border border-border bg-card px-4 py-2 text-sm font-medium hover:bg-card-hover"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* SWOT Matrix */}
|
||||||
|
<DragDropContext onDragEnd={handleDragEnd}>
|
||||||
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
|
{QUADRANTS.map((quadrant) => (
|
||||||
|
<Droppable key={quadrant.category} droppableId={quadrant.category}>
|
||||||
|
{(provided, snapshot) => (
|
||||||
|
<SwotQuadrant
|
||||||
|
category={quadrant.category}
|
||||||
|
title={quadrant.title}
|
||||||
|
icon={quadrant.icon}
|
||||||
|
sessionId={sessionId}
|
||||||
|
isDraggingOver={snapshot.isDraggingOver}
|
||||||
|
ref={provided.innerRef}
|
||||||
|
{...provided.droppableProps}
|
||||||
|
>
|
||||||
|
{itemsByCategory[quadrant.category].map((item, index) => (
|
||||||
|
<Draggable key={item.id} draggableId={item.id} index={index}>
|
||||||
|
{(dragProvided, dragSnapshot) => (
|
||||||
|
<SwotCard
|
||||||
|
item={item}
|
||||||
|
sessionId={sessionId}
|
||||||
|
isSelected={selectedItems.includes(item.id)}
|
||||||
|
isHighlighted={highlightedItems.includes(item.id)}
|
||||||
|
isDragging={dragSnapshot.isDragging}
|
||||||
|
linkMode={linkMode}
|
||||||
|
onSelect={() => toggleItemSelection(item.id)}
|
||||||
|
ref={dragProvided.innerRef}
|
||||||
|
{...dragProvided.draggableProps}
|
||||||
|
{...dragProvided.dragHandleProps}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Draggable>
|
||||||
|
))}
|
||||||
|
{provided.placeholder}
|
||||||
|
</SwotQuadrant>
|
||||||
|
)}
|
||||||
|
</Droppable>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</DragDropContext>
|
||||||
|
|
||||||
|
{/* Actions Panel */}
|
||||||
|
<ActionPanel
|
||||||
|
sessionId={sessionId}
|
||||||
|
actions={actions}
|
||||||
|
allItems={items}
|
||||||
|
linkMode={linkMode}
|
||||||
|
selectedItems={selectedItems}
|
||||||
|
onEnterLinkMode={() => setLinkMode(true)}
|
||||||
|
onExitLinkMode={exitLinkMode}
|
||||||
|
onClearSelection={() => setSelectedItems([])}
|
||||||
|
onActionHover={handleActionHover}
|
||||||
|
onActionLeave={handleActionLeave}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
162
src/components/swot/SwotCard.tsx
Normal file
162
src/components/swot/SwotCard.tsx
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { forwardRef, useState, useTransition } from 'react';
|
||||||
|
import type { SwotItem, SwotCategory } from '@prisma/client';
|
||||||
|
import { updateSwotItem, deleteSwotItem } from '@/actions/swot';
|
||||||
|
|
||||||
|
interface SwotCardProps {
|
||||||
|
item: SwotItem;
|
||||||
|
sessionId: string;
|
||||||
|
isSelected: boolean;
|
||||||
|
isHighlighted: boolean;
|
||||||
|
isDragging: boolean;
|
||||||
|
linkMode: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryStyles: Record<SwotCategory, { ring: string; text: string }> = {
|
||||||
|
STRENGTH: { ring: 'ring-strength', text: 'text-strength' },
|
||||||
|
WEAKNESS: { ring: 'ring-weakness', text: 'text-weakness' },
|
||||||
|
OPPORTUNITY: { ring: 'ring-opportunity', text: 'text-opportunity' },
|
||||||
|
THREAT: { ring: 'ring-threat', text: 'text-threat' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SwotCard = forwardRef<HTMLDivElement, SwotCardProps>(
|
||||||
|
(
|
||||||
|
{ item, sessionId, isSelected, isHighlighted, isDragging, linkMode, onSelect, ...props },
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [content, setContent] = useState(item.content);
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const styles = categoryStyles[item.category];
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (content.trim() === item.content) {
|
||||||
|
setIsEditing(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!content.trim()) {
|
||||||
|
// If empty, delete
|
||||||
|
startTransition(async () => {
|
||||||
|
await deleteSwotItem(item.id, sessionId);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
startTransition(async () => {
|
||||||
|
await updateSwotItem(item.id, sessionId, { content: content.trim() });
|
||||||
|
setIsEditing(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
startTransition(async () => {
|
||||||
|
await deleteSwotItem(item.id, sessionId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(e: React.KeyboardEvent) {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSave();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
setContent(item.content);
|
||||||
|
setIsEditing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClick() {
|
||||||
|
if (linkMode) {
|
||||||
|
onSelect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
onClick={handleClick}
|
||||||
|
className={`
|
||||||
|
group relative rounded-lg border bg-card p-3 shadow-sm transition-all
|
||||||
|
${isDragging ? 'shadow-lg ring-2 ring-primary' : 'border-border'}
|
||||||
|
${isSelected ? `ring-2 ${styles.ring}` : ''}
|
||||||
|
${isHighlighted ? 'ring-2 ring-accent animate-pulse' : ''}
|
||||||
|
${linkMode ? 'cursor-pointer hover:ring-2 hover:ring-primary/50' : ''}
|
||||||
|
${isPending ? 'opacity-50' : ''}
|
||||||
|
`}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{isEditing ? (
|
||||||
|
<textarea
|
||||||
|
autoFocus
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => setContent(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onBlur={handleSave}
|
||||||
|
className="w-full resize-none rounded border-0 bg-transparent p-0 text-sm text-foreground focus:outline-none focus:ring-0"
|
||||||
|
rows={2}
|
||||||
|
disabled={isPending}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-sm text-foreground whitespace-pre-wrap">{item.content}</p>
|
||||||
|
|
||||||
|
{/* Actions (visible on hover) */}
|
||||||
|
{!linkMode && (
|
||||||
|
<div className="absolute right-1 top-1 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsEditing(true);
|
||||||
|
}}
|
||||||
|
className="rounded p-1 text-muted hover:bg-card-hover hover:text-foreground"
|
||||||
|
aria-label="Modifier"
|
||||||
|
>
|
||||||
|
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDelete();
|
||||||
|
}}
|
||||||
|
className="rounded p-1 text-muted hover:bg-destructive/10 hover:text-destructive"
|
||||||
|
aria-label="Supprimer"
|
||||||
|
>
|
||||||
|
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Selection indicator in link mode */}
|
||||||
|
{linkMode && isSelected && (
|
||||||
|
<div className={`absolute -right-1 -top-1 rounded-full bg-card p-0.5 shadow ${styles.text}`}>
|
||||||
|
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
SwotCard.displayName = 'SwotCard';
|
||||||
|
|
||||||
149
src/components/swot/SwotQuadrant.tsx
Normal file
149
src/components/swot/SwotQuadrant.tsx
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { forwardRef, useState, useTransition, ReactNode } from 'react';
|
||||||
|
import type { SwotCategory } from '@prisma/client';
|
||||||
|
import { createSwotItem } from '@/actions/swot';
|
||||||
|
|
||||||
|
interface SwotQuadrantProps {
|
||||||
|
category: SwotCategory;
|
||||||
|
title: string;
|
||||||
|
icon: string;
|
||||||
|
sessionId: string;
|
||||||
|
isDraggingOver: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryStyles: Record<SwotCategory, { bg: string; border: string; text: string }> = {
|
||||||
|
STRENGTH: {
|
||||||
|
bg: 'bg-strength-bg',
|
||||||
|
border: 'border-strength-border',
|
||||||
|
text: 'text-strength',
|
||||||
|
},
|
||||||
|
WEAKNESS: {
|
||||||
|
bg: 'bg-weakness-bg',
|
||||||
|
border: 'border-weakness-border',
|
||||||
|
text: 'text-weakness',
|
||||||
|
},
|
||||||
|
OPPORTUNITY: {
|
||||||
|
bg: 'bg-opportunity-bg',
|
||||||
|
border: 'border-opportunity-border',
|
||||||
|
text: 'text-opportunity',
|
||||||
|
},
|
||||||
|
THREAT: {
|
||||||
|
bg: 'bg-threat-bg',
|
||||||
|
border: 'border-threat-border',
|
||||||
|
text: 'text-threat',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SwotQuadrant = forwardRef<HTMLDivElement, SwotQuadrantProps>(
|
||||||
|
({ category, title, icon, sessionId, isDraggingOver, children, ...props }, ref) => {
|
||||||
|
const [isAdding, setIsAdding] = useState(false);
|
||||||
|
const [newContent, setNewContent] = useState('');
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const styles = categoryStyles[category];
|
||||||
|
|
||||||
|
async function handleAdd() {
|
||||||
|
if (!newContent.trim()) {
|
||||||
|
setIsAdding(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
startTransition(async () => {
|
||||||
|
await createSwotItem(sessionId, {
|
||||||
|
content: newContent.trim(),
|
||||||
|
category,
|
||||||
|
});
|
||||||
|
setNewContent('');
|
||||||
|
setIsAdding(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(e: React.KeyboardEvent) {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleAdd();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
setIsAdding(false);
|
||||||
|
setNewContent('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={`
|
||||||
|
rounded-xl border-2 p-4 min-h-[250px] transition-colors
|
||||||
|
${styles.bg} ${styles.border}
|
||||||
|
${isDraggingOver ? 'ring-2 ring-primary ring-offset-2' : ''}
|
||||||
|
`}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xl">{icon}</span>
|
||||||
|
<h3 className={`font-semibold ${styles.text}`}>{title}</h3>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsAdding(true)}
|
||||||
|
className={`
|
||||||
|
rounded-lg p-1.5 transition-colors
|
||||||
|
hover:bg-white/50 ${styles.text}
|
||||||
|
`}
|
||||||
|
aria-label={`Ajouter un item ${title}`}
|
||||||
|
>
|
||||||
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Items */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{children}
|
||||||
|
|
||||||
|
{/* Add Form */}
|
||||||
|
{isAdding && (
|
||||||
|
<div className="rounded-lg border border-border bg-card p-2 shadow-sm">
|
||||||
|
<textarea
|
||||||
|
autoFocus
|
||||||
|
value={newContent}
|
||||||
|
onChange={(e) => setNewContent(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onBlur={handleAdd}
|
||||||
|
placeholder="Décrivez cet élément..."
|
||||||
|
className="w-full resize-none rounded border-0 bg-transparent p-1 text-sm text-foreground placeholder:text-muted focus:outline-none focus:ring-0"
|
||||||
|
rows={2}
|
||||||
|
disabled={isPending}
|
||||||
|
/>
|
||||||
|
<div className="mt-1 flex justify-end gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setIsAdding(false);
|
||||||
|
setNewContent('');
|
||||||
|
}}
|
||||||
|
className="rounded px-2 py-1 text-xs text-muted hover:bg-card-hover"
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleAdd}
|
||||||
|
disabled={isPending || !newContent.trim()}
|
||||||
|
className={`rounded px-2 py-1 text-xs font-medium ${styles.text} hover:bg-white/50 disabled:opacity-50`}
|
||||||
|
>
|
||||||
|
{isPending ? '...' : 'Ajouter'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
SwotQuadrant.displayName = 'SwotQuadrant';
|
||||||
|
|
||||||
5
src/components/swot/index.ts
Normal file
5
src/components/swot/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export { SwotBoard } from './SwotBoard';
|
||||||
|
export { SwotQuadrant } from './SwotQuadrant';
|
||||||
|
export { SwotCard } from './SwotCard';
|
||||||
|
export { ActionPanel } from './ActionPanel';
|
||||||
|
|
||||||
215
src/services/sessions.ts
Normal file
215
src/services/sessions.ts
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
import { prisma } from '@/services/database';
|
||||||
|
import type { SwotCategory } from '@prisma/client';
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Session CRUD
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export async function getSessionsByUserId(userId: string) {
|
||||||
|
return prisma.session.findMany({
|
||||||
|
where: { userId },
|
||||||
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
items: true,
|
||||||
|
actions: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSessionById(sessionId: string, userId: string) {
|
||||||
|
return prisma.session.findFirst({
|
||||||
|
where: {
|
||||||
|
id: sessionId,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
items: {
|
||||||
|
orderBy: { order: 'asc' },
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
include: {
|
||||||
|
links: {
|
||||||
|
include: {
|
||||||
|
swotItem: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSession(userId: string, data: { title: string; collaborator: string }) {
|
||||||
|
return prisma.session.create({
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSession(
|
||||||
|
sessionId: string,
|
||||||
|
userId: string,
|
||||||
|
data: { title?: string; collaborator?: string }
|
||||||
|
) {
|
||||||
|
return prisma.session.updateMany({
|
||||||
|
where: { id: sessionId, userId },
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSession(sessionId: string, userId: string) {
|
||||||
|
return prisma.session.deleteMany({
|
||||||
|
where: { id: sessionId, userId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// SWOT Items CRUD
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export async function createSwotItem(
|
||||||
|
sessionId: string,
|
||||||
|
data: { content: string; category: SwotCategory }
|
||||||
|
) {
|
||||||
|
// Get max order for this category
|
||||||
|
const maxOrder = await prisma.swotItem.aggregate({
|
||||||
|
where: { sessionId, category: data.category },
|
||||||
|
_max: { order: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return prisma.swotItem.create({
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
sessionId,
|
||||||
|
order: (maxOrder._max.order ?? -1) + 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSwotItem(
|
||||||
|
itemId: string,
|
||||||
|
data: { content?: string; category?: SwotCategory; order?: number }
|
||||||
|
) {
|
||||||
|
return prisma.swotItem.update({
|
||||||
|
where: { id: itemId },
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSwotItem(itemId: string) {
|
||||||
|
return prisma.swotItem.delete({
|
||||||
|
where: { id: itemId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reorderSwotItems(
|
||||||
|
sessionId: string,
|
||||||
|
category: SwotCategory,
|
||||||
|
itemIds: string[]
|
||||||
|
) {
|
||||||
|
const updates = itemIds.map((id, index) =>
|
||||||
|
prisma.swotItem.update({
|
||||||
|
where: { id },
|
||||||
|
data: { order: index },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return prisma.$transaction(updates);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function moveSwotItem(
|
||||||
|
itemId: string,
|
||||||
|
newCategory: SwotCategory,
|
||||||
|
newOrder: number
|
||||||
|
) {
|
||||||
|
return prisma.swotItem.update({
|
||||||
|
where: { id: itemId },
|
||||||
|
data: {
|
||||||
|
category: newCategory,
|
||||||
|
order: newOrder,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Actions CRUD
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export async function createAction(
|
||||||
|
sessionId: string,
|
||||||
|
data: {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
priority?: number;
|
||||||
|
linkedItemIds: string[];
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
return prisma.action.create({
|
||||||
|
data: {
|
||||||
|
title: data.title,
|
||||||
|
description: data.description,
|
||||||
|
priority: data.priority ?? 0,
|
||||||
|
sessionId,
|
||||||
|
links: {
|
||||||
|
create: data.linkedItemIds.map((swotItemId) => ({
|
||||||
|
swotItemId,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
links: {
|
||||||
|
include: {
|
||||||
|
swotItem: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAction(
|
||||||
|
actionId: string,
|
||||||
|
data: {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
priority?: number;
|
||||||
|
status?: string;
|
||||||
|
dueDate?: Date | null;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
return prisma.action.update({
|
||||||
|
where: { id: actionId },
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAction(actionId: string) {
|
||||||
|
return prisma.action.delete({
|
||||||
|
where: { id: actionId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function linkItemToAction(actionId: string, swotItemId: string) {
|
||||||
|
return prisma.actionLink.create({
|
||||||
|
data: {
|
||||||
|
actionId,
|
||||||
|
swotItemId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unlinkItemFromAction(actionId: string, swotItemId: string) {
|
||||||
|
return prisma.actionLink.deleteMany({
|
||||||
|
where: {
|
||||||
|
actionId,
|
||||||
|
swotItemId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user