chore: update
This commit is contained in:
+11
-1
@@ -9,6 +9,7 @@ import { Register } from './pages/Register'
|
||||
import { Demos } from './pages/backoffice/Demos'
|
||||
import { PremiumDemos } from './pages/backoffice/PremiumDemos'
|
||||
import { Codes } from './pages/backoffice/Codes'
|
||||
import { Projects } from './pages/backoffice/Projects'
|
||||
import { Subscription } from './pages/backoffice/Subscription'
|
||||
import { AppDownloads } from './pages/backoffice/AppDownloads'
|
||||
import { Profile } from './pages/backoffice/Profile'
|
||||
@@ -21,7 +22,7 @@ import { MyDemoPlat } from './pages/backoffice/MyDemoPlat'
|
||||
|
||||
// Chemins admin uniquement : un accès non authentifié y renvoie vers la
|
||||
// page de connexion admin plutôt que la page client.
|
||||
const ADMIN_ONLY_PATHS = ['/app/demos', '/app/premium', '/app/codes']
|
||||
const ADMIN_ONLY_PATHS = ['/app/demos', '/app/premium', '/app/projects', '/app/codes']
|
||||
|
||||
function RequireAuth({ children }: { children: JSX.Element }) {
|
||||
const { isAuthenticated, initializing } = useAuth()
|
||||
@@ -98,6 +99,15 @@ export function App() {
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="projects"
|
||||
element={
|
||||
<RequireAdmin>
|
||||
<Projects />
|
||||
</RequireAdmin>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="premium"
|
||||
element={
|
||||
|
||||
@@ -78,6 +78,7 @@ export function BackofficeLayout() {
|
||||
{isClient && <NavItem to="/app/myservices">{isPremium ? 'Ma plateforme' : 'Ma démo'}</NavItem>}
|
||||
{isAdmin && <NavItem to="/app/demos">Démos</NavItem>}
|
||||
{isAdmin && <NavItem to="/app/premium">Premium</NavItem>}
|
||||
{isAdmin && <NavItem to="/app/projects">Projets</NavItem>}
|
||||
{isAdmin && <NavItem to="/app/codes">Codes</NavItem>}
|
||||
</HStack>
|
||||
<Spacer />
|
||||
@@ -134,6 +135,11 @@ export function BackofficeLayout() {
|
||||
Premium
|
||||
</NavItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<NavItem to="/app/projects" onClick={onClose} mobile>
|
||||
Projets
|
||||
</NavItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<NavItem to="/app/codes" onClick={onClose} mobile>
|
||||
Codes
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
FormHelperText,
|
||||
FormLabel,
|
||||
Input,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
NumberDecrementStepper,
|
||||
NumberIncrementStepper,
|
||||
NumberInput,
|
||||
NumberInputField,
|
||||
NumberInputStepper,
|
||||
Select,
|
||||
Stack,
|
||||
useToast,
|
||||
} from '@chakra-ui/react'
|
||||
import { api, ApiError } from '../lib/api'
|
||||
import { PROJECT_MONTHS, monthsLabel } from '../lib/format'
|
||||
import { PasswordInput } from './PasswordInput'
|
||||
|
||||
interface CreateProjectModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onCreated: () => void
|
||||
}
|
||||
|
||||
const MIN_PASSWORD = 12
|
||||
|
||||
// Déploiement d'un projet vitrine pour un client : durée de l'abonnement
|
||||
// (1 à 12 mois), compte admin et nombre d'admins autorisés. Le paiement est
|
||||
// géré en dehors d'Omnex : l'admin saisit la durée réellement souscrite.
|
||||
export function CreateProjectModal({ isOpen, onClose, onCreated }: CreateProjectModalProps) {
|
||||
const toast = useToast()
|
||||
const [clientName, setClientName] = useState('')
|
||||
const [months, setMonths] = useState(1)
|
||||
const [adminUsername, setAdminUsername] = useState('')
|
||||
const [adminPassword, setAdminPassword] = useState('')
|
||||
const [adminNumber, setAdminNumber] = useState(1)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const usernameOk = /^[A-Za-z0-9]{3,64}$/.test(adminUsername)
|
||||
const valid =
|
||||
clientName.trim().length > 0 && usernameOk && adminPassword.length >= MIN_PASSWORD && adminNumber >= 1
|
||||
|
||||
const reset = () => {
|
||||
setClientName('')
|
||||
setMonths(1)
|
||||
setAdminUsername('')
|
||||
setAdminPassword('')
|
||||
setAdminNumber(1)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
if (saving) return
|
||||
reset()
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleCreate = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await api.createProject({
|
||||
clientName: clientName.trim(),
|
||||
months,
|
||||
adminUsername,
|
||||
adminPassword,
|
||||
adminNumber,
|
||||
})
|
||||
toast({ status: 'success', title: 'Déploiement lancé', description: `Abonnement de ${monthsLabel(months)}` })
|
||||
reset()
|
||||
onCreated()
|
||||
onClose()
|
||||
} catch (err) {
|
||||
const msg = err instanceof ApiError ? err.message : 'Erreur'
|
||||
toast({ status: 'error', title: 'Déploiement impossible', description: msg })
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={handleClose} closeOnOverlayClick={!saving}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Déployer un projet</ModalHeader>
|
||||
<ModalCloseButton isDisabled={saving} />
|
||||
<ModalBody>
|
||||
<Stack spacing={4}>
|
||||
<FormControl isRequired>
|
||||
<FormLabel fontSize="sm">Client</FormLabel>
|
||||
<Input
|
||||
value={clientName}
|
||||
maxLength={64}
|
||||
onChange={(e) => setClientName(e.target.value)}
|
||||
isDisabled={saving}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl isRequired>
|
||||
<FormLabel fontSize="sm">Durée de l'abonnement</FormLabel>
|
||||
<Select value={months} onChange={(e) => setMonths(Number(e.target.value))} isDisabled={saving}>
|
||||
{PROJECT_MONTHS.map((m) => (
|
||||
<option key={m} value={m}>
|
||||
{monthsLabel(m)}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<FormHelperText>
|
||||
Durée réellement payée par le client. À l'échéance le projet est suspendu, puis supprimé
|
||||
après le délai de grâce s'il n'est pas renouvelé.
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
<FormControl isRequired isInvalid={adminUsername !== '' && !usernameOk}>
|
||||
<FormLabel fontSize="sm">Identifiant admin du projet</FormLabel>
|
||||
<Input
|
||||
value={adminUsername}
|
||||
maxLength={64}
|
||||
onChange={(e) => setAdminUsername(e.target.value)}
|
||||
isDisabled={saving}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<FormHelperText>3 à 64 caractères alphanumériques.</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
<FormControl isRequired isInvalid={adminPassword !== '' && adminPassword.length < MIN_PASSWORD}>
|
||||
<FormLabel fontSize="sm">Mot de passe admin</FormLabel>
|
||||
<PasswordInput
|
||||
value={adminPassword}
|
||||
onChange={(e) => setAdminPassword(e.target.value)}
|
||||
isDisabled={saving}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<FormHelperText>{MIN_PASSWORD} caractères minimum. Il n'est pas conservé par Omnex.</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
<FormControl isRequired>
|
||||
<FormLabel fontSize="sm">Nombre d'admins autorisés</FormLabel>
|
||||
<NumberInput
|
||||
min={1}
|
||||
max={20}
|
||||
value={adminNumber}
|
||||
onChange={(_, n) => setAdminNumber(Number.isNaN(n) ? 1 : n)}
|
||||
isDisabled={saving}
|
||||
>
|
||||
<NumberInputField />
|
||||
<NumberInputStepper>
|
||||
<NumberIncrementStepper />
|
||||
<NumberDecrementStepper />
|
||||
</NumberInputStepper>
|
||||
</NumberInput>
|
||||
<FormHelperText>Nombre maximum de comptes admin dans le projet (1 à 20).</FormHelperText>
|
||||
</FormControl>
|
||||
</Stack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="ghost" mr={3} onClick={handleClose} isDisabled={saving}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button colorScheme="primary" onClick={handleCreate} isLoading={saving} isDisabled={!valid}>
|
||||
Déployer
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
FormHelperText,
|
||||
FormLabel,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
Select,
|
||||
useToast,
|
||||
} from '@chakra-ui/react'
|
||||
import { api, ApiError, type Project } from '../lib/api'
|
||||
import { PROJECT_MONTHS, monthsLabel } from '../lib/format'
|
||||
|
||||
interface ExtendProjectModalProps {
|
||||
project: Project | null
|
||||
onClose: () => void
|
||||
onExtended: () => void
|
||||
}
|
||||
|
||||
// Renouvellement d'un abonnement : 1 à 12 mois ajoutés à l'échéance courante
|
||||
// (ou à aujourd'hui si elle est dépassée). Relance un projet suspendu.
|
||||
export function ExtendProjectModal({ project, onClose, onExtended }: ExtendProjectModalProps) {
|
||||
const toast = useToast()
|
||||
const [months, setMonths] = useState(1)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMonths(1)
|
||||
}, [project])
|
||||
|
||||
const handleClose = () => {
|
||||
if (saving) return
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleExtend = async () => {
|
||||
if (!project) return
|
||||
setSaving(true)
|
||||
try {
|
||||
await api.extendProject(project.id, months)
|
||||
toast({ status: 'success', title: `Abonnement prolongé de ${monthsLabel(months)}` })
|
||||
onExtended()
|
||||
onClose()
|
||||
} catch (err) {
|
||||
const msg = err instanceof ApiError ? err.message : 'Erreur'
|
||||
toast({ status: 'error', title: 'Renouvellement impossible', description: msg })
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen={!!project} onClose={handleClose} closeOnOverlayClick={!saving}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Renouveler l'abonnement</ModalHeader>
|
||||
<ModalCloseButton isDisabled={saving} />
|
||||
<ModalBody>
|
||||
<FormControl>
|
||||
<FormLabel fontSize="sm">Mois à ajouter — {project?.client_name}</FormLabel>
|
||||
<Select value={months} onChange={(e) => setMonths(Number(e.target.value))} isDisabled={saving}>
|
||||
{PROJECT_MONTHS.map((m) => (
|
||||
<option key={m} value={m}>
|
||||
{monthsLabel(m)}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<FormHelperText>
|
||||
{project?.status === 'suspended'
|
||||
? "Le projet est suspendu : il sera relancé et l'abonnement repart d'aujourd'hui."
|
||||
: "Les mois s'ajoutent à l'échéance actuelle."}
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="ghost" mr={3} onClick={handleClose} isDisabled={saving}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button colorScheme="primary" onClick={handleExtend} isLoading={saving}>
|
||||
Renouveler
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -58,6 +58,41 @@ export interface Demo {
|
||||
expires_at: string
|
||||
}
|
||||
|
||||
// Projets vitrine : abonnement de 1 à 12 mois, suspendu à l'échéance puis
|
||||
// supprimé après un délai de grâce (voir internal/projects côté API).
|
||||
export type ProjectStatus =
|
||||
| 'pending'
|
||||
| 'provisioning'
|
||||
| 'ready'
|
||||
| 'suspended'
|
||||
| 'deleting'
|
||||
| 'deleted'
|
||||
| 'failed'
|
||||
|
||||
export interface Project {
|
||||
id: string
|
||||
client_name: string
|
||||
status: ProjectStatus
|
||||
namespace: string
|
||||
host: string
|
||||
url: string
|
||||
admin_username: string
|
||||
admin_number: number
|
||||
months_purchased: number
|
||||
created_at: string
|
||||
expires_at: string
|
||||
delete_at: string
|
||||
suspended_at?: string
|
||||
}
|
||||
|
||||
export interface CreateProjectParams {
|
||||
clientName: string
|
||||
months: number
|
||||
adminUsername: string
|
||||
adminPassword: string
|
||||
adminNumber: number
|
||||
}
|
||||
|
||||
export type StorageDriver = 'local' | 's3'
|
||||
|
||||
export interface CreateDemoParams {
|
||||
@@ -215,6 +250,19 @@ export const api = {
|
||||
request<Demo>('POST', `/demos/${id}/domain`, { domain }),
|
||||
transferDemoToPremium: (id: string) => request<Demo>('POST', `/demos/${id}/premium`),
|
||||
|
||||
listProjects: () => request<{ items: Project[] }>('GET', '/projects'),
|
||||
createProject: (params: CreateProjectParams) =>
|
||||
request<Project>('POST', '/projects', {
|
||||
client_name: params.clientName,
|
||||
months: params.months,
|
||||
admin_username: params.adminUsername,
|
||||
admin_password: params.adminPassword,
|
||||
admin_number: params.adminNumber,
|
||||
}),
|
||||
extendProject: (id: string, months: number) =>
|
||||
request<Project>('POST', `/projects/${id}/extend`, { months }),
|
||||
deleteProject: (id: string) => request<Project>('DELETE', `/projects/${id}`),
|
||||
|
||||
listCodes: () => request<{ items: CodeBuySub[] }>('GET', '/codes'),
|
||||
listPremiumUsers: () => request<{ items: PremiumUser[] }>('GET', '/premium'),
|
||||
createCode: (username: string) =>
|
||||
|
||||
+41
-1
@@ -1,4 +1,4 @@
|
||||
import type { DemoStatus } from './api'
|
||||
import type { DemoStatus, ProjectStatus } from './api'
|
||||
|
||||
// Couleur de badge Chakra par statut de démo.
|
||||
export function statusColor(s: DemoStatus): string {
|
||||
@@ -92,3 +92,43 @@ export function formatDate(iso: string): string {
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
// Couleur de badge Chakra par statut de projet vitrine.
|
||||
export function projectStatusColor(s: ProjectStatus): string {
|
||||
switch (s) {
|
||||
case 'ready':
|
||||
return 'green'
|
||||
case 'provisioning':
|
||||
case 'pending':
|
||||
return 'blue'
|
||||
case 'suspended':
|
||||
return 'orange'
|
||||
case 'deleting':
|
||||
case 'failed':
|
||||
return 'red'
|
||||
case 'deleted':
|
||||
default:
|
||||
return 'gray'
|
||||
}
|
||||
}
|
||||
|
||||
// Libellé FR du statut d'un projet vitrine.
|
||||
export function projectStatusLabel(s: ProjectStatus): string {
|
||||
const map: Record<ProjectStatus, string> = {
|
||||
pending: 'En attente',
|
||||
provisioning: 'Déploiement…',
|
||||
ready: 'En ligne',
|
||||
suspended: 'Suspendu',
|
||||
deleting: 'Suppression…',
|
||||
deleted: 'Supprimé',
|
||||
failed: 'Échec',
|
||||
}
|
||||
return map[s] ?? s
|
||||
}
|
||||
|
||||
// Durées d'abonnement proposées (1 à 12 mois, contrainte de l'API).
|
||||
export const PROJECT_MONTHS = Array.from({ length: 12 }, (_, i) => i + 1)
|
||||
|
||||
export function monthsLabel(n: number): string {
|
||||
return `${n} mois`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
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 : temps restant, ou date de suppression si suspendu.
|
||||
const deadline = (p: Project) => {
|
||||
if (p.status === 'suspended') {
|
||||
return (
|
||||
<Text color="orange.500" fontSize="sm">
|
||||
Suppression le {formatDate(p.delete_at)}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<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' || p.status === 'suspended'
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user