174 lines
5.7 KiB
TypeScript
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>
|
|
)
|
|
}
|