Improve Etapes UI and overview section
This commit is contained in:
parent
054bcb63ee
commit
f0b574302d
13 changed files with 212 additions and 81 deletions
|
|
@ -10,6 +10,7 @@ import {
|
|||
useTabloFileNames,
|
||||
} from "../hooks/tablo_data";
|
||||
import { useIsReadOnlyUser } from "../providers/UserStoreProvider";
|
||||
import { TypographyH3, TypographyMuted } from "@xtablo/ui/components/typography";
|
||||
|
||||
interface TabloFilesSectionProps {
|
||||
tablo: UserTablo;
|
||||
|
|
@ -154,8 +155,10 @@ export const TabloFilesSection = ({ tablo, isAdmin }: TabloFilesSectionProps) =>
|
|||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">Fichiers</h1>
|
||||
<p className="text-muted-foreground mt-1">Gérez les fichiers attachés à ce tablo</p>
|
||||
<TypographyH3 className="text-3xl font-bold text-foreground">Fichiers</TypographyH3>
|
||||
<TypographyMuted className="text-muted-foreground mt-1">
|
||||
Gérez les fichiers attachés à ce tablo
|
||||
</TypographyMuted>
|
||||
</div>
|
||||
|
||||
{/* Error Banner */}
|
||||
|
|
|
|||
|
|
@ -1,38 +1,29 @@
|
|||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Button } from "@xtablo/ui/components/button";
|
||||
import { Input } from "@xtablo/ui/components/input";
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
Check,
|
||||
Edit2,
|
||||
Loader2,
|
||||
Plus,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Progress } from "@xtablo/ui/components/progress";
|
||||
import { ArrowDown, ArrowUp, Check, Edit2, Loader2, Trash2, X } from "lucide-react";
|
||||
import type { UserTablo } from "@xtablo/shared/types/tablos.types";
|
||||
import { TabloFilesSection } from "./TabloFilesSection";
|
||||
import {
|
||||
useCreateEtape,
|
||||
useDeleteEtape,
|
||||
useReorderEtapes,
|
||||
useTabloEtapes,
|
||||
useTasksByTablo,
|
||||
useUpdateEtape,
|
||||
} from "../hooks/tasks";
|
||||
import { useTablo } from "../hooks/tablos";
|
||||
import { useUser } from "../providers/UserStoreProvider";
|
||||
|
||||
import { TypographyH3, TypographyMuted, TypographyP } from "@xtablo/ui/components/typography";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { pluralize, toast } from "@xtablo/shared";
|
||||
interface TabloOverviewSectionProps {
|
||||
tablo: UserTablo;
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
export const TabloOverviewSection = ({ tablo, isAdmin }: TabloOverviewSectionProps) => {
|
||||
const { data: detailedTablo } = useTablo(tablo.id);
|
||||
const { id: currentUserId } = useUser();
|
||||
|
||||
const { t } = useTranslation();
|
||||
const { data: etapes = [], isLoading: isLoadingEtapes } = useTabloEtapes(tablo.id);
|
||||
const { data: tasks = [] } = useTasksByTablo(tablo.id);
|
||||
const createEtape = useCreateEtape();
|
||||
const updateEtape = useUpdateEtape();
|
||||
const deleteEtape = useDeleteEtape();
|
||||
|
|
@ -42,17 +33,42 @@ export const TabloOverviewSection = ({ tablo, isAdmin }: TabloOverviewSectionPro
|
|||
const [editingEtapeId, setEditingEtapeId] = useState<string | null>(null);
|
||||
const [editingTitle, setEditingTitle] = useState("");
|
||||
|
||||
const isOwner = detailedTablo?.owner_id === currentUserId;
|
||||
const canManageEtapes = isOwner;
|
||||
const canManageEtapes = isAdmin;
|
||||
|
||||
const sortedEtapes = useMemo(
|
||||
() => [...etapes].sort((a, b) => a.position - b.position),
|
||||
[etapes]
|
||||
const sortedEtapes = useMemo(() => [...etapes].sort((a, b) => a.position - b.position), [etapes]);
|
||||
|
||||
// Calculate overall tablo progress
|
||||
const overallProgress = useMemo(() => {
|
||||
const totalTasks = tasks.length;
|
||||
const doneTasks = tasks.filter((task) => task.status === "done").length;
|
||||
const percentage = totalTasks > 0 ? Math.round((doneTasks / totalTasks) * 100) : 0;
|
||||
return { total: totalTasks, done: doneTasks, percentage };
|
||||
}, [tasks]);
|
||||
|
||||
// Calculate task counts per etape
|
||||
const getEtapeTaskCounts = useCallback(
|
||||
(etapeId: string) => {
|
||||
const etapeTasks = tasks.filter((task) => task.parent_task_id === etapeId);
|
||||
const total = etapeTasks.length;
|
||||
const done = etapeTasks.filter((task) => task.status === "done").length;
|
||||
const ongoing = etapeTasks.filter(
|
||||
(task) => task.status === "in_progress" || task.status === "in_review"
|
||||
).length;
|
||||
return { total, done, ongoing };
|
||||
},
|
||||
[tasks]
|
||||
);
|
||||
|
||||
const handleCreateEtape = async () => {
|
||||
const title = newEtapeTitle.trim();
|
||||
if (!title) return;
|
||||
if (!title) {
|
||||
toast.add({
|
||||
title: "Erreur",
|
||||
description: "Le nom de l'Étape est requis",
|
||||
type: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await createEtape.mutateAsync({
|
||||
tabloId: tablo.id,
|
||||
|
|
@ -126,17 +142,17 @@ export const TabloOverviewSection = ({ tablo, isAdmin }: TabloOverviewSectionPro
|
|||
if (!sortedEtapes.length) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed border-border p-6 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
<TypographyP className="text-muted-foreground">
|
||||
Aucune Étape n'a encore été définie pour ce tablo.
|
||||
</p>
|
||||
</TypographyP>
|
||||
{canManageEtapes ? (
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
<TypographyP className="text-sm text-muted-foreground mt-2">
|
||||
Créez votre première Étape pour structurer les tâches du tablo.
|
||||
</p>
|
||||
</TypographyP>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
<TypographyP className="text-sm text-muted-foreground mt-2">
|
||||
Seul le propriétaire du tablo peut ajouter des Étapes.
|
||||
</p>
|
||||
</TypographyP>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -207,10 +223,34 @@ export const TabloOverviewSection = ({ tablo, isAdmin }: TabloOverviewSectionPro
|
|||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-base font-medium text-foreground">{etape.title}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Position {etape.position + 1}
|
||||
</p>
|
||||
<TypographyP className="text-base font-medium text-foreground">
|
||||
{etape.title}
|
||||
</TypographyP>
|
||||
<TypographyMuted className="text-xs text-muted-foreground">
|
||||
Étape {etape.position + 1}
|
||||
</TypographyMuted>
|
||||
{(() => {
|
||||
const { total, done, ongoing } = getEtapeTaskCounts(etape.id);
|
||||
return (
|
||||
<div className="flex gap-3 mt-2 text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
<span className="font-medium text-foreground">{total}</span>{" "}
|
||||
{pluralize("tâche", total)}
|
||||
</span>
|
||||
{ongoing > 0 && (
|
||||
<span className="text-blue-600 dark:text-blue-400">
|
||||
<span className="font-medium">{ongoing}</span> en cours
|
||||
</span>
|
||||
)}
|
||||
{done > 0 && (
|
||||
<span className="text-green-600 dark:text-green-400">
|
||||
<span className="font-medium">{done}</span>{" "}
|
||||
{pluralize("terminée", done)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -246,55 +286,58 @@ export const TabloOverviewSection = ({ tablo, isAdmin }: TabloOverviewSectionPro
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="bg-card border border-border rounded-lg p-6">
|
||||
<div className="flex items-start justify-between flex-wrap gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">Vue d'ensemble</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Configurez les Étapes du tablo pour clarifier les grandes phases de votre projet.
|
||||
</p>
|
||||
</div>
|
||||
{canManageEtapes && (
|
||||
<div className="flex gap-2 w-full sm:w-auto">
|
||||
<Input
|
||||
value={newEtapeTitle}
|
||||
onChange={(event) => setNewEtapeTitle(event.target.value)}
|
||||
placeholder="Ajouter une nouvelle Étape"
|
||||
className="sm:w-64"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleCreateEtape}
|
||||
disabled={!newEtapeTitle.trim() || createEtape.isPending}
|
||||
>
|
||||
{createEtape.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Ajouter
|
||||
</Button>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<TypographyH3 className="text-3xl font-bold text-foreground">Vue d'ensemble</TypographyH3>
|
||||
<TypographyMuted className="text-muted-foreground mt-1">
|
||||
Configurez les Étapes du tablo pour clarifier les grandes phases de votre projet.
|
||||
</TypographyMuted>
|
||||
</div>
|
||||
|
||||
{!canManageEtapes && (
|
||||
<div className="mt-4 rounded-md bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
|
||||
Seul le propriétaire du tablo peut modifier les Étapes. Contactez l'administrateur si vous
|
||||
avez besoin d'une nouvelle Étape.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Overall Progress */}
|
||||
{overallProgress.total > 0 && (
|
||||
<div className="bg-card border border-border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<TypographyP className="text-sm font-medium text-foreground">
|
||||
Progression globale
|
||||
</TypographyP>
|
||||
<TypographyMuted className="text-xs">
|
||||
{overallProgress.done} sur {overallProgress.total}{" "}
|
||||
{pluralize("tâche", overallProgress.total)}{" "}
|
||||
{pluralize("terminée", overallProgress.done)}
|
||||
</TypographyMuted>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!canManageEtapes && (
|
||||
<div className="mt-4 rounded-md bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
|
||||
Seul le propriétaire du tablo peut modifier les Étapes. Contactez l'administrateur si
|
||||
vous avez besoin d'une nouvelle Étape.
|
||||
<div className="text-2xl font-bold text-primary">{overallProgress.percentage}%</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6">
|
||||
<EtapeList />
|
||||
<Progress value={overallProgress.done} max={overallProgress.total} className="h-3" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6">
|
||||
<EtapeList />
|
||||
</div>
|
||||
|
||||
<div className="bg-card border border-border rounded-lg p-6">
|
||||
<h2 className="text-2xl font-semibold text-foreground mb-4">Fichiers du tablo</h2>
|
||||
<TabloFilesSection tablo={tablo} isAdmin={isAdmin} />
|
||||
</div>
|
||||
{canManageEtapes && (
|
||||
<div className="flex w-full sm:w-auto items-center gap-2">
|
||||
<Input
|
||||
value={newEtapeTitle}
|
||||
onChange={(event) => setNewEtapeTitle(event.target.value)}
|
||||
placeholder="Nom de l'Étape"
|
||||
className="h-9 sm:w-64"
|
||||
/>
|
||||
<Button onClick={handleCreateEtape} disabled={createEtape.isPending}>
|
||||
{t("common:actions.add", "Nouvelle Étape")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { toast } from "@xtablo/shared";
|
||||
import { pluralize, toast } from "@xtablo/shared";
|
||||
import { UserTablo } from "@xtablo/shared/types/tablos.types";
|
||||
import type { KanbanColumn, KanbanTask, KanbanTaskInsert, TaskStatus } from "@xtablo/shared-types";
|
||||
import { ListChecks } from "lucide-react";
|
||||
import { AlertTriangle, ListChecks } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTabloMembers } from "../hooks/tablos";
|
||||
import { useCreateTask, useTabloEtapes, useTasksByTablo, useUpdateTaskPositions } from "../hooks/tasks";
|
||||
|
|
@ -34,6 +34,11 @@ export const TabloTasksSection = ({ tablo }: TabloTasksSectionProps) => {
|
|||
[etapes]
|
||||
);
|
||||
|
||||
// Check for tasks without parent (orphaned tasks)
|
||||
const orphanedTasks = useMemo(() => {
|
||||
return tasks?.filter((task) => !task.parent_task_id) || [];
|
||||
}, [tasks]);
|
||||
|
||||
// Helper functions defined before use
|
||||
const initializeColumns = useCallback((tasks: KanbanTask[]): KanbanColumn[] => {
|
||||
const defaultColumns: KanbanColumn[] = [
|
||||
|
|
@ -160,6 +165,25 @@ export const TabloTasksSection = ({ tablo }: TabloTasksSectionProps) => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Warning for orphaned tasks */}
|
||||
{orphanedTasks.length > 0 && (
|
||||
<div className="bg-yellow-50 dark:bg-yellow-950/20 border border-yellow-200 dark:border-yellow-900/50 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="h-5 w-5 text-yellow-600 dark:text-yellow-500 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-yellow-800 dark:text-yellow-200">
|
||||
{orphanedTasks.length} {pluralize("tâche", orphanedTasks.length)} sans Étape
|
||||
</p>
|
||||
<p className="text-sm text-yellow-700 dark:text-yellow-300 mt-1">
|
||||
{orphanedTasks.length === 1
|
||||
? "Cette tâche n'est associée à aucune Étape. Modifiez-la pour l'associer à une Étape."
|
||||
: "Ces tâches ne sont associées à aucune Étape. Modifiez-les pour les associer à une Étape."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Kanban Board */}
|
||||
<div className="bg-card rounded-lg border border-border p-6">
|
||||
<KanbanBoard
|
||||
|
|
|
|||
|
|
@ -153,3 +153,4 @@ For a fresh setup:
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -323,3 +323,4 @@ All standard Stripe objects synced automatically:
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -210,3 +210,4 @@ However, this is not recommended as the old implementation had incorrect logic.
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -280,3 +280,4 @@ await stripeSync.syncSubscriptions();
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -202,3 +202,4 @@ All 142 tests are now passing with proper authentication logic being tested. The
|
|||
✅ 100% test pass rate
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@
|
|||
"date-fns": "^4.1.0",
|
||||
"jspdf": "^3.0.3",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"pluralize": "^8.0.0",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"react-router-dom": "^7.9.4",
|
||||
|
|
@ -42,6 +43,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.2.5",
|
||||
"@types/pluralize": "^0.0.33",
|
||||
"@types/react": "19.0.10",
|
||||
"@types/react-dom": "19.0.4",
|
||||
"typescript": "^5.7.0"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
import type { Database, EventAndTablo } from "@xtablo/shared-types";
|
||||
import jsPDF from "jspdf";
|
||||
import pluralizeLib from "pluralize";
|
||||
|
||||
/**
|
||||
* Re-export pluralize library for use throughout the app
|
||||
*/
|
||||
export const pluralize = pluralizeLib;
|
||||
|
||||
export const calculateTax = (amount: number, taxRate: number) => {
|
||||
return (amount * taxRate) / 100;
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ export * from "./field";
|
|||
export * from "./input";
|
||||
export * from "./label";
|
||||
export * from "./popover";
|
||||
export * from "./progress";
|
||||
export * from "./select";
|
||||
export * from "./separator";
|
||||
export * from "./slider";
|
||||
|
|
|
|||
30
packages/ui/src/components/progress.tsx
Normal file
30
packages/ui/src/components/progress.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import * as React from "react";
|
||||
import { cn } from "@xtablo/shared";
|
||||
|
||||
const Progress = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & { value?: number; max?: number }
|
||||
>(({ className, value = 0, max = 100, ...props }, ref) => {
|
||||
const percentage = Math.min(Math.max((value / max) * 100, 0), 100);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative h-2 w-full overflow-hidden rounded-full bg-secondary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className="h-full w-full flex-1 bg-primary transition-all duration-300 ease-in-out"
|
||||
style={{ transform: `translateX(-${100 - percentage}%)` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
Progress.displayName = "Progress";
|
||||
|
||||
export { Progress };
|
||||
|
||||
|
|
@ -528,6 +528,9 @@ importers:
|
|||
jwt-decode:
|
||||
specifier: ^4.0.0
|
||||
version: 4.0.0
|
||||
pluralize:
|
||||
specifier: ^8.0.0
|
||||
version: 8.0.0
|
||||
react:
|
||||
specifier: 19.0.0
|
||||
version: 19.0.0
|
||||
|
|
@ -559,6 +562,9 @@ importers:
|
|||
'@biomejs/biome':
|
||||
specifier: 2.2.5
|
||||
version: 2.2.5
|
||||
'@types/pluralize':
|
||||
specifier: ^0.0.33
|
||||
version: 0.0.33
|
||||
'@types/react':
|
||||
specifier: 19.0.10
|
||||
version: 19.0.10
|
||||
|
|
@ -4054,6 +4060,9 @@ packages:
|
|||
'@types/phoenix@1.6.6':
|
||||
resolution: {integrity: sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==}
|
||||
|
||||
'@types/pluralize@0.0.33':
|
||||
resolution: {integrity: sha512-JOqsl+ZoCpP4e8TDke9W79FDcSgPAR0l6pixx2JHkhnRjvShyYiAYw2LVsnA7K08Y6DeOnaU6ujmENO4os/cYg==}
|
||||
|
||||
'@types/raf@3.4.3':
|
||||
resolution: {integrity: sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==}
|
||||
|
||||
|
|
@ -6983,6 +6992,10 @@ packages:
|
|||
resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
pluralize@8.0.0:
|
||||
resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
possible-typed-array-names@1.1.0:
|
||||
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
|
@ -13024,6 +13037,8 @@ snapshots:
|
|||
|
||||
'@types/phoenix@1.6.6': {}
|
||||
|
||||
'@types/pluralize@0.0.33': {}
|
||||
|
||||
'@types/raf@3.4.3':
|
||||
optional: true
|
||||
|
||||
|
|
@ -16818,6 +16833,8 @@ snapshots:
|
|||
dependencies:
|
||||
find-up: 4.1.0
|
||||
|
||||
pluralize@8.0.0: {}
|
||||
|
||||
possible-typed-array-names@1.1.0: {}
|
||||
|
||||
postcss@8.5.6:
|
||||
|
|
|
|||
Loading…
Reference in a new issue