{board.locations.map((location) => (
))}
diff --git a/src/app/globals.css b/src/app/globals.css
index 21b3b22..0bf23b1 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -365,3 +365,61 @@ body {
transition-duration: 1ms !important;
}
}
+
+/* ---------------------------------------------------------------------------
+ Impression
+ ---------------------------------------------------------------------------
+ Un planning affiché reste affiché ; un planning imprimé est le document que
+ l'équipe lit dans la réserve. Deux exigences en découlent :
+
+ - la couleur ne doit pas être nécessaire (les codes de poste sont déjà
+ imprimés dans chaque bloc), mais les aplats doivent sortir quand même,
+ d'où `print-color-adjust: exact` ;
+ - rien ne doit être coupé : la grille tient en paysage et les lignes ne se
+ scindent pas entre deux pages.
+*/
+@media print {
+ @page {
+ size: A4 landscape;
+ margin: 10mm;
+ }
+
+ :root {
+ /* L'impression part toujours du thème clair : un aplat sombre consomme
+ l'encre sans rien apporter. */
+ color-scheme: light;
+ }
+
+ html,
+ body {
+ background: #fff;
+ color: #000;
+ }
+
+ * {
+ print-color-adjust: exact;
+ -webkit-print-color-adjust: exact;
+ }
+
+ /* Les zones à défilement horizontal doivent se déplier, sinon la fin de la
+ semaine n'est tout simplement pas sur la feuille. */
+ .overflow-x-auto {
+ overflow: visible !important;
+ }
+
+ .min-w-\[1040px\] {
+ min-width: 0 !important;
+ }
+
+ [data-print='hide'],
+ nav,
+ aside {
+ display: none !important;
+ }
+
+ section,
+ tr,
+ .print-keep {
+ break-inside: avoid;
+ }
+}
diff --git a/src/components/planning/PrintButton.tsx b/src/components/planning/PrintButton.tsx
new file mode 100644
index 0000000..29a12dc
--- /dev/null
+++ b/src/components/planning/PrintButton.tsx
@@ -0,0 +1,19 @@
+'use client';
+
+import { Button } from '@/components/ui/Button';
+
+/**
+ * Impression du planning.
+ *
+ * Pas de génération PDF côté serveur : le navigateur sait déjà paginer, et sa
+ * boîte d'impression permet d'enregistrer en PDF. Un moteur de rendu de plus
+ * serait un second endroit où la grille peut diverger de ce qui est à l'écran.
+ * La mise en page d'impression vit dans `globals.css`.
+ */
+export function PrintButton({ label = 'Imprimer' }: { label?: string }) {
+ return (
+
+ );
+}
diff --git a/src/components/planning/TeamSection.tsx b/src/components/planning/TeamSection.tsx
index c0c2571..d0d407b 100644
--- a/src/components/planning/TeamSection.tsx
+++ b/src/components/planning/TeamSection.tsx
@@ -6,12 +6,18 @@ import { WeekGrid } from '@/components/planning/WeekGrid';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { formatMinutes } from '@/domain/counters/week';
-import { displayName, type BoardRow } from '@/domain/planning/board';
+import {
+ displayName,
+ type BoardRow,
+ type BoardShift,
+} from '@/domain/planning/board';
import {
createShiftAction,
deleteShiftAction,
+ duplicateWeekAction,
publishWeekAction,
unpublishWeekAction,
+ updateShiftAction,
type PlanningActionState,
} from '@/server/planning/actions';
import type { BoardLabel, BoardSection } from '@/server/planning/queries';
@@ -22,13 +28,26 @@ export interface TeamSectionProps {
dates: string[];
labels: BoardLabel[];
weekParam: string;
+ /** Semaine précédente : proposition par défaut de la duplication. */
+ previousParam: string;
/** Faux quand la capacité de modification manque : la grille reste lisible. */
canEdit: boolean;
canPublish: boolean;
+ canDuplicate: boolean;
}
const empty: PlanningActionState = {};
+/** Cible du panneau d'édition : création dans une case, ou créneau existant. */
+type Target =
+ | { kind: 'create'; membershipId: string | null; dayIndex: number }
+ | {
+ kind: 'edit';
+ shift: BoardShift;
+ membershipId: string | null;
+ dayIndex: number;
+ };
+
/**
* Une équipe, une grille, un état de publication.
*
@@ -42,19 +61,21 @@ export function TeamSection({
dates,
labels,
weekParam,
+ previousParam,
canEdit,
canPublish,
+ canDuplicate,
}: TeamSectionProps) {
- const [target, setTarget] = useState<{
- membershipId: string | null;
- dayIndex: number;
- } | null>(null);
+ const [target, setTarget] = useState(null);
const published = section.status === 'PUBLISHED';
const plannedMinutes = section.rows.reduce(
(total, row) => total + row.counters.plannedMinutes,
0,
);
+ const isEmpty =
+ section.rows.every((row) => row.days.flat().length === 0) &&
+ !section.unassignedRow;
if (section.hidden) {
// Le brouillon n'est pas seulement masqué : il n'a pas été chargé. Dire
@@ -86,14 +107,23 @@ export function TeamSection({