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:
Julien Froidefond
2025-11-27 13:15:56 +01:00
parent 27e409fb76
commit 628d64a5c6
12 changed files with 1398 additions and 45 deletions

BIN
dev.db

Binary file not shown.

View File

@@ -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)
--- ---

View File

@@ -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
View File

@@ -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
View 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' };
}
}

View 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>
);
}

View 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&apos;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>
);
}

View 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>
);
}

View 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';

View 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';

View 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
View 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,
},
});
}