chore: build
ci-api / test (push) Successful in 23m32s
ci-web / test (push) Successful in 14m7s

This commit is contained in:
Xor290
2026-09-20 18:52:09 +02:00
parent 9e11f01c2c
commit 8d857faa45
66 changed files with 3673 additions and 1564 deletions
-1510
View File
File diff suppressed because one or more lines are too long
+1510
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Omnex — Plateforme de gestion de commandes & livraison</title>
<meta name="description" content="Déployez en un clic une démo complète de la plateforme de gestion de commandes et de livraison." />
<script type="module" crossorigin src="/assets/index-DkbJvUQ3.js"></script>
<script type="module" crossorigin src="/assets/index-rDhSK02l.js"></script>
</head>
<body>
<div id="root"></div>
+2
View File
@@ -248,6 +248,8 @@ export const api = {
transferDemoToPremium: (id: string) => request<Demo>('POST', `/demos/${id}/premium`),
listProjects: () => request<{ items: Project[] }>('GET', '/projects'),
// État live des pods d'un projet (même format que getDemoDetails).
getProjectDetails: (id: string) => request<DemoDetails>('GET', `/projects/${id}/details`),
createProject: (params: CreateProjectParams) =>
request<Project>('POST', '/projects', {
client_name: params.clientName,
+151 -34
View File
@@ -1,10 +1,13 @@
import { useCallback, useEffect, useState } from 'react'
import { Fragment, useCallback, useEffect, useState } from 'react'
import {
Badge,
Box,
Button,
Collapse,
Flex,
Heading,
HStack,
Icon,
Link,
Spacer,
Spinner,
@@ -20,15 +23,19 @@ import {
Wrap,
} from '@chakra-ui/react'
import { useNavigate } from 'react-router-dom'
import { api, ApiError, type Project } from '../../lib/api'
import { api, ApiError, type DemoDetails, type Project } from '../../lib/api'
import { formatDate, monthsLabel, projectStatusColor, projectStatusLabel, timeRemaining } from '../../lib/format'
import { ConfirmDialog } from '../../components/ConfirmDialog'
import { CreateProjectModal } from '../../components/CreateProjectModal'
import { ExtendProjectModal } from '../../components/ExtendProjectModal'
import { ChevronIcon, PodStatusPanel } from '../../components/PodStatusPanel'
// Un déploiement en cours => on rafraîchit régulièrement.
const POLL_MS = 5000
// Rafraîchissement de l'état des pods pendant que le détail d'une ligne est déplié.
const DETAILS_POLL_MS = 5000
// Projets vitrine déployés pour des clients sur abonnement de 1 à 12 mois.
export function Projects() {
const toast = useToast()
@@ -40,6 +47,11 @@ export function Projects() {
const [toExtend, setToExtend] = useState<Project | null>(null)
const [toDelete, setToDelete] = useState<Project | null>(null)
// --- Ligne dépliée (état live des pods) ---
const [expandedId, setExpandedId] = useState<string | null>(null)
const [details, setDetails] = useState<DemoDetails | null>(null)
const [detailsLoading, setDetailsLoading] = useState(false)
const load = useCallback(async () => {
try {
const res = await api.listProjects()
@@ -75,6 +87,55 @@ export function Projects() {
}
}
// Seul un projet déployé (ou en cours de déploiement) a des pods à afficher.
const hasPods = (p: Project) => p.status === 'ready' || p.status === 'provisioning'
const toggleRow = async (p: Project) => {
if (!hasPods(p)) return
if (expandedId === p.id) {
setExpandedId(null)
setDetails(null)
return
}
setExpandedId(p.id)
setDetails(null)
setDetailsLoading(true)
try {
setDetails(await api.getProjectDetails(p.id))
} catch (err) {
const msg = err instanceof ApiError ? err.message : 'Erreur'
toast({ status: 'error', title: 'État des pods indisponible', description: msg })
setExpandedId(null)
} finally {
setDetailsLoading(false)
}
}
// Tant qu'une ligne est dépliée, on rafraîchit l'état des pods en direct
// (silencieux : pas de spinner, juste la mise à jour des badges/jauges).
useEffect(() => {
if (!expandedId) return
const id = setInterval(() => {
api
.getProjectDetails(expandedId)
.then(setDetails)
.catch(() => {
/* échec silencieux : on garde le dernier état connu affiché */
})
}, DETAILS_POLL_MS)
return () => clearInterval(id)
}, [expandedId])
// Le projet déplié a été supprimé ou n'a plus de pods : on referme la ligne.
useEffect(() => {
if (!expandedId) return
const current = projects.find((p) => p.id === expandedId)
if (!current || !hasPods(current)) {
setExpandedId(null)
setDetails(null)
}
}, [projects, expandedId])
// Échéance affichée : date de suppression et temps restant.
const deadline = (p: Project) => (
<Text fontSize="sm">
@@ -113,40 +174,96 @@ export function Projects() {
<Tbody>
{projects.map((p) => {
const locked = busy === p.id
const canExtend = p.status === 'ready' || p.status === 'provisioning'
const canExtend = hasPods(p)
const isOpen = expandedId === p.id
return (
<Tr key={p.id}>
<Td>{p.client_name}</Td>
<Td>
<Badge colorScheme={projectStatusColor(p.status)}>{projectStatusLabel(p.status)}</Badge>
</Td>
<Td>
<Link href={p.url} isExternal fontFamily="mono" fontSize="sm">
{p.host}
</Link>
</Td>
<Td>{monthsLabel(p.months_purchased)} au total</Td>
<Td>{deadline(p)}</Td>
<Td>
{p.admin_username} · max {p.admin_number}
</Td>
<Td>
<Wrap justify="flex-end" spacing={2}>
<Button size="xs" colorScheme="primary" isDisabled={!canExtend || locked} onClick={() => setToExtend(p)}>
Renouveler
</Button>
<Button
size="xs"
variant="outline"
colorScheme="red"
isDisabled={p.status === 'deleting' || locked}
onClick={() => setToDelete(p)}
<Fragment key={p.id}>
<Tr
cursor={hasPods(p) ? 'pointer' : undefined}
_hover={hasPods(p) ? { bg: 'chakra-subtle-bg' } : undefined}
onClick={() => void toggleRow(p)}
>
<Td>
<HStack spacing={2}>
{hasPods(p) && (
<Icon
as={ChevronIcon}
boxSize={3}
color="gray.400"
transform={isOpen ? 'rotate(90deg)' : undefined}
transition="transform 0.15s"
/>
)}
<Text>{p.client_name}</Text>
</HStack>
</Td>
<Td>
<Badge colorScheme={projectStatusColor(p.status)}>{projectStatusLabel(p.status)}</Badge>
</Td>
<Td>
<Link
href={p.url}
isExternal
fontFamily="mono"
fontSize="sm"
onClick={(e) => e.stopPropagation()}
>
Supprimer
</Button>
</Wrap>
</Td>
</Tr>
{p.host}
</Link>
</Td>
<Td>{monthsLabel(p.months_purchased)} au total</Td>
<Td>{deadline(p)}</Td>
<Td>
{p.admin_username} · max {p.admin_number}
</Td>
<Td>
<Wrap justify="flex-end" spacing={2}>
<Button
size="xs"
colorScheme="primary"
isDisabled={!canExtend || locked}
onClick={(e) => {
e.stopPropagation()
setToExtend(p)
}}
>
Renouveler
</Button>
<Button
size="xs"
variant="outline"
colorScheme="red"
isDisabled={p.status === 'deleting' || locked}
onClick={(e) => {
e.stopPropagation()
setToDelete(p)
}}
>
Supprimer
</Button>
</Wrap>
</Td>
</Tr>
<Tr>
<Td p={0} border={isOpen ? undefined : 'none'} colSpan={7}>
<Collapse in={isOpen} unmountOnExit animateOpacity>
<Box p={4} bg="chakra-subtle-bg" borderTopWidth="1px">
{detailsLoading && !details ? (
<Flex justify="center" py={4}>
<Spinner size="sm" />
</Flex>
) : details ? (
<PodStatusPanel state={details.state} />
) : (
<Text color="gray.500" fontSize="sm">
Aucune donnée.
</Text>
)}
</Box>
</Collapse>
</Td>
</Tr>
</Fragment>
)
})}
</Tbody>