From abbe821d9144e924161398db3b33108f1dd636fe Mon Sep 17 00:00:00 2001 From: keyhan Date: Sun, 24 May 2026 00:33:16 +0330 Subject: [PATCH] Improve delete UX and prepaid credit time display. Show minutes and local expiry for resource credits; add shared delete hook with row/card loading overlays, detail-page deleting modal, and disabled controls to prevent double-delete. Co-authored-by: Cursor --- backend/src/billing/billing.service.ts | 15 +++++- .../src/app/dashboard/admin/apps/page.tsx | 48 +++++++++++------ frontend/src/app/dashboard/apps/[id]/page.tsx | 47 +++++++++++----- frontend/src/app/dashboard/apps/page.tsx | 42 ++++++++------- frontend/src/app/dashboard/deploy/page.tsx | 6 ++- frontend/src/app/dashboard/page.tsx | 4 ++ .../src/app/dashboard/services/[id]/page.tsx | 28 ++++++---- frontend/src/app/dashboard/services/page.tsx | 38 +++++++------ .../components/database-snapshots-panel.tsx | 25 +++++++-- .../src/components/delete-button-label.tsx | 14 +++++ frontend/src/components/deleting-modal.tsx | 44 +++++++++++++++ frontend/src/components/deleting-overlay.tsx | 45 ++++++++++++++++ frontend/src/lib/format-utils.ts | 22 ++++++++ frontend/src/lib/use-application-delete.ts | 54 +++++++++++++++++++ frontend/src/types/index.ts | 3 ++ 15 files changed, 352 insertions(+), 83 deletions(-) create mode 100644 frontend/src/components/delete-button-label.tsx create mode 100644 frontend/src/components/deleting-modal.tsx create mode 100644 frontend/src/components/deleting-overlay.tsx create mode 100644 frontend/src/lib/use-application-delete.ts diff --git a/backend/src/billing/billing.service.ts b/backend/src/billing/billing.service.ts index 4bed463..c2a40d9 100644 --- a/backend/src/billing/billing.service.ts +++ b/backend/src/billing/billing.service.ts @@ -801,6 +801,15 @@ export class BillingService { const remainingMs = Math.max(0, new Date(credit.expiresAt).getTime() - now); const remainingDays = Math.floor(remainingMs / 86400000); const remainingHours = Math.floor((remainingMs % 86400000) / 3600000); + const remainingMinutes = Math.floor((remainingMs % 3600000) / 60000); + const remainingLabel = + remainingDays > 0 + ? `${remainingDays}d ${remainingHours}h ${remainingMinutes}m` + : remainingHours > 0 + ? `${remainingHours}h ${remainingMinutes}m` + : remainingMinutes > 0 + ? `${remainingMinutes}m` + : 'less than 1m'; return { id: credit.id, sourceAppName: credit.sourceAppName, @@ -818,8 +827,10 @@ export class BillingService { billingCycle: credit.billingCycle, expiresAt: credit.expiresAt, remainingMs, - remainingLabel: - remainingDays > 0 ? `${remainingDays}d ${remainingHours}h` : `${remainingHours}h`, + remainingDays, + remainingHours, + remainingMinutes, + remainingLabel, }; } diff --git a/frontend/src/app/dashboard/admin/apps/page.tsx b/frontend/src/app/dashboard/admin/apps/page.tsx index b48b435..22f53e0 100644 --- a/frontend/src/app/dashboard/admin/apps/page.tsx +++ b/frontend/src/app/dashboard/admin/apps/page.tsx @@ -8,8 +8,11 @@ import { toast } from 'react-toastify'; import type { Application, AppLifecycleStatus, ApplicationMigrationEvent, ApplicationMigrationJob, BillingCycle, Cluster } from '@/types'; import { Search, X, Package, Hexagon, User, Database, Box, AlertTriangle, Clock, ArrowRightLeft, RefreshCw } from 'lucide-react'; import { useConfirm } from '@/components/confirm-modal'; +import { DeleteButtonLabel } from '@/components/delete-button-label'; +import { DeletingCardOverlay, DeletingTableRowCell, deletingResourceMessage } from '@/components/deleting-overlay'; import { TruncatedText } from '@/components/truncated-text'; import { useDebounce } from '@/hooks/useDebounce'; +import { useApplicationDelete } from '@/lib/use-application-delete'; const statusColors: Record = { running: 'badge-green', @@ -96,13 +99,9 @@ export default function AdminAppsPage() { .then((r) => r.data), }); - const deleteMutation = useMutation({ - mutationFn: (id: string) => api.delete(`/applications/${id}`), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['admin-applications'] }); - toast.success('Application deleted'); - }, - onError: () => toast.error('Failed to delete application'), + const { deleteApplication, isDeleting, isAnyDeleting } = useApplicationDelete({ + invalidateKeys: [['admin-applications']], + successMessage: 'Application deleted', }); const { data: clusters = [] } = useQuery({ @@ -308,8 +307,19 @@ export default function AdminAppsPage() { const expiry = formatExpiry(app.planExpiresAt); const latestMigration = migrations.find((migration) => migration.applicationId === app.id); const assignedCluster = app.clusterId ? clusterById.get(app.clusterId) : undefined; + const rowDeleting = isDeleting(app.id); return ( - + + {rowDeleting ? ( + + ) : ( + <>
@@ -420,16 +430,19 @@ export default function AdminAppsPage() {
+ + )} ); })} @@ -445,11 +458,15 @@ export default function AdminAppsPage() { const expiry = formatExpiry(app.planExpiresAt); const latestMigration = migrations.find((migration) => migration.applicationId === app.id); const assignedCluster = app.clusterId ? clusterById.get(app.clusterId) : undefined; + const cardDeleting = isDeleting(app.id); return (
+ {cardDeleting && ( + + )}
@@ -538,6 +555,7 @@ export default function AdminAppsPage() {
diff --git a/frontend/src/app/dashboard/apps/[id]/page.tsx b/frontend/src/app/dashboard/apps/[id]/page.tsx index b0a46e6..4f25b29 100644 --- a/frontend/src/app/dashboard/apps/[id]/page.tsx +++ b/frontend/src/app/dashboard/apps/[id]/page.tsx @@ -10,6 +10,8 @@ import NextLink from 'next/link'; import { Hexagon, Rocket, Play, Square, RotateCw, Globe, Package, Server, Scale, CheckCircle, Clock, XCircle, AlertCircle, Link, GitBranch, KeyRound, FolderUp, BarChart3, ChevronDown, FileText, Monitor, Hammer, Settings, RefreshCw, Upload, Circle, Pin, Database, Eye, EyeOff, Copy, Check, History, Download, RotateCcw, Camera, Trash2, Archive, Zap, Wallet, CreditCard, AlertTriangle, ScrollText } from 'lucide-react'; import { ServiceExternalAccessPanel } from '@/components/service-external-access-panel'; import { WorkloadLogsPanel } from '@/components/workload-logs-panel'; +import { DeletingModal } from '@/components/deleting-modal'; +import { useApplicationDelete } from '@/lib/use-application-delete'; import { isApplicationProduct, isManagedProduct } from '@/lib/product-type'; import { useConfirm } from '@/components/confirm-modal'; import { useAuthStore } from '@/lib/store'; @@ -388,8 +390,15 @@ export default function AppDetailPage() { onError: () => toast.error('Rollback failed'), }); + const [deletingSnapshotId, setDeletingSnapshotId] = useState(null); const deleteSnapshotMutation = useMutation({ mutationFn: (snapshotId: string) => api.delete(`/snapshots/${snapshotId}`), + onMutate: (snapshotId) => { + setDeletingSnapshotId(snapshotId); + }, + onSettled: () => { + setDeletingSnapshotId(null); + }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['snapshots', appId] }); toast.success('Snapshot deleted'); @@ -576,12 +585,10 @@ export default function AppDetailPage() { onError: () => toast.error('Failed to trigger redeploy'), }); - const deleteMutation = useMutation({ - mutationFn: () => api.delete(`/applications/${appId}`), - onSuccess: (res) => { - queryClient.invalidateQueries({ queryKey: ['applications'] }); - queryClient.invalidateQueries({ queryKey: ['resource-credits'] }); - if (res.data?.resourceCredit) { + const { deleteApplication, isAnyDeleting } = useApplicationDelete({ + invalidateKeys: [['applications', 'application'], ['applications']], + onSuccess: (data) => { + if (data?.resourceCredit) { toast.success('Application deleted. Prepaid resources are on your dashboard.'); } else { toast.success('Application deleted'); @@ -835,11 +842,18 @@ export default function AppDetailPage() { confirmText: 'Delete', variant: 'danger', }); - if (ok) deleteMutation.mutate(); + if (ok) deleteApplication(appId); }; + const pageLocked = isAnyDeleting; + return ( -
+ <> + +
{/* Header */}
@@ -905,8 +919,12 @@ export default function AppDetailPage() { )} )} -
@@ -2635,11 +2653,15 @@ export default function AppDetailPage() {
)} @@ -2663,5 +2685,6 @@ export default function AppDetailPage() { isStopped={isStopped} />
+ ); } diff --git a/frontend/src/app/dashboard/apps/page.tsx b/frontend/src/app/dashboard/apps/page.tsx index de47ab1..44e1cb3 100644 --- a/frontend/src/app/dashboard/apps/page.tsx +++ b/frontend/src/app/dashboard/apps/page.tsx @@ -1,13 +1,15 @@ 'use client'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; import Link from 'next/link'; import api from '@/lib/api'; -import { toast } from 'react-toastify'; import type { Application } from '@/types'; import { Rocket, Package, Hexagon, Database, Box, AlertTriangle, Clock } from 'lucide-react'; import { useConfirm } from '@/components/confirm-modal'; +import { DeleteButtonLabel } from '@/components/delete-button-label'; +import { DeletingTableRowCell, deletingResourceMessage } from '@/components/deleting-overlay'; import { filterApplications } from '@/lib/product-type'; +import { useApplicationDelete } from '@/lib/use-application-delete'; const statusColors: Record = { running: 'badge-green', @@ -49,7 +51,6 @@ function formatExpiry(expiresAt?: string): { text: string; urgent: boolean } { } export default function AppsPage() { - const queryClient = useQueryClient(); const confirm = useConfirm(); const { data: appsRaw = [], isLoading } = useQuery({ @@ -58,18 +59,10 @@ export default function AppsPage() { }); const apps = filterApplications(appsRaw); - const deleteMutation = useMutation({ - mutationFn: (id: string) => api.delete(`/applications/${id}`), - onSuccess: (res) => { - queryClient.invalidateQueries({ queryKey: ['applications'] }); - queryClient.invalidateQueries({ queryKey: ['resource-credits'] }); - if (res.data?.resourceCredit) { - toast.success('Application deleted. Your prepaid resources are shown on the dashboard.'); - } else { - toast.success('Application deleted'); - } - }, - onError: () => toast.error('Failed to delete application'), + const { deleteApplication, isDeleting, isAnyDeleting } = useApplicationDelete({ + invalidateKeys: [['applications', 'application'], ['applications']], + successMessage: 'Application deleted', + successWithCreditMessage: 'Application deleted. Your prepaid resources are shown on the dashboard.', }); if (isLoading) { @@ -135,11 +128,19 @@ export default function AppsPage() { const latestStatus = app.deployments?.[0]?.status || 'pending'; const lifecycle = app.lifecycleStatus || 'active'; const expiry = formatExpiry(app.planExpiresAt); + const rowDeleting = isDeleting(app.id); return ( + {rowDeleting ? ( + + ) : ( + <>
@@ -175,6 +176,7 @@ export default function AppsPage() {
+ + )} ); })} diff --git a/frontend/src/app/dashboard/deploy/page.tsx b/frontend/src/app/dashboard/deploy/page.tsx index 5a2040d..d8c029d 100644 --- a/frontend/src/app/dashboard/deploy/page.tsx +++ b/frontend/src/app/dashboard/deploy/page.tsx @@ -2674,7 +2674,11 @@ export default function DeployPage() {

Prepaid credit applied

Resources from "{costData.creditApplied.sourceAppName || 'deleted app'}" are covered - until {costData.creditApplied.remainingLabel} remaining. + until {costData.creditApplied.remainingLabel} remaining + {costData.creditApplied.expiresAt + ? ` (expires ${new Date(costData.creditApplied.expiresAt).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })})` + : ''} + . {payAmount > 0 ? ` You only pay for new add-ons below (prorated to ${costData.prorateRemainingDays ?? '?'}/${costData.proratePeriodDays ?? '?'} days left on your credit).` : ' No charge for this deploy.'} diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index 20111b0..0e2fb2d 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -8,6 +8,7 @@ import { useAuthStore } from '@/lib/store'; import type { Application } from '@/types'; import { Rocket, Package, Circle, Hexagon, Wallet, Clock, Database, Plus } from 'lucide-react'; import type { ResourceCredit } from '@/types'; +import { formatExpiresAtLocal } from '@/lib/format-utils'; import { managedServiceTypeLabel } from '@/lib/optional-service-defaults'; import { filterApplications, filterManagedServices } from '@/lib/product-type'; @@ -112,6 +113,9 @@ export default function DashboardPage() { {credit.remainingLabel} remaining

+

+ Expires {formatExpiresAtLocal(credit.expiresAt)} +

  • CPU: {credit.cpuLimit}
  • RAM: {credit.memoryLimit}
  • diff --git a/frontend/src/app/dashboard/services/[id]/page.tsx b/frontend/src/app/dashboard/services/[id]/page.tsx index 770a74e..aaf8dda 100644 --- a/frontend/src/app/dashboard/services/[id]/page.tsx +++ b/frontend/src/app/dashboard/services/[id]/page.tsx @@ -32,6 +32,8 @@ import { XCircle, } from 'lucide-react'; import { useConfirm } from '@/components/confirm-modal'; +import { DeletingModal } from '@/components/deleting-modal'; +import { useApplicationDelete } from '@/lib/use-application-delete'; import { ServiceExternalAccessPanel } from '@/components/service-external-access-panel'; import { WorkloadLogsPanel } from '@/components/workload-logs-panel'; import { ManagedServiceResourcesPanel } from '@/components/managed-service-resources-panel'; @@ -155,12 +157,11 @@ export default function ManagedServiceDetailPage() { }, }); - const deleteMutation = useMutation({ - mutationFn: () => api.delete(`/applications/${serviceId}`), - onSuccess: (res) => { - queryClient.invalidateQueries({ queryKey: ['applications', 'managed'] }); - queryClient.invalidateQueries({ queryKey: ['resource-credits'] }); - if (res.data?.resourceCredit) { + const { deleteApplication, isAnyDeleting } = useApplicationDelete({ + invalidateKeys: [['applications', 'managed'], ['application', serviceId]], + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: ['application', serviceId] }); + if (data?.resourceCredit) { toast.success('Service deleted. Prepaid resources are on your dashboard.'); } else { toast.success('Service deleted'); @@ -228,8 +229,15 @@ export default function ManagedServiceDetailPage() { ? `${optionalRes.memoryRequest} / ${optionalRes.memoryLimit}` : '—'; + const pageLocked = isAnyDeleting; + return ( -
    + <> + +
    @@ -294,7 +302,8 @@ export default function ManagedServiceDetailPage() { )}
    )}
    + ); } diff --git a/frontend/src/app/dashboard/services/page.tsx b/frontend/src/app/dashboard/services/page.tsx index b043679..03f7752 100644 --- a/frontend/src/app/dashboard/services/page.tsx +++ b/frontend/src/app/dashboard/services/page.tsx @@ -1,14 +1,16 @@ 'use client'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; import Link from 'next/link'; import api from '@/lib/api'; -import { toast } from 'react-toastify'; import type { Application } from '@/types'; import { managedServiceTypeLabel } from '@/lib/optional-service-defaults'; import { Database, Plus, AlertTriangle, Clock } from 'lucide-react'; import { useConfirm } from '@/components/confirm-modal'; +import { DeleteButtonLabel } from '@/components/delete-button-label'; +import { DeletingCardOverlay, deletingResourceMessage } from '@/components/deleting-overlay'; import { filterManagedServices } from '@/lib/product-type'; +import { useApplicationDelete } from '@/lib/use-application-delete'; const lifecycleColors: Record = { active: 'text-green-600 bg-green-50', @@ -50,7 +52,6 @@ function formatExpiry(expiresAt?: string): { text: string; urgent: boolean } { } export default function ServicesPage() { - const queryClient = useQueryClient(); const confirm = useConfirm(); const { data: servicesRaw = [], isLoading } = useQuery({ @@ -59,18 +60,10 @@ export default function ServicesPage() { }); const services = filterManagedServices(servicesRaw); - const deleteMutation = useMutation({ - mutationFn: (id: string) => api.delete(`/applications/${id}`), - onSuccess: (res) => { - queryClient.invalidateQueries({ queryKey: ['applications', 'managed'] }); - queryClient.invalidateQueries({ queryKey: ['resource-credits'] }); - if (res.data?.resourceCredit) { - toast.success('Service deleted. Prepaid resources are on your dashboard.'); - } else { - toast.success('Service deleted'); - } - }, - onError: () => toast.error('Failed to delete service'), + const { deleteApplication, isDeleting, isAnyDeleting } = useApplicationDelete({ + invalidateKeys: [['applications', 'managed']], + successMessage: 'Service deleted', + successWithCreditMessage: 'Service deleted. Prepaid resources are on your dashboard.', }); if (isLoading) { @@ -126,13 +119,17 @@ export default function ServicesPage() { const lifecycle = svc.lifecycleStatus || 'active'; const expiry = formatExpiry(svc.planExpiresAt); const latestStatus = svc.deployments?.[0]?.status || 'pending'; + const cardDeleting = isDeleting(svc.id); return (
    + {cardDeleting && ( + + )}
    @@ -161,7 +158,8 @@ export default function ServicesPage() {
    diff --git a/frontend/src/components/database-snapshots-panel.tsx b/frontend/src/components/database-snapshots-panel.tsx index 037d06b..f8a8194 100644 --- a/frontend/src/components/database-snapshots-panel.tsx +++ b/frontend/src/components/database-snapshots-panel.tsx @@ -109,8 +109,15 @@ export function DatabaseSnapshotsPanel({ }, }); + const [deletingSnapshotId, setDeletingSnapshotId] = useState(null); const deleteMutation = useMutation({ mutationFn: (id: string) => api.delete(`/snapshots/${id}`), + onMutate: (id) => { + setDeletingSnapshotId(id); + }, + onSettled: () => { + setDeletingSnapshotId(null); + }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['snapshots', serviceId] }); toast.success('Backup deleted'); @@ -281,11 +288,15 @@ export function DatabaseSnapshotsPanel({
    )} @@ -293,11 +304,15 @@ export function DatabaseSnapshotsPanel({ )}
    diff --git a/frontend/src/components/delete-button-label.tsx b/frontend/src/components/delete-button-label.tsx new file mode 100644 index 0000000..ec06234 --- /dev/null +++ b/frontend/src/components/delete-button-label.tsx @@ -0,0 +1,14 @@ +'use client'; + +import { Clock } from 'lucide-react'; + +export function DeleteButtonLabel({ loading, label = 'Delete' }: { loading?: boolean; label?: string }) { + if (loading) { + return ( + <> + Deleting… + + ); + } + return <>{label}; +} diff --git a/frontend/src/components/deleting-modal.tsx b/frontend/src/components/deleting-modal.tsx new file mode 100644 index 0000000..7f9cb2a --- /dev/null +++ b/frontend/src/components/deleting-modal.tsx @@ -0,0 +1,44 @@ +'use client'; + +import { Clock } from 'lucide-react'; + +export function DeletingModal({ + open, + resourceName, + resourceKind = 'application', +}: { + open: boolean; + resourceName: string; + resourceKind?: 'application' | 'service'; +}) { + if (!open) return null; + + const detail = + resourceKind === 'service' + ? 'Removing this service and its data from the cluster. This may take a minute.' + : 'Removing this application, deployments, and data from the cluster. This may take a minute.'; + + return ( +
    +
    +
    + +
    +

    + Deleting… +

    +

    + {resourceName} is being permanently removed. +

    +

    {detail}

    +

    Please wait — do not close this page.

    +
    +
    + ); +} diff --git a/frontend/src/components/deleting-overlay.tsx b/frontend/src/components/deleting-overlay.tsx new file mode 100644 index 0000000..8ab0577 --- /dev/null +++ b/frontend/src/components/deleting-overlay.tsx @@ -0,0 +1,45 @@ +'use client'; + +import { Clock } from 'lucide-react'; + +export function deletingResourceMessage( + kind: 'application' | 'service', + name?: string, +): string { + if (kind === 'service') { + return name ? `Deleting “${name}”…` : 'Deleting service…'; + } + return name ? `Deleting “${name}”…` : 'Deleting application…'; +} + +/** Single table cell spanning the full row — content centered in the row. */ +export function DeletingTableRowCell({ + colSpan, + message = 'Deleting…', +}: { + colSpan: number; + message?: string; +}) { + return ( + +
    + + {message} +
    + + ); +} + +/** Full-card overlay while delete is in progress. Parent must be `relative`. */ +export function DeletingCardOverlay({ message = 'Deleting…' }: { message?: string }) { + return ( +
    + + {message} +
    + ); +} diff --git a/frontend/src/lib/format-utils.ts b/frontend/src/lib/format-utils.ts index 73bd5ef..a9b4f6f 100644 --- a/frontend/src/lib/format-utils.ts +++ b/frontend/src/lib/format-utils.ts @@ -18,3 +18,25 @@ export function parseMemoryToMi(mem: string): number { if (mem.endsWith('Ki')) return parseFloat(mem) / 1024; return parseFloat(mem); } + +/** Human-readable time left (days, hours, minutes). */ +export function formatRemainingDurationMs(remainingMs: number): string { + const ms = Math.max(0, remainingMs); + const days = Math.floor(ms / 86400000); + const hours = Math.floor((ms % 86400000) / 3600000); + const minutes = Math.floor((ms % 3600000) / 60000); + if (days > 0) return `${days}d ${hours}h ${minutes}m`; + if (hours > 0) return `${hours}h ${minutes}m`; + if (minutes > 0) return `${minutes}m`; + return 'less than 1m'; +} + +/** Expiry timestamp in the user's locale and timezone. */ +export function formatExpiresAtLocal(expiresAt: string | Date): string { + const date = typeof expiresAt === 'string' ? new Date(expiresAt) : expiresAt; + if (Number.isNaN(date.getTime())) return '—'; + return date.toLocaleString(undefined, { + dateStyle: 'medium', + timeStyle: 'short', + }); +} diff --git a/frontend/src/lib/use-application-delete.ts b/frontend/src/lib/use-application-delete.ts new file mode 100644 index 0000000..7207253 --- /dev/null +++ b/frontend/src/lib/use-application-delete.ts @@ -0,0 +1,54 @@ +'use client'; + +import { useState } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import api from '@/lib/api'; +import { toast } from 'react-toastify'; + +type DeleteResponse = { resourceCredit?: unknown }; + +export function useApplicationDelete(options?: { + invalidateKeys?: unknown[][]; + onSuccess?: (res: DeleteResponse, id: string) => void; + onError?: () => void; + successMessage?: string; + successWithCreditMessage?: string; +}) { + const queryClient = useQueryClient(); + const [deletingId, setDeletingId] = useState(null); + + const mutation = useMutation({ + mutationFn: (id: string) => api.delete(`/applications/${id}`).then((r) => r.data as DeleteResponse), + onMutate: (id) => { + setDeletingId(id); + }, + onSettled: () => { + setDeletingId(null); + }, + onSuccess: (data, id) => { + for (const key of options?.invalidateKeys ?? [['applications']]) { + queryClient.invalidateQueries({ queryKey: key }); + } + queryClient.invalidateQueries({ queryKey: ['resource-credits'] }); + + if (options?.onSuccess) { + options.onSuccess(data, id); + } else if (data?.resourceCredit && options?.successWithCreditMessage) { + toast.success(options.successWithCreditMessage); + } else { + toast.success(options?.successMessage ?? 'Deleted successfully'); + } + }, + onError: () => { + if (options?.onError) options.onError(); + else toast.error('Failed to delete'); + }, + }); + + return { + deleteApplication: mutation.mutate, + deletingId, + isDeleting: (id: string) => mutation.isPending && deletingId === id, + isAnyDeleting: mutation.isPending, + }; +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index e33c268..8cca2a6 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -187,6 +187,9 @@ export interface ResourceCredit { billingCycle?: string; expiresAt: string; remainingMs: number; + remainingDays?: number; + remainingHours?: number; + remainingMinutes?: number; remainingLabel: string; }