feat: merge backoffice into settings and add CRUD for credentials
- Move backoffice functionality into settings page as new tab - Remove standalone backoffice page and sidebar link - Add edit/delete buttons for credentials management - Update credentials service to allow overwriting existing credentials - Add API documentation for system credentials endpoints
This commit is contained in:
parent
f22bd51c5d
commit
754acd9d3c
6 changed files with 635 additions and 577 deletions
|
|
@ -35,8 +35,6 @@ export class CredentialsService {
|
|||
let cred = await this.credentialsRepo.findOne({ where: { serviceName } });
|
||||
if (!cred) {
|
||||
cred = this.credentialsRepo.create({ serviceName });
|
||||
} else {
|
||||
throw new BadRequestException('Credentials already configured for this service.');
|
||||
}
|
||||
cred.encryptedPayload = encrypted;
|
||||
cred.updatedBy = updatedBy;
|
||||
|
|
|
|||
94
docs/API.md
94
docs/API.md
|
|
@ -397,6 +397,100 @@ GET /docs/
|
|||
|
||||
---
|
||||
|
||||
## 🔑 Module: System Credentials
|
||||
|
||||
Manage encrypted credentials for external services (Stripe, SMTP, Storage, etc.).
|
||||
|
||||
### List Configured Services
|
||||
```http
|
||||
GET /api/v1/system/credentials
|
||||
```
|
||||
| Field | Auth | Roles | Description |
|
||||
|-------|------|-------|-------------|
|
||||
| Protected | ✅ | superadmin, admin | List all configured services (metadata only, no secrets) |
|
||||
|
||||
**Response (200):**
|
||||
```json
|
||||
{
|
||||
"services": [
|
||||
{
|
||||
"service_name": "stripe",
|
||||
"updated_at": "2026-02-23T10:30:00Z",
|
||||
"updated_by": "admin-uuid",
|
||||
"is_configured": true
|
||||
},
|
||||
{
|
||||
"service_name": "smtp",
|
||||
"updated_at": "2026-02-22T14:00:00Z",
|
||||
"updated_by": "superadmin-uuid",
|
||||
"is_configured": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Save Credentials
|
||||
```http
|
||||
POST /api/v1/system/credentials
|
||||
```
|
||||
| Field | Auth | Roles | Description |
|
||||
|-------|------|-------|-------------|
|
||||
| Protected | ✅ | superadmin, admin | Create or update service credentials |
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"serviceName": "stripe",
|
||||
"payload": {
|
||||
"secretKey": "sk_live_xxx",
|
||||
"webhookSecret": "whsec_xxx",
|
||||
"publishableKey": "pk_live_xxx"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response (200):**
|
||||
```json
|
||||
{
|
||||
"message": "Credentials saved successfully"
|
||||
}
|
||||
```
|
||||
|
||||
**Supported Services:**
|
||||
| Service Name | Fields |
|
||||
|--------------|--------|
|
||||
| `stripe` | `secretKey`, `webhookSecret`, `publishableKey` |
|
||||
| `smtp` | `host`, `port`, `username`, `password`, `from_email`, `from_name`, `secure` |
|
||||
| `storage` | `endpoint`, `region`, `bucket`, `accessKey`, `secretKey` |
|
||||
| `cloudflare_config` | `apiToken`, `zoneId` |
|
||||
| `firebase` | `serviceAccountJson` (JSON string) |
|
||||
| `appwrite` | `endpoint`, `projectId`, `apiKey` |
|
||||
| `lavinmq` | `amqpUrl` |
|
||||
| `cpanel` | `host`, `username`, `apiToken` |
|
||||
|
||||
### Delete Credentials
|
||||
```http
|
||||
DELETE /api/v1/system/credentials/{serviceName}
|
||||
```
|
||||
| Field | Auth | Roles | Description |
|
||||
|-------|------|-------|-------------|
|
||||
| Protected | ✅ | superadmin, admin | Delete service credentials |
|
||||
|
||||
**Response (200):**
|
||||
```json
|
||||
{
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
**Security Notes:**
|
||||
- Credentials are encrypted with RSA-OAEP-SHA256 before storage
|
||||
- Only metadata (service name, timestamps) is returned on list operations
|
||||
- Actual secret values cannot be retrieved after saving
|
||||
- Updating credentials overwrites the existing configuration
|
||||
|
||||
---
|
||||
|
||||
## 🔑 Permission Matrix
|
||||
|
||||
| Route | Guest | JobSeeker | Recruiter | CompanyAdmin | Admin | SuperAdmin |
|
||||
|
|
|
|||
|
|
@ -1,554 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import {
|
||||
adminAccessApi,
|
||||
adminAuditApi,
|
||||
adminCompaniesApi,
|
||||
adminJobsApi,
|
||||
adminTagsApi,
|
||||
backofficeApi,
|
||||
plansApi,
|
||||
type AdminCompany,
|
||||
type AdminJob,
|
||||
type AdminLoginAudit,
|
||||
type AdminRoleAccess,
|
||||
type AdminTag,
|
||||
} from "@/lib/api"
|
||||
import { getCurrentUser, isAdminUser } from "@/lib/auth"
|
||||
import { toast } from "sonner"
|
||||
import { Archive, CheckCircle, Copy, ExternalLink, PauseCircle, Plus, RefreshCw, XCircle } from "lucide-react"
|
||||
import { ConfirmModal } from "@/components/confirm-modal"
|
||||
|
||||
const auditDateFormatter = new Intl.DateTimeFormat("pt-BR", {
|
||||
dateStyle: "short",
|
||||
timeStyle: "short",
|
||||
timeZone: "America/Sao_Paulo",
|
||||
})
|
||||
|
||||
const jobStatusBadge: Record<string, { label: string; variant: "default" | "secondary" | "destructive" | "outline" }> = {
|
||||
draft: { label: "Draft", variant: "outline" },
|
||||
review: { label: "Review", variant: "secondary" },
|
||||
published: { label: "Published", variant: "default" },
|
||||
paused: { label: "Paused", variant: "outline" },
|
||||
expired: { label: "Expired", variant: "destructive" },
|
||||
archived: { label: "Archived", variant: "outline" },
|
||||
reported: { label: "Reported", variant: "destructive" },
|
||||
open: { label: "Open", variant: "default" },
|
||||
closed: { label: "Closed", variant: "outline" },
|
||||
}
|
||||
|
||||
export default function BackofficePage() {
|
||||
const router = useRouter()
|
||||
const [roles, setRoles] = useState<AdminRoleAccess[]>([])
|
||||
const [audits, setAudits] = useState<AdminLoginAudit[]>([])
|
||||
const [companies, setCompanies] = useState<AdminCompany[]>([])
|
||||
const [jobs, setJobs] = useState<AdminJob[]>([])
|
||||
const [tags, setTags] = useState<AdminTag[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [creatingTag, setCreatingTag] = useState(false)
|
||||
const [tagForm, setTagForm] = useState({ name: "", category: "area" as "area" | "level" | "stack" })
|
||||
|
||||
useEffect(() => {
|
||||
const user = getCurrentUser()
|
||||
if (!isAdminUser(user)) {
|
||||
router.push("/dashboard")
|
||||
return
|
||||
}
|
||||
loadBackoffice()
|
||||
}, [router])
|
||||
|
||||
const [stats, setStats] = useState<any>(null)
|
||||
|
||||
// ... imports and other state ...
|
||||
|
||||
// ... imports and other state ...
|
||||
const [plans, setPlans] = useState<any[]>([])
|
||||
const [activeTab, setActiveTab] = useState("dashboard")
|
||||
|
||||
// Plan Form State
|
||||
const [isPlanDialogOpen, setIsPlanDialogOpen] = useState(false)
|
||||
const [planForm, setPlanForm] = useState<any>({ name: "", description: "", monthlyPrice: 0, yearlyPrice: 0, features: [] })
|
||||
const [editingPlanId, setEditingPlanId] = useState<string | null>(null)
|
||||
const [deletePlanId, setDeletePlanId] = useState<string | null>(null)
|
||||
|
||||
const loadBackoffice = async (silent = false) => {
|
||||
try {
|
||||
if (!silent) setLoading(true)
|
||||
const [rolesData, auditData, companiesData, jobsData, tagsData, statsData, plansData] = await Promise.all([
|
||||
adminAccessApi.listRoles(),
|
||||
adminAuditApi.listLogins(20),
|
||||
adminCompaniesApi.list(false),
|
||||
adminJobsApi.list({ status: "review", limit: 10 }),
|
||||
adminTagsApi.list(),
|
||||
backofficeApi.admin.getStats().catch(() => null),
|
||||
plansApi.getAll().catch(() => [])
|
||||
])
|
||||
setRoles(rolesData)
|
||||
setAudits(auditData)
|
||||
setCompanies(companiesData.data || [])
|
||||
setJobs(jobsData.data || [])
|
||||
setTags(tagsData)
|
||||
setStats(statsData)
|
||||
|
||||
setPlans(plansData)
|
||||
} catch (error) {
|
||||
console.error("Error loading backoffice:", error)
|
||||
toast.error("Failed to load backoffice data")
|
||||
} finally {
|
||||
if (!silent) setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleApproveCompany = async (companyId: string) => {
|
||||
try {
|
||||
await adminCompaniesApi.updateStatus(companyId, { verified: true })
|
||||
toast.success("Company approved")
|
||||
loadBackoffice(true)
|
||||
} catch (error) {
|
||||
console.error("Error approving company:", error)
|
||||
toast.error("Failed to approve company")
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeactivateCompany = async (companyId: string) => {
|
||||
try {
|
||||
await adminCompaniesApi.updateStatus(companyId, { active: false })
|
||||
toast.success("Company deactivated")
|
||||
loadBackoffice(true)
|
||||
} catch (error) {
|
||||
console.error("Error deactivating company:", error)
|
||||
toast.error("Failed to deactivate company")
|
||||
}
|
||||
}
|
||||
|
||||
const handleJobStatus = async (jobId: string, status: string) => {
|
||||
try {
|
||||
await adminJobsApi.updateStatus(jobId, status)
|
||||
toast.success("Job status updated")
|
||||
loadBackoffice(true)
|
||||
} catch (error) {
|
||||
console.error("Error updating job status:", error)
|
||||
toast.error("Failed to update job status")
|
||||
}
|
||||
}
|
||||
|
||||
const handleDuplicateJob = async (jobId: string) => {
|
||||
try {
|
||||
await adminJobsApi.duplicate(jobId)
|
||||
toast.success("Job duplicated as draft")
|
||||
loadBackoffice(true)
|
||||
} catch (error) {
|
||||
console.error("Error duplicating job:", error)
|
||||
toast.error("Failed to duplicate job")
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateTag = async () => {
|
||||
if (!tagForm.name.trim()) {
|
||||
toast.error("Tag name is required")
|
||||
return
|
||||
}
|
||||
try {
|
||||
setCreatingTag(true)
|
||||
await adminTagsApi.create({ name: tagForm.name.trim(), category: tagForm.category })
|
||||
toast.success("Tag created")
|
||||
setTagForm({ name: "", category: "area" })
|
||||
loadBackoffice(true)
|
||||
} catch (error) {
|
||||
console.error("Error creating tag:", error)
|
||||
toast.error("Failed to create tag")
|
||||
} finally {
|
||||
setCreatingTag(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleTag = async (tag: AdminTag) => {
|
||||
try {
|
||||
await adminTagsApi.update(tag.id, { active: !tag.active })
|
||||
toast.success("Tag updated")
|
||||
loadBackoffice(true)
|
||||
} catch (error) {
|
||||
console.error("Error updating tag:", error)
|
||||
toast.error("Failed to update tag")
|
||||
}
|
||||
}
|
||||
|
||||
const handleSavePlan = async () => {
|
||||
try {
|
||||
const payload = {
|
||||
...planForm,
|
||||
monthlyPrice: Number(planForm.monthlyPrice),
|
||||
yearlyPrice: Number(planForm.yearlyPrice),
|
||||
features: Array.isArray(planForm.features) ? planForm.features : planForm.features.split(',').map((f: string) => f.trim())
|
||||
}
|
||||
|
||||
if (editingPlanId) {
|
||||
await plansApi.update(editingPlanId, payload)
|
||||
toast.success("Plan updated")
|
||||
} else {
|
||||
await plansApi.create(payload)
|
||||
toast.success("Plan created")
|
||||
}
|
||||
setIsPlanDialogOpen(false)
|
||||
setPlanForm({ name: "", description: "", monthlyPrice: 0, yearlyPrice: 0, features: [] })
|
||||
setEditingPlanId(null)
|
||||
loadBackoffice(true)
|
||||
} catch (error) {
|
||||
toast.error("Failed to save plan")
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeletePlan = async (id: string) => {
|
||||
setDeletePlanId(id)
|
||||
}
|
||||
|
||||
const confirmDeletePlan = async () => {
|
||||
if (!deletePlanId) return
|
||||
try {
|
||||
await plansApi.delete(deletePlanId)
|
||||
toast.success("Plan deleted")
|
||||
loadBackoffice(true)
|
||||
} catch (error) {
|
||||
toast.error("Failed to delete plan")
|
||||
} finally {
|
||||
setDeletePlanId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePurgeCache = async () => {
|
||||
try {
|
||||
await backofficeApi.externalServices.purgeCloudflareCache()
|
||||
toast.success("Cloudflare cache purged")
|
||||
} catch (error) {
|
||||
toast.error("Failed to purge cache")
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">Backoffice</h1>
|
||||
<p className="text-muted-foreground mt-1">SaaS Administration & Operations</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => loadBackoffice(false)} className="gap-2">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="dashboard" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="dashboard">Dashboard</TabsTrigger>
|
||||
<TabsTrigger value="plans">Plans</TabsTrigger>
|
||||
<TabsTrigger value="stripe">Stripe</TabsTrigger>
|
||||
<TabsTrigger value="system">System</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="dashboard" className="space-y-4">
|
||||
{/* Stats Overview */}
|
||||
{stats && (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Revenue</CardTitle>
|
||||
<span className="text-xs text-muted-foreground">$</span>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">${stats.monthlyRevenue?.toLocaleString() || '0'}</div>
|
||||
<p className="text-xs text-muted-foreground">{stats.revenueGrowth ? `+${stats.revenueGrowth}% from last month` : 'This month'}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Active Subscriptions</CardTitle>
|
||||
<CheckCircle className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stats.activeSubscriptions || 0}</div>
|
||||
<p className="text-xs text-muted-foreground">{stats.subscriptionGrowth ? `+${stats.subscriptionGrowth} this week` : 'Current active'}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Companies</CardTitle>
|
||||
<div className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stats.totalCompanies || 0}</div>
|
||||
<p className="text-xs text-muted-foreground">Platform total</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">New (Month)</CardTitle>
|
||||
<Plus className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">+{stats.newCompaniesThisMonth || 0}</div>
|
||||
<p className="text-xs text-muted-foreground">Since start of month</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
|
||||
<Card className="col-span-4">
|
||||
<CardHeader>
|
||||
<CardTitle>Empresas pendentes</CardTitle>
|
||||
<CardDescription>Aprovação e verificação de empresas.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Empresa</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Ações</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{companies.slice(0, 5).map((company) => (
|
||||
<TableRow key={company.id}>
|
||||
<TableCell className="font-medium">{company.name}</TableCell>
|
||||
<TableCell>
|
||||
{company.verified ? <Badge className="bg-green-500">Verificada</Badge> : <Badge variant="secondary">Pendente</Badge>}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button size="sm" variant="ghost" onClick={() => handleApproveCompany(company.id)}>
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="col-span-3">
|
||||
<CardHeader>
|
||||
<CardTitle>Auditoria Recente</CardTitle>
|
||||
<CardDescription>Últimos acessos.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-8">
|
||||
{audits.slice(0, 5).map((audit) => (
|
||||
<div key={audit.id} className="flex items-center">
|
||||
<div className="ml-4 space-y-1">
|
||||
<p className="text-sm font-medium leading-none">{audit.identifier}</p>
|
||||
<p className="text-xs text-muted-foreground">{auditDateFormatter.format(new Date(audit.createdAt))}</p>
|
||||
</div>
|
||||
<div className="ml-auto font-medium text-xs text-muted-foreground">{audit.roles}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="plans" className="space-y-4">
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => { setEditingPlanId(null); setPlanForm({ name: "", description: "", monthlyPrice: 0, yearlyPrice: 0, features: [] }); setIsPlanDialogOpen(true) }}>
|
||||
<Plus className="mr-2 h-4 w-4" /> Create Plan
|
||||
</Button>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Plans Management</CardTitle>
|
||||
<CardDescription>Configure subscription plans.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Monthly</TableHead>
|
||||
<TableHead>Yearly</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{plans.map((plan) => (
|
||||
<TableRow key={plan.id}>
|
||||
<TableCell className="font-medium">{plan.name}</TableCell>
|
||||
<TableCell>${plan.monthlyPrice}</TableCell>
|
||||
<TableCell>${plan.yearlyPrice}</TableCell>
|
||||
<TableCell className="text-right space-x-2">
|
||||
<Button size="sm" variant="outline" onClick={() => { setEditingPlanId(plan.id); setPlanForm({ ...plan }); setIsPlanDialogOpen(true) }}>Edit</Button>
|
||||
<Button size="sm" variant="destructive" onClick={() => handleDeletePlan(plan.id)}>Delete</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={isPlanDialogOpen} onOpenChange={setIsPlanDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingPlanId ? 'Edit Plan' : 'Create Plan'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>Name</Label>
|
||||
<Input value={planForm.name} onChange={(e) => setPlanForm({ ...planForm, name: e.target.value })} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Description</Label>
|
||||
<Input value={planForm.description} onChange={(e) => setPlanForm({ ...planForm, description: e.target.value })} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>Monthly Price</Label>
|
||||
<Input type="number" value={planForm.monthlyPrice} onChange={(e) => setPlanForm({ ...planForm, monthlyPrice: e.target.value })} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Yearly Price</Label>
|
||||
<Input type="number" value={planForm.yearlyPrice} onChange={(e) => setPlanForm({ ...planForm, yearlyPrice: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Features (comma separated)</Label>
|
||||
<Textarea value={Array.isArray(planForm.features) ? planForm.features.join(', ') : planForm.features} onChange={(e) => setPlanForm({ ...planForm, features: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsPlanDialogOpen(false)}>Cancel</Button>
|
||||
<Button onClick={handleSavePlan}>Save</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="stripe" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Stripe Integration</CardTitle>
|
||||
<CardDescription>Manage subscriptions and payments directly in Stripe Dashboard.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="p-4 border rounded bg-muted/20">
|
||||
<p className="text-sm">
|
||||
For security and advanced management (refunds, disputes, tax settings), please use the official Stripe Dashboard.
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<a href="https://dashboard.stripe.com" target="_blank" rel="noreferrer">
|
||||
<Button variant="outline">
|
||||
Open Stripe Dashboard <ExternalLink className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="system" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>System & Caching</CardTitle>
|
||||
<CardDescription>Maintenance tasks.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between p-4 border rounded">
|
||||
<div>
|
||||
<p className="font-medium">Cloudflare Cache</p>
|
||||
<p className="text-sm text-muted-foreground">Purge all cached files from the edge.</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={handlePurgeCache}>Purge Cache</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Tags Management</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Reusing existing Tags Table logic here if desired, or keep it in a sub-section */}
|
||||
<div className="flex flex-col md:flex-row gap-4 mb-4">
|
||||
<Input placeholder="New Tag" value={tagForm.name} onChange={(e) => setTagForm({ ...tagForm, name: e.target.value })} />
|
||||
<Select value={tagForm.category} onValueChange={(val: any) => setTagForm({ ...tagForm, category: val })}>
|
||||
<SelectTrigger className="w-40"><SelectValue placeholder="Category" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="area">Area</SelectItem>
|
||||
<SelectItem value="level">Level</SelectItem>
|
||||
<SelectItem value="stack">Stack</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button onClick={handleCreateTag} disabled={creatingTag}><Plus className="mr-2 h-4 w-4" /> Add</Button>
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Tag</TableHead>
|
||||
<TableHead>Category</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Action</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{tags.map((tag) => (
|
||||
<TableRow key={tag.id}>
|
||||
<TableCell>{tag.name}</TableCell>
|
||||
<TableCell>{tag.category}</TableCell>
|
||||
<TableCell>{tag.active ? <Badge className="bg-green-500">Active</Badge> : <Badge variant="outline">Inactive</Badge>}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button size="sm" variant="ghost" onClick={() => handleToggleTag(tag)}>Toggle</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<ConfirmModal
|
||||
isOpen={!!deletePlanId}
|
||||
onClose={() => setDeletePlanId(null)}
|
||||
onConfirm={confirmDeletePlan}
|
||||
title="Are you sure you want to delete this plan?"
|
||||
description="This action cannot be undone."
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -8,7 +8,7 @@ import { Label } from "@/components/ui/label"
|
|||
import { Separator } from "@/components/ui/separator"
|
||||
import { settingsApi, credentialsApi, ConfiguredService, storageApi } from "@/lib/api"
|
||||
import { toast } from "sonner"
|
||||
import { Loader2, Check, Key } from "lucide-react"
|
||||
import { Loader2, Check, Key, CheckCircle, Plus, ExternalLink } from "lucide-react"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -21,6 +21,33 @@ import {
|
|||
} from "@/components/ui/dialog"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { ConfirmModal } from "@/components/confirm-modal"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
|
||||
const auditDateFormatter = new Intl.DateTimeFormat("pt-BR", {
|
||||
dateStyle: "short",
|
||||
timeStyle: "short",
|
||||
timeZone: "America/Sao_Paulo",
|
||||
})
|
||||
|
||||
const jobStatusBadge: Record<string, { label: string; variant: "default" | "secondary" | "destructive" | "outline" }> = {
|
||||
draft: { label: "Draft", variant: "outline" },
|
||||
review: { label: "Review", variant: "secondary" },
|
||||
published: { label: "Published", variant: "default" },
|
||||
paused: { label: "Paused", variant: "outline" },
|
||||
expired: { label: "Expired", variant: "destructive" },
|
||||
archived: { label: "Archived", variant: "outline" },
|
||||
reported: { label: "Reported", variant: "destructive" },
|
||||
open: { label: "Open", variant: "default" },
|
||||
closed: { label: "Closed", variant: "outline" },
|
||||
}
|
||||
|
||||
interface ThemeConfig {
|
||||
logoUrl: string
|
||||
|
|
@ -87,10 +114,6 @@ export default function SettingsPage() {
|
|||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([fetchSettings(), fetchCredentials()]).finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const handleSaveTheme = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
|
|
@ -222,12 +245,169 @@ export default function SettingsPage() {
|
|||
}
|
||||
}
|
||||
|
||||
// ... existing imports ...
|
||||
// Note: State definition needs update to object payload
|
||||
const handleDeleteCredential = async (serviceName: string) => {
|
||||
if (!confirm(`Are you sure you want to delete credentials for ${schemas[serviceName]?.label || serviceName}?`)) return
|
||||
|
||||
try {
|
||||
await credentialsApi.delete(serviceName)
|
||||
toast.success(`Credentials for ${schemas[serviceName]?.label || serviceName} deleted`)
|
||||
fetchCredentials()
|
||||
} catch (error) {
|
||||
console.error("Failed to delete credential", error)
|
||||
toast.error("Failed to delete credential")
|
||||
}
|
||||
}
|
||||
|
||||
// State migrated from backoffice
|
||||
// const [credentialPayload, setCredentialPayload] = useState<any>({})
|
||||
|
||||
const isSelectedConfigured = selectedService ? configuredMap.has(selectedService) : false
|
||||
|
||||
// State migrated from backoffice
|
||||
const [roles, setRoles] = useState<any[]>([])
|
||||
const [audits, setAudits] = useState<any[]>([])
|
||||
const [companies, setCompanies] = useState<any[]>([])
|
||||
const [jobs, setJobs] = useState<any[]>([])
|
||||
const [tags, setTags] = useState<any[]>([])
|
||||
const [creatingTag, setCreatingTag] = useState(false)
|
||||
const [tagForm, setTagForm] = useState({ name: "", category: "area" as "area" | "level" | "stack" })
|
||||
const [stats, setStats] = useState<any>(null)
|
||||
const [plans, setPlans] = useState<any[]>([])
|
||||
const [isPlanDialogOpen, setIsPlanDialogOpen] = useState(false)
|
||||
const [planForm, setPlanForm] = useState<any>({ name: "", description: "", monthlyPrice: 0, yearlyPrice: 0, features: [] })
|
||||
const [editingPlanId, setEditingPlanId] = useState<string | null>(null)
|
||||
const [deletePlanId, setDeletePlanId] = useState<string | null>(null)
|
||||
|
||||
const loadBackoffice = async (silent = false) => {
|
||||
try {
|
||||
if (!silent) setLoading(true)
|
||||
const { adminAccessApi, adminAuditApi, adminCompaniesApi, adminJobsApi, adminTagsApi, backofficeApi, plansApi } = await import("@/lib/api")
|
||||
const [rolesData, auditData, companiesData, jobsData, tagsData, statsData, plansData] = await Promise.all([
|
||||
adminAccessApi.listRoles(),
|
||||
adminAuditApi.listLogins(20),
|
||||
adminCompaniesApi.list(false),
|
||||
adminJobsApi.list({ status: "review", limit: 10 }),
|
||||
adminTagsApi.list(),
|
||||
backofficeApi.admin.getStats().catch(() => null),
|
||||
plansApi.getAll().catch(() => [])
|
||||
])
|
||||
setRoles(rolesData)
|
||||
setAudits(auditData)
|
||||
setCompanies(companiesData.data || [])
|
||||
setJobs(jobsData.data || [])
|
||||
setTags(tagsData)
|
||||
setStats(statsData)
|
||||
setPlans(plansData)
|
||||
} catch (error) {
|
||||
console.error("Error loading backoffice:", error)
|
||||
toast.error("Failed to load backoffice data")
|
||||
} finally {
|
||||
if (!silent) setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleApproveCompany = async (companyId: string) => {
|
||||
try {
|
||||
const { adminCompaniesApi } = await import("@/lib/api")
|
||||
await adminCompaniesApi.updateStatus(companyId, { verified: true })
|
||||
toast.success("Company approved")
|
||||
loadBackoffice(true)
|
||||
} catch (error) {
|
||||
console.error("Error approving company:", error)
|
||||
toast.error("Failed to approve company")
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateTag = async () => {
|
||||
if (!tagForm.name.trim()) {
|
||||
toast.error("Tag name is required")
|
||||
return
|
||||
}
|
||||
try {
|
||||
const { adminTagsApi } = await import("@/lib/api")
|
||||
setCreatingTag(true)
|
||||
await adminTagsApi.create({ name: tagForm.name.trim(), category: tagForm.category })
|
||||
toast.success("Tag created")
|
||||
setTagForm({ name: "", category: "area" })
|
||||
loadBackoffice(true)
|
||||
} catch (error) {
|
||||
console.error("Error creating tag:", error)
|
||||
toast.error("Failed to create tag")
|
||||
} finally {
|
||||
setCreatingTag(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleTag = async (tag: any) => {
|
||||
try {
|
||||
const { adminTagsApi } = await import("@/lib/api")
|
||||
await adminTagsApi.update(tag.id, { active: !tag.active })
|
||||
toast.success("Tag updated")
|
||||
loadBackoffice(true)
|
||||
} catch (error) {
|
||||
console.error("Error updating tag:", error)
|
||||
toast.error("Failed to update tag")
|
||||
}
|
||||
}
|
||||
|
||||
const handleSavePlan = async () => {
|
||||
try {
|
||||
const { plansApi } = await import("@/lib/api")
|
||||
const payload = {
|
||||
...planForm,
|
||||
monthlyPrice: Number(planForm.monthlyPrice),
|
||||
yearlyPrice: Number(planForm.yearlyPrice),
|
||||
features: Array.isArray(planForm.features) ? planForm.features : planForm.features.split(',').map((f: string) => f.trim())
|
||||
}
|
||||
|
||||
if (editingPlanId) {
|
||||
await plansApi.update(editingPlanId, payload)
|
||||
toast.success("Plan updated")
|
||||
} else {
|
||||
await plansApi.create(payload)
|
||||
toast.success("Plan created")
|
||||
}
|
||||
setIsPlanDialogOpen(false)
|
||||
setPlanForm({ name: "", description: "", monthlyPrice: 0, yearlyPrice: 0, features: [] })
|
||||
setEditingPlanId(null)
|
||||
loadBackoffice(true)
|
||||
} catch (error) {
|
||||
toast.error("Failed to save plan")
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeletePlan = async (id: string) => {
|
||||
setDeletePlanId(id)
|
||||
}
|
||||
|
||||
const confirmDeletePlan = async () => {
|
||||
if (!deletePlanId) return
|
||||
try {
|
||||
const { plansApi } = await import("@/lib/api")
|
||||
await plansApi.delete(deletePlanId)
|
||||
toast.success("Plan deleted")
|
||||
loadBackoffice(true)
|
||||
} catch (error) {
|
||||
toast.error("Failed to delete plan")
|
||||
} finally {
|
||||
setDeletePlanId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePurgeCache = async () => {
|
||||
try {
|
||||
const { backofficeApi } = await import("@/lib/api")
|
||||
await backofficeApi.externalServices.purgeCloudflareCache()
|
||||
toast.success("Cloudflare cache purged")
|
||||
} catch (error) {
|
||||
toast.error("Failed to purge cache")
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([fetchSettings(), fetchCredentials(), loadBackoffice()]).finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header ... */}
|
||||
|
|
@ -237,10 +417,11 @@ export default function SettingsPage() {
|
|||
</div>
|
||||
<Separator />
|
||||
|
||||
<Tabs defaultValue="integrations" className="space-y-4"> {/* Default to integrations for visibility */}
|
||||
<Tabs defaultValue="integrations" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="theme">Branding & Theme</TabsTrigger>
|
||||
<TabsTrigger value="integrations">Integrations & Credentials</TabsTrigger>
|
||||
<TabsTrigger value="backoffice">Backoffice</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Theme Tab Content (unchanged) */}
|
||||
|
|
@ -306,7 +487,7 @@ export default function SettingsPage() {
|
|||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>External Services</CardTitle>
|
||||
<CardDescription>Manage credentials for third-party integrations securely. Keys are encrypted and locked after saving.</CardDescription>
|
||||
<CardDescription>Manage credentials for third-party integrations securely. Keys are encrypted before storage.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
|
|
@ -329,23 +510,31 @@ export default function SettingsPage() {
|
|||
: 'Not configured'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-auto">
|
||||
<div className="flex flex-col gap-2 mt-auto">
|
||||
{svc.service_name === 'storage' && svc.is_configured && (
|
||||
<Button variant="secondary" size="sm" className="w-full mb-2" onClick={handleTestStorageConnection} disabled={testingConnection}>
|
||||
<Button variant="secondary" size="sm" className="w-full" onClick={handleTestStorageConnection} disabled={testingConnection}>
|
||||
{testingConnection ? <Loader2 className="w-3 h-3 animate-spin mr-1" /> : "Test Connection"}
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex w-full gap-2">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
className="flex-1"
|
||||
onClick={() => handleOpenCredentialDialog(svc.service_name)}
|
||||
disabled={svc.is_configured}
|
||||
>
|
||||
<Key className="w-3 h-3 mr-2" />
|
||||
{svc.is_configured ? "Configured" : "Setup"}
|
||||
{svc.is_configured ? "Edit" : "Setup"}
|
||||
</Button>
|
||||
{svc.is_configured && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteCredential(svc.service_name)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -354,7 +543,286 @@ export default function SettingsPage() {
|
|||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="backoffice" className="space-y-4">
|
||||
<Tabs defaultValue="dashboard" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="dashboard">Dashboard</TabsTrigger>
|
||||
<TabsTrigger value="plans">Plans</TabsTrigger>
|
||||
<TabsTrigger value="stripe">Stripe</TabsTrigger>
|
||||
<TabsTrigger value="system">System</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="dashboard" className="space-y-4">
|
||||
{/* Stats Overview */}
|
||||
{stats && (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Revenue</CardTitle>
|
||||
<span className="text-xs text-muted-foreground">$</span>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">${stats.monthlyRevenue?.toLocaleString() || '0'}</div>
|
||||
<p className="text-xs text-muted-foreground">{stats.revenueGrowth ? `+${stats.revenueGrowth}% from last month` : 'This month'}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Active Subscriptions</CardTitle>
|
||||
<CheckCircle className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stats.activeSubscriptions || 0}</div>
|
||||
<p className="text-xs text-muted-foreground">{stats.subscriptionGrowth ? `+${stats.subscriptionGrowth} this week` : 'Current active'}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Companies</CardTitle>
|
||||
<div className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stats.totalCompanies || 0}</div>
|
||||
<p className="text-xs text-muted-foreground">Platform total</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">New (Month)</CardTitle>
|
||||
<Plus className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">+{stats.newCompaniesThisMonth || 0}</div>
|
||||
<p className="text-xs text-muted-foreground">Since start of month</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
|
||||
<Card className="col-span-4">
|
||||
<CardHeader>
|
||||
<CardTitle>Empresas pendentes</CardTitle>
|
||||
<CardDescription>Aprovação e verificação de empresas.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Empresa</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Ações</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{companies.slice(0, 5).map((company) => (
|
||||
<TableRow key={company.id}>
|
||||
<TableCell className="font-medium">{company.name}</TableCell>
|
||||
<TableCell>
|
||||
{company.verified ? <Badge className="bg-green-500">Verificada</Badge> : <Badge variant="secondary">Pendente</Badge>}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button size="sm" variant="ghost" onClick={() => handleApproveCompany(company.id)}>
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="col-span-3">
|
||||
<CardHeader>
|
||||
<CardTitle>Auditoria Recente</CardTitle>
|
||||
<CardDescription>Últimos acessos.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-8">
|
||||
{audits.slice(0, 5).map((audit) => (
|
||||
<div key={audit.id} className="flex items-center">
|
||||
<div className="ml-4 space-y-1">
|
||||
<p className="text-sm font-medium leading-none">{audit.identifier}</p>
|
||||
<p className="text-xs text-muted-foreground">{auditDateFormatter.format(new Date(audit.createdAt))}</p>
|
||||
</div>
|
||||
<div className="ml-auto font-medium text-xs text-muted-foreground">{audit.roles}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="plans" className="space-y-4">
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => { setEditingPlanId(null); setPlanForm({ name: "", description: "", monthlyPrice: 0, yearlyPrice: 0, features: [] }); setIsPlanDialogOpen(true) }}>
|
||||
<Plus className="mr-2 h-4 w-4" /> Create Plan
|
||||
</Button>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Plans Management</CardTitle>
|
||||
<CardDescription>Configure subscription plans.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Monthly</TableHead>
|
||||
<TableHead>Yearly</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{plans.map((plan) => (
|
||||
<TableRow key={plan.id}>
|
||||
<TableCell className="font-medium">{plan.name}</TableCell>
|
||||
<TableCell>${plan.monthlyPrice}</TableCell>
|
||||
<TableCell>${plan.yearlyPrice}</TableCell>
|
||||
<TableCell className="text-right space-x-2">
|
||||
<Button size="sm" variant="outline" onClick={() => { setEditingPlanId(plan.id); setPlanForm({ ...plan }); setIsPlanDialogOpen(true) }}>Edit</Button>
|
||||
<Button size="sm" variant="destructive" onClick={() => handleDeletePlan(plan.id)}>Delete</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={isPlanDialogOpen} onOpenChange={setIsPlanDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingPlanId ? 'Edit Plan' : 'Create Plan'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>Name</Label>
|
||||
<Input value={planForm.name} onChange={(e) => setPlanForm({ ...planForm, name: e.target.value })} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Description</Label>
|
||||
<Input value={planForm.description} onChange={(e) => setPlanForm({ ...planForm, description: e.target.value })} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>Monthly Price</Label>
|
||||
<Input type="number" value={planForm.monthlyPrice} onChange={(e) => setPlanForm({ ...planForm, monthlyPrice: e.target.value })} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Yearly Price</Label>
|
||||
<Input type="number" value={planForm.yearlyPrice} onChange={(e) => setPlanForm({ ...planForm, yearlyPrice: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Features (comma separated)</Label>
|
||||
<Textarea value={Array.isArray(planForm.features) ? planForm.features.join(', ') : planForm.features} onChange={(e) => setPlanForm({ ...planForm, features: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsPlanDialogOpen(false)}>Cancel</Button>
|
||||
<Button onClick={handleSavePlan}>Save</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="stripe" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Stripe Integration</CardTitle>
|
||||
<CardDescription>Manage subscriptions and payments directly in Stripe Dashboard.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="p-4 border rounded bg-muted/20">
|
||||
<p className="text-sm">
|
||||
For security and advanced management (refunds, disputes, tax settings), please use the official Stripe Dashboard.
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<a href="https://dashboard.stripe.com" target="_blank" rel="noreferrer">
|
||||
<Button variant="outline">
|
||||
Open Stripe Dashboard <ExternalLink className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="system" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>System & Caching</CardTitle>
|
||||
<CardDescription>Maintenance tasks.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between p-4 border rounded">
|
||||
<div>
|
||||
<p className="font-medium">Cloudflare Cache</p>
|
||||
<p className="text-sm text-muted-foreground">Purge all cached files from the edge.</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={handlePurgeCache}>Purge Cache</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Tags Management</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Reusing existing Tags Table logic here if desired, or keep it in a sub-section */}
|
||||
<div className="flex flex-col md:flex-row gap-4 mb-4">
|
||||
<Input placeholder="New Tag" value={tagForm.name} onChange={(e) => setTagForm({ ...tagForm, name: e.target.value })} />
|
||||
<Select value={tagForm.category} onValueChange={(val: any) => setTagForm({ ...tagForm, category: val })}>
|
||||
<SelectTrigger className="w-40"><SelectValue placeholder="Category" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="area">Area</SelectItem>
|
||||
<SelectItem value="level">Level</SelectItem>
|
||||
<SelectItem value="stack">Stack</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button onClick={handleCreateTag} disabled={creatingTag}><Plus className="mr-2 h-4 w-4" /> Add</Button>
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Tag</TableHead>
|
||||
<TableHead>Category</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Action</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{tags.map((tag) => (
|
||||
<TableRow key={tag.id}>
|
||||
<TableCell>{tag.name}</TableCell>
|
||||
<TableCell>{tag.category}</TableCell>
|
||||
<TableCell>{tag.active ? <Badge className="bg-green-500">Active</Badge> : <Badge variant="outline">Inactive</Badge>}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button size="sm" variant="ghost" onClick={() => handleToggleTag(tag)}>Toggle</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<ConfirmModal
|
||||
isOpen={!!deletePlanId}
|
||||
onClose={() => setDeletePlanId(null)}
|
||||
onConfirm={confirmDeletePlan}
|
||||
title="Are you sure you want to delete this plan?"
|
||||
description="This action cannot be undone."
|
||||
/>
|
||||
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogContent>
|
||||
|
|
|
|||
58
frontend/src/components/confirm-modal.tsx
Normal file
58
frontend/src/components/confirm-modal.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
"use client"
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
interface ConfirmModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onConfirm: () => void
|
||||
title: string
|
||||
description?: string
|
||||
cancelText?: string
|
||||
confirmText?: string
|
||||
isDestructive?: boolean
|
||||
}
|
||||
|
||||
export function ConfirmModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title,
|
||||
description,
|
||||
cancelText = "Cancelar",
|
||||
confirmText = "Confirmar",
|
||||
isDestructive = true,
|
||||
}: ConfirmModalProps) {
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
{description && <DialogDescription className="mt-2">{description}</DialogDescription>}
|
||||
</DialogHeader>
|
||||
<DialogFooter className="mt-4">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
{cancelText}
|
||||
</Button>
|
||||
<Button
|
||||
variant={isDestructive ? "destructive" : "default"}
|
||||
onClick={() => {
|
||||
onConfirm()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
{confirmText}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
|
@ -40,11 +40,6 @@ const Sidebar = () => {
|
|||
href: "/dashboard/companies",
|
||||
icon: Building2,
|
||||
},
|
||||
{
|
||||
title: t('sidebar.backoffice'),
|
||||
href: "/dashboard/backoffice",
|
||||
icon: FileText,
|
||||
},
|
||||
{
|
||||
title: "Seeder",
|
||||
href: "/dashboard/seeder",
|
||||
|
|
@ -124,7 +119,6 @@ const Sidebar = () => {
|
|||
items = isSuperadmin
|
||||
? adminItems
|
||||
: adminItems.filter(item =>
|
||||
item.href !== "/dashboard/backoffice" &&
|
||||
item.href !== "/dashboard/companies" &&
|
||||
!('superadminOnly' in item && item.superadminOnly)
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in a new issue