Files
omnex/web/src/pages/backoffice/Projects.tsx
T
Xor290 9e11f01c2c
ci-api / test (push) Failing after 14m25s
ci-web / test (push) Failing after 8m17s
chore: build
2026-09-20 16:14:29 +02:00

174 lines
5.7 KiB
TypeScript

import { useCallback, useEffect, useState } from 'react'
import {
Badge,
Box,
Button,
Flex,
Heading,
Link,
Spacer,
Spinner,
Table,
TableContainer,
Tbody,
Td,
Text,
Th,
Thead,
Tr,
useToast,
Wrap,
} from '@chakra-ui/react'
import { useNavigate } from 'react-router-dom'
import { api, ApiError, 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'
// Un déploiement en cours => on rafraîchit régulièrement.
const POLL_MS = 5000
// Projets vitrine déployés pour des clients sur abonnement de 1 à 12 mois.
export function Projects() {
const toast = useToast()
const navigate = useNavigate()
const [projects, setProjects] = useState<Project[]>([])
const [loading, setLoading] = useState(true)
const [busy, setBusy] = useState<string | null>(null)
const [createOpen, setCreateOpen] = useState(false)
const [toExtend, setToExtend] = useState<Project | null>(null)
const [toDelete, setToDelete] = useState<Project | null>(null)
const load = useCallback(async () => {
try {
const res = await api.listProjects()
setProjects((res.items ?? []).filter((p) => p.status !== 'deleted'))
} catch (err) {
if (err instanceof ApiError && err.status === 401) navigate('/admin/login')
else toast({ status: 'error', title: 'Chargement des projets impossible' })
} finally {
setLoading(false)
}
}, [navigate, toast])
useEffect(() => {
void load()
const id = setInterval(() => void load(), POLL_MS)
return () => clearInterval(id)
}, [load])
const confirmDelete = async () => {
if (!toDelete) return
const p = toDelete
setBusy(p.id)
try {
await api.deleteProject(p.id)
toast({ status: 'success', title: 'Projet supprimé' })
setToDelete(null)
await load()
} catch (err) {
const msg = err instanceof ApiError ? err.message : 'Erreur'
toast({ status: 'error', title: 'Suppression impossible', description: msg })
} finally {
setBusy(null)
}
}
// Échéance affichée : date de suppression et temps restant.
const deadline = (p: Project) => (
<Text fontSize="sm">
{formatDate(p.expires_at)} · {timeRemaining(p.expires_at)}
</Text>
)
return (
<Box p={{ base: 4, md: 8 }}>
<Flex mb={6} align="center" gap={3}>
<Heading size="md">Projets</Heading>
<Spacer />
<Button colorScheme="primary" onClick={() => setCreateOpen(true)}>
Déployer un projet
</Button>
</Flex>
{loading ? (
<Spinner />
) : projects.length === 0 ? (
<Text color="gray.500">Aucun projet déployé.</Text>
) : (
<TableContainer>
<Table size="sm">
<Thead>
<Tr>
<Th>Client</Th>
<Th>Statut</Th>
<Th>Adresse</Th>
<Th>Abonnement</Th>
<Th>Échéance</Th>
<Th>Admins</Th>
<Th />
</Tr>
</Thead>
<Tbody>
{projects.map((p) => {
const locked = busy === p.id
const canExtend = p.status === 'ready' || p.status === 'provisioning'
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)}
>
Supprimer
</Button>
</Wrap>
</Td>
</Tr>
)
})}
</Tbody>
</Table>
</TableContainer>
)}
<CreateProjectModal isOpen={createOpen} onClose={() => setCreateOpen(false)} onCreated={() => void load()} />
<ExtendProjectModal project={toExtend} onClose={() => setToExtend(null)} onExtended={() => void load()} />
<ConfirmDialog
isOpen={!!toDelete}
title="Supprimer le projet"
confirmLabel="Supprimer définitivement"
confirmColorScheme="red"
isLoading={busy === toDelete?.id}
onConfirm={() => void confirmDelete()}
onClose={() => setToDelete(null)}
>
Le projet de {toDelete?.client_name} sera supprimé immédiatement, avec ses données et ses sauvegardes. Cette
action est irréversible.
</ConfirmDialog>
</Box>
)
}